oldj 8 years ago
parent
commit
3e372e5987
66 changed files with 16709 additions and 5 deletions
  1. 3 3
      package.json
  2. 12682 0
      src/bundle.js
  3. 80 0
      src/common/lang/cn.js
  4. 80 0
      src/common/lang/en.js
  5. 12 0
      src/index.html
  6. 147 0
      src/main.js
  7. 36 0
      src/package.json
  8. 61 0
      src/renderer/Agent.js
  9. 24 0
      src/server/Server.js
  10. 8 0
      src/server/actions.js
  11. 22 0
      src/server/actions/getHosts.js
  12. 14 0
      src/server/actions/getLang.js
  13. 25 0
      src/server/actions/getPref.js
  14. 21 0
      src/server/actions/getSysHosts.js
  15. 30 0
      src/server/actions/getUserHosts.js
  16. 16 0
      src/server/actions/index.js
  17. 23 0
      src/server/actions/setPref.js
  18. 11 0
      src/server/actions/test.js
  19. 57 0
      src/server/io.js
  20. 43 0
      src/server/lang.js
  21. 42 0
      src/server/paths.js
  22. 53 0
      src/server/pref.js
  23. 108 0
      src/ui/components/app.js
  24. 20 0
      src/ui/components/app.less
  25. 13 0
      src/ui/components/cfg.less
  26. 50 0
      src/ui/components/content/cm_hl.js
  27. 95 0
      src/ui/components/content/content.js
  28. 61 0
      src/ui/components/content/content.less
  29. 127 0
      src/ui/components/content/editor.js
  30. 33 0
      src/ui/components/content/editor.less
  31. 295 0
      src/ui/components/frame/edit.js
  32. 55 0
      src/ui/components/frame/edit.less
  33. 73 0
      src/ui/components/frame/frame.js
  34. 108 0
      src/ui/components/frame/frame.less
  35. 95 0
      src/ui/components/frame/group.js
  36. 62 0
      src/ui/components/frame/group.less
  37. 239 0
      src/ui/components/frame/preferences.js
  38. 35 0
      src/ui/components/frame/preferences.less
  39. 85 0
      src/ui/components/frame/sudo.js
  40. 7 0
      src/ui/components/frame/sudo.less
  41. 119 0
      src/ui/components/panel/buttons.js
  42. 53 0
      src/ui/components/panel/buttons.less
  43. 83 0
      src/ui/components/panel/iconfont/iconfont.css
  44. 0 0
      src/ui/components/panel/iconfont/iconfont.eot
  45. 102 0
      src/ui/components/panel/iconfont/iconfont.js
  46. 150 0
      src/ui/components/panel/iconfont/iconfont.svg
  47. BIN
      src/ui/components/panel/iconfont/iconfont.ttf
  48. 0 0
      src/ui/components/panel/iconfont/iconfont.woff
  49. 184 0
      src/ui/components/panel/list.js
  50. 10 0
      src/ui/components/panel/list.less
  51. 144 0
      src/ui/components/panel/list_item.js
  52. 65 0
      src/ui/components/panel/list_item.less
  53. 32 0
      src/ui/components/panel/panel.js
  54. 10 0
      src/ui/components/panel/panel.less
  55. 80 0
      src/ui/components/panel/searchbar.js
  56. 19 0
      src/ui/components/panel/searchbar.less
  57. 14 0
      src/ui/configs.js
  58. 15 0
      src/ui/index.js
  59. 43 0
      src/ui/lang.js
  60. 24 0
      src/ui/libs/default_data.js
  61. 72 0
      src/ui/libs/kw.js
  62. 15 0
      src/ui/libs/util.js
  63. 305 0
      src/ui/menu/mainMenu.js
  64. 116 0
      src/ui/menu/tray.js
  65. 1 0
      src/version.js
  66. 2 2
      webpack.config.js

+ 3 - 3
package.json

@@ -5,8 +5,8 @@
   "main": "",
   "scripts": {
     "test": "nyc ava",
-    "start": "electron app",
-    "dev": "ENV=dev electron app",
+    "start": "electron src",
+    "dev": "ENV=dev electron src",
     "build": "gulp ver && webpack -p",
     "build-w": "webpack -w",
     "pack": "gulp pack",
@@ -65,4 +65,4 @@
       "presets": ["latest", "stage-0"]
     }
   }
-}
+}

File diff suppressed because it is too large
+ 12682 - 0
src/bundle.js


+ 80 - 0
src/common/lang/cn.js

@@ -0,0 +1,80 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+exports.content = {
+    _lang_name: '简体中文'
+    , add: '添加'
+    , add_host: '添加 host 规则'
+    , auto_launch: '随系统一起启动'
+    , auto_refresh: '自动更新'
+    , bad_url: 'URL 地址有误。'
+    , cancel: '取消'
+    , check_update: '检查更新'
+    , check_update_err: '检查更新出错,请稍后再试。:-('
+    , check_update_found: '发现新版本 ${0},前往下载?'
+    , check_update_nofound: '当前版本 ${0} 已是最新版本。'
+    , comment: '注释'
+    , confirm_del: '确定要删除此 host 吗?'
+    , confirm_import: '确定要导入吗?原方案列表将被覆盖,此操作不可撤销。'
+    // , current_version: '当前版本:'
+    , day: '天'
+    , days: '天'
+    , del_host: '删除当前 host'
+    , edit: '编辑'
+    , edit_host: '修改 host'
+    , export: '导出'
+    , feedback: '意见反馈'
+    , file: '文件'
+    , help: '帮助'
+    , hide_at_launch: '启动时隐藏'
+    , hide_dock_icon: '隐藏 Dock 图标'
+    , homepage: '主页'
+    , host_title: 'host 方案名'
+    , host_title_cant_be_empty: 'Host 方案名不能为空!'
+    , hour: '小时'
+    , hours: '小时'
+    , import: '导入'
+    , input_sudo_pswd: '请输入您的开机密码(sudo 密码)'
+    , is_updated: '当前版本是最新版本。'
+    , is_updated_title: '已是最新'
+    , language: '语言'
+    , last_refresh: '上次更新:'
+    , never: '从不'
+    , new: '新建'
+    , new_version_available: '检测到新版本,立刻下载?'
+    , no_valid_host_found: '所指定的文件中未找到合法的 host 配置'
+    , ok: '确定'
+    , please_run_as_admin: '请以管理员身份运行 SwitchHosts!'
+    , pref_after_cmd: 'Host 应用后命令'
+    , pref_after_cmd_info: '每次 Host 应用后将执行下面的系统命令:'
+    , pref_after_cmd_placeholder: '在这儿输入你的命令'
+    , pref_choice_mode: '选择模式'
+    , pref_choice_mode_multiple: '多选'
+    , pref_choice_mode_single: '单选'
+    , preferences: '设置'
+    , quit: '退出'
+    , readonly: '只读'
+    , refresh: '刷新'
+    , remote_hosts: '远程方案'
+    , search: '搜索'
+    , set_and_back: '设置并返回'
+    , set_and_relaunch_app: '确定并重启程序'
+    , should_restart_after_change_language: '修改语言后需要重启应用方可生效。'
+    , show_dock_icon: '显示 Dock 图标'
+    , sudo_pswd: '密码'
+    , sys_host_title: '系统 Hosts'
+    , tmp_clean: '临时去掉所有绑定'
+    , tmp_recover: '恢复绑定'
+    , toggle_dock_icon: '显示/隐藏 Dock 图标'
+    , untitled: '未命名'
+    , url: 'URL 地址'
+    , view: '视图'
+    , where_group: '分组'
+    , where_local: '本地'
+    , where_remote: '远程'
+    , window: '窗口'
+};

+ 80 - 0
src/common/lang/en.js

@@ -0,0 +1,80 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+exports.content = {
+    _lang_name: 'English'
+    , add: 'Add'
+    , add_host: 'Add new rules'
+    , auto_launch: 'Run at login'
+    , auto_refresh: 'Auto refresh'
+    , bad_url: 'URL is not valid.'
+    , cancel: 'Cancel'
+    , check_update: 'Check for updates'
+    , check_update_err: 'Something went wrong while checking updates, please try again later. :-('
+    , check_update_found: 'New version ${0} is avaliable, download it now?'
+    , check_update_nofound: 'Current version ${0} is the latest version.'
+    , comment: 'Comment'
+    , confirm_del: 'Are you sure you want to delete this host?'
+    , confirm_import: 'You sure you want to import it? The original rules will be overwriten, this operation can not be undone.'
+    // , current_version: 'Current version: '
+    , day: 'day'
+    , days: 'days'
+    , del_host: 'Delete current host'
+    , edit: 'Edit'
+    , edit_host: 'Edit host'
+    , export: 'Export'
+    , feedback: 'Feedback'
+    , file: 'File'
+    , help: 'Help'
+    , hide_at_launch: 'Hide at launch'
+    , hide_dock_icon: 'Hide Dock Icon'
+    , homepage: 'Homepage'
+    , host_title: 'Host title'
+    , host_title_cant_be_empty: 'Host title could not be empty!'
+    , hour: 'hour'
+    , hours: 'hours'
+    , import: 'Import'
+    , input_sudo_pswd: 'Input your sudo password'
+    , is_updated: 'You already have the latest version of SwitchHosts! installed.'
+    , is_updated_title: 'You are up to date!'
+    , language: 'Language'
+    , last_refresh: 'Last refresh: '
+    , never: 'never'
+    , new: 'New'
+    , new_version_available: 'New version available, download now?'
+    , no_valid_host_found: 'There is no valid host in the file.'
+    , ok: 'OK'
+    , please_run_as_admin: 'Please run SwitchHosts! as an Administrator.'
+    , pref_after_cmd: 'Command after a host be applied'
+    , pref_after_cmd_info: 'The following system commands will be executed when Host applied: '
+    , pref_after_cmd_placeholder: 'input your commands here'
+    , pref_choice_mode: 'Choide mode'
+    , pref_choice_mode_multiple: 'Multiple choice'
+    , pref_choice_mode_single: 'Single choice'
+    , preferences: 'Preferences'
+    , quit: 'Quit'
+    , readonly: 'Read only'
+    , refresh: 'Refresh'
+    , remote_hosts: 'Remote hosts'
+    , search: 'Search'
+    , set_and_back: 'Set and back'
+    , set_and_relaunch_app: 'Set and Relaunch App'
+    , should_restart_after_change_language: 'Should relaunch this App after changing language.'
+    , show_dock_icon: 'Show Dock Icon'
+    , sudo_pswd: 'Password'
+    , sys_host_title: 'System Hosts'
+    , tmp_clean: 'Temporarily turn off all rules.'
+    , tmp_recover: 'Recover rules.'
+    , toggle_dock_icon: 'Toggle Dock Icon'
+    , untitled: 'untitled'
+    , url: 'URL'
+    , view: 'View'
+    , where_group: 'Group'
+    , where_local: 'local'
+    , where_remote: 'remote'
+    , window: 'Window'
+};

+ 12 - 0
src/index.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<title>SwitchHosts! 2</title>
+</head>
+<body>
+<div id="app"></div>
+<script>require('./bundle')</script>
+<!--<script src="./build/bundle.js"></script>-->
+</body>
+</html>

+ 147 - 0
src/main.js

@@ -0,0 +1,147 @@
+/**
+ * SwitchHosts!
+ *
+ * @author oldj
+ * @blog http://oldj.net
+ * @homepage https://oldj.github.io/SwitchHosts/
+ * @source https://github.com/oldj/SwitchHosts
+ */
+
+const electron = require('electron')
+const fs = require('fs')
+const app = electron.app
+const BrowserWindow = electron.BrowserWindow
+
+const pref = require('./server/pref')
+let user_language = pref.get('user_language') ||
+  (app.getLocale() || '').split('-')[0].toLowerCase() || 'en'
+global.user_language = user_language
+
+//const tray = require('./ui/modules/tray')
+const SHServer = require('./server/Server')
+
+// Keep a global reference of the window object, if you don't, the window will
+// be closed automatically when the JavaScript object is garbage collected.
+let mainWindow
+let contents
+let willQuitApp = false
+let is_tray_initialized
+let renderer
+
+function createWindow () {
+  // Create the browser window.
+  mainWindow = new BrowserWindow({
+    width: 800, height: 500,
+    minWidth: 400, minHeight: 250,
+    fullscreenable: true
+  })
+  contents = mainWindow.webContents
+  app.mainWindow = mainWindow
+
+  // and load the index.html of the app.
+  mainWindow.loadURL(`file://${__dirname}/index.html?lang=${user_language}`)
+
+  if (process.env && process.env.ENV === 'dev') {
+    // Open the DevTools.
+    mainWindow.webContents.openDevTools()
+  }
+
+  if (pref.get('hide_at_launch')) {
+    // mainWindow.minimize();
+    mainWindow.hide()
+  }
+
+  mainWindow.on('close', (e) => {
+    if (willQuitApp) {
+      /* the user tried to quit the app */
+      mainWindow = null
+    } else {
+      /* the user only tried to close the window */
+      e.preventDefault()
+      mainWindow.hide()
+    }
+  })
+
+  // Emitted when the window is closed.
+  mainWindow.on('closed', () => {
+    // Dereference the window object, usually you would store windows
+    // in an array if your app supports multi windows, this is the time
+    // when you should delete the corresponding element.
+    mainWindow = null
+    contents = null
+  })
+
+  contents.on('did-finish-load', () => {
+    if (!is_tray_initialized) {
+      //tray.makeTray(app, contents, user_language)
+      is_tray_initialized = true
+    }
+  })
+
+  //require('./bg/events').init(app, contents)
+}
+
+const should_quit = app.makeSingleInstance((commandLine, workingDirectory) => {
+  // Someone tried to run a second instance, we should focus our window.
+  if (mainWindow) {
+    if (mainWindow.isMinimized()) {
+      mainWindow.restore()
+    }
+    mainWindow.show()
+    // mainWindow.focus();
+  }
+})
+
+if (should_quit) {
+  app.quit()
+}
+
+// This method will be called when Electron has finished
+// initialization and is ready to create browser windows.
+// Some APIs can only be used after this event occurs.
+app.on('ready', () => {
+  createWindow()
+  //require('./ui/modules/mainMenu').init(app, user_language)
+
+  setTimeout(() => {
+    if (renderer) {
+      //require('./bg/check_for_update').check(true, renderer)
+    }
+  }, 1000)
+})
+
+electron.ipcMain.on('reg_renderer', (e) => {
+  renderer = e.sender
+})
+
+// Quit when all windows are closed.
+app.on('window-all-closed', function () {
+  // if (process.platform !== 'darwin') {
+  //     app.quit();
+  // }
+})
+
+app.on('show', function () {
+  if (mainWindow) {
+    if (mainWindow.isMinimized()) {
+      mainWindow.restore()
+    }
+    mainWindow.show()
+  } else {
+    createWindow()
+  }
+})
+
+app.on('activate', function () {
+  // On OS X it's common to re-create a window in the app when the
+  // dock icon is clicked and there are no other windows open.
+  if (mainWindow === null) {
+    createWindow()
+  } else if (mainWindow.isMinimized()) {
+    mainWindow.restore()
+  } else {
+    mainWindow.show()
+  }
+})
+
+app.on('before-quit', () => willQuitApp = true)

+ 36 - 0
src/package.json

@@ -0,0 +1,36 @@
+{
+  "name": "SwitchHosts!",
+  "version": "3.3.0",
+  "description": "Switch hosts quickly!",
+  "main": "main.js",
+  "scripts": {},
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/oldj/SwitchHosts.git"
+  },
+  "keywords": [
+    "SwitchHosts!",
+    "host"
+  ],
+  "author": "oldj",
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/oldj/SwitchHosts/issues"
+  },
+  "homepage": "https://oldj.github.io/SwitchHosts/",
+  "dependencies": {
+    "cheerio": "^0.22.0",
+    "classnames": "^2.2.5",
+    "codemirror": "^5.17.0",
+    "moment": "^2.14.1",
+    "node-notifier": "^4.6.1",
+    "react": "^15.4.2",
+    "react-addons-update": "^15.4.2",
+    "react-dom": "^15.4.2",
+    "request": "^2.79.0",
+    "sortablejs": "^1.5.1",
+    "wheel-js": "0.0.2",
+    "yargs": "^6.6.0"
+  },
+  "devDependencies": {}
+}

+ 61 - 0
src/renderer/Agent.js

@@ -0,0 +1,61 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+const IS_DEV = process.env.ENV === 'dev'
+//const SH_event = require('./ui/event').event
+//const SH_Agent = require('./ui/agent')
+const {ipcRenderer} = require('electron')
+const notifier = require('node-notifier')
+const platform = process.platform
+
+ipcRenderer.setMaxListeners(20)
+
+const EventEmitter = require('events')
+class MyEmitter extends EventEmitter {}
+const evt = new MyEmitter();
+
+let x_get_idx = 0
+
+/**
+ * act
+ * @param action {String}
+ * @param [data] {Any}
+ * @param callback {Function}
+ */
+function act (action, data, callback) {
+  let fn = ['_cb', (new Date()).getTime(), (x_get_idx++)].join('_')
+
+  if (!callback && typeof data === 'function') {
+    callback = data
+    data = null
+  }
+
+  if (typeof callback === 'function') {
+    ipcRenderer.once(fn, (e, d) => callback.apply(null, d))
+  }
+
+  ipcRenderer.send('x', {
+    action
+    , data
+    , callback: fn
+  })
+}
+
+function pact (action, data) {
+  return new Promise((resolve, reject) => act(action, data,
+    (err, result) => err ? reject(err) : resolve(result)))
+}
+
+module.exports = {
+  IS_DEV
+  , notifier
+  , platform
+  , act
+  , pact
+  , on: (...args) => evt.on(...args)
+  , emit: (...args) => evt.emit(...args)
+}

+ 24 - 0
src/server/Server.js

@@ -0,0 +1,24 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+const {ipcMain} = require('electron')
+const actions = require('./actions')
+
+ipcMain.on('x', (e, d) => {
+  let sender = e.sender
+  let action = d.action
+  if (typeof actions[action] === 'function') {
+    actions[action](...(d.args || []))
+      .then(v => {
+        sender.send(d.callback, [null, v])
+      })
+      .catch(e => {
+        console.log(e)
+        sender.send(d.callback, [e])
+      })
+  }
+})

+ 8 - 0
src/server/actions.js

@@ -0,0 +1,8 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+module.exports = require('./actions/index')

+ 22 - 0
src/server/actions/getHosts.js

@@ -0,0 +1,22 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ *
+ * 读取系统 hosts 以及本地保存的 hosts 数据
+ */
+
+'use strict'
+
+const getSysHosts = require('./getSysHosts')
+const getUserHosts = require('./getUserHosts')
+
+module.exports = () => {
+  return Promise
+    .all([getSysHosts(), getUserHosts()])
+    .then(([sys_hosts, user_hosts]) => {
+      return {
+        sys: sys_hosts,
+        list: user_hosts
+      }
+    })
+}

+ 14 - 0
src/server/actions/getLang.js

@@ -0,0 +1,14 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+const m_lang = require('../lang')
+
+exports.getLang = (user_lang = 'en') => {
+  let lang = m_lang.getLang(user_lang)
+
+  return Promise.resolve().then(() => lang)
+}

+ 25 - 0
src/server/actions/getPref.js

@@ -0,0 +1,25 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+const paths = require('../paths')
+const io = require('../io')
+
+module.exports = () => {
+  let fn = paths.preference_path
+  return io
+    .pReadFile(fn)
+    .then(cnt => {
+      let data
+      try {
+        data = JSON.parse(cnt)
+      } catch (e) {
+        console.log(e)
+        data = {}
+      }
+      return data
+    })
+}

+ 21 - 0
src/server/actions/getSysHosts.js

@@ -0,0 +1,21 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+const paths = require('../paths')
+const io = require('../io')
+
+module.exports = () => {
+  let fn = paths.sys_host_path
+  return io
+    .pReadFile(fn)
+    .then(cnt => {
+      return {
+        content: cnt
+        , is_sys: true
+      }
+    })
+}

+ 30 - 0
src/server/actions/getUserHosts.js

@@ -0,0 +1,30 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+const paths = require('../paths')
+const io = require('../io')
+
+module.exports = () => {
+  let fn = paths.data_path
+  return io.pReadFile(fn)
+    .then(cnt => {
+      let data
+      try {
+        data = JSON.parse(cnt)
+      } catch (e) {
+        console.log(e)
+        data = {}
+      }
+      return data
+    })
+    .then(data => {
+      if (!Array.isArray(data.list)) {
+        data.list = []
+      }
+      return data
+    })
+}

+ 16 - 0
src/server/actions/index.js

@@ -0,0 +1,16 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+const path = require('path')
+
+require('fs').readdirSync(__dirname).map((file) => {
+  /* If its the current file ignore it */
+  if (file === 'index.js') return
+
+  /* Store module with its name (from filename) */
+  module.exports[path.basename(file, '.js')] = require(path.join(__dirname, file))
+})

+ 23 - 0
src/server/actions/setPref.js

@@ -0,0 +1,23 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+const paths = require('../paths')
+const io = require('../io')
+const getPref = require('./getPref')
+
+module.exports = (k, v) => {
+  let fn = paths.preference_path
+
+  return Promise
+    .resolve()
+    .then(() => getPref())
+    .then(prefs => {
+      prefs[k] = v
+      return prefs
+    })
+    .then(prefs => io.pWriteFile(fn, JSON.stringify(prefs)))
+}

+ 11 - 0
src/server/actions/test.js

@@ -0,0 +1,11 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+module.exports = () => {
+  return Promise.resolve().then(() => 'ttt33')
+}
+

+ 57 - 0
src/server/io.js

@@ -0,0 +1,57 @@
+/**
+ * io
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+const fs = require('fs')
+
+exports.getUserHome = () => {
+  return process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME']
+}
+
+let isFile = exports.isFile = (p) => {
+  try {
+    if (fs.statSync(p).isFile()) {
+      return true
+    }
+  } catch (e) {
+  }
+  return false
+}
+
+exports.isDirectory = (p) => {
+  try {
+    if (fs.statSync(p).isDirectory()) {
+      return true
+    }
+  } catch (e) {
+  }
+  return false
+}
+
+let writeFile = exports.writeFile = (fn, data, callback) => {
+  fs.writeFile(fn, data, 'utf-8', callback)
+}
+
+exports.pWriteFile = (fn, data) => {
+  return new Promise((resolve, reject) => {
+    writeFile(fn, data, (e, v) => e ? reject(e) : resolve(v))
+  })
+}
+
+let readFile = exports.readFile = (fn, callback) => {
+  if (!isFile) {
+    callback(null, '')
+  } else {
+    fs.readFile(fn, 'utf-8', callback)
+  }
+}
+
+exports.pReadFile = (fn) => {
+  return new Promise((resolve, reject) => {
+    readFile(fn, (e, v) => e ? reject(e) : resolve(v))
+  })
+}

+ 43 - 0
src/server/lang.js

@@ -0,0 +1,43 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+const languages = {
+  'en': require('../common/lang/en').content,
+  'cn': require('../common/lang/cn').content
+}
+
+module.exports = {
+  languages: languages,
+  lang_list: (() => {
+    let list = []
+    for (let k in languages) {
+      if (languages.hasOwnProperty(k)) {
+        list.push({
+          key: k,
+          name: languages[k]._lang_name
+        })
+      }
+    }
+    return list
+  })(),
+  getLang: (lang) => {
+    lang = lang.toLowerCase()
+    if (lang === 'cn' || lang === 'zh-cn') {
+      lang = 'cn'
+    } else {
+      lang = 'en'
+    }
+    return languages[lang] || languages['en']
+  },
+  fill: (tpl, ...vals) => {
+    vals.map((v, idx) => {
+      let r = new RegExp('\\$\\{' + idx + '\\}', 'g')
+      tpl = tpl.replace(r, v)
+    })
+    return tpl
+  }
+}

+ 42 - 0
src/server/paths.js

@@ -0,0 +1,42 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+const path = require('path')
+const io = require('./io')
+const platform = process.platform
+
+// Windows 系统有可能不安装在 C 盘
+const sys_host_path = platform === 'win32' ? `${process.env.windir ||
+  'C:\\WINDOWS'}\\system32\\drivers\\etc\\hosts` : '/etc/hosts'
+
+const home_path = io.getUserHome()
+const work_path = path.join(home_path, '.SwitchHosts')
+const data_path = path.join(work_path, 'data.json')
+const preference_path = path.join(work_path, 'preferences.json')
+
+function getCurrentAppPath () {
+  let a = __dirname.split(path.sep)
+  // console.log(a);
+  while (a.length > 0) {
+    let i = a[a.length - 1]
+    if (i.endsWith('.app')) {
+      return a.join(path.sep)
+    }
+    a.pop()
+  }
+
+  return null
+}
+
+module.exports = {
+  home_path: home_path
+  , work_path: work_path
+  , data_path: data_path
+  , preference_path: preference_path
+  , sys_host_path: sys_host_path
+  // ,current_app_path: getCurrentAppPath()
+}

+ 53 - 0
src/server/pref.js

@@ -0,0 +1,53 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+const fs = require('fs')
+const paths = require('./paths')
+const io = require('./io')
+
+let is_loaded
+let data = {}
+let _t
+
+function load () {
+  if (io.isFile(paths.preference_path)) {
+    let cnt = fs.readFileSync(paths.preference_path, 'utf-8')
+    try {
+      data = JSON.parse(cnt)
+    } catch (e) {
+      console.log(e)
+    }
+  }
+
+  return data
+}
+
+function get (key, default_value = null) {
+  if (!is_loaded) load()
+  return key in data ? data[key] : default_value
+}
+
+function set (key, value, callback) {
+  clearTimeout(_t)
+  if (!is_loaded) load()
+
+  data[key] = value
+  _t = setTimeout(() => {
+    fs.writeFile(paths.preference_path, JSON.stringify(data), 'utf-8', (err) => {
+      if (err) {
+        console.log(err)
+      }
+      typeof callback === 'function' && callback(err)
+    })
+  }, 100)
+}
+
+module.exports = {
+  load: load,
+  get: get,
+  set: set
+}

+ 108 - 0
src/ui/components/app.js

@@ -0,0 +1,108 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+import React from 'react'
+import Panel from './panel/panel'
+import Content from './content/content'
+import SudoPrompt from './frame/sudo'
+import EditPrompt from './frame/edit'
+import PreferencesPrompt from './frame/preferences'
+import Agent from '../../renderer/Agent'
+import './app.less'
+
+class App extends React.Component {
+  constructor (props) {
+    super(props)
+
+    this.state = {
+      list: [],
+      sys: {},
+      current: {},
+      lang: {}
+    }
+
+    Agent.act('getUserHosts', (e, data) => {
+      this.setState({
+        list: data.list,
+        sys: data.sys
+      })
+    })
+
+    Agent.act('getLang', (e, lang) => {
+      this.setState({lang})
+    })
+  }
+
+  setCurrent (hosts) {
+    if (hosts.is_sys) {
+      Agent.act('getSysHosts', (e, _hosts) => {
+        this.setState({
+          current: _hosts
+        })
+      })
+    } else {
+      this.setState({
+        current: hosts
+      })
+    }
+  }
+
+  static isReadOnly (host) {
+    return !host || host.is_sys || host.where === 'remote'
+  }
+
+  toSave () {
+    clearTimeout(this._t)
+
+    this._t = setTimeout(() => {
+      Agent.emit('change')
+    }, 1000)
+  }
+
+  setHostContent (v) {
+    if (this.state.current.content === v) return // not changed
+
+    this.state.current.content = v || ''
+    this.toSave()
+  }
+
+  componentDidMount () {
+    window.addEventListener('keydown', (e) => {
+      if (e.keyCode === 27) {
+        Agent.emit('esc')
+      }
+    }, false)
+  }
+
+  render () {
+    let current = this.state.current
+    return (
+      <div id="app" className={'platform-' + Agent.platform}>
+        <Panel
+          list={this.state.list}
+          sys={this.state.sys}
+          current={current}
+          setCurrent={this.setCurrent.bind(this)}
+          lang={this.state.lang}
+        />
+        {/*<Content*/}
+          {/*current={current}*/}
+          {/*readonly={App.isReadOnly(current)}*/}
+          {/*setHostContent={this.setHostContent.bind(this)}*/}
+          {/*lang={this.state.lang}*/}
+        {/*/>*/}
+        {/*<div className="frames">*/}
+          {/*<SudoPrompt/>*/}
+          {/*<EditPrompt hosts={this.state.hosts}/>*/}
+          {/*<PreferencesPrompt/>*/}
+        {/*</div>*/}
+      </div>
+    )
+  }
+}
+
+export default App

+ 20 - 0
src/ui/components/app.less

@@ -0,0 +1,20 @@
+@import "./cfg.less";
+
+html, body {
+  margin: 0;
+  padding: 0;
+  height: 100%;
+  font-size: 12px;
+  font-family: Arial, Helvetica, sans-serif;
+  color: @font_color;
+  line-height: 20px;
+  background: #fff;
+}
+
+a {
+  text-decoration: none;
+}
+
+#app {
+  height: 100%;
+}

+ 13 - 0
src/ui/components/cfg.less

@@ -0,0 +1,13 @@
+@font_color: #212121;
+@bd_color: bg_left;
+@bg_prompt: #f5f5f5;
+@color_hover: #09f;
+@color_on: #af9;
+@color_off: font_color_left;
+@color_danger: #f03;
+
+// left
+@bg_left: #373d47;
+@bg_left_search: @bg_left * 1.1;
+@left_width: 240px;
+@font_color_left: #979da7;

+ 50 - 0
src/ui/components/content/cm_hl.js

@@ -0,0 +1,50 @@
+// custom mode
+
+'use strict';
+
+import CodeMirror from 'codemirror';
+
+export default function () {
+
+    CodeMirror.defineMode('host', function () {
+        function tokenBase(stream) {
+            if (stream.eatSpace()) return null;
+
+            var sol = stream.sol();
+            var ch = stream.next();
+
+            var s = stream.string;
+
+            if (ch === '#') {
+                stream.skipToEnd();
+                return 'comment';
+            }
+            if (!s.match(/^\s*([\d\.]+|[\da-f:\.%lo]+)\s+\w/i)) {
+                return 'error';
+            }
+
+            if (sol && ch.match(/[\w\.:%]/)) {
+                stream.eatWhile(/[\w\.:%]/);
+                return 'ip';
+            }
+
+            return null;
+        }
+
+        function tokenize(stream, state) {
+            return (state.tokens[0] || tokenBase)(stream, state);
+        }
+
+        return {
+            startState: function () {
+                return {tokens: []};
+            },
+            token: function (stream, state) {
+                return tokenize(stream, state);
+            },
+            lineComment: '#'
+        };
+    });
+
+    //CodeMirror.defineMIME('text/x-host', 'host');
+}

+ 95 - 0
src/ui/components/content/content.js

@@ -0,0 +1,95 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+import React from 'react'
+import Editor from './editor'
+import Agent from '../../../renderer/Agent'
+import classnames from 'classnames'
+import './content.less'
+
+export default class Content extends React.Component {
+
+  constructor (props) {
+    super(props)
+
+    this.codemirror = null
+    this.state = {
+      is_loading: this.props.current.is_loading,
+      code: this.props.current.content || ''
+    }
+    this._t = null
+
+    Agent.on('loading', (host) => {
+      if (host === this.props.current) {
+        this.setState({
+          is_loading: true
+        })
+      }
+    })
+
+    Agent.on('loading_done', (host, data) => {
+      if (host === this.props.current) {
+        this.setState({
+          is_loading: false,
+          code: data.content || ''
+        })
+      }
+    })
+  }
+
+  setValue (v) {
+    this.props.setHostContent(v)
+  }
+
+  componentWillReceiveProps (next_props) {
+    this.setState({
+      is_loading: next_props.current.is_loading,
+      code: next_props.current.content || ''
+    })
+  }
+
+  render () {
+    let {current} = this.props
+
+    return (
+      <div id="sh-content">
+        <div className="inform">
+                    <span
+                      className={classnames({
+                        loading: 1,
+                        show: this.state.is_loading
+                      })}
+                    >loading...</span>
+          <i
+            className={classnames({
+              show: current.where === 'remote',
+              iconfont: 1,
+              'icon-earth': 1
+            })}
+            title={Agent.lang.remote_hosts}
+          />
+          <i
+            className={classnames({
+              show: this.props.readonly,
+              iconfont: 1,
+              'icon-lock2': 1
+            })}
+            title={Agent.lang.readonly}
+          />
+        </div>
+        <div className={classnames({
+          errorMessage: 1,
+          show: !!this.props.current.error
+        })}>{this.props.current.error}</div>
+        <Editor
+          code={this.state.code}
+          readonly={this.props.readonly}
+          setValue={this.setValue.bind(this)}/>
+      </div>
+    )
+  }
+}

+ 61 - 0
src/ui/components/content/content.less

@@ -0,0 +1,61 @@
+#sh-content {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 240px;
+  height: 100%;
+
+  .inform {
+    position: absolute;
+    z-index: 100;
+    top: 5px;
+    right: 10px;
+    opacity: 0.5;
+    background: #fff;
+
+    i {
+      display: none;
+      color: #666;
+      margin-left: 5px;
+
+      &.show {
+        display: inline-block;
+      }
+    }
+
+    span {
+      display: none;
+
+      &.show {
+        display: inline-block;
+      }
+    }
+  }
+
+  .errorMessage {
+    display: none;
+    position: absolute;
+    z-index: 101;
+    top: 0;
+    left: 0;
+    right: 0;
+    padding: 4px 40px;
+    text-align: center;
+    background: rgba(153, 0, 0, 0.5);
+    color: #fff;
+    transition: 0.5s;
+
+    &.show {
+      display: block;
+    }
+  }
+}
+
+.platform-win32 {
+  #sh-content {
+    .inform {
+      right: 20px;
+    }
+  }
+}

+ 127 - 0
src/ui/components/content/editor.js

@@ -0,0 +1,127 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+import React from 'react';
+import CodeMirror from 'codemirror';
+// import '../../../node_modules/codemirror/addon/comment/comment';
+import 'codemirror/addon/comment/comment';
+import classnames from 'classnames';
+import modeHost from './cm_hl';
+import m_kw from '../../libs/kw';
+import 'codemirror/lib/codemirror.css';
+import './editor.less'
+
+export default class Editor extends React.Component {
+
+    constructor(props) {
+        super(props);
+
+        this.codemirror = null;
+
+        modeHost();
+
+        this.marks = [];
+        this.kw = '';
+
+        SH_event.on('search', (kw) => {
+            this.kw = kw;
+            this.highlightKeyword();
+        });
+    }
+
+    highlightKeyword() {
+        while (this.marks.length > 0) {
+            this.marks.shift().clear();
+        }
+
+        let code = this.props.code;
+        let pos = m_kw.findPositions(this.kw, code) || [];
+        // this.codemirror.markText({line: 6, ch: 16}, {line: 6, ch: 22}, {className: 'cm-hl'});
+
+        pos.map((p) => {
+            this.marks.push(this.codemirror.markText(p[0], p[1], {className: 'cm-hl'}));
+        });
+    }
+
+    setValue(v) {
+        this.props.setValue(v);
+    }
+
+    toComment() {
+        let doc = this.codemirror.getDoc();
+        let cur = doc.getCursor();
+        let line = cur.line;
+        let info = doc.lineInfo(line);
+        this.codemirror.toggleComment({
+            line: line,
+            cur: 0
+        }, {
+            line: line,
+            cur: info.text.length
+        });
+    }
+
+    componentDidMount() {
+        // console.log(this.cnt_node, this.cnt_node.value);
+        this.codemirror = CodeMirror.fromTextArea(this.cnt_node, {
+            lineNumbers: true,
+            readOnly: true,
+            mode: 'host'
+        });
+
+        this.codemirror.setSize('100%', '100%');
+
+        this.codemirror.on('change', (a) => {
+            let v = a.getDoc().getValue();
+            this.setValue(v);
+        });
+
+        this.codemirror.on('gutterClick', (cm, n) => {
+            if (this.props.readonly === true) return;
+
+            let info = cm.lineInfo(n);
+            //cm.setGutterMarker(n, "breakpoints", info.gutterMarkers ? null : makeMarker());
+            let ln = info.text;
+            if (/^\s*$/.test(ln)) return;
+
+            let new_ln;
+            if (/^#/.test(ln)) {
+                new_ln = ln.replace(/^#\s*/, '');
+            } else {
+                new_ln = '# ' + ln;
+            }
+            this.codemirror.getDoc().replaceRange(new_ln, {line: info.line, ch: 0}, {line: info.line, ch: ln.length});
+            //app.caculateHosts();
+        });
+
+        ipcRenderer.on('to_comment', () => {
+            this.toComment();
+        });
+    }
+
+    componentWillReceiveProps(next_props) {
+        // console.log(next_props);
+        this.codemirror.getDoc().setValue(next_props.code);
+        this.codemirror.setOption('readOnly', next_props.readonly);
+        setTimeout(() => {
+            this.highlightKeyword();
+        }, 100);
+    }
+
+    render() {
+        return (
+            <div id="sh-editor" className={classnames({
+                readonly: this.props.readonly
+            })}>
+                <textarea
+                    ref={(c) => this.cnt_node = c}
+                    defaultValue={this.props.code || ''}
+                />
+            </div>
+        );
+    }
+}

+ 33 - 0
src/ui/components/content/editor.less

@@ -0,0 +1,33 @@
+#sh-editor {
+  height: 100%;
+  font-family: Menlo, "Source Code Pro", Monaco, "Courier New", sans-serif;
+
+  // CodeMirror
+  .cm-s-default .cm-comment {
+    color: #090;
+  }
+  .cm-s-default .cm-ip {
+    color: #00a;
+    font-weight: bold;
+  }
+  .cm-s-default .cm-hl {
+    background: #ff0;
+  }
+  .CodeMirror-gutters {
+    border-right: none;
+    padding-right: 6px;
+  }
+
+  .CodeMirror-linenumber {
+    cursor: pointer;
+  }
+
+  &.readonly .CodeMirror {
+    .CodeMirror-linenumber {
+      cursor: default;
+    }
+    .CodeMirror-cursors {
+      display: none;
+    }
+  }
+}

+ 295 - 0
src/ui/components/frame/edit.js

@@ -0,0 +1,295 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+import React from 'react'
+import MyFrame from './frame'
+import classnames from 'classnames'
+import Group from './group'
+import util from '../../libs/util'
+import './edit.less'
+
+export default class EditPrompt extends React.Component {
+  constructor (props) {
+    super(props)
+
+    this.state = {
+      show: false,
+      add: true,
+      where: 'local',
+      title: '',
+      url: '',
+      last_refresh: null,
+      refresh_interval: 0,
+      is_loading: false,
+    }
+
+    this.current_host = null
+  }
+
+  tryToFocus () {
+    let el = this.refs.body && this.refs.body.querySelector('input[type=text]')
+    el && el.focus()
+  }
+
+  clear () {
+    this.setState({
+      where: 'local',
+      title: '',
+      url: '',
+      last_refresh: null,
+      refresh_interval: 0,
+    })
+  }
+
+  componentDidMount () {
+    SH_event.on('add_host', () => {
+      this.setState({
+        show: true,
+        add: true,
+      })
+      setTimeout(() => {
+        this.tryToFocus()
+      }, 100)
+    })
+
+    SH_event.on('edit_host', (host) => {
+      this.current_host = host
+      this.setState({
+        show: true,
+        add: false,
+        where: host.where || 'local',
+        title: host.title || '',
+        url: host.url || '',
+        last_refresh: host.last_refresh || null,
+        refresh_interval: host.refresh_interval || 0,
+      })
+      setTimeout(() => {
+        this.tryToFocus()
+      }, 100)
+    })
+
+    SH_event.on('loading_done', (old_host, data) => {
+      if (old_host === this.current_host) {
+        this.setState({
+          last_refresh: data.last_refresh,
+          is_loading: false,
+        })
+        SH_event.emit('host_refreshed', data, this.current_host)
+      }
+    })
+  }
+
+  onOK () {
+    this.setState({
+      title: (this.state.title || '').replace(/^\s+|\s+$/g, ''),
+      url: (this.state.url || '').replace(/^\s+|\s+$/g, ''),
+    })
+
+    if (this.state.title === '') {
+      this.refs.title.focus()
+      return false
+    }
+    if (this.state.where === 'remote' && this.state.url === '') {
+      this.refs.url.focus()
+      return false
+    }
+
+    let data = Object.assign({}, this.current_host, this.state,
+      this.state.add ? {
+        content: `# ${this.state.title}`,
+        on: false,
+      } : {})
+
+    if (!data.id) data.id = util.makeId()
+
+    delete data['add']
+    SH_event.emit('host_' + (this.state.add ? 'add' : 'edit') + 'ed', data,
+      this.current_host)
+
+    this.setState({
+      show: false,
+    })
+    this.clear()
+  }
+
+  onCancel () {
+    this.setState({
+      show: false,
+    })
+    this.clear()
+  }
+
+  confirmDel () {
+    if (!confirm(SH_Agent.lang.confirm_del)) return
+    SH_event.emit('del_host', this.current_host)
+    this.setState({
+      show: false,
+    })
+    this.clear()
+  }
+
+  static getRefreshOptions () {
+    let k = [
+      [0, `${SH_Agent.lang.never}`],
+      [1, `1 ${SH_Agent.lang.hour}`],
+      [24, `24 ${SH_Agent.lang.hours}`],
+      [168, `7 ${SH_Agent.lang.days}`],
+    ]
+    if (IS_DEV) {
+      k.splice(1, 0, [0.002778, `10s (for DEV)`]) // dev test only
+    }
+    return k.map(([v, n], idx) => {
+      return (
+        <option value={v} key={idx}>{n}</option>
+      )
+    })
+  }
+
+  getEditOperations () {
+    if (this.state.add) return null
+
+    return (
+      <div>
+        <div className="ln">
+          <a href="#" className="del"
+             onClick={this.confirmDel.bind(this)}
+          >
+            <i className="iconfont icon-delete"/>
+            <span>{SH_Agent.lang.del_host}</span>
+          </a>
+        </div>
+      </div>
+    )
+  }
+
+  refresh () {
+    if (this.state.is_loading) return
+
+    SH_event.emit('check_host_refresh', this.current_host, true)
+    this.setState({
+      is_loading: true,
+    }, () => {
+      setTimeout(() => {
+        this.setState({
+          is_loading: false,
+        })
+      }, 1000)
+    })
+
+  }
+
+  renderGroup () {
+    if (this.state.where !== 'group') return null
+
+    return <Group hosts={this.props.hosts}/>
+  }
+
+  renderRemoteInputs () {
+    if (this.state.where !== 'remote') return null
+
+    return (
+      <div className="remote-ipts">
+        <div className="ln">
+          <div className="title">{SH_Agent.lang.url}</div>
+          <div className="cnt">
+            <input
+              type="text"
+              ref="url"
+              value={this.state.url}
+              placeholder="http://"
+              onChange={(e) => this.setState({url: e.target.value})}
+              onKeyDown={(e) => (e.keyCode === 13 && this.onOK()) ||
+              (e.keyCode === 27 && this.onCancel())}
+            />
+          </div>
+        </div>
+        <div className="ln">
+          <div className="title">{SH_Agent.lang.auto_refresh}</div>
+          <div className="cnt">
+            <select
+              value={this.state.refresh_interval}
+              onChange={(e) => this.setState(
+                {refresh_interval: parseFloat(e.target.value) || 0})}
+            >
+              {EditPrompt.getRefreshOptions()}
+            </select>
+
+            <i
+              className={classnames({
+                iconfont: 1,
+                'icon-refresh': 1,
+                'invisible': !this.current_host ||
+                this.state.url != this.current_host.url,
+                'loading': this.state.is_loading,
+              })}
+              title={SH_Agent.lang.refresh}
+              onClick={() => this.refresh()}
+            />
+
+            <span className="last-refresh">
+                            {SH_Agent.lang.last_refresh}
+              {this.state.last_refresh || 'N/A'}
+                        </span>
+          </div>
+        </div>
+      </div>
+    )
+  }
+
+  body () {
+    return (
+      <div ref="body">
+        <div className="ln">
+          <input id="ipt-local" type="radio" name="where" value="local"
+                 checked={this.state.where === 'local'}
+                 onChange={(e) => this.setState({where: e.target.value})}
+          />
+          <label htmlFor="ipt-local">{SH_Agent.lang.where_local}</label>
+          <input id="ipt-remote" type="radio" name="where" value="remote"
+                 checked={this.state.where === 'remote'}
+                 onChange={(e) => this.setState({where: e.target.value})}
+          />
+          <label htmlFor="ipt-remote">{SH_Agent.lang.where_remote}</label>
+          <input id="ipt-remote" type="radio" name="where" value="group"
+                 checked={this.state.where === 'group'}
+                 onChange={(e) => this.setState({where: e.target.value})}
+          />
+          <label htmlFor="ipt-remote">{SH_Agent.lang.where_group}</label>
+        </div>
+        <div className="ln">
+          <div className="title">{SH_Agent.lang.host_title}</div>
+          <div className="cnt">
+            <input
+              type="text"
+              ref="title"
+              name="text"
+              value={this.state.title}
+              onChange={(e) => this.setState({title: e.target.value})}
+              onKeyDown={(e) => (e.keyCode === 13 && this.onOK() ||
+              e.keyCode === 27 && this.onCancel())}
+            />
+          </div>
+        </div>
+        {this.renderRemoteInputs()}
+        {this.renderGroup()}
+        {this.getEditOperations()}
+      </div>
+    )
+  }
+
+  render () {
+    return (
+      <MyFrame
+        show={this.state.show}
+        head={SH_Agent.lang[this.state.add ? 'add_host' : 'edit_host']}
+        body={this.body()}
+        onOK={() => this.onOK()}
+        onCancel={() => this.onCancel()}
+      />
+    )
+  }
+}

+ 55 - 0
src/ui/components/frame/edit.less

@@ -0,0 +1,55 @@
+@keyframes loading {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.frame {
+  label {
+    padding: 0 4em 0 0.5em;
+  }
+
+  .spiner {
+    transform-origin: right center;
+    animation: spin 1s;
+    animation-iteration-count: 1;
+    -webkit-animation-iteration-count: 1;
+  }
+
+  .ln {
+    i.icon-refresh {
+      display: inline-block;
+      color: #999;
+      margin: 0 0.6em;
+      cursor: pointer;
+
+      &:hover {
+        color: #333;
+      }
+
+      &.loading {
+        animation: loading 1s infinite linear;
+      }
+
+      &.invisible {
+        visibility: hidden;
+      }
+    }
+  }
+
+  .last-refresh {
+    //padding-left: 1em;
+    color: #999;
+  }
+
+  a.del {
+    color: red;
+
+    span {
+      padding-left: 0.5em;
+    }
+  }
+}

+ 73 - 0
src/ui/components/frame/frame.js

@@ -0,0 +1,73 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+import React from 'react';
+import './frame.less';
+
+export default class MyFrame extends React.Component {
+    constructor(props) {
+        super(props);
+    }
+
+    componentDidMount() {
+        SH_event.on('esc', () => {
+            this.onCancel();
+        });
+    }
+
+    onOK() {
+        this.props.onOK();
+    }
+
+    onCancel() {
+        this.props.onCancel();
+    }
+
+    renderFootButtons() {
+        let html = [];
+
+        html.push(
+            <div
+                className="button btn-cancel"
+                key="btn-cancel"
+                onClick={this.onCancel.bind(this)}
+            >
+                {this.props.cancel_title || SH_Agent.lang.cancel}
+            </div>
+        );
+
+        html.push(
+            <div
+                className="button btn-ok btn-default"
+                key="btn-ok"
+                onClick={this.onOK.bind(this)}
+            >
+                {this.props.ok_title || SH_Agent.lang.ok}
+            </div>
+        );
+
+        return html;
+    }
+
+
+    render() {
+        if (!this.props.show) {
+            return null;
+        }
+
+        return (
+            <div className="frame" ref="frame">
+                <div className="overlay"></div>
+                <div className="prompt">
+                    <div className="head">{this.props.head}</div>
+                    <div className="body">{this.props.body}</div>
+                    <div className="foot">{this.renderFootButtons()}</div>
+                </div>
+            </div>
+        );
+    }
+}

+ 108 - 0
src/ui/components/frame/frame.less

@@ -0,0 +1,108 @@
+.frame {
+  @bg: #f5f5f5;
+  @btn_default: #05a;
+  @lh: 24px;
+
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+
+  .overlay {
+    position: absolute;
+    z-index: -1;
+    width: 100%;
+    height: 100%;
+    background: #000;
+    opacity: 0.5;
+  }
+
+  .prompt {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    min-width: 300px;
+    max-width: 600px;
+    background: #fff;
+    box-shadow: 0 0 4px 4px rgba(0, 0, 0, 0.1);
+
+    .head {
+      padding: 20px;
+      font-size: 16px;
+      background: @bg;
+      //border-bottom: solid 1px #ccc;
+    }
+
+    .body {
+      padding: 20px 20px;
+
+      .ln {
+        line-height: 30px;
+        padding: 2px 0;
+
+        .title {
+          float: left;
+          width: 100px;
+          line-height: @lh;
+        }
+        .cnt {
+          margin-left: 100px;
+          line-height: @lh;
+          input[type=text] {
+            width: 240px;
+            outline: none;
+            padding: 6px 10px;
+          }
+          textarea {
+            font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
+          }
+        }
+
+        .inform {
+          color: #999;
+          line-height: @lh;
+        }
+      }
+
+      input {
+        padding: 6px 4px;
+      }
+
+      input[type=password] {
+        letter-spacing: 8px;
+        width: 240px;
+        outline: none;
+        padding: 6px 10px;
+      }
+    }
+
+    .foot {
+      padding: 20px;
+      background: @bg;
+      text-align: right;
+
+      .button {
+        display: inline-block;
+        background: #ccc;
+        padding: 8px 20px;
+        margin-left: 1em;
+        cursor: pointer;
+
+        &:hover {
+          background: #ddd;
+        }
+
+        &.btn-default {
+          background: @btn_default;
+          color: #fff;
+
+          &:hover {
+            background: @btn_default * 1.4;
+          }
+        }
+      }
+    }
+  }
+}

+ 95 - 0
src/ui/components/frame/group.js

@@ -0,0 +1,95 @@
+/**
+ * @author oldj
+ * @blog https://oldj.net
+ */
+
+'use strict'
+
+import React from 'react'
+import classnames from 'classnames'
+import Sortable from 'sortablejs'
+import listToArray from 'wheel-js/src/common/list-to-array'
+import './group.less'
+
+export default class Group extends React.Component {
+  constructor (props) {
+    super(props)
+
+    this.state = {
+      list: this.props.hosts.list
+    }
+
+    this.current_host = null
+  }
+
+  makeItem (item) {
+    let attrs = {
+      'data-id': 'id:' + (item.id || '')
+    }
+    return (
+      <div className="hosts-item" {...attrs}>
+        <i className={classnames({
+          'iconfont': 1
+          , 'item-icon': 1
+          , 'icon-file': item.where !== 'group'
+          , 'icon-files': item.where === 'group'
+        })}
+        />
+        <span>{item.title}</span>
+      </div>
+    )
+  }
+
+  makeList () {
+    let items = this.state.list
+      .filter(item => item.where !== 'group')
+      .map(item => this.makeItem(item))
+
+    return (
+      <div id="hosts-group-valid">
+        <div ref="group_valid" className="hosts-group-list">
+          {items}
+        </div>
+      </div>
+    )
+  }
+
+  currentList () {
+    return (
+      <div id="hosts-group-current">
+        <div ref="group_current" className="hosts-group-list"></div>
+      </div>
+    )
+  }
+
+  getCurrentListFromDOM () {
+    let nodes = this.refs.group_current.getElementsByClassName('hosts-item')
+    nodes = listToArray(nodes)
+    console.log(nodes)
+  }
+
+  componentDidMount () {
+    Sortable.create(this.refs.group_valid, {
+      group: 'sorting'
+      , sort: false
+    })
+
+    Sortable.create(this.refs.group_current, {
+      group: 'sorting'
+      , sort: true
+      , onSort: evt => {
+        this.getCurrentListFromDOM()
+      }
+    })
+  }
+
+  render () {
+    return (
+      <div id="hosts-group">
+        {this.makeList()}
+        <div className="arrow"/>
+        {this.currentList()}
+      </div>
+    )
+  }
+}

+ 62 - 0
src/ui/components/frame/group.less

@@ -0,0 +1,62 @@
+#hosts-group {
+  @h: 160px;
+
+  position: relative;
+  margin-top: 20px;
+  height: @h + 2px;
+  overflow: hidden;
+
+  &::after {
+    content: '';
+    clear: both;
+  }
+
+  .arrow {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    width: 20px;
+    height: 20px;
+    line-height: 20px;
+    text-align: center;
+    transform: translate(-50%, -50%);
+
+    &::after {
+      content: '→';
+    }
+  }
+
+  .hosts-group-list {
+    height: @h;
+    overflow: auto;
+    border: solid 1px #ccc;
+
+    .hosts-item {
+      cursor: move;
+      padding: 2px 6px;
+
+      &:hover {
+        background: #f5f5f5;
+      }
+
+      i {
+        font-size: 12px;
+        color: #999;
+      }
+
+      & > span {
+        padding-left: 6px;
+      }
+    }
+  }
+}
+
+#hosts-group-valid {
+  width: 45%;
+  float: left;
+}
+
+#hosts-group-current {
+  width: 45%;
+  float: right;
+}

+ 239 - 0
src/ui/components/frame/preferences.js

@@ -0,0 +1,239 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+import React from 'react';
+import Frame from './frame';
+import classnames from 'classnames';
+import './preferences.less';
+import lang from '../../lang';
+import util from '../../libs/util';
+const current_version = require('../../../version').version;
+
+const AUTO_LAUNCH = 'auto_launch';
+
+export default class PreferencesPrompt extends React.Component {
+    constructor(props) {
+        super(props);
+
+        let choice_mode = SH_Agent.pref.get('choice_mode');
+        if (!choice_mode || (choice_mode != 'multiple' && choice_mode != 'single')) {
+            choice_mode = 'multiple';
+        }
+
+        this.state = {
+            show: false,
+            lang_key: SH_Agent.lang_key,
+            after_cmd: SH_Agent.pref.get('after_cmd') || '',
+            choice_mode: choice_mode,
+            auto_launch: !!SH_Agent.pref.get(AUTO_LAUNCH),
+            hide_at_launch: !!SH_Agent.pref.get('hide_at_launch'),
+            update_found: false // 发现新版本
+        };
+
+    }
+
+    componentDidMount() {
+        SH_event.on('show_preferences', () => {
+            this.setState({
+                show: true
+            });
+        });
+        ipcRenderer.on('show_preferences', () => {
+            this.setState({
+                show: true
+            });
+        });
+
+        ipcRenderer.on('update_found', (v) => {
+            console.log(v);
+            this.setState({
+                update_found: true
+            });
+        });
+    }
+
+    onOK() {
+        this.setState({
+            show: false
+        }, () => {
+            setTimeout(() => {
+                SH_Agent.relaunch();
+            }, 200);
+        });
+    }
+
+    onCancel() {
+        this.setState({
+            show: false
+        });
+    }
+
+    static getLanguageOptions() {
+        return lang.lang_list.map(({key, name}, idx) => {
+            return (
+                <option value={key} key={idx}>{name}</option>
+            );
+        });
+    }
+
+    updateLangKey(v) {
+        SH_Agent.lang_key = v;
+        SH_Agent.pref.set('user_language', v);
+        this.setState({
+            lange_key: v
+        });
+    }
+
+    updateChoiceMode(v) {
+        SH_Agent.pref.set('choice_mode', v);
+        this.setState({
+            choice_mode: v
+        });
+    }
+
+    updateAfterCmd(v) {
+        SH_Agent.pref.set('after_cmd', v);
+        this.setState({
+            after_cmd: v
+        });
+    }
+
+    updateAutoLaunch(v) {
+        SH_Agent.pref.set(AUTO_LAUNCH, v);
+        this.setState({
+            auto_launch: v
+        });
+
+        // todo set auto launch
+    }
+
+    updateMinimizeAtLaunch(v) {
+        SH_Agent.pref.set('hide_at_launch', v);
+        this.setState({
+            hide_at_launch: v
+        });
+    }
+
+    prefLanguage() {
+        return (
+            <div className="ln">
+                <div className="title">{SH_Agent.lang.language}</div>
+                <div className="cnt">
+                    <select
+                        value={SH_Agent.lang_key}
+                        onChange={(e) => this.updateLangKey(e.target.value)}
+                    >
+                        {PreferencesPrompt.getLanguageOptions()}
+                    </select>
+
+                    <div className="inform">{SH_Agent.lang.should_restart_after_change_language}</div>
+                </div>
+            </div>
+        )
+    }
+
+    prefChoiceMode() {
+        return (
+            <div className="ln">
+                <div className="title">{SH_Agent.lang.pref_choice_mode}</div>
+                <div className="cnt">
+                    <input type="radio" id="pref-choice-mode-single" name="choice_mode" value="single"
+                           defaultChecked={this.state.choice_mode === 'single'}
+                           onChange={(e) => this.updateChoiceMode(e.target.value)}
+                    />
+                    <label htmlFor="pref-choice-mode-single">{SH_Agent.lang.pref_choice_mode_single}</label>
+                    <input type="radio" id="pref-choice-mode-multiple" name="choice_mode" value="multiple"
+                           defaultChecked={this.state.choice_mode === 'multiple'}
+                           onChange={(e) => this.updateChoiceMode(e.target.value)}
+                    />
+                    <label htmlFor="pref-choice-mode-multiple">{SH_Agent.lang.pref_choice_mode_multiple}</label>
+                </div>
+            </div>
+        )
+    }
+
+    prefAfterCmd() {
+        return (
+            <div className="ln">
+                <div className="title">{SH_Agent.lang.pref_after_cmd}</div>
+                <div className="cnt">
+                    <div className="inform">{SH_Agent.lang.pref_after_cmd_info}</div>
+                    <textarea
+                        name=""
+                        defaultValue={this.state.after_cmd}
+                        placeholder={SH_Agent.lang.pref_after_cmd_placeholder}
+                        onChange={(e) => this.updateAfterCmd(e.target.value)}
+                    />
+                </div>
+            </div>
+        )
+    }
+
+    prefAutoLaunch() {
+        return (
+            <div className="ln">
+                <div className="title">{SH_Agent.lang.auto_launch}</div>
+                <div className="cnt">
+                    <input type="checkbox" name=""
+                           defaultChecked={this.state.auto_launch}
+                           onChange={(e) => this.updateAutoLaunch(e.target.checked)}
+                    />
+                </div>
+            </div>
+        )
+    }
+
+    prefMinimizeAtLaunch() {
+        return (
+            <div className="ln">
+                <div className="title">{SH_Agent.lang.hide_at_launch}</div>
+                <div className="cnt">
+                    <input type="checkbox" name=""
+                           defaultChecked={this.state.hide_at_launch}
+                           onChange={(e) => this.updateMinimizeAtLaunch(e.target.checked)}
+                    />
+                </div>
+            </div>
+        )
+    }
+
+    openDownloadPage() {
+        ipcRenderer.send('open_url', require('../../configs').url_download);
+    }
+
+    body() {
+        return (
+            <div ref="body">
+                {/*<div className="title">{SH_Agent.lang.host_title}</div>*/}
+                {/*<div className="cnt">*/}
+                {/*</div>*/}
+                <div className={classnames("current-version", {"update-found": this.state.update_found})}>
+                    <a href="#" onClick={this.openDownloadPage}>{util.formatVersion(current_version)}</a>
+                </div>
+                {this.prefLanguage()}
+                {this.prefChoiceMode()}
+                {this.prefAfterCmd()}
+                {/*{this.prefAutoLaunch()}*/}
+                {this.prefMinimizeAtLaunch()}
+            </div>
+        )
+    }
+
+    render() {
+        return (
+            <Frame
+                show={this.state.show}
+                head={SH_Agent.lang.preferences}
+                body={this.body()}
+                onOK={() => this.onOK()}
+                onCancel={() => this.onCancel()}
+                cancel_title={SH_Agent.lang.set_and_back}
+                ok_title={SH_Agent.lang.set_and_relaunch_app}
+            />
+        );
+    }
+}

+ 35 - 0
src/ui/components/frame/preferences.less

@@ -0,0 +1,35 @@
+.frame {
+  textarea {
+    width: 300px;
+    height: 80px;
+    padding: 2px 4px;
+    outline: none;
+    border: solid 1px #ccc;
+  }
+
+  .current-version {
+    float: right;
+    margin-top: -60px;
+    color: #999;
+
+    a {
+      color: #999;
+
+      &:hover {
+        color: #000;
+      }
+    }
+
+    &.update-found {
+      &:after {
+        content: '';
+        display: block;
+        float: right;
+        width: 6px;
+        height: 6px;
+        background: #f00;
+        border-radius: 3px;
+      }
+    }
+  }
+}

+ 85 - 0
src/ui/components/frame/sudo.js

@@ -0,0 +1,85 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+import React from 'react';
+import Frame from './frame';
+import './sudo.less';
+
+export default class SudoPrompt extends React.Component {
+    constructor(props) {
+        super(props);
+
+        this.onSuccess = null;
+        this.state = {
+            show: false,
+            pswd: ''
+        }
+    }
+
+    componentDidMount() {
+        SH_event.on('sudo_prompt', (success) => {
+            this.setState({show: true});
+            this.onSuccess = success;
+            setTimeout(() => {
+                let el = this.refs.body;
+                el && el.querySelector('input').focus();
+            }, 100);
+        });
+    }
+
+    onOK() {
+        let pswd = this.refs.pswd.value;
+        if (!pswd) return;
+
+        this.setState({
+            show: false,
+            pswd: pswd
+        });
+
+        SH_event.emit('sudo_pswd', pswd);
+        if (typeof this.onSuccess === 'function') {
+            this.onSuccess(pswd);
+        }
+        this.onSuccess = null;
+    }
+
+    onCancel() {
+        this.setState({
+            show: false
+        });
+        this.onSuccess = null;
+    }
+
+    body() {
+        return (
+            <div ref="body">
+                <div className="ln">
+                    <div className="title">{SH_Agent.lang.sudo_pswd}</div>
+                    <div className="cnt">
+                        <input
+                            type="password"
+                            ref="pswd"
+                            onKeyDown={(e)=>(e.keyCode === 13 && this.onOK()||e.keyCode===27 && this.onCancel())}
+                        />
+                    </div>
+                </div>
+            </div>
+        )
+    }
+
+    render() {
+        return (
+            <Frame
+                show={this.state.show}
+                head={SH_Agent.lang.input_sudo_pswd}
+                body={this.body()}
+                onOK={() => this.onOK()}
+                onCancel={() => this.onCancel()}
+            />
+        );
+    }
+}

+ 7 - 0
src/ui/components/frame/sudo.less

@@ -0,0 +1,7 @@
+@import "frame";
+
+.frame {
+  .prompt {
+    width: 480px;
+  }
+}

+ 119 - 0
src/ui/components/panel/buttons.js

@@ -0,0 +1,119 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+import React from 'react'
+import classnames from 'classnames'
+import Agent from '../../../renderer/Agent'
+import './buttons.less'
+
+export default class Buttons extends React.Component {
+
+  constructor (props) {
+    super(props)
+
+    this.state = {
+      top_toggle_on: true,
+      search_on: false
+    }
+
+    this.on_items = null
+
+    Agent.on('toggle_host', (on) => {
+      if (on && !this.state.top_toggle_on) {
+        this.setState({
+          top_toggle_on: true
+        })
+        this.on_items = null
+      }
+    })
+
+    Agent.on('cancel_search', () => {
+      this.calcelSearch()
+    })
+
+    Agent.on('to_add_host', () => {
+      Agent.emit('add_host')
+    })
+
+  }
+
+  static btnAdd () {
+    Agent.emit('add_host')
+  }
+
+  btnToggle () {
+    if (this.state.top_toggle_on) {
+      Agent.emit('get_on_hosts', (items) => {
+        this.on_items = items
+      })
+    }
+
+    this.setState({
+      top_toggle_on: !this.state.top_toggle_on
+    }, () => {
+      Agent.emit('top_toggle', this.state.top_toggle_on, this.on_items)
+      if (this.state.top_toggle_on) {
+        this.on_items = null
+      }
+    })
+  }
+
+  btnSearch () {
+    this.setState({
+      search_on: !this.state.search_on
+    }, () => {
+      Agent.emit(this.state.search_on ? 'search_on' : 'search_off')
+    })
+  }
+
+  calcelSearch () {
+    this.setState({
+      search_on: false
+    }, () => {
+      Agent.emit('search_off')
+    })
+  }
+
+  componentDidMount () {
+    Agent.on('to_search', () => {
+      this.btnSearch()
+    })
+  }
+
+  render () {
+    return (
+      <div id="sh-buttons">
+        <div className="left">
+          <a
+            className="btn-add"
+            href="#"
+            onClick={() => Buttons.btnAdd()}
+          >+</a>
+        </div>
+
+        <div className="right">
+          <i
+            className={classnames({
+              iconfont: 1,
+              'icon-search': 1,
+              'on': this.state.search_on
+            })}
+            onClick={() => this.btnSearch()}
+          />
+          <i
+            className={classnames({
+              iconfont: 1,
+              'icon-switchon': this.state.top_toggle_on,
+              'icon-switchoff': !this.state.top_toggle_on
+            })}
+            onClick={() => this.btnToggle()}
+          />
+        </div>
+      </div>
+    )
+  }
+}

+ 53 - 0
src/ui/components/panel/buttons.less

@@ -0,0 +1,53 @@
+@import '../cfg.less';
+
+#sh-buttons {
+  @bg_left: #373d47;
+
+  position: absolute;
+  bottom: 0;
+  width: 240px;
+  height: 30px;
+  line-height: 30px;
+  background: @bg_left;
+
+  .left {
+    float: left;
+    padding-left: 10px;
+
+    .btn-add {
+      display: inline-block;
+      text-align: center;
+      width: 20px;
+      color: @font_color_left;
+      text-decoration: none;
+
+      &:hover {
+        background: rgba(255, 255, 255, 0.1);
+      }
+    }
+  }
+
+  .right {
+    float: right;
+    padding-right: 17px;
+    height: 30px;
+
+    i {
+      display: inline-block;
+      margin-left: 10px;
+      cursor: pointer;
+
+      &.icon-switchoff {
+        position: relative;
+        top: -1px;
+      }
+
+      &.icon-search {
+        padding: 0 8px;
+        &.on {
+          background: @bg_left_search;
+        }
+      }
+    }
+  }
+}

+ 83 - 0
src/ui/components/panel/iconfont/iconfont.css

@@ -0,0 +1,83 @@
+
+@font-face {font-family: "iconfont";
+  src: url('iconfont.eot?t=1490237956135'); /* IE9*/
+  src: url('iconfont.eot?t=1490237956135#iefix') format('embedded-opentype'), /* IE6-IE8 */
+  url('iconfont.woff?t=1490237956135') format('woff'), /* chrome, firefox */
+  url('iconfont.ttf?t=1490237956135') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
+  url('iconfont.svg?t=1490237956135#iconfont') format('svg'); /* iOS 4.1- */
+}
+
+.iconfont {
+  font-family:"iconfont" !important;
+  font-size:16px;
+  font-style:normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-switchoff:before { content: "\e60d"; }
+
+.icon-icon:before { content: "\e600"; }
+
+.icon-warnfill:before { content: "\e607"; }
+
+.icon-warn:before { content: "\e608"; }
+
+.icon-refresh:before { content: "\e616"; }
+
+.icon-ok:before { content: "\e604"; }
+
+.icon-h:before { content: "\e617"; }
+
+.icon-lock:before { content: "\e61d"; }
+
+.icon-off:before { content: "\e613"; }
+
+.icon-on:before { content: "\e614"; }
+
+.icon-search:before { content: "\e61c"; }
+
+.icon-edit:before { content: "\e609"; }
+
+.icon-info:before { content: "\e601"; }
+
+.icon-add-s:before { content: "\e612"; }
+
+.icon-more:before { content: "\e602"; }
+
+.icon-grid:before { content: "\e603"; }
+
+.icon-movedown:before { content: "\e60a"; }
+
+.icon-moveup:before { content: "\e60b"; }
+
+.icon-add:before { content: "\e60c"; }
+
+.icon-folder:before { content: "\e618"; }
+
+.icon-group:before { content: "\e619"; }
+
+.icon-lock2:before { content: "\e61e"; }
+
+.icon-files:before { content: "\e61f"; }
+
+.icon-timescircle:before { content: "\e60e"; }
+
+.icon-earth:before { content: "\e61a"; }
+
+.icon-move:before { content: "\e60f"; }
+
+.icon-delete:before { content: "\e610"; }
+
+.icon-doc:before { content: "\e606"; }
+
+.icon-line:before { content: "\e611"; }
+
+.icon-file-box:before { content: "\e61b"; }
+
+.icon-switchon:before { content: "\e615"; }
+
+.icon-sysserver:before { content: "\e605"; }
+
+.icon-file:before { content: "\e77d"; }
+

+ 0 - 0
src/ui/components/panel/iconfont/iconfont.eot


File diff suppressed because it is too large
+ 102 - 0
src/ui/components/panel/iconfont/iconfont.js


+ 150 - 0
src/ui/components/panel/iconfont/iconfont.svg

@@ -0,0 +1,150 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
+<svg xmlns="http://www.w3.org/2000/svg">
+<metadata>
+Created by FontForge 20120731 at Thu Mar 23 10:59:16 2017
+ By admin
+</metadata>
+<defs>
+<font id="iconfont" horiz-adv-x="1024" >
+  <font-face 
+    font-family="iconfont"
+    font-weight="500"
+    font-stretch="normal"
+    units-per-em="1024"
+    panose-1="2 0 6 3 0 0 0 0 0 0"
+    ascent="896"
+    descent="-128"
+    x-height="792"
+    bbox="0 -212 1024 896"
+    underline-thickness="0"
+    underline-position="0"
+    unicode-range="U+0078-E77D"
+  />
+<missing-glyph 
+ />
+    <glyph glyph-name=".notdef" 
+ />
+    <glyph glyph-name=".notdef" 
+ />
+    <glyph glyph-name=".null" horiz-adv-x="0" 
+ />
+    <glyph glyph-name="nonmarkingreturn" horiz-adv-x="341" 
+ />
+    <glyph glyph-name="x" unicode="x" horiz-adv-x="1001" 
+d="M281 543q-27 -1 -53 -1h-83q-18 0 -36.5 -6t-32.5 -18.5t-23 -32t-9 -45.5v-76h912v41q0 16 -0.5 30t-0.5 18q0 13 -5 29t-17 29.5t-31.5 22.5t-49.5 9h-133v-97h-438v97zM955 310v-52q0 -23 0.5 -52t0.5 -58t-10.5 -47.5t-26 -30t-33 -16t-31.5 -4.5q-14 -1 -29.5 -0.5
+t-29.5 0.5h-32l-45 128h-439l-44 -128h-29h-34q-20 0 -45 1q-25 0 -41 9.5t-25.5 23t-13.5 29.5t-4 30v167h911zM163 247q-12 0 -21 -8.5t-9 -21.5t9 -21.5t21 -8.5q13 0 22 8.5t9 21.5t-9 21.5t-22 8.5zM316 123q-8 -26 -14 -48q-5 -19 -10.5 -37t-7.5 -25t-3 -15t1 -14.5
+t9.5 -10.5t21.5 -4h37h67h81h80h64h36q23 0 34 12t2 38q-5 13 -9.5 30.5t-9.5 34.5q-5 19 -11 39h-368zM336 498v228q0 11 2.5 23t10 21.5t20.5 15.5t34 6h188q31 0 51.5 -14.5t20.5 -52.5v-227h-327z" />
+    <glyph glyph-name="switchoff" unicode="&#xe60d;" 
+d="M832 812h-640q-80 0 -136 -56t-56 -136v-640q0 -79 56 -135.5t136 -56.5h640q79 0 135.5 56.5t56.5 135.5v640q0 80 -56.5 136t-135.5 56zM160 -84q-13 0 -22.5 9.5t-9.5 22.5t9.5 22.5t22.5 9.5t22.5 -9.5t9.5 -22.5t-9.5 -22.5t-22.5 -9.5zM160 620q-13 0 -22.5 9.5
+t-9.5 22.5t9.5 22.5t22.5 9.5t22.5 -9.5t9.5 -22.5t-9.5 -22.5t-22.5 -9.5zM704 -20q0 -27 -18.5 -45.5t-45.5 -18.5h-256q-27 0 -45.5 18.5t-18.5 45.5v640q0 26 19 45t45 19h256q27 0 45.5 -18.5t18.5 -45.5v-640zM864 -84q-13 0 -22.5 9.5t-9.5 22.5t9.5 22.5t22.5 9.5
+t22.5 -9.5t9.5 -22.5t-9.5 -22.5t-22.5 -9.5zM864 620q-13 0 -22.5 9.5t-9.5 22.5t9.5 22.5t22.5 9.5t22.5 -9.5t9.5 -22.5t-9.5 -22.5t-22.5 -9.5zM576 300h-128q-26 0 -45 -19t-19 -45v-192q0 -27 18.5 -45.5t45.5 -18.5h128q27 0 45.5 18.5t18.5 45.5v192
+q0 27 -18.5 45.5t-45.5 18.5z" />
+    <glyph glyph-name="icon" unicode="&#xe600;" 
+d="M488 745l132 -269l297 -43l-215 -209l51 -296l-265 140l-266 -140l51 296l-215 209l297 43z" />
+    <glyph glyph-name="warnfill" unicode="&#xe607;" 
+d="M943 127l-341 609q-35 63 -90.5 63t-90.5 -63l-340 -609q-34 -62 -6 -111q29 -48 100 -48h674q71 0 99.5 48.5t-5.5 110.5zM480 576q0 13 9.5 22.5t22.5 9.5t22.5 -9.5t9.5 -22.5v-288q0 -13 -9.5 -22.5t-22.5 -9.5t-22.5 9.5t-9.5 22.5v288zM512 64q-20 0 -34 14t-14 34
+t14 34t34 14t34 -14t14 -34t-14 -34t-34 -14z" />
+    <glyph glyph-name="warn" unicode="&#xe608;" 
+d="M849 -33h-674q-71 0 -99.5 49t5.5 111l340 609q35 63 90.5 63t90.5 -63l341 -609q34 -63 5.5 -111.5t-99.5 -48.5zM512 735q-18 0 -35 -30l-340 -610q-17 -30 -7 -47t45 -17h674q35 0 45 17t-7 47l-341 610q-16 30 -34 30zM512 256q-13 0 -22.5 9.5t-9.5 22.5v288
+q0 13 9.5 22.5t22.5 9.5t22.5 -9.5t9.5 -22.5v-288q0 -13 -9.5 -22.5t-22.5 -9.5zM512 144zM464 144q0 20 14 34t34 14t34 -14t14 -34t-14 -34t-34 -14t-34 14t-14 34z" />
+    <glyph glyph-name="refresh" unicode="&#xe616;" 
+d="M504 17q-97 0 -179.5 48t-130.5 130.5t-48 179.5q0 110 62 201l93 -93v245h-246l92 -92q-85 -116 -85 -261q0 -90 35 -171.5t94.5 -141t141 -94.5t171.5 -35q85 0 163 31l-50 71q-55 -18 -113 -18v0zM720 277v-246h246l-96 96q76 112 76 248q0 90 -35 172t-94 141
+t-141 94t-172 35q-70 0 -136 -21l52 -73q41 10 84 10q98 0 180.5 -47.5t130 -130t47.5 -180.5q0 -101 -52 -187z" />
+    <glyph glyph-name="ok" unicode="&#xe604;" 
+d="M512 709q-111 0 -205.5 -54.5t-149 -149t-54.5 -205.5t54.5 -205.5t149 -149t205.5 -54.5t205.5 54.5t149 149t54.5 205.5t-54.5 205.5t-149 149t-205.5 54.5zM376 253q0 -1 5 -6l9 -9t10 -7.5t10 -3.5q16 0 109 104l138 158q7 8 15 9q13 0 21 -7l45 -44q7 -8 7.5 -18.5
+t-6.5 -18.5l-250 -264v0l-53 -57q-7 -7 -18 -7.5t-18 6.5l-151 142q-8 7 -8.5 18t7.5 19l35 37q7 8 18 8.5t19 -7.5z" />
+    <glyph glyph-name="h" unicode="&#xe617;" 
+d="M512 802q-104 0 -192.5 -51.5t-140 -140t-51.5 -193t51.5 -193t140 -140t192.5 -51.5t192.5 51.5t140 140t51.5 193t-51.5 193t-140 140t-192.5 51.5zM660 214h-50v175h-196v-175h-50v395h50v-179h196v179h50v-395z" />
+    <glyph glyph-name="lock" unicode="&#xe61d;" 
+d="M427 151q0 35 25 60t60 25t60 -25t25 -60q0 -47 -39 -72q9 -50 18 -110q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5l18 110q-39 25 -39 72zM341 385v150q0 70 50 120t121 50t121 -50t50 -120v-150h-342zM235 535v-150h-64q-36 0 -61 -25t-25 -60v-427
+q0 -35 25 -60t61 -25h682q36 0 61 25t25 60v427q0 35 -25 60t-61 25h-64v150q0 115 -81 196t-196 81t-196 -81t-81 -196v0z" />
+    <glyph glyph-name="off" unicode="&#xe613;" 
+d="M729 90h-434q-116 0 -197.5 81.5t-81.5 196.5v32q0 115 81.5 196.5t197.5 81.5h434q116 0 197.5 -81.5t81.5 -196.5v-32q0 -115 -81.5 -196.5t-197.5 -81.5zM946 400q0 90 -63.5 153t-153.5 63h-434q-90 0 -153.5 -63t-63.5 -153v-32q0 -90 63.5 -153.5t153.5 -63.5h434
+q90 0 153.5 63.5t63.5 153.5v32zM322 223q-68 0 -116 48.5t-48 116.5t48 116t116 48t116 -48t48 -116t-48 -116.5t-116 -48.5z" />
+    <glyph glyph-name="on" unicode="&#xe614;" 
+d="M729 90h-434q-116 0 -197.5 81.5t-81.5 196.5v32q0 115 81.5 196.5t197.5 81.5h434q116 0 197.5 -81.5t81.5 -196.5v-32q0 -115 -81.5 -196.5t-197.5 -81.5zM947 399q0 90 -63.5 153.5t-153.5 63.5h-436q-89 0 -152.5 -63.5t-63.5 -153.5v-31q0 -90 63.5 -153.5
+t152.5 -63.5h436q90 0 153.5 63.5t63.5 153.5v31v0zM702 223q-68 0 -116 48.5t-48 116.5t48 116t116 48t116.5 -48t48.5 -116t-48.5 -116.5t-116.5 -48.5z" />
+    <glyph glyph-name="search" unicode="&#xe61c;" 
+d="M995 3l-272 227q-2 2 -6 4q37 79 37 160q0 82 -34.5 158t-99.5 131q-105 89 -243 89q-82 0 -158 -35t-131 -100q-88 -105 -88 -243q0 -82 34.5 -158.5t100.5 -131.5q105 -88 241 -88h1q121 0 221 72q1 0 2 -1.5l1 -1.5l272 -227q27 -23 63 -18.5t59 33.5l6 7
+q24 28 22 64.5t-28 58.5zM585 219q-40 -47 -94.5 -72t-113.5 -25q-98 1 -174 64q-47 39 -72 94t-25 114q0 99 64 174q39 48 93.5 72.5t113.5 24.5q99 0 174 -63q47 -40 72 -94.5t25 -113.5q0 -99 -63 -175z" />
+    <glyph glyph-name="edit" unicode="&#xe609;" 
+d="M384 3l-169 169q-6 5 -6 13.5t6 14.5l380 380q6 6 14 6t14 -6l169 -169q6 -6 6 -14t-6 -14l-380 -380q-6 -6 -14.5 -6t-13.5 6zM693 678q18 18 42.5 18t42.5 -18l112 -112q18 -18 18 -42.5t-18 -42.5l-56 -56l-197 197zM116 -96l56 254l198 -198z" />
+    <glyph glyph-name="info" unicode="&#xe601;" 
+d="M512 -20q-110 0 -203 54t-147 147t-54 203t54 203t147 147t203 54t203 -54t147 -147t54 -203t-54 -203t-147 -147t-203 -54zM567 605q0 7 -5.5 12.5t-12.5 5.5h-74q-7 0 -12.5 -5.5t-5.5 -12.5v-74q0 -8 5.5 -13t12.5 -5h74q7 0 12.5 5t5.5 13v74zM567 402q0 8 -5.5 13.5
+t-12.5 5.5h-74q-7 0 -12.5 -5.5t-5.5 -13.5v-239q0 -7 5.5 -12.5t12.5 -5.5h74q7 0 12.5 5.5t5.5 12.5v239z" />
+    <glyph glyph-name="add-s" unicode="&#xe612;" 
+d="M699 358h-157v-158q0 -10 -7.5 -18t-18.5 -8t-19 8t-8 18v158h-157q-11 0 -18.5 7.5t-7.5 18.5t7.5 18.5t18.5 7.5h157v157q0 11 8 18.5t19 7.5t18.5 -7.5t7.5 -18.5v-157h157q11 0 18.5 -7.5t7.5 -18.5t-7.5 -18.5t-18.5 -7.5v0z" />
+    <glyph glyph-name="more" unicode="&#xe602;" 
+d="M174.5 501q-41.5 0 -71 -29.5t-29.5 -71.5t29.5 -71.5t71 -29.5t71.5 29.5t30 71.5t-30 71.5t-71.5 29.5zM511 501q-42 0 -71.5 -29.5t-29.5 -71.5t29.5 -71.5t71.5 -29.5t71.5 29.5t29.5 71.5t-29.5 71.5t-71.5 29.5zM848 501q-42 0 -71.5 -29.5t-29.5 -71.5t29.5 -71.5
+t71.5 -29.5t71.5 29.5t29.5 71.5t-29.5 71.5t-71.5 29.5z" />
+    <glyph glyph-name="grid" unicode="&#xe603;" 
+d="M601 830h-179q-12 0 -21 -9t-9 -21v-179q0 -12 9 -21t21 -9h179q12 0 21 9t9 21v179q0 12 -9 21t-21 9zM929 830h-179q-12 0 -21 -9t-9 -21v-179q0 -12 9 -21t21 -9h179q12 0 21 9t9 21v179q0 12 -9 21t-21 9zM273 830h-179q-12 0 -21 -9t-9 -21v-179q0 -12 9 -21t21 -9
+h179q12 0 21 9t9 21v179q0 12 -9 21t-21 9zM601 502h-179q-12 0 -21 -9t-9 -21v-179q0 -13 9 -21.5t21 -8.5h179q12 0 21 8.5t9 21.5v179q0 12 -9 21t-21 9zM929 502h-179q-12 0 -21 -9t-9 -21v-179q0 -13 9 -21.5t21 -8.5h179q12 0 21 8.5t9 21.5v179q0 12 -9 21t-21 9z
+M273 502h-179q-12 0 -21 -9t-9 -21v-179q0 -13 9 -21.5t21 -8.5h179q12 0 21 8.5t9 21.5v179q0 12 -9 21t-21 9zM601 174h-179q-12 0 -21 -9t-9 -21v-179q0 -13 9 -21.5t21 -8.5h179q12 0 21 8.5t9 21.5v179q0 12 -9 21t-21 9zM929 174h-179q-12 0 -21 -9t-9 -21v-179
+q0 -13 9 -21.5t21 -8.5h179q12 0 21 8.5t9 21.5v179q0 12 -9 21t-21 9zM273 174h-179q-12 0 -21 -9t-9 -21v-179q0 -13 9 -21.5t21 -8.5h179q12 0 21 8.5t9 21.5v179q0 12 -9 21t-21 9z" />
+    <glyph glyph-name="movedown" unicode="&#xe60a;" 
+d="M768 108v384h-64v-384h-160l192 -192l192 192h-160zM320 556v-192h-192v192h192zM384 620h-320v-320h320v320zM64 172h96v-64h-96v64zM192 172h96v-64h-96v64zM320 172h64v-96h-64v96zM64 -52h64v-96h-64v96zM160 -84h96v-64h-96v64zM288 -84h96v-64h-96v64zM64 76h64
+v-96h-64v96zM320 44h64v-96h-64v96z" />
+    <glyph glyph-name="moveup" unicode="&#xe60b;" 
+d="M704 300v-384h64v384h160l-192 192l-192 -192h160zM64 620h96v-64h-96v64zM192 620h96v-64h-96v64zM320 620h64v-96h-64v96zM64 396h64v-96h-64v96zM160 364h96v-64h-96v64zM288 364h96v-64h-96v64zM64 524h64v-96h-64v96zM320 492h64v-96h-64v96zM320 108v-192h-192v192
+h192zM384 172h-320v-320h320v320z" />
+    <glyph glyph-name="add" unicode="&#xe60c;" 
+d="M512 -41q-115 0 -213 57t-155 155t-57 213t57 213t155 155t213 57t213 -57t155 -155t57 -213t-57 -213t-155 -155t-213 -57zM724 426h-170v170h-84v-170h-170v-84h170v-170h84v170h170v84z" />
+    <glyph glyph-name="folder" unicode="&#xe618;" 
+d="M961 640q0 40 -28.5 68t-67.5 28h-186h-1h-1q-6 -1 -10 -2l-1 -1h-1q-5 -3 -8 -6q-18 -17 -35 -88q-3 -12 -4 -14q-4 -17 -27 -17h-7.5h-7.5h-416q-40 0 -68 -28.5t-28 -69.5l32 -414q0 -40 28 -68t68 -28h640q40 0 68 28t28 66zM864 96q0 -13 -9.5 -22.5t-22.5 -9.5
+h-640q-13 0 -22.5 10t-9.5 24l-32 414q0 13 9.5 22.5t22.5 9.5h416h7.5h7.5q35 0 58.5 17t30.5 48q2 4 4 15q8 34 13 48h168q13 0 22.5 -9.5t9.5 -22.5v-2v-1zM128 672h416q13 0 22.5 9.5t9.5 22.5t-9.5 22.5t-22.5 9.5h-416q-13 0 -22.5 -9.5t-9.5 -22.5t9.5 -22.5
+t22.5 -9.5z" />
+    <glyph glyph-name="group" unicode="&#xe619;" 
+d="M521 194l-376 247l-50 -52l426 -291l426 291l-51 53zM947 550l-426 291l-426 -291l426 -291zM521 756l302 -206l-302 -207l-302 207zM521 33l-376 248l-50 -52l426 -291l426 291l-51 53z" />
+    <glyph glyph-name="lock2" unicode="&#xe61e;" 
+d="M769 448v135v0q-3 104 -77.5 176.5t-178.5 72.5t-178.5 -72.5t-77.5 -175.5v0v-136h-1q-27 0 -45.5 -18.5t-18.5 -45.5v-384q0 -27 18.5 -45.5t45.5 -18.5h512q27 0 45.5 18.5t18.5 45.5v384q0 26 -18.5 45t-44.5 19zM321 581v0q2 78 58 132.5t134 54.5t134 -54.5
+t58 -132.5v0v-133h-384v133zM768 32q0 -13 -9.5 -22.5t-22.5 -9.5h-448q-13 0 -22.5 9.5t-9.5 22.5v320q0 13 9.5 22.5t22.5 9.5h448q13 0 22.5 -9.5t9.5 -22.5v-320zM544 206v52q0 13 -9.5 22.5t-22.5 9.5t-22.5 -9.5t-9.5 -22.5v-52q-28 -18 -28 -51q0 -25 17.5 -42.5
+t42.5 -17.5t42.5 17.5t17.5 42.5q0 33 -28 51z" />
+    <glyph glyph-name="files" unicode="&#xe61f;" 
+d="M744 434h-260q-12 0 -20.5 -8.5t-8.5 -20.5t8.5 -20.5t20.5 -8.5h260q12 0 20.5 8.5t8.5 20.5t-8.5 20.5t-20.5 8.5zM744 290h-260q-12 0 -20.5 -8.5t-8.5 -20.5t8.5 -20.5t20.5 -8.5h260q12 0 20.5 8.5t8.5 20.5t-8.5 20.5t-20.5 8.5zM744 839h-347q-45 0 -80 -35.5
+t-35 -80.5h-34q-45 0 -78 -35t-33 -80v-578q0 -45 35 -80.5t81 -35.5h433q46 0 81 35.5t35 80.5h29q46 0 81 35.5t35 79.5v405v57zM686 -28h-433q-23 0 -40.5 18t-17.5 40v578q0 22 16.5 39.5t38.5 17.5l32 1v-521q0 -44 35 -79.5t80 -35.5h347q0 -22 -17.5 -40t-40.5 -18v0
+zM889 145q0 -22 -17.5 -39.5t-40.5 -17.5h-434q-22 0 -39.5 17.5t-17.5 39.5v578q0 23 17.5 40.5t39.5 17.5h289v-116q0 -45 35 -80t81 -35h87v-405zM802 608q-21 0 -39.5 30.5t-18.5 55.5v86v0l145 -172h-87z" />
+    <glyph glyph-name="timescircle" unicode="&#xe60e;" 
+d="M657 171q0 15 -11 26l-104 103l104 103q11 11 11 26q0 16 -11 26l-52 52q-11 11 -26 11t-26 -11l-103 -104l-104 104q-10 11 -25 11q-16 0 -27 -11l-51 -52q-11 -10 -11 -26q0 -15 11 -26l103 -103l-103 -103q-11 -11 -11 -26q0 -16 11 -26l51 -52q11 -11 27 -11
+q15 0 25 11l104 104l103 -104q11 -11 26 -11t26 11l52 52q11 10 11 26zM878 300q0 -119 -59 -220t-160 -160t-220.5 -59t-220 59t-159.5 160t-59 220t59 220t159.5 160t220 59t220.5 -59t160 -160t59 -220z" />
+    <glyph glyph-name="earth" unicode="&#xe61a;" 
+d="M874 662q-73 73 -166 111.5t-196 38.5t-196 -38.5t-166 -111.5t-111.5 -166t-38.5 -196t38.5 -196t111.5 -166t166 -111.5t196 -38.5t196 38.5t166 111.5t111.5 166t38.5 196t-38.5 196t-111.5 166zM942 466q-7 12 -22 19.5t-46 18.5q-16 5 -28.5 20t-18.5 28.5t-15 41.5
+q-8 24 -13.5 37t-17 31t-25.5 29q130 -82 186 -225zM798 290q2 -19 2.5 -35.5t-8.5 -38.5t-29 -45q-12 -13 -26 -50q-8 -22 -13.5 -33.5t-16.5 -26t-28 -21t-41 -6.5q-7 8 -12 37q-4 24 -10 90q-8 111 -21 160q-25 92 -84 112q-26 9 -55 9q-14 0 -39 -3q-17 -2 -24 -2v0
+q-8 0 -13 1.5t-13 10.5t-16 26q-11 30 -10 63.5t20.5 69t53.5 57.5q55 37 89 37q27 0 69 -23q39 -20 81 -20q8 0 22 1h16q18 0 31 -9t21.5 -26t19.5 -48q8 -24 13.5 -37.5t16.5 -33t27 -32t37 -19.5q22 -8 31 -11q-9 -9 -36 -34q-21 -18 -30 -27q-21 -19 -25.5 -46t0.5 -47
+v0zM51 304q8 -1 18 -3q34 -8 48 -15q-3 -6 -12 -18q-28 -41 -22 -65q5 -22 -6 -56q-26 74 -26 153v2v2v0zM512 -161q-129 0 -238 66.5t-168 176.5q40 76 27 132q0 5 14 24q6 8 9 12.5t7 13t5.5 14.5t1 14.5t-4.5 15.5q-12 26 -80 41q-13 3 -31 6q14 113 78 206t164 146.5
+t216 53.5q119 0 223 -58q-19 8 -43 8h-19q-13 -1 -19 -1q-29 0 -57 15q-53 28 -93 28q-49 0 -117 -45q-64 -42 -88 -117q-23 -73 5 -136q30 -69 89 -69v0q10 0 30 2q22 3 33 3q20 0 38 -6q34 -11 51 -77q12 -44 20 -151q6 -80 13 -110q6 -26 17 -41q16 -24 41 -24
+q68 0 108 44q21 24 41 77q11 28 16 34q27 30 39.5 61.5t11 53.5t-2.5 44q-3 21 -1.5 31.5t9.5 18.5q13 11 30 26q32 29 41 39q22 24 18 44v1q27 -75 27 -156q0 -94 -36.5 -179t-98.5 -147t-147.5 -98.5t-178.5 -36.5v0z" />
+    <glyph glyph-name="move" unicode="&#xe60f;" 
+d="M1008 330l-156 155q-14 14 -33.5 14t-33 -13.5t-13.5 -33t14 -33.5l76 -76h-303v309l76 -75q14 -14 33.5 -14t33 13.5t13.5 33t-14 33.5l-155 155q-14 14 -33.5 14t-32.5 -14l-156 -155q-14 -14 -14 -33.5t13.5 -33t33 -13.5t33.5 13l76 76v-309h-304l76 76
+q14 14 14 33.5t-13.5 33t-33 13.5t-33.5 -14l-156 -155q-13 -14 -13 -33.5t13 -32.5l156 -156q14 -14 33.5 -14t33 14t13.5 33t-14 33l-76 76h304v-303l-76 76q-14 14 -33.5 14t-33 -13.5t-13.5 -33t14 -33.5l155 -155q14 -14 33.5 -14t33.5 14l155 155q14 14 14 33.5
+t-13.5 33t-33 13.5t-33.5 -14l-76 -75v302h303l-76 -76q-14 -14 -14 -33t13.5 -33t33 -14t33.5 14l156 156q13 13 13 32.5t-13 33.5z" />
+    <glyph glyph-name="delete" unicode="&#xe610;" 
+d="M924 573h-154v105q0 29 -20.5 49.5t-49.5 20.5h-376q-29 0 -49.5 -20.5t-20.5 -49.5v-105h-155q-14 0 -24 -10t-10 -24.5t10 -24.5t24 -10h79v-581q0 -29 20.5 -49.5t49.5 -20.5h528q29 0 49.5 20.5t20.5 49.5v558v0v23h78q15 0 25 10t10 24.5t-10 24.5t-25 10z
+M412.5 -10q-14.5 0 -24.5 10t-10 25l-1 372q0 15 10.5 25t25 10t24.5 -10t10 -25v-372q0 -15 -10 -25t-24.5 -10zM611.5 -10q-14.5 0 -24.5 10t-10 25v372q0 15 10 25t24.5 10t24.5 -10t10 -25l1 -372q0 -15 -10.5 -25t-25 -10zM323 635q0 17 13 30t31 13h290q18 0 31 -13
+t13 -30v-62h-378v62z" />
+    <glyph glyph-name="doc" unicode="&#xe606;" horiz-adv-x="1281" 
+d="M1280 422zM492 -7q2 -1 3.5 -2t5.5 -5.5t5.5 -9.5t-1.5 -11.5t-12 -13.5h-151q-4 0 -10.5 1.5t-24.5 9.5t-32 20.5t-26.5 38t-15.5 58.5v397v0q0 4 1 12t7.5 28.5t17 37t32 32t49.5 19.5h341q4 0 10.5 -1.5t24 -10.5t31.5 -22t26.5 -40t14.5 -62v-102v-160t0 -75
+q-1 -4 -2.5 -11.5t-11.5 -29.5t-24.5 -43t-43 -48t-65.5 -49h-2h-5h-7h-8.5h-7.5h-5h-2q-15 4 -15.5 24.5t0.5 86.5v0q0 3 1 8t6 18.5t12.5 24.5t23.5 21.5t36 14.5h81v326q0 3 -1 8.5t-5.5 20t-11.5 25.5t-21 21.5t-32 12.5h-332q-3 0 -7.5 -1t-16.5 -6t-21 -13.5
+t-17.5 -27.5t-10.5 -44q-1 -203 -1 -386q1 -3 1.5 -9t5 -21t11.5 -26.5t21.5 -22.5t34.5 -13h146zM736 106h-70q-4 -1 -9.5 -3.5t-16.5 -15.5t-12 -30v-16v-31.5v-15.5l8 -1q5 0 34 29q37 37 66 84zM651 430q0 -10 -7.5 -17.5t-17.5 -7.5h-221q-10 0 -17.5 7.5t-7.5 17.5v0
+q0 11 7.5 18.5t17.5 7.5h221q10 0 17.5 -7.5t7.5 -18.5v0zM652 282q0 -11 -7.5 -18.5t-18.5 -7.5h-220q-11 0 -18.5 7.5t-7.5 18.5v0q0 11 7.5 18.5t18.5 7.5h220q11 0 18.5 -7.5t7.5 -18.5v0zM524 133q0 -11 -7.5 -19t-18.5 -8h-91q-11 0 -19 8t-8 19v0q0 11 8 19t19 8h91
+q11 0 18.5 -8t7.5 -19v0z" />
+    <glyph glyph-name="line" unicode="&#xe611;" 
+d="M960 552q0 -17 -12 -29.5t-30 -12.5h-812q-18 0 -30 12.5t-12 29.5v0q0 18 12 30t30 12h812q18 0 30 -12t12 -30v0zM960 300q0 -17 -12 -29.5t-30 -12.5h-812q-18 0 -30 12.5t-12 29.5v0q0 17 12 29.5t30 12.5h812q18 0 30 -12.5t12 -29.5v0zM960 48q0 -18 -12 -30
+t-30 -12h-812q-18 0 -30 12t-12 30v0q0 17 12 29.5t30 12.5h812q18 0 30 -12.5t12 -29.5v0z" />
+    <glyph glyph-name="file-box" unicode="&#xe61b;" 
+d="M804 770l50 -431l-57 -7l-45 386h-480l-45 -386l-57 7l50 431h584zM319 674h386v-48h-386v48zM319 577h386v-48h-386v48zM319 481h386v-49h-386v49zM319 384h386v-48h-386v48zM874 287h-724q-10 0 -15 -6.5t-2 -15.5l82 -244q3 -10 12 -16.5t19 -6.5h532q10 0 19 6.5
+t12 16.5l82 244q3 9 -2 15.5t-15 6.5zM609 191h-194v48h194v-48z" />
+    <glyph glyph-name="switchon" unicode="&#xe615;" 
+d="M832 896h-640q-80 0 -136 -56t-56 -136v-640q0 -79 56 -135.5t136 -56.5h640q79 0 135.5 56.5t56.5 135.5v640q0 80 -56.5 136t-135.5 56zM160 0q-13 0 -22.5 9.5t-9.5 22.5t9.5 22.5t22.5 9.5t22.5 -9.5t9.5 -22.5t-9.5 -22.5t-22.5 -9.5zM160 704q-13 0 -22.5 9.5
+t-9.5 22.5t9.5 22.5t22.5 9.5t22.5 -9.5t9.5 -22.5t-9.5 -22.5t-22.5 -9.5zM704 64q0 -27 -18.5 -45.5t-45.5 -18.5h-256q-27 0 -45.5 18.5t-18.5 45.5v640q0 26 19 45t45 19h256q27 0 45.5 -18.5t18.5 -45.5v-640zM864 0q-13 0 -22.5 9.5t-9.5 22.5t9.5 22.5t22.5 9.5
+t22.5 -9.5t9.5 -22.5t-9.5 -22.5t-22.5 -9.5zM864 704q-13 0 -22.5 9.5t-9.5 22.5t9.5 22.5t22.5 9.5t22.5 -9.5t9.5 -22.5t-9.5 -22.5t-22.5 -9.5zM576 704h-128q-26 0 -45 -19t-19 -45v-192q0 -26 19 -45t45 -19h128q27 0 45.5 18.5t18.5 45.5v192q0 27 -18.5 45.5
+t-45.5 18.5z" />
+    <glyph glyph-name="sysserver" unicode="&#xe605;" 
+d="M515 145q-36 0 -71 -12t-64 -41t-36 -69h338q-5 53 -57 87.5t-110 34.5zM955 157q-3 265 0 528q0 34 -12.5 47t-46.5 13q-45 -1 -384 -1v0q-339 0 -390 1q-28 0 -40.5 -11t-12.5 -39q1 -231 0 -544q0 -28 11.5 -40.5t39.5 -11.5q35 1 107 0.5t107 0.5q25 1 48 17
+q56 40 115.5 45t116.5 -28q63 -37 150 -35q94 2 132 0q33 -2 46 11t13 47zM892 213h-760v456h760v-456z" />
+    <glyph glyph-name="file" unicode="&#xe77d;" 
+d="M645 809h-372q-44 0 -75 -31t-31 -75v-638q0 -44 31 -75t75 -31h478q44 0 75 31t31 75v532zM643 726l127 -127h-104q-9 0 -16 6.5t-7 16.5v104zM751 19h-478q-19 0 -32.5 13.5t-13.5 32.5v638q0 19 13.5 32.5t32.5 13.5h310v-127q0 -34 24.5 -58.5t58.5 -24.5h131v-474
+q0 -19 -13.5 -32.5t-32.5 -13.5zM682 388h-340q-12 0 -21 -9t-9 -21.5t9 -21t21 -8.5h340q12 0 21 8.5t9 21t-9 21.5t-21 9zM682 232h-340q-12 0 -21 -9t-9 -21.5t9 -21t21 -8.5h340q12 0 21 8.5t9 21t-9 21.5t-21 9z" />
+  </font>
+</defs></svg>

BIN
src/ui/components/panel/iconfont/iconfont.ttf


+ 0 - 0
src/ui/components/panel/iconfont/iconfont.woff


+ 184 - 0
src/ui/components/panel/list.js

@@ -0,0 +1,184 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+import React from 'react'
+import ListItem from './list_item'
+import Agent from '../../../renderer/Agent'
+import update from 'react-addons-update'
+import './list.less'
+
+class List extends React.Component {
+
+  constructor (props) {
+    super(props)
+
+    this.state = {
+      current: this.props.current,
+      list: this.props.list,
+      sys: this.props.sys
+    }
+    this.last_content = this.props.sys.content
+
+    // auto check refresh
+    setTimeout(() => {
+      this.autoCheckRefresh()
+    }, 1000 * 5)
+  }
+
+  /**
+   * 检查当前 host 是否需要从网络下载更新
+   * @param host
+   * @param force {Boolean} 如果为 true,则只要是 remote 且 refresh_interval != 0,则强制更新
+   */
+  checkUpdateHost (host, force = false) {
+    Agent.emit('check_host_refresh', host, force)
+  }
+
+  autoCheckRefresh () {
+    let remote_idx = -1
+    this.state.list.map((host, idx) => {
+      if (host.where === 'remote') {
+        remote_idx++
+      }
+      setTimeout(() => {
+        Agent.emit('check_host_refresh', host)
+      }, 1000 * 5 * remote_idx + idx)
+    })
+
+    // let wait = 1000 * 60 * 10;
+    let wait = 1000 * 30 // test only
+    setTimeout(() => {
+      this.autoCheckRefresh()
+    }, wait)
+  }
+
+  apply (content, success) {
+    Agent.emit('apply', content, () => {
+      this.last_content = content
+      success()
+      Agent.emit('save_data', this.state.list)
+      Agent.notify({
+        message: 'host updated.'
+      })
+    })
+  }
+
+  selectOne (host) {
+    this.setState({
+      current: host
+    })
+
+    this.props.setCurrent(host)
+  }
+
+  toggleOne (idx, success) {
+
+    let content = this.getOnContent(idx)
+    this.apply(content, () => {
+      let choice_mode = Agent.pref.get('choice_mode')
+      if (choice_mode === 'single') {
+        // 单选模式
+        this.setState({
+          list: this.state.list.map((item, _idx) => {
+            if (idx !== _idx) {
+              item.on = false
+            }
+            return item
+          })
+        })
+      }
+
+      if (typeof success === 'function') {
+        success()
+      }
+    })
+  }
+
+  getOnItems (idx = -1) {
+    let choice_mode = Agent.pref.get('choice_mode')
+    return this.state.list.filter((item, _idx) => {
+      if (choice_mode === 'single') {
+        return !item.on && _idx === idx
+      } else {
+        return (item.on && _idx !== idx) || (!item.on && _idx === idx)
+      }
+    })
+  }
+
+  getOnContent (idx = -1) {
+    let contents = this.getOnItems(idx).map((item) => {
+      return item.content || ''
+    })
+
+    contents.unshift('# SwitchHosts!')
+
+    return contents.join(`\n\n`)
+  }
+
+  customItems () {
+    return this.state.list.map((item, idx) => {
+      return (
+        <ListItem
+          data={item}
+          idx={idx}
+          selectOne={this.selectOne.bind(this)}
+          current={this.state.current}
+          onToggle={(success) => this.toggleOne(idx, success)}
+          key={'host-' + idx}
+          dragOrder={(sidx, tidx) => this.dragOrder(sidx, tidx)}
+        />
+      )
+    })
+  }
+
+  dragOrder (source_idx, target_idx) {
+    let source = this.state.list[source_idx]
+    let target = this.state.list[target_idx]
+
+    let list = this.state.list.filter((item, idx) => idx !== source_idx)
+    let new_target_idx = list.findIndex((item) => item === target)
+
+    let to_idx
+    if (source_idx < target_idx) {
+      // append
+      to_idx = new_target_idx + 1
+    } else {
+      // insert before
+      to_idx = new_target_idx
+    }
+    list.splice(to_idx, 0, source)
+
+    this.setState({
+      list: list
+    })
+
+    setTimeout(() => {
+      Agent.emit('change')
+    }, 100)
+  }
+
+  componentDidMount () {
+  }
+
+  render () {
+    return (
+      <div id="sh-list">
+        <ListItem
+          data={this.props.sys}
+          lang={this.props.lang}
+          selectOne={this.selectOne.bind(this)}
+          current={this.state.current}
+          sys="1"/>
+        <div ref="items" className="custom-items">
+          {this.customItems()}
+        </div>
+      </div>
+    )
+  }
+}
+
+export default List

+ 10 - 0
src/ui/components/panel/list.less

@@ -0,0 +1,10 @@
+#sh-list {
+
+  .custom-items {
+    position: fixed;
+    width: 240px;
+    top: 36px;
+    bottom: 30px;
+    overflow: auto;
+  }
+}

+ 144 - 0
src/ui/components/panel/list_item.js

@@ -0,0 +1,144 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+import React from 'react'
+import classnames from 'classnames'
+import Agent from '../../../renderer/Agent'
+import { kw2re } from '../../libs/kw'
+import './list_item.less'
+
+export default class ListItem extends React.Component {
+  constructor (props) {
+    super(props)
+
+    this.is_sys = !!this.props.sys
+    this.state = {
+      is_selected: false,
+      search_kw: '',
+      search_re: null
+      // on: this.props.data.on,
+    }
+
+    Agent.on('search', (kw) => {
+      this.setState({
+        search_kw: kw,
+        search_re: kw ? kw2re(kw) : null
+      })
+    })
+  }
+
+  getTitle () {
+    let {lang} = this.props
+    return this.is_sys ? lang.sys_host_title : this.props.data.title ||
+      lang.untitled
+  }
+
+  beSelected () {
+    // this.setState({
+    //     is_selected: true
+    // });
+
+    this.props.selectOne(this.props.data)
+  }
+
+  toEdit () {
+    Agent.emit('edit_host', this.props.data)
+  }
+
+  toggle () {
+    let on = !this.props.data.on
+
+    this.props.onToggle(() => {
+      this.props.data.on = on
+      this.forceUpdate()
+    })
+
+    Agent.emit('toggle_host', on)
+  }
+
+  allowedDrop (e) {
+    e.preventDefault()
+  }
+
+  onDrop (e) {
+    if (this.props.sys) {
+      e.preventDefault()
+      return false
+    }
+    let source_idx = parseInt(e.dataTransfer.getData('text'))
+
+    this.props.dragOrder(source_idx, this.props.idx)
+  }
+
+  onDrag (e) {
+    e.dataTransfer.setData('text', this.props.idx)
+  }
+
+  isMatched () {
+    if (this.props.sys) return true
+    let kw = this.state.search_kw
+    let re = this.state.search_re
+    if (!kw || kw === '/') return true
+
+    let {title, content} = this.props.data
+
+    if (re) {
+      return re.test(title) || re.test(content)
+    } else {
+      return title.indexOf(kw) > -1 || content.indexOf(kw) > -1
+    }
+  }
+
+  render () {
+    let {data, sys, current} = this.props
+    let is_selected = data === current
+
+    return (
+      <div className={classnames({
+        'list-item': 1
+        , 'hidden': !this.isMatched()
+        , 'sys-host': sys
+        , 'selected': is_selected
+      })}
+           onClick={this.beSelected.bind(this)}
+           draggable={!sys}
+           onDragStart={(e) => this.onDrag(e)}
+           onDragOver={(e) => this.allowedDrop(e)}
+           onDrop={(e) => this.onDrop(e)}
+      >
+        { sys ? null : (
+          <div>
+            <i className={classnames({
+              'switch': 1
+              , 'iconfont': 1
+              , 'icon-on': data.on
+              , 'icon-off': !data.on
+            })}
+               onClick={this.toggle.bind(this)}
+            />
+            <i
+              className="iconfont icon-edit"
+              onClick={this.toEdit.bind(this)}
+            />
+          </div>
+        )
+        }
+        <i className={classnames({
+          'iconfont': 1
+          , 'item-icon': 1
+          , 'icon-warn': !!data.error
+          , 'icon-file': !sys && !data.error && data.where !== 'group'
+          , 'icon-files': data.where === 'group'
+          , 'icon-sysserver': sys && !data.error
+        })}
+           title={data.error || ''}
+        />
+        <span>{this.getTitle()}</span>
+      </div>
+    )
+  }
+}

+ 65 - 0
src/ui/components/panel/list_item.less

@@ -0,0 +1,65 @@
+#sh-list {
+  .list-item {
+    padding: 7px 10px 7px 15px;
+    cursor: pointer;
+
+    &.hidden {
+      display: none;
+    }
+
+    &.sys-host {
+      font-size: 14px;
+      padding: 8px 10px 8px 12px;
+
+      i {
+        width: 23px;
+      }
+    }
+
+    &.selected {
+      background: #2d3138;
+
+      span {
+        color: #fff;
+      }
+
+      i.item-icon {
+        color: #fff;
+      }
+
+      &:hover {
+        i.icon-edit {
+          display: block;
+          color: #fff;
+        }
+      }
+    }
+
+    i {
+      display: inline-block;
+      width: 20px;
+      text-align: center;
+      margin-right: 5px;
+
+      &.item-icon {
+        font-size: 12px;
+      }
+
+      &.switch {
+        float: right;
+        cursor: pointer;
+        line-height: 23px;
+
+        &.icon-on {
+          color: #af9;
+        }
+      }
+
+      &.icon-edit {
+        float: right;
+        display: none;
+      }
+    }
+
+  }
+}

+ 32 - 0
src/ui/components/panel/panel.js

@@ -0,0 +1,32 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+import React from 'react'
+import Buttons from './buttons'
+import SearchBar from './searchbar'
+import List from './list'
+import './panel.less'
+
+export default class Panel extends React.Component {
+  render () {
+    let {current, list, sys, setCurrent, lang} = this.props
+
+    return (
+      <div id="panel">
+        <List
+          list={list}
+          sys={sys}
+          current={current}
+          setCurrent={setCurrent}
+          lang={lang}
+        />
+        <SearchBar/>
+        <Buttons/>
+      </div>
+    )
+  }
+}

+ 10 - 0
src/ui/components/panel/panel.less

@@ -0,0 +1,10 @@
+@import '../cfg.less';
+@import "iconfont/iconfont.css";
+
+#panel {
+
+  width: 240px;
+  height: 100%;
+  background: @bg_left;
+  color: @font_color_left;
+}

+ 80 - 0
src/ui/components/panel/searchbar.js

@@ -0,0 +1,80 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+import React from 'react'
+import Agent from '../../../renderer/Agent'
+import classnames from 'classnames'
+import './searchbar.less'
+
+export default class SearchBar extends React.Component {
+
+  constructor (props) {
+    super(props)
+
+    this.state = {
+      show: false,
+      keyword: ''
+    }
+
+    this._t = null
+
+    Agent.on('search_on', () => {
+      this.setState({
+        show: true
+      }, () => {
+        setTimeout(() => {
+          this.refs.keyword.focus()
+        }, 100)
+      })
+    })
+
+    Agent.on('search_off', () => {
+      this.clearSearch()
+    })
+  }
+
+  clearSearch () {
+    this.setState({
+      show: false,
+      keyword: ''
+    })
+    Agent.emit('search', '')
+  }
+
+  doSearch (kw) {
+    this.setState({
+      keyword: kw
+    })
+
+    clearTimeout(this._t)
+    this._t = setTimeout(() => {
+      Agent.emit('search', kw)
+    }, 300)
+  }
+
+  onCancel () {
+    Agent.emit('cancel_search')
+  }
+
+  render () {
+    if (!this.state.show) {
+      return null
+    }
+    return (
+      <div id="sh-searchbar">
+        <input
+          ref="keyword"
+          type="text"
+          placeholder="keyword"
+          value={this.state.keyword}
+          onChange={(e) => this.doSearch(e.target.value)}
+          onKeyDown={(e) => (e.keyCode === 27 && this.onCancel())}
+        />
+      </div>
+    )
+  }
+}

+ 19 - 0
src/ui/components/panel/searchbar.less

@@ -0,0 +1,19 @@
+@import '../cfg.less';
+
+#sh-searchbar {
+  position: fixed;
+  width: 240px;
+  left: 0;
+  bottom: 30px;
+  background: @bg_left_search;
+  padding: 6px 20px 5px 18px;
+  border-top: solid 1px @bg_left * 0.9;
+
+  input {
+    background: transparent;
+    border: 0;
+    outline: 0;
+    color: #fff;
+    font-size: 13px;
+  }
+}

+ 14 - 0
src/ui/configs.js

@@ -0,0 +1,14 @@
+/**
+ * configs.js
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+const m_ver = require('../version').version;
+
+exports.version = m_ver.slice(0, 3).join('.');
+exports.version_full = m_ver.join('.');
+exports.url_download = 'https://github.com/oldj/SwitchHosts/releases';
+

+ 15 - 0
src/ui/index.js

@@ -0,0 +1,15 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+import React from 'react';
+import ReactDom from 'react-dom';
+import App from './components/app';
+
+ReactDom.render(
+    <App/>
+    , document.getElementById('app')
+);

+ 43 - 0
src/ui/lang.js

@@ -0,0 +1,43 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+const languages = {
+  'en': require('../common/lang/en').content,
+  'cn': require('../common/lang/cn').content
+}
+
+module.exports = {
+  languages: languages,
+  lang_list: (() => {
+    let list = []
+    for (let k in languages) {
+      if (languages.hasOwnProperty(k)) {
+        list.push({
+          key: k,
+          name: languages[k]._lang_name
+        })
+      }
+    }
+    return list
+  })(),
+  getLang: (lang) => {
+    lang = lang.toLowerCase()
+    if (lang === 'cn' || lang === 'zh-cn') {
+      lang = 'cn'
+    } else {
+      lang = 'en'
+    }
+    return languages[lang] || languages['en']
+  },
+  fill: (tpl, ...vals) => {
+    vals.map((v, idx) => {
+      let r = new RegExp('\\$\\{' + idx + '\\}', 'g')
+      tpl = tpl.replace(r, v)
+    })
+    return tpl
+  }
+}

+ 24 - 0
src/ui/libs/default_data.js

@@ -0,0 +1,24 @@
+/**
+ * default_data, created on 2016/8/27.
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+function makeDefaultData() {
+    return {
+        sys: {
+            is_sys: true
+            , content: ''
+        },
+        list: [
+            {
+                title: 'my hosts',
+                content: '# input hosts here\n\n'
+            }
+        ]
+    };
+}
+
+exports.make = makeDefaultData;

+ 72 - 0
src/ui/libs/kw.js

@@ -0,0 +1,72 @@
+/**
+ * kw
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+function kw2re(kw) {
+    // 模糊搜索
+    let r;
+    let m;
+    let flag = [];
+
+    if (kw === '/') {
+        return;
+    } else if ((m = kw.match(/^\/([^\/]+)\/?(\w*)$/))) {
+        if (m[2].indexOf('i') > -1) {
+            flag.push('i');
+        }
+        // if (m[2].indexOf('g') > -1) {
+        flag.push('g');
+        // }
+        try {
+            r = new RegExp(m[1], flag.join(''));
+        } catch (e) {
+        }
+    } else if (kw.indexOf('*') > -1) {
+        try {
+            r = new RegExp(kw.replace(/\*/g, '.*'), 'ig');
+        } catch (e) {
+        }
+    }
+
+    return r;
+}
+
+exports.findPositions = function (kw, code) {
+    if (!kw || kw === '/') return;
+
+    let r = kw2re(kw);
+    if (!r) {
+        try {
+            r = new RegExp(kw
+                    .replace(/([\.\?\*\+\^\$\(\)\-\[\]\{\}])/g, '\\$1')
+                , 'ig');
+        } catch (e) {
+            console.log(e);
+            return;
+        }
+    }
+    let indexes = [];
+
+    let lines = code.split('\n');
+
+    lines.map((ln, idx) => {
+        let match;
+        let max_loop = 30;
+        while (match = r.exec(ln)) {
+            indexes.push([
+                {line: idx, ch: match.index},
+                {line: idx, ch: match.index + match[0].length},
+            ]);
+            max_loop--;
+            if (max_loop < 0) break;
+        }
+    });
+
+    return indexes;
+};
+
+exports.kw2re = kw2re;

+ 15 - 0
src/ui/libs/util.js

@@ -0,0 +1,15 @@
+/**
+ * util
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict'
+
+exports.formatVersion = (v) => {
+  return 'v' + v.slice(0, 3).join('.') + ` (${v[3]})`
+}
+
+exports.makeId = () => {
+  return (new Date()).getTime() + '-' + Math.floor(Math.random() * 1e6)
+}

+ 305 - 0
src/ui/menu/mainMenu.js

@@ -0,0 +1,305 @@
+/**
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+const path = require('path');
+const paths = require('../libs/paths');
+const {Menu, shell, ipcMain, dialog} = require('electron');
+const m_chk_update = require('../../bg/check_for_update');
+const m_lang = require('../lang');
+const pref = require('./../libs/pref');
+const version = require('../../version').version;
+
+exports.init = function (app, sys_lang = 'en') {
+    let lang = m_lang.getLang(pref.get('user_language', sys_lang));
+    let last_path = null;
+
+    const template = [
+        {
+            label: lang.file,
+            submenu: [
+                {
+                    label: lang.new,
+                    accelerator: 'CommandOrControl+N',
+                    click: () => {
+                        ipcMain.emit('to_add_host');
+                    }
+                }, {
+                    type: 'separator'
+                }, {
+                    label: lang.import,
+                    accelerator: 'Alt+CommandOrControl+I',
+                    click: () => {
+                        dialog.showOpenDialog({
+                            title: lang.import,
+                            defaultPath: path.join(last_path || paths.home_path, 'sh.json'),
+                            filters: [
+                                {name: 'JSON', extensions: ['json']},
+                                {name: 'All Files', extensions: ['*']}
+                            ]
+                        }, (fns) => {
+                            if (fns && fns.length > 0) {
+                                ipcMain.emit('to_import', fns[0]);
+                                last_path = path.dirname(fns[0]);
+                            }
+                        });
+                    }
+                }, {
+                    label: lang.export,
+                    accelerator: 'Alt+CommandOrControl+E',
+                    click: () => {
+                        dialog.showSaveDialog({
+                            title: lang.export,
+                            defaultPath: path.join(last_path || paths.home_path, 'sh.json'),
+                            filters: [
+                                {name: 'JSON', extensions: ['json']},
+                                {name: 'All Files', extensions: ['*']}
+                            ]
+                        }, (fn) => {
+                            if (fn) {
+                                ipcMain.emit('to_export', fn);
+                                last_path = path.dirname(fn);
+                            }
+                        });
+                    }
+                }, {
+                    type: 'separator'
+                }, {
+                    label: lang.preferences,
+                    accelerator: 'CommandOrControl+,',
+                    click: () => {
+                        app.mainWindow.webContents.send('show_preferences');
+                    }
+                }
+            ]
+        },
+        {
+            label: lang.edit,
+            submenu: [{
+                role: 'undo'
+            }, {
+                role: 'redo'
+            }, {
+                type: 'separator'
+            }, {
+                role: 'cut'
+            }, {
+                role: 'copy'
+            }, {
+                role: 'paste'
+            }, {
+                role: 'pasteandmatchstyle'
+            }, {
+                role: 'delete'
+            }, {
+                role: 'selectall'
+            }, {
+                type: 'separator'
+            }, {
+                label: lang.search,
+                accelerator: 'CommandOrControl+F',
+                click () {
+                    // ipcMain.emit('to_search');
+                    app.mainWindow.webContents.send('to_search');
+                }
+            }, {
+                label: lang.comment,
+                accelerator: 'CommandOrControl+/',
+                click () {
+                    // ipcMain.emit('to_search');
+                    app.mainWindow.webContents.send('to_comment');
+                }
+            }]
+        }, {
+            label: lang.view,
+            submenu: [
+                // {
+                //     label: 'Reload',
+                //     accelerator: 'CmdOrCtrl+R',
+                //     click (item, focusedWindow) {
+                //         if (focusedWindow) focusedWindow.reload()
+                //     }
+                // },
+                // {
+                //     label: 'Toggle Developer Tools',
+                //     accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
+                //     click (item, focusedWindow) {
+                //         if (focusedWindow) focusedWindow.webContents.toggleDevTools()
+                //     }
+                // },
+                // {
+                //     type: 'separator'
+                // },
+                {
+                    role: 'resetzoom'
+                }, {
+                    role: 'zoomin'
+                }, {
+                    role: 'zoomout'
+                }, {
+                    type: 'separator'
+                }, {
+                    role: 'togglefullscreen'
+                }
+            ]
+        }, {
+            label: lang.window,
+            role: 'window',
+            submenu: [{
+                role: 'minimize'
+            }, {
+                role: 'close'
+            }]
+        }, {
+            label: lang.help,
+            role: 'help',
+            submenu: [{
+                label: lang.check_update,
+                click () {
+                    m_chk_update.check();
+                }
+            }, {
+                type: 'separator'
+            }, {
+                label: lang.feedback,
+                click () {
+                    shell.openExternal('https://github.com/oldj/SwitchHosts/issues');
+                }
+            }, {
+                label: lang.homepage,
+                click () {
+                    shell.openExternal('http://oldj.github.io/SwitchHosts/');
+                }
+            }]
+        }
+    ];
+
+    const name = require('electron').app.getName();
+    const os = process.platform;
+    if (os === 'darwin') {
+        template.unshift({
+            label: name,
+            submenu: [{
+                role: 'about'
+            }, {
+                type: 'separator'
+            },
+                // {
+                //     role: 'services',
+                //     submenu: []
+                // },
+                // {
+                //     type: 'separator'
+                // },
+                {
+                    role: 'hide'
+                }, {
+                    role: 'hideothers'
+                }, {
+                    role: 'unhide'
+                }, {
+                    type: 'separator'
+                }, {
+                    role: 'quit'
+                }]
+        });
+        // Edit menu.
+        /*template[2].submenu.push(
+         {
+         type: 'separator'
+         },
+         {
+         label: 'Speech',
+         submenu: [
+         {
+         role: 'startspeaking'
+         },
+         {
+         role: 'stopspeaking'
+         }
+         ]
+         }
+         );*/
+        // Window menu.
+        template[4].submenu = [
+            {
+                label: 'Close',
+                accelerator: 'CmdOrCtrl+W',
+                role: 'close'
+            },
+            {
+                label: 'Minimize',
+                accelerator: 'CmdOrCtrl+M',
+                role: 'minimize'
+            },
+            {
+                label: 'Zoom',
+                role: 'zoom'
+            },
+            {
+                type: 'separator'
+            },
+            {
+                label: 'Bring All to Front',
+                role: 'front'
+            }
+        ]
+    } else if (os == 'win32') {
+        template[0].submenu.unshift({
+            type: 'separator'
+        });
+        template[0].submenu.unshift({
+            role: 'about',
+            click: () => {
+                dialog.showMessageBox({
+                    type: 'info',
+                    buttons: [],
+                    title: 'About',
+                    message: `${name} v${version.slice(0, 3).join('.')} (${version[3]})`
+                });
+            }
+        });
+
+        template[0].submenu.push({
+            type: 'separator'
+        });
+        template[0].submenu.push({
+            label: 'Quit',
+            role: 'quit',
+            accelerator: 'CmdOrCtrl+Q',
+        });
+
+        // VIEW
+        template[2].submenu.splice(0, 4);
+    }
+
+    if (process.env.ENV == 'dev') {
+        // VIEW
+        template[3].submenu = [
+            {
+                label: 'Reload',
+                accelerator: 'CmdOrCtrl+R',
+                click (item, focusedWindow) {
+                    if (focusedWindow) focusedWindow.reload()
+                }
+            },
+            {
+                label: 'Toggle Developer Tools',
+                accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
+                click (item, focusedWindow) {
+                    if (focusedWindow) focusedWindow.webContents.toggleDevTools()
+                }
+            },
+            {
+                type: 'separator'
+            }
+        ].concat(template[3].submenu);
+    }
+
+    const menu = Menu.buildFromTemplate(template);
+    Menu.setApplicationMenu(menu);
+}
+;

+ 116 - 0
src/ui/menu/tray.js

@@ -0,0 +1,116 @@
+/**
+ * tray
+ * @author oldj
+ * @blog http://oldj.net
+ */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const {Menu, Tray, ipcMain, shell} = require('electron');
+const m_lang = require('../lang');
+const m_chk_update = require('../../bg/check_for_update');
+const pref = require('./../libs/pref');
+const os = process.platform;
+const util = require('../libs/util');
+const current_version = require('../../version').version;
+
+let tray = null;
+
+function makeMenu(app, list, contents, sys_lang) {
+    let menu = [];
+    let lang = m_lang.getLang(pref.get('user_language', sys_lang));
+
+    menu.push({
+        label: 'SwitchHosts!',
+        type: 'normal',
+        // sublabel: util.formatVersion(current_version), // does not work on Mac
+        click: () => {
+            app.emit('show');
+        }
+    });
+    menu.push({label: util.formatVersion(current_version), type: 'normal', enabled: false});
+    menu.push({label: '-', type: 'separator'});
+
+    let ac = '1234567890abcdefghijklmnopqrstuvwxyz'.split('');
+    list.map((item, idx) => {
+        menu.push({
+            label: item.title || 'untitled',
+            type: 'checkbox',
+            checked: item.on,
+            accelerator: ac[idx],
+            click: () => {
+                contents.send('tray_toggle_host', idx);
+                contents.send('get_host_list');
+            }
+        });
+    });
+
+    menu.push({type: 'separator'});
+    menu.push({
+        label: lang.feedback, type: 'normal', click: () => {
+            shell.openExternal('https://github.com/oldj/SwitchHosts/issues');
+        }
+    });
+
+    menu.push({
+        label: lang.check_update, type: 'normal', click: () => {
+            m_chk_update.check();
+        }
+    });
+
+    if (os === 'darwin') {
+        menu.push({
+            label: lang.toggle_dock_icon, type: 'normal', click: () => {
+                let is_dock_visible = app.dock.isVisible();
+                if (is_dock_visible) {
+                    app.dock.hide();
+                } else {
+                    app.dock.show();
+                }
+                pref.set('is_dock_icon_hidden', is_dock_visible);
+            }
+        });
+    }
+
+    menu.push({type: 'separator'});
+    menu.push({
+        label: lang.quit, type: 'normal', accelerator: 'CommandOrControl+Q', click: () => {
+            app.quit();
+        }
+    });
+
+    return menu;
+}
+
+function makeTray(app, contents, sys_lang = 'en') {
+    let icon = 'logo.png';
+    if (process.platform === 'darwin') {
+        icon = 'ilogoTemplate.png';
+    }
+
+    tray = new Tray(path.join(__dirname, '..', 'assets', icon));
+    tray.setToolTip('SwitchHosts!');
+
+    contents.send('get_host_list');
+
+    ipcMain.on('send_host_list', (e, d) => {
+        const contextMenu = Menu.buildFromTemplate(makeMenu(app, d, contents, sys_lang));
+        tray.setContextMenu(contextMenu);
+    });
+
+    let is_dock_icon_hidden = pref.get('is_dock_icon_hidden', false);
+    if (is_dock_icon_hidden) {
+        app.dock.hide();
+    }
+
+    // windows only
+    if (process.platform === 'win32') {
+        tray.on('click', () => {
+            app.emit('show');
+        });
+    }
+}
+
+exports.makeTray = makeTray;

+ 1 - 0
src/version.js

@@ -0,0 +1 @@
+exports.version = [3,3,0,4319];

+ 2 - 2
webpack.config.js

@@ -9,10 +9,10 @@ const path = require('path')
 const webpack = require('webpack')
 
 module.exports = {
-  entry: './app/ui2/ui.js',
+  entry: './src/ui/index.js',
   devtool: 'source-map',
   output: {
-    path: path.join(__dirname, 'app', 'build')
+    path: path.join(__dirname, 'src')
     , filename: 'bundle.js'
     , sourceMapFilename: 'bundle.js.map'
   },

Some files were not shown because too many files changed in this diff