├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── 1-bug-report.yml │ ├── 2-proposal.yml │ └── config.yml ├── dependabot.yml └── workflows │ ├── mac-test-1.yml │ └── mac-test-2.yml ├── .gitignore ├── .sample.env ├── LICENSE ├── README.md ├── README_cn.md ├── build ├── bin │ ├── .yarnclean │ ├── build-common.js │ ├── build.js │ ├── clean.js │ ├── copy.js │ ├── install.js │ ├── pre-push │ ├── release │ └── run-prod.sh └── vite │ ├── common.js │ ├── conf.js │ ├── def.js │ └── dev-server.js ├── config.sample.js ├── examples ├── nginx-ssl.conf └── nginx.conf ├── package-lock.json ├── package.json ├── run-electerm-web.sh └── src ├── app ├── app.js ├── common │ ├── build-run-scripts.js │ ├── build-ssh-tunnel.js │ ├── config-default.js │ ├── constants.js │ ├── count-folder-data.js │ ├── create-session-log-file-path.js │ ├── default-setting.js │ ├── fs-functions.js │ ├── get-json.js │ ├── is-ip.js │ ├── log.js │ ├── pass-enc.js │ ├── runtime-constants.js │ ├── time.js │ ├── uid.js │ └── version-compare.js ├── lib │ ├── ai.js │ ├── build-proxy.js │ ├── conf.js │ ├── db.js │ ├── enc.js │ ├── extensions.js │ ├── font-list.js │ ├── fs.js │ ├── get-constants.js │ ├── global-state.js │ ├── init.js │ ├── iterm-theme.js │ ├── jwt.js │ ├── login.js │ ├── lookup.js │ ├── nedb.js │ ├── proxy-agent.js │ ├── run-sync.js │ ├── serial-port.js │ ├── show-item-in-folder.js │ ├── ssh-config.js │ ├── style.js │ ├── user-config.js │ ├── view.js │ └── watch-file.js ├── routes │ ├── http.js │ └── ws.js ├── server │ ├── dispatch-center.js │ ├── fetch.js │ ├── fs.js │ ├── ftp-file.js │ ├── ftp-transfer.js │ ├── global-state.js │ ├── remote-common.js │ ├── server.js │ ├── session-base.js │ ├── session-common.js │ ├── session-ftp.js │ ├── session-local.js │ ├── session-log.js │ ├── session-rdp.js │ ├── session-serial.js │ ├── session-sftp.js │ ├── session-ssh.js │ ├── session-telnet.js │ ├── session-vnc.js │ ├── session.js │ ├── sftp-file.js │ ├── socks.js │ ├── ssh-tunnel.js │ ├── ssh2-alg.js │ ├── sync.js │ ├── telnet.js │ ├── terminal-api.js │ └── transfer.js ├── upgrade │ ├── db-defaults.js │ ├── index.js │ ├── init-nedb.js │ └── version-upgrade.js └── views │ └── index.pug └── client ├── entry-web ├── basic.js ├── electerm.jsx ├── rle.js └── worker.js ├── file-select-dialog ├── file-item.jsx ├── file-select-dialog.jsx └── file-select-dialog.styl ├── simple-auth ├── logout.jsx ├── logout.styl └── web-login.jsx ├── statics ├── favicon.ico └── rle.wasm └── web-components ├── path.js ├── store-login.js ├── style-overide.styl ├── web-api.js ├── web-main.jsx ├── web-pre.js └── web-store.js /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | > First off, thank you for considering contributing to electerm-web. 4 | 5 | > electerm-web is an open source project and I love to receive contributions from our community — you! There are many ways to contribute, from writing tutorials or blog posts, improving the documentation, submitting bug reports and feature requests or writing code which can be incorporated into electerm-web itself. 6 | 7 | > Working on your first Pull Request? You can learn how from this *free* series, [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). 8 | 9 | Basic process: 10 | 11 | > 1. Create your own fork of the code 12 | > 2. Do the changes in your fork 13 | > 3. If you like the change and think the project could use it: 14 | * Be sure you have followed the code style for the project by running `npm run lint`. 15 | * Send a pull request 16 | 17 | > Want to report a issue or give some suggestions? 18 | [Submit a issue](https://github.com/electerm/electerm-web/issues/new) 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: electerm 2 | open_collective: electerm 3 | custom: https://paypal.me/zhaoxudongPS -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 👉 [Please follow one of these issue templates](https://github.com/electerm/electerm-web/issues/new/choose) 👈 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug-report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug report(提交bug)" 2 | description: Create a report to help us improve 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for reporting an issue. 8 | 9 | This issue tracker is for bugs and issues found within electerm-web. 10 | If you require more general support please start a discussion on our discussion board https://github.com/electerm/electerm-web/discussions 11 | 12 | Please fill in as much of the form below as you're able. 13 | 14 | Before continue, make sure you are using the latest release: check https://github.com/electerm/electerm-web/releases for latest release, also please 15 | 16 | 仅用来追踪bug, 如果有其他的讨论请移步讨论区 https://github.com/electerm/electerm-web/discussions, 请务必确保问题是基于最新版本: https://github.com/electerm/electerm-web/releases 17 | 18 | - type: input 19 | attributes: 20 | label: electerm-web Version and download file extension(electerm-web版本和下载文件后缀) 21 | description: | 22 | - electerm-web-xx.xx.xx-win-x64.tar.gz 23 | validations: 24 | required: true 25 | - type: input 26 | attributes: 27 | label: Platform detail (平台详情) 28 | description: | 29 | windows 7/8/10/11, mac os(ARM or x64), or linux 30 | UNIX: output of `uname -a` 31 | Windows: output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in PowerShell console 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: What steps will reproduce the bug?(重新问题的详细步骤) 37 | description: Enter details about your bug, preferably a simple code snippet that can be run using `node` directly without installing third-party dependencies. 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: What should have happened?(期望的结果) 43 | description: If possible please provide screenshots. 44 | validations: 45 | required: true 46 | - type: textarea 47 | attributes: 48 | label: Would this happen in other terminal app(是否能够在其他同类软件重现这个问题) 49 | description: really important. 50 | - type: textarea 51 | attributes: 52 | label: Additional information(其他任何相关信息) 53 | description: Tell us anything else you think we should know. 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-proposal.yml: -------------------------------------------------------------------------------- 1 | name: "💥 Proposal / Feature (建议和新功能)" 2 | description: Propose a non-trivial change or new feature for electerm-web 3 | body: 4 | - type: textarea 5 | attributes: 6 | label: What feature you'd like to see or proposal(期望什么新功能/特性或者建议) 7 | description: A clear and concise description of what the proposal or feature is.(请尽可能详细描述,以便帮助其他人能够理解) 8 | validations: 9 | required: true 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ⁉️ Need help with electerm-web? 4 | url: https://github.com/electerm/electerm-web/discussions 5 | about: Please start a discussion before opening a bug. 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/mac-test-1.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: mac-test-1 5 | 6 | on: 7 | push: 8 | branches: [ build, test ] 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: macos-11 14 | environment: build 15 | if: "!contains(github.event.head_commit.message, '[skip test]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip mac]') && !contains(github.event.head_commit.message, '[skip test2]')" 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Use Node.js 18.x 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 18.x 23 | 24 | # before_install: 25 | - run: npm i 26 | - run: npx playwright install chromium 27 | - run: npm i -g pm2 28 | # script: 29 | - run: npm run build 30 | - run: cp .sample.env .env 31 | - run: echo "npm run prod" > run.sh && pm2 start run.sh 32 | - name: test 33 | uses: GabrielBB/xvfb-action@v1 34 | with: 35 | run: npm run test1 36 | env: 37 | NODE_TEST: 1 38 | TEST_HOST: ${{ secrets.TEST_HOST }} 39 | TEST_USER: ${{ secrets.TEST_USER_DARWIN }} 40 | TEST_PASS: ${{ secrets.TEST_PASS_DARWIN }} 41 | GIST_TOKEN: ${{ secrets.GIST_TOKEN }} 42 | GIST_ID: ${{ secrets.GIST_ID }} 43 | GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} 44 | GITEE_ID: ${{ secrets.GITEE_ID }} 45 | CUSTOM_SYNC_URL: ${{ secrets.CUSTOM_SYNC_URL }} 46 | CUSTOM_SYNC_USER: ${{ secrets.CUSTOM_SYNC_USER }} 47 | CUSTOM_SYNC_SECRET: ${{ secrets.CUSTOM_SYNC_SECRET }} 48 | 49 | -------------------------------------------------------------------------------- /.github/workflows/mac-test-2.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: mac-test-2 5 | 6 | on: 7 | push: 8 | branches: [ build, test ] 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: macos-11 14 | environment: build 15 | if: "!contains(github.event.head_commit.message, '[skip test]') && !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip mac]') && !contains(github.event.head_commit.message, '[skip test2]')" 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Use Node.js 18.x 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 18.x 23 | 24 | # before_install: 25 | - run: npm i 26 | - run: npx playwright install chromium 27 | - run: npm i -g pm2 28 | # script: 29 | - run: npm run build 30 | - run: cp .sample.env .env 31 | - run: npm run test3 32 | - run: echo "npm run prod" > run.sh && pm2 start run.sh 33 | - name: test 34 | uses: GabrielBB/xvfb-action@v1 35 | with: 36 | run: npm run test2 37 | env: 38 | NODE_TEST: 1 39 | TEST_HOST: ${{ secrets.TEST_HOST }} 40 | TEST_USER: ${{ secrets.TEST_USER_DARWIN }} 41 | TEST_PASS: ${{ secrets.TEST_PASS_DARWIN }} 42 | GIST_TOKEN: ${{ secrets.GIST_TOKEN }} 43 | GIST_ID: ${{ secrets.GIST_ID }} 44 | GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} 45 | GITEE_ID: ${{ secrets.GITEE_ID }} 46 | CUSTOM_SYNC_URL: ${{ secrets.CUSTOM_SYNC_URL }} 47 | CUSTOM_SYNC_USER: ${{ secrets.CUSTOM_SYNC_USER }} 48 | CUSTOM_SYNC_SECRET: ${{ secrets.CUSTOM_SYNC_SECRET }} 49 | 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | .DS_Store 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # custom 108 | /temp/ 109 | /dist/ 110 | /work/ 111 | /version 112 | /*.txt 113 | /build/iTerm2-Color-Schemes 114 | /database 115 | /electerm_session_logs 116 | /src/electerm-react 117 | /test-results 118 | /data 119 | /src/client/electerm-react -------------------------------------------------------------------------------- /.sample.env: -------------------------------------------------------------------------------- 1 | # run `cp .sample.env .env` to create your local env 2 | 3 | # production server config 4 | HOST=127.0.0.1 5 | PORT=5577 6 | # SERVER=http://xxx.com 7 | # CDN=http://xxx.com 8 | SERVER_SECRET=x4345655@!3446%5FS334*sfdgsf # change it in production env 9 | # ENABLE_AUTH=1 10 | SERVER_USER=default_user 11 | SERVER_PASS=1 12 | TOKEN_EXPIRED_TIME=120y 13 | # DB_PATH=/some/custom-path-to-db-folder 14 | # 15 | # to use same data as desktop electerm 16 | # for Mac OS DB_PATH="/Users//Library/Application Support/electerm" 17 | # for Linux OS DB_PATH="/home//.config/electerm" 18 | # for Windows OS DB_PATH="C:\\Users\\\\AppData\\Roaming\\electerm" 19 | 20 | # dev server host port 21 | DEV_HOST=127.0.0.1 22 | DEV_PORT=5580 23 | 24 | # # test 25 | # ## test ssh ip or host, required 26 | # TEST_HOST=your-host 27 | 28 | # ## test ssh username, required 29 | # TEST_USER=your-username 30 | 31 | # ## test ssh password, required 32 | # TEST_PASS=your-pass 33 | 34 | # # for sync data test 35 | # GIST_ID= 36 | # GIST_TOKEN= 37 | # GITEE_TOKEN= 38 | # GITEE_ID= 39 | # CUSTOM_SYNC_URL= 40 | # CUSTOM_SYNC_USER= 41 | # CUSTOM_SYNC_SECRET= -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) since 2017~ ZHAO Xudong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 | 7 | [中文](README_cn.md) 8 | 9 | # electerm-web [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Open%20sourced%20terminal%2Fssh%2Fsftp%20client(linux%2C%20mac%2C%20win)&url=https%3A%2F%2Fgithub.com%2Felecterm%2Felecterm-web&hashtags=electerm,ssh,terminal,sftp) 10 | 11 | This is web app version of [electerm app](https://github.com/electerm/electerm), running in browser, almost has the same features as the desktop version. 12 | 13 | Powered by [manate](https://github.com/tylerlong/manate) 14 | 15 | [![GitHub version](https://img.shields.io/github/release/electerm/electerm/all.svg)](https://github.com/electerm/electerm/releases) 16 | [![license](https://img.shields.io/github/license/electerm/electerm.svg)](https://github.com/electerm/electerm-dev/blob/master/LICENSE) 17 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 18 | [![Get it from the Snap Store](https://img.shields.io/badge/Snap-Store-green)](https://snapcraft.io/electerm) 19 | [![Get it from the Microsoft Store](https://img.shields.io/badge/Microsoft-Store-blue)](https://www.microsoft.com/store/apps/9NCN7272GTFF) 20 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/electerm?label=Sponsors)](https://github.com/sponsors/electerm) 21 | 22 | Open-sourced terminal/ssh/telnet/serialport/VNC/RDP/sftp/ftp client(linux, mac, win). 23 | 24 |
25 | 26 |
27 | 28 | ## Features 29 | 30 | - Works as a terminal/file manager or ssh/telnet/serialport/RDP/VNC/WEB/sftp/ftp client 31 | - Global hotkey to toggle window visibility (similar to guake, default is `ctrl + 2`) 32 | - Multi platform(linux, mac, win) 33 | - 🇺🇸 🇨🇳 🇧🇷 🇷🇺 🇪🇸 🇫🇷 🇹🇷 🇭🇰 🇯🇵 🇸🇦 🇩🇪 🇰🇷 Multi-language support([electerm-locales](https://github.com/electerm/electerm-locales), contributions/fixes welcome) 34 | - Double click to directly edit (small) remote files. 35 | - Auth with publicKey + password. 36 | - Support Zmodem(rz, sz). 37 | - Support ssh tunnel. 38 | - Support [Trzsz](https://github.com/trzsz/trzsz)(trz/tsz), similar to rz/sz, and compatible with tmux. 39 | - Transparent window(Mac, win). 40 | - Terminal background image. 41 | - Global/session proxy. 42 | - Quick commands 43 | - UI/terminal theme 44 | - Sync bookmarks/themes/quick commands to github/gitee secret gist 45 | - Quick input to one or all terminals. 46 | - Init from url query string [wiki](https://github.com/electerm/electerm-web/wiki/Init-from-url-query-string) 47 | - Support mobile device(responsive design) 48 | - AI assistant integration (supporting [DeepSeek](https://www.deepseek.com), OpenAI, and other AI APIs) to help with command suggestions, script writing, and explaining selected terminal content 49 | 50 | ## Download 51 | 52 | todo 53 | 54 | ## Upgrade 55 | 56 | todo 57 | 58 | ## Support 59 | 60 | Would love to hear from you, please tell me what you think, [submit an issue](https://github.com/electerm/electerm-web/issues/new/choose), [Start a new discussion](https://github.com/electerm/electerm-web/discussions/new), [create/fix language files](https://github.com/electerm/electerm-locales) or create pull requests, all welcome. 61 | 62 | ## Sponsor this project 63 | 64 | github sponsor 65 | 66 | [https://github.com/sponsors/electerm](https://github.com/sponsors/electerm) 67 | 68 | kofi 69 | 70 | [https://ko-fi.com/zhaoxudong](https://ko-fi.com/zhaoxudong) 71 | 72 | wechat donate 73 | 74 | [![wechat donate](https://electerm.html5beta.com/electerm-wechat-donate.png)](https://github.com/electerm) 75 | 76 | ## Prerequisites 77 | 78 | - git 79 | - Nodejs 20+/npm, recommend use [fnm](https://github.com/Schniz/fnm) to install nodejs/npm 80 | - python/make tools, for Linux: `sudo apt install -y make python g++ build-essential`, for MacOS: install Xcode, for Windows, install `vs studio` or `npm install --global --production windows-build-tools` 81 | 82 | ## One line script to deploy from source code 83 | 84 | for Linux or Mac 85 | 86 | ```sh 87 | curl -o- https://electerm.html5beta.com/scripts/one-line-web.sh | bash 88 | ``` 89 | or 90 | 91 | ```sh 92 | wget -qO- https://electerm.html5beta.com/scripts/one-line-web.sh | bash 93 | ``` 94 | 95 | for Windows 96 | 97 | ```powershell 98 | Invoke-WebRequest -Uri "https://electerm.html5beta.com/scripts/one-line-web.bat" -OutFile "one-line-web.bat" 99 | cmd.exe /c ".\one-line-web.bat" 100 | 101 | ``` 102 | 103 | ## Deploy from docker image 104 | 105 | Check [electerm-web-docker](https://github.com/electerm/electerm-web-docker) 106 | 107 | ## Dev 108 | 109 | ```bash 110 | # tested in ubuntu16.04+/mac os 10.13+ only 111 | # needs nodejs/npm, suggest using nvm to install nodejs/npm 112 | # https://github.com/creationix/nvm 113 | # with nodejs 18.x 114 | 115 | git clone git@github.com:electerm/electerm-web.git 116 | cd electerm-web 117 | cp .sample.env .env 118 | # edit DB_PATH to set db path, default path ./database 119 | # to use same data as desktop electerm 120 | # for Mac OS DB_PATH="/Users//Library/Application Support/electerm" 121 | # for Linux OS DB_PATH="/home//.config/electerm" 122 | # for Windows OS DB_PATH="C:\\Users\\\\AppData\\Roaming\\electerm" 123 | 124 | npm i 125 | 126 | # start webpack dev server 127 | npm start 128 | 129 | # in a separate terminal session run app 130 | npm run dev 131 | 132 | #then visit http://127.0.0.1:5580 with browser 133 | 134 | # code format check 135 | npm run lint 136 | 137 | # code format fix 138 | npm run fix 139 | ``` 140 | 141 | ## Build && run in production 142 | 143 | ```sh 144 | npm run build 145 | 146 | # run production server 147 | npm run prod 148 | 149 | # or ./build/bin/run-prod.sh 150 | 151 | #then visit http://127.0.0.1:5577 with browser 152 | ``` 153 | 154 | ## Run in server 155 | 156 | ```sh 157 | # Edit .env, set 158 | ENABLE_AUTH=1 # if not enabled, everyone can use it without login 159 | SERVER_SECRET=some-server-secret 160 | SERVER_PASS=some-login-pass-word 161 | SERVER=http://xxx.com # if you want to bind domain 162 | CDN=http://xxx.com # if you want to use cdn serve static files 163 | 164 | # run prod app 165 | ./run-electerm-web.sh 166 | 167 | # Check examples/nginx.conf examples/nginx-ssl.conf for domain binding nginx conf example 168 | ``` 169 | 170 | ## Test 171 | 172 | ```bash 173 | npx playwright install --with-deps chromium 174 | # or with a proxy if needed 175 | HTTPS_PROXY=http://127.0.0.1:1087 npx playwright install --with-deps chromium 176 | # then edit .env, edit test related env 177 | npm run test 178 | ``` 179 | 180 | ## License 181 | 182 | MIT 183 | -------------------------------------------------------------------------------- /README_cn.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 | 7 | [English](README_cn.md) 8 | 9 | # electerm-web [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Open%20sourced%20terminal%2Fssh%2Fsftp%20client(linux%2C%20mac%2C%20win)&url=https%3A%2F%2Fgithub.com%2Felecterm%2Felecterm-web&hashtags=electerm,ssh,terminal,sftp) 10 | 11 | 这是Electerm应用的Web版本,可以在浏览器中运行,几乎拥有与桌面版本相同的功能。 12 | 13 | Powered by [manate](https://github.com/tylerlong/manate) 14 | 15 | [![GitHub version](https://img.shields.io/github/release/electerm/electerm/all.svg)](https://github.com/electerm/electerm/releases) 16 | [![license](https://img.shields.io/github/license/electerm/electerm.svg)](https://github.com/electerm/electerm-dev/blob/master/LICENSE) 17 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 18 | [![Get it from the Snap Store](https://img.shields.io/badge/Snap-Store-green)](https://snapcraft.io/electerm) 19 | [![Get it from the Microsoft Store](https://img.shields.io/badge/Microsoft-Store-blue)](https://www.microsoft.com/store/apps/9NCN7272GTFF) 20 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/electerm?label=Sponsors)](https://github.com/sponsors/electerm) 21 | 22 | 开源的终端/SSH/Telnet/串口//VNC/RDP/WEB/SFTP/FTP客户端(Linux,Mac,Windows). 23 | 24 |
25 | 26 |
27 | 28 | ## 功能 29 | 30 | - 可作为终端/文件管理器或 ssh/telnet/serialport/RDP/VNC/sftp/ftp 客户端 31 | - 可全局热键切换窗口可见性(类似于 guake,默认是 `ctrl + 2`) 32 | - 支持多平台(Linux、Mac、Win) 33 | - 🇺🇸 🇨🇳 🇧🇷 🇷🇺 🇪🇸 🇫🇷 🇹🇷 🇭🇰 🇯🇵 🇸🇦 🇩🇪 🇰🇷 支持多语言([electerm-locales](https://github.com/electerm/electerm-locales),欢迎贡献/修复) 34 | - 双击即可直接编辑(小)远程文件 35 | - 使用公钥 + 密码进行身份验证 36 | - 支持 Zmodem(rz、sz) 37 | - 支持 ssh 隧道 38 | - 支持 [Trzsz](https://github.com/trzsz/trzsz)(trz/tsz),类似于 rz/sz,并与 tmux 兼容 39 | - 支持透明窗口(Mac、win) 40 | - 支持终端背景图片 41 | - 支持全局/会话代理 42 | - 支持快速命令 43 | - 支持 UI/终端主题 44 | - 将书签/主题/快速命令同步到 github/gitee 的 secret gist 45 | - 支持快速输入到任意或所有终端 46 | - 可从 URL 查询字符串进行初始化 [wiki](https://github.com/electerm/electerm-web/wiki/Init-from-url-query-string) 47 | - 支持移动设备(响应式设计) 48 | - AI助手集成(支持[DeepSeek](https://www.deepseek.com)、OpenAI等AI API),协助命令建议、脚本编写、以及解释所选终端内容 49 | 50 | ## 下载 51 | 52 | 待完成 53 | 54 | ## 升级 55 | 56 | 待完成 57 | 58 | ## 支持 59 | 60 | 非常欢迎您与我联系,请告诉我您的想法,[提交问题](https://github.com/electerm/electerm-web/issues/new/choose),[发起新的讨论](https://github.com/electerm/electerm-web/discussions/new),[创建/修复语言文件](https://github.com/electerm/electerm-locales) 或创建 pull requests,都非常欢迎。 61 | 62 | ## 赞助此项目 63 | 64 | github 赞助 65 | 66 | [https://github.com/sponsors/electerm](https://github.com/sponsors/electerm) 67 | 68 | kofi 69 | 70 | [https://ko-fi.com/zhaoxudong](https://ko-fi.com/zhaoxudong) 71 | 72 | 微信捐赠 73 | 74 | [![wechat donate](https://electerm.html5beta.com/electerm-wechat-donate.png)](https://github.com/electerm) 75 | 76 | ## 先决条件 77 | 78 | - git 79 | - Nodejs 18+/npm,推荐使用 [fnm](https://github.com/Schniz/fnm) 安装 nodejs/npm 80 | - python/make 工具,对于 Linux:`sudo apt install -y make python g++ build-essential`,对于 MacOS:安装 Xcode,对于 Windows,安装 `vs studio` 或 `npm install --global --production windows-build-tools` 81 | 82 | ## 一行脚本从源代码部署 83 | 84 | 对于 Linux 或 Mac 85 | 86 | ```sh 87 | curl -o- https://electerm.html5beta.com/scripts/one-line-web.sh | bash 88 | ``` 89 | 或 90 | 91 | ```sh 92 | wget -qO- https://electerm.html5beta.com/scripts/one-line-web.sh | bash 93 | ``` 94 | 95 | 对于 Windows 96 | 97 | ```powershell 98 | Invoke-WebRequest -Uri "https://electerm.html5beta.com/scripts/one-line-web.bat" -OutFile "one-line-web.bat" 99 | cmd.exe /c ".\one-line-web.bat" 100 | ``` 101 | 102 | ## 从 docker 镜像部署 103 | 104 | 查看 [electerm-web-docker](https://github.com/electerm/electerm-web-docker) 105 | 106 | ## 开发 107 | 108 | ```bash 109 | # 仅在 ubuntu16.04+/mac os 10.13+ 上测试过 110 | # 需要 nodejs/npm,建议使用 nvm 安装 nodejs/npm 111 | # https://github.com/creationix/nvm 112 | # 使用 nodejs 18.x 113 | 114 | git clone git@github.com:electerm/electerm-web.git 115 | cd electerm-web 116 | cp .sample.env .env 117 | # 编辑 DB_PATH 设置数据库路径,默认路径 ./database 118 | # 若要使用与桌面版 electerm 相同的数据库数据 119 | # 对于 Mac OS,DB_PATH="/Users//Library/Application Support/electerm" 120 | # 对于 Linux OS,DB_PATH="/home//.config/electerm" 121 | # 对于 Windows OS,DB_PATH="C:\\Users\\\\AppData\\Roaming\\electerm" 122 | 123 | npm install 124 | 125 | # 启动 webpack 开发服务器 126 | npm start 127 | 128 | # 在另一个终端会话中运行应用程序 129 | npm run dev 130 | 131 | # 然后访问 http://127.0.0.1:5580 在浏览器中查看 132 | 133 | # 代码格式检查 134 | npm run lint 135 | 136 | # 代码格式修复 137 | npm run fix 138 | ``` 139 | 140 | ## 构建 && 在生产环境中运行 141 | 142 | ```sh 143 | npm run build 144 | 145 | # 在生产环境中运行应用程序服务器 146 | npm run prod 147 | 148 | # 或者 ./build/bin/run-prod.sh 149 | 150 | # 然后访问 http://127.0.0.1:5577 在浏览器中查看 151 | ``` 152 | 153 | ## 在服务器上运行 154 | 155 | ```sh 156 | # 编辑 .env,设置以下参数: 157 | ENABLE_AUTH=1 # 如果未启用,每个人都可以无需登录使用它。 158 | SERVER_SECRET=some-server-secret 159 | SERVER_PASS=some-login-pass-word 160 | SERVER=http://xxx.com # 如果要绑定域名。 161 | CDN=http://xxx.com # 如果要使用 CDN 提供静态文件服务。 162 | 163 | # 运行生产应用程序服务器脚本文件。 164 | ./run-electerm-web.sh 165 | 166 | # 查看 examples/nginx.conf 和 examples/nginx-ssl.conf 以获取域名绑定的 nginx 配置示例。 167 | ``` 168 | 169 | ## 测试 170 | 171 | ```bash 172 | npx playwright install --with-deps chromium 173 | # 或者如果需要代理,请使用以下命令: 174 | HTTPS_PROXY=http://127.0.0.1:1087 npx playwright install --with-deps chromium 175 | # 然后编辑 .env,编辑与测试相关的环境变量。 176 | npm run test 177 | ``` 178 | 179 | ## 许可证 180 | 181 | MIT 182 | -------------------------------------------------------------------------------- /build/bin/.yarnclean: -------------------------------------------------------------------------------- 1 | # files 2 | Makefile 3 | Gulpfile.js 4 | Gruntfile.js 5 | .tern-project 6 | .gitattributes 7 | .editorconfig 8 | .eslintrc 9 | .jshintrc 10 | .flowconfig 11 | .documentup.json 12 | .yarn-metadata.json 13 | .travis.yml 14 | appveyor.yml 15 | LICENSE.txt 16 | LICENSE 17 | AUTHORS 18 | CONTRIBUTORS 19 | .yarn-integrity 20 | *.md 21 | *.ts 22 | *.jst 23 | *.coffee 24 | 25 | # folders 26 | __tests__ 27 | test 28 | tests 29 | powered-test 30 | docs 31 | doc 32 | website 33 | images 34 | assets 35 | example 36 | examples 37 | coverage 38 | .nyc_output 39 | 40 | # ignores 41 | !*.d.ts 42 | -------------------------------------------------------------------------------- /build/bin/build-common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * common functions for build 3 | */ 4 | 5 | import { exec } from 'child_process' 6 | 7 | export const run = function (cmd) { 8 | return new Promise((resolve, reject) => { 9 | exec(cmd, (err, stdout, stderr) => { 10 | if (err || stderr) { 11 | return reject(err || stderr) 12 | } 13 | resolve(stdout) 14 | }) 15 | }).then(console.log).catch(console.error) 16 | } 17 | 18 | export const cwd = process.cwd() 19 | -------------------------------------------------------------------------------- /build/bin/build.js: -------------------------------------------------------------------------------- 1 | /** 2 | * build 3 | */ 4 | import pkg from 'shelljs' 5 | const { exec, echo, mkdir } = pkg 6 | 7 | echo('start build') 8 | 9 | const timeStart = Date.now() 10 | 11 | // echo('clean') 12 | // exec('npm run clean') 13 | mkdir('-p', 'dist/assets/iTerm2-Color-Schemes') 14 | echo('js/css file') 15 | exec('npm run vite-build') 16 | echo('copy file') 17 | exec('node ./build/bin/copy.js') 18 | 19 | const endTime = Date.now() 20 | echo(`done build in ${(endTime - timeStart) / 1000} s`) 21 | -------------------------------------------------------------------------------- /build/bin/clean.js: -------------------------------------------------------------------------------- 1 | import pkg from 'shelljs' 2 | 3 | const { rm } = pkg 4 | 5 | rm('-rf', [ 6 | 'dist' 7 | ]) 8 | -------------------------------------------------------------------------------- /build/bin/copy.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import pkg from 'shelljs' 3 | import { cwd } from './build-common.js' 4 | 5 | const { cp } = pkg 6 | 7 | const f1 = resolve( 8 | cwd, 9 | 'src/client/statics/*' 10 | ) 11 | const from0 = resolve( 12 | cwd, 13 | 'node_modules/vscode-icons/icons' 14 | ) 15 | const from1 = resolve( 16 | cwd, 17 | 'src/app/views' 18 | ) 19 | const from3 = resolve( 20 | cwd, 21 | 'build/iTerm2-Color-Schemes/electerm/*' 22 | ) 23 | const t1 = resolve( 24 | cwd, 25 | 'dist/assets/' 26 | ) 27 | const to1 = resolve( 28 | cwd, 29 | 'dist' 30 | ) 31 | const to2 = resolve( 32 | cwd, 33 | 'dist/assets/icons' 34 | ) 35 | const to4 = resolve( 36 | cwd, 37 | 'dist/assets/iTerm2-Color-Schemes/' 38 | ) 39 | const arr = [ 40 | { 41 | from: f1, 42 | to: t1 43 | }, 44 | { 45 | from: from1, 46 | to: to1 47 | }, 48 | { 49 | from: from0, 50 | to: to2 51 | }, { 52 | from: from3, 53 | to: to4 54 | } 55 | ] 56 | 57 | for (const obj of arr) { 58 | const { 59 | file, from, to 60 | } = obj 61 | if (file) { 62 | cp(from, to) 63 | } else { 64 | cp('-r', from, to) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /build/bin/install.js: -------------------------------------------------------------------------------- 1 | import pkg from 'shelljs' 2 | 3 | const { echo, rm, cp } = pkg 4 | 5 | echo('install required modules') 6 | 7 | rm('-rf', 'src/client/electerm-react') 8 | cp('-r', 'node_modules/@electerm/electerm-react/client', 'src/client/electerm-react') 9 | echo('done install required modules') 10 | -------------------------------------------------------------------------------- /build/bin/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd `dirname $0` 3 | cd ../../ 4 | npm run lint -------------------------------------------------------------------------------- /build/bin/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd `dirname $0` 3 | cd ../.. 4 | git co main 5 | git pull 6 | git pull 7 | git delete-branch build 8 | git create-branch build 9 | git push origin build -u 10 | git co - 11 | -------------------------------------------------------------------------------- /build/bin/run-prod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd `dirname $0` 3 | cd ../.. 4 | NODE_ENV=production node ./src/app/app.js -------------------------------------------------------------------------------- /build/vite/common.js: -------------------------------------------------------------------------------- 1 | import { config as conf } from 'dotenv' 2 | import { readFileSync } from 'fs' 3 | import { resolve } from 'path' 4 | 5 | conf() 6 | 7 | export const cwd = process.cwd() 8 | export const env = process.env 9 | export const isProd = env.NODE_ENV === 'production' 10 | export const isMac = env.PLATFORM === 'darwin' 11 | export const isWin = env.PLATFORM === 'win32' 12 | const packPath = resolve(cwd, 'package.json') 13 | export const pack = JSON.parse(readFileSync(packPath).toString()) 14 | export const version = pack.version 15 | export const viewPath = resolve(cwd, 'src/app/views') 16 | export const staticPaths = [ 17 | { 18 | dir: resolve(cwd, 'node_modules/vscode-icons/icons'), 19 | path: '/icons' 20 | }, 21 | { 22 | dir: resolve(cwd, 'node_modules/@electerm/electerm-resource/tray-icons'), 23 | path: '/images' 24 | }, 25 | { 26 | dir: resolve(cwd, 'node_modules/@electerm/electerm-resource/res/imgs'), 27 | path: '/images' 28 | }, 29 | { 30 | dir: resolve(cwd, 'src/client/statics'), 31 | path: '/' 32 | } 33 | ] 34 | -------------------------------------------------------------------------------- /build/vite/conf.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import { cwd, version } from './common.js' 4 | import { resolve } from 'path' 5 | import def from './def.js' 6 | import commonjs from 'vite-plugin-commonjs' 7 | 8 | function buildInput () { 9 | return { 10 | electerm: resolve(cwd, 'src/client/entry-web/electerm.jsx'), 11 | basic: resolve(cwd, 'src/client/entry-web/basic.js'), 12 | worker: resolve(cwd, 'src/client/entry-web/worker.js'), 13 | rle: resolve(cwd, 'src/client/entry-web/rle.js') 14 | } 15 | } 16 | 17 | // Custom plugin to combine CSS with separate basic.css 18 | function combineCSSPlugin () { 19 | return { 20 | name: 'combine-css', 21 | generateBundle (options, bundle) { 22 | let mainCSS = '' 23 | let basicCSS = '' 24 | 25 | for (const fileName in bundle) { 26 | if (fileName.endsWith('.css')) { 27 | // Get the CSS source 28 | const cssSource = bundle[fileName].source 29 | // Check if this CSS is from basic.js's imports 30 | // We can check the source for imports from mobile.styl or basic.styl 31 | if (fileName.includes('basic.css')) { 32 | basicCSS += cssSource 33 | } else { 34 | mainCSS += cssSource 35 | } 36 | 37 | // Remove the original CSS chunk 38 | delete bundle[fileName] 39 | } 40 | } 41 | 42 | // Emit main CSS bundle 43 | if (mainCSS) { 44 | this.emitFile({ 45 | type: 'asset', 46 | fileName: `css/${version}-electerm.css`, 47 | source: mainCSS 48 | }) 49 | } 50 | 51 | // Emit basic CSS bundle 52 | if (basicCSS) { 53 | this.emitFile({ 54 | type: 'asset', 55 | fileName: `css/${version}-basic.css`, 56 | source: basicCSS 57 | }) 58 | } 59 | } 60 | } 61 | } 62 | 63 | // https://vitejs.dev/config/ 64 | export default defineConfig({ 65 | plugins: [ 66 | commonjs(), 67 | react({ include: /\.(mdx|js|jsx|ts|tsx|mjs)$/ }), 68 | combineCSSPlugin() 69 | ], 70 | define: def, 71 | publicDir: false, 72 | css: { 73 | codeSplit: false 74 | }, 75 | root: resolve(cwd), 76 | build: { 77 | emptyOutDir: false, 78 | outDir: resolve(cwd, 'dist/assets'), 79 | rollupOptions: { 80 | input: buildInput(), 81 | output: { 82 | inlineDynamicImports: false, 83 | format: 'esm', 84 | entryFileNames: `js/[name]-${version}.js`, 85 | chunkFileNames: `chunk/[name]-${version}-[hash].js`, 86 | assetFileNames: chunkInfo => { 87 | const { name } = chunkInfo 88 | return name.endsWith('.css') 89 | ? `css/_temp_${name}` 90 | : `images/${name}` 91 | }, 92 | manualChunks: (id) => { 93 | if (id.includes('node_modules')) { 94 | if (id.includes('react') || 95 | id.includes('react-dom') || 96 | id.includes('scheduler') || 97 | id.includes('prop-types')) { 98 | return 'react-vendor' 99 | } 100 | if ( 101 | id.includes('react-colorful') || 102 | id.includes('react-delta-hooks') || 103 | id.includes('react-markdown') 104 | ) { 105 | return 'react-utils' 106 | } 107 | if (id.includes('lodash-es')) { 108 | return 'lodash-es' 109 | } 110 | if (id.includes('dayjs')) { 111 | return 'dayjs' 112 | } 113 | if (id.includes('@ant-design/icons')) { 114 | return 'ant-icons' 115 | } 116 | if (id.includes('@ant-design') || id.includes('@rc-component') || id.includes('classnames') || id.includes('@ctrl/tinycolor')) { 117 | return 'antd-deps' 118 | } 119 | if (id.includes('antd')) { 120 | return 'antd' 121 | } 122 | if (id.includes('@xterm/addon')) { 123 | return 'xterm-addons' 124 | } 125 | if (id.includes('@xterm')) { 126 | return 'xterm' 127 | } 128 | if (id.includes('trzsz')) { 129 | return 'trzsz' 130 | } 131 | if (id.includes('manate')) { 132 | return 'manate' 133 | } 134 | if (id.includes('zmodem-ts')) { 135 | return 'zmodem-ts' 136 | } 137 | if (id.includes('vscode-icons-js')) { 138 | return 'vscode-icons-js' 139 | } 140 | if (id.includes('@novnc/novnc')) { 141 | return 'novnc' 142 | } 143 | // Combine rest of node_modules into one chunk 144 | return 'vendor' 145 | } else if (id.includes('batch-op/batch-op')) { 146 | return 'batch-op' 147 | } 148 | } 149 | } 150 | } 151 | } 152 | }) 153 | -------------------------------------------------------------------------------- /build/vite/def.js: -------------------------------------------------------------------------------- 1 | import { version } from './common.js' 2 | 3 | export default { 4 | 'process.env.VER': JSON.stringify(version) 5 | } 6 | -------------------------------------------------------------------------------- /build/vite/dev-server.js: -------------------------------------------------------------------------------- 1 | import logger from 'morgan' 2 | import { 3 | viewPath, 4 | env, 5 | staticPaths, 6 | pack, 7 | isProd, 8 | cwd, 9 | isWin, 10 | isMac 11 | } from './common.js' 12 | import express from 'express' 13 | import { createServer as createViteServer } from 'vite' 14 | import conf from './conf.js' 15 | import os from 'os' 16 | import copy from 'json-deep-copy' 17 | import proxy from 'express-http-proxy' 18 | import fsFunctions from '../../src/app/common/fs-functions.js' 19 | import { createToken } from '../../src/app/lib/jwt.js' 20 | import { logDir } from '../../src/app/server/session-log.js' 21 | import { loadDevStylus } from '../../src/app/lib/style.js' 22 | 23 | const devPort = env.DEV_PORT || 5570 24 | const devHost = env.DEV_HOST || '127.0.0.1' 25 | const port = env.PORT || 5577 26 | const host = env.HOST || '127.0.0.1' 27 | const h = '' 28 | const tar = `http://${host}:${port}` 29 | const base = { 30 | version: pack.version, 31 | isDev: !isProd, 32 | siteName: pack.name, 33 | isWin, 34 | isMac, 35 | fsFunctions, 36 | packInfo: pack, 37 | home: os.homedir(), 38 | server: h, 39 | cdn: h, 40 | stylus: loadDevStylus(), 41 | isWebApp: true, 42 | sessionLogPath: logDir, 43 | tokenElecterm: process.env.ENABLE_AUTH ? '' : createToken() 44 | } 45 | 46 | function handleIndex (req, res) { 47 | const data = { 48 | ...base, 49 | query: req.query 50 | } 51 | const view = 'index' 52 | res.render(view, { 53 | ...data, 54 | _global: copy(data) 55 | }) 56 | } 57 | 58 | function redirect (req, res) { 59 | const { 60 | name 61 | } = req.params 62 | const mapper = { 63 | electerm: '/src/client/entry-web/electerm.jsx', 64 | worker: '/src/client/entry-web/worker.js' 65 | } 66 | res.redirect(mapper[name]) 67 | } 68 | 69 | async function createServer () { 70 | const app = express() 71 | 72 | // Create Vite server in middleware mode and configure the app type as 73 | // 'custom', disabling Vite's own HTML serving logic so parent server 74 | // can take control 75 | const vite = await createViteServer({ 76 | ...conf, 77 | server: { 78 | middlewareMode: true, 79 | hmr: { 80 | overlay: true 81 | } 82 | }, 83 | appType: 'custom' 84 | }) 85 | app.use( 86 | logger('dev') 87 | ) 88 | app.use(express.json()) 89 | app.use(express.urlencoded({ 90 | extended: true 91 | })) 92 | staticPaths.forEach(({ path, dir }) => { 93 | app.use( 94 | path, 95 | express.static(dir, { maxAge: '170d' }) 96 | ) 97 | }) 98 | 99 | app.set('views', viewPath) 100 | app.set('view engine', 'pug') 101 | 102 | // Use vite's connect instance as middleware. If you use your own 103 | // express router (express.Router()), you should use router.use 104 | app.use(vite.middlewares) 105 | app.get(['/', '/index.html'], handleIndex) 106 | app.get('/:dir/:name.:ext', redirect) 107 | app.listen(devPort, devHost, () => { 108 | console.log('cwd:', cwd) 109 | console.log(`server started at ${h || `http://${devHost}:${devPort}`}`) 110 | }) 111 | app.use( 112 | '/api/login', 113 | proxy(tar, { 114 | proxyReqPathResolver: function (req) { 115 | return '/api/login' 116 | } 117 | }) 118 | ) 119 | app.use( 120 | '/api/get-constants', 121 | proxy(tar, { 122 | proxyReqPathResolver: function (req) { 123 | return '/api/get-constants' 124 | } 125 | }) 126 | ) 127 | } 128 | 129 | createServer() 130 | -------------------------------------------------------------------------------- /config.sample.js: -------------------------------------------------------------------------------- 1 | /** 2 | * sample config file 3 | * write your own custom db wrapper or extensions 4 | * use cp config.sample.js config.js to create one 5 | */ 6 | 7 | /** 8 | * db class 9 | * similar to mongodb 10 | */ 11 | 12 | /* 13 | export class Db { 14 | constructor (conf) { 15 | console.log(conf.tableName) 16 | } 17 | async findOne () {} 18 | async insert () {} 19 | async update () {} 20 | async remove () {} 21 | async find () {} 22 | } 23 | */ 24 | 25 | /** 26 | * extensions 27 | */ 28 | 29 | /* 30 | export const extensions = [ 31 | { 32 | appExtend: (app, jwtMiddleWare, jwtErrorHandler) => { 33 | app.get('/hello', (req, res) => { 34 | res.send('hello world') 35 | }) 36 | app.get('/api/api-need-login', jwtMiddleWare, jwtErrorHandle, (req, res) => { 37 | res.send('hello api') 38 | }) 39 | } 40 | } 41 | ] 42 | */ 43 | -------------------------------------------------------------------------------- /examples/nginx-ssl.conf: -------------------------------------------------------------------------------- 1 | 2 | upstream electerm { 3 | server 127.0.0.1:5577; 4 | } 5 | 6 | server { 7 | listen 443 ssl; 8 | ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2; 9 | ssl_prefer_server_ciphers on; 10 | try_files $uri $uri.html /index.html =404; 11 | # disables all weak ciphers 12 | ssl_ciphers "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS !RC4"; 13 | keepalive_timeout 75 75; 14 | ssl_certificate /home/path-to/certs/electerm.xxxx.com/fullchain.pem; 15 | ssl_certificate_key /home/path-to/certs/electerm.xxxx.com/privkey.pem; 16 | ssl_session_timeout 5m; 17 | 18 | server_name electerm.xxxx.com; 19 | 20 | location / { 21 | proxy_pass http://electerm; 22 | proxy_buffering off; 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Real-IP $remote_addr; 25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 26 | proxy_set_header Upgrade $http_upgrade; 27 | proxy_set_header Connection "upgrade"; 28 | proxy_read_timeout 86400; # 1 day timeout 29 | proxy_send_timeout 86400; # 1 day timeout 30 | } 31 | } -------------------------------------------------------------------------------- /examples/nginx.conf: -------------------------------------------------------------------------------- 1 | 2 | upstream electerm { 3 | server 127.0.0.1:5577; 4 | } 5 | 6 | server { 7 | listen 80; 8 | server_name electerm.xxxx.com; 9 | 10 | location / { 11 | proxy_pass http://electerm; 12 | proxy_buffering off; 13 | proxy_set_header Host $host; 14 | proxy_set_header X-Real-IP $remote_addr; 15 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 16 | proxy_set_header Upgrade $http_upgrade; 17 | proxy_set_header Connection "upgrade"; 18 | proxy_read_timeout 86400; # 1 day timeout 19 | proxy_send_timeout 86400; # 1 day timeout 20 | } 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electerm-web", 3 | "version": "2.91.8", 4 | "description": "Running electerm in as web app", 5 | "main": "src/app/app.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "NODE_ENV=development node ./src/app/app.js", 9 | "prod": "NODE_ENV=production node ./src/app/app.js", 10 | "build": "npm run compile", 11 | "start": "NODE_ENV=development node ./build/vite/dev-server.js", 12 | "clean": "node build/bin/clean", 13 | "compile": "node build/bin/build", 14 | "vite-build": "cross-env NODE_ENV=production vite build --config ./build/vite/conf.js", 15 | "install": "node build/bin/install", 16 | "lint": "./node_modules/.bin/standard --verbose", 17 | "fix": "./node_modules/.bin/standard --fix", 18 | "lock": "npm i --package-lock-only", 19 | "r": "./build/bin/release", 20 | "test": "npm run test1 && npm run test2", 21 | "test1": "./node_modules/.bin/playwright test test/e2e/00*.js --config test/playwright.conf.js", 22 | "test2": "./node_modules/.bin/playwright test test/e2e/01*.js --config test/playwright.conf.js", 23 | "test3": "./node_modules/.bin/playwright test test/unit/*.js" 24 | }, 25 | "license": "MIT", 26 | "langugeRepo": "https://github.com/electerm/electerm-locales", 27 | "privacyNoticeLink": "https://github.com/electerm/electerm/wiki/privacy-notice", 28 | "knownIssuesLink": "https://github.com/electerm/electerm/wiki/Know-issues", 29 | "sponsorLink": "https://electerm.html5beta.com/sponsor-electerm.html", 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/electerm/electerm-web.git" 33 | }, 34 | "author": { 35 | "name": "ZHAO Xudong", 36 | "email": "zxdong@gmail.com", 37 | "url": "https://github.com/zxdong262" 38 | }, 39 | "bugs": { 40 | "url": "https://github.com/electerm/electerm-web/issues" 41 | }, 42 | "homepage": "https://electerm.html5beta.com", 43 | "releases": "https://github.com/electerm/electerm-web/releases", 44 | "engines": { 45 | "node": ">=18.0.0" 46 | }, 47 | "preferGlobal": true, 48 | "devDependencies": { 49 | "@ant-design/icons": "5.6.1", 50 | "@electerm/electerm-react": "1.91.8", 51 | "@electerm/electerm-resource": "1.3.7", 52 | "@electerm/strip-ansi": "^1.0.0", 53 | "@novnc/novnc": "^1.4.0", 54 | "@types/node": "22.9.3", 55 | "@vitejs/plugin-react": "4.3.4", 56 | "@xterm/addon-attach": "0.11.0", 57 | "@xterm/addon-canvas": "0.7.0", 58 | "@xterm/addon-fit": "0.10.0", 59 | "@xterm/addon-ligatures": "0.9.0", 60 | "@xterm/addon-search": "0.15.0", 61 | "@xterm/addon-unicode11": "0.8.0", 62 | "@xterm/addon-web-links": "0.11.0", 63 | "@xterm/addon-webgl": "0.18.0", 64 | "@xterm/xterm": "5.5.0", 65 | "antd": "5.24.9", 66 | "classnames": "2.5.1", 67 | "cross-env": "7.0.3", 68 | "escape-string-regexp": "^5.0.0", 69 | "express-http-proxy": "^2.0.0", 70 | "filesize": "10.1.6", 71 | "filesize-parser": "1.5.1", 72 | "firacode": "^6.2.0", 73 | "lodash-es": "^4.17.21", 74 | "manate": "2.0.0", 75 | "memoize-one": "6.0.0", 76 | "react": "18.3.1", 77 | "react-colorful": "^5.6.1", 78 | "react-delta-hooks": "1.1.5", 79 | "react-dom": "18.3.1", 80 | "react-markdown": "9.0.1", 81 | "replace-in-file": "6.3.5", 82 | "shelljs": "0.8.5", 83 | "standard": "^17.1.0", 84 | "trzsz": "1.1.3", 85 | "vite": "6.0.1", 86 | "vite-plugin-commonjs": "0.10.4", 87 | "vscode-icons": "vscode-icons/vscode-icons", 88 | "vscode-icons-js": "11.6.1", 89 | "zmodem-ts": "^1.0.4" 90 | }, 91 | "dependencies": { 92 | "@electerm/electerm-locales": "2.1.46", 93 | "@electerm/electerm-themes": "^1.0.1", 94 | "@electerm/rdpjs": "^1.0.0", 95 | "@electerm/ssh2": "1.16.2", 96 | "@yetzt/nedb": "1.8.0", 97 | "axios": "^1.7.7", 98 | "basic-ftp": "^5.0.5", 99 | "dayjs": "^1.11.13", 100 | "diffie-hellman": "^5.0.3", 101 | "dotenv": "16.3.1", 102 | "electerm-sync": "1.2.1", 103 | "electron-log": "^4.4.8", 104 | "express": "4.21.2", 105 | "express-jwt": "^8.4.1", 106 | "express-ws": "5.0.2", 107 | "fast-deep-equal": "3.1.3", 108 | "find-free-port": "2.0.0", 109 | "font-list": "1.5.1", 110 | "gist-wrapper": "1.0.0", 111 | "gitee-client": "1.0.0", 112 | "glob": "^11.0.0", 113 | "https-proxy-agent": "7.0.1", 114 | "json-deep-copy": "1.3.1", 115 | "jsonwebtoken": "^9.0.1", 116 | "lodash": "4.17.21", 117 | "morgan": "^1.10.0", 118 | "nanoid": "3.3.8", 119 | "node-bash": "5.0.1", 120 | "node-pty": "1.1.0-beta14", 121 | "os-locale-s": "1.0.8", 122 | "pug": "3.0.2", 123 | "serialport": "13.0.0", 124 | "socks": "2.7.1", 125 | "socks-proxy-agent": "8.0.1", 126 | "socksv5": "^0.0.6", 127 | "ssh-config": "5.0.1", 128 | "strip-ansi": "^7.1.0", 129 | "stylus": "0.64.0", 130 | "tar": "7.4.3" 131 | }, 132 | "files": [ 133 | "npm", 134 | "README.md", 135 | "LICENSE" 136 | ], 137 | "standard": { 138 | "globals": [ 139 | "log", 140 | "MouseEvent", 141 | "WebSocket", 142 | "FileReader", 143 | "CustomEvent", 144 | "onmessage", 145 | "self" 146 | ], 147 | "ignore": [ 148 | "/public/", 149 | "src/client/entry-web/rle.js" 150 | ], 151 | "sourceType": "module" 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /run-electerm-web.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd `dirname $0` 3 | NODE_ENV=production node ./src/app/app.js -------------------------------------------------------------------------------- /src/app/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * app entry 3 | */ 4 | 5 | import log from './common/log.js' 6 | import { createApp } from './server/server.js' 7 | 8 | process.on('uncaughtException', (err) => { 9 | log.error('uncaughtException', err) 10 | }) 11 | process.on('unhandledRejection', (err) => { 12 | log.error('unhandledRejection', err) 13 | }) 14 | 15 | async function main () { 16 | log.info('app start') 17 | const app = await createApp() 18 | 19 | const { HOST, PORT } = process.env 20 | 21 | app.listen(PORT, HOST, () => { 22 | log.info(`server runs on http://${HOST}:${PORT}`) 23 | }) 24 | } 25 | 26 | main() 27 | -------------------------------------------------------------------------------- /src/app/common/build-run-scripts.js: -------------------------------------------------------------------------------- 1 | export const buildRunScripts = function (inst) { 2 | return [{ 3 | delay: inst.loginScriptDelay || 0, 4 | script: inst.loginScript 5 | }] 6 | } 7 | -------------------------------------------------------------------------------- /src/app/common/build-ssh-tunnel.js: -------------------------------------------------------------------------------- 1 | export const buildSshTunnels = function (inst) { 2 | return [{ 3 | sshTunnel: inst.sshTunnel, 4 | sshTunnelRemotePort: inst.sshTunnelRemotePort, 5 | sshTunnelLocalPort: inst.sshTunnelLocalPort, 6 | sshTunnelRemoteHost: inst.sshTunnelRemoteHost 7 | }] 8 | } 9 | -------------------------------------------------------------------------------- /src/app/common/config-default.js: -------------------------------------------------------------------------------- 1 | import defaultSettings from './default-setting.js' 2 | 3 | export default { 4 | keepaliveInterval: 0, 5 | rightClickSelectsWord: false, 6 | pasteWhenContextMenu: false, 7 | ctrlOrMetaOpenTerminalLink: false, 8 | ...defaultSettings, 9 | terminalTimeout: 5000, 10 | enableGlobalProxy: false, 11 | zoom: 1, 12 | debug: false, 13 | theme: 'default', 14 | syncSetting: { 15 | lastUpdateTime: Date.now(), 16 | autoSync: false 17 | }, 18 | terminalTypes: [ 19 | 'xterm-256color', 20 | 'xterm-new', 21 | 'xterm-color', 22 | 'xterm-vt220', 23 | 'xterm', 24 | 'linux', 25 | 'vt100', 26 | 'ansi', 27 | 'rxvt' 28 | ], 29 | host: '127.0.0.1' 30 | } 31 | -------------------------------------------------------------------------------- /src/app/common/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * contants shared in app/client 3 | */ 4 | 5 | export const userConfigId = 'userConfig' 6 | export const instSftpKeys = [ 7 | 'connect', 8 | 'list', 9 | 'download', 10 | 'upload', 11 | 'mkdir', 12 | 'getHomeDir', 13 | 'rmdir', 14 | 'stat', 15 | 'lstat', 16 | 'chmod', 17 | 'rename', 18 | 'rm', 19 | 'touch', 20 | 'readlink', 21 | 'realpath', 22 | 'mv', 23 | 'cp', 24 | 'readFile', 25 | 'writeFile' 26 | ] 27 | -------------------------------------------------------------------------------- /src/app/common/count-folder-data.js: -------------------------------------------------------------------------------- 1 | export const getSizeCount = function (str) { 2 | const [s1, s2] = str.split('\n').map(d => d.trim()) 3 | const arr = s1.split(/\s+/) 4 | const d1 = arr[0] 5 | let size = parseFloat(d1) 6 | const unit = d1.slice(-1) 7 | if (unit === 'M') { 8 | size = size / 1024 9 | } else if (unit === 'K') { 10 | size = size / 1024 / 1024 11 | } 12 | const count = parseInt(s2, 10) 13 | return { 14 | count, 15 | size 16 | } 17 | } 18 | 19 | export const getSizeCountWin = function (str) { 20 | const arr = str.trim().split('\n') 21 | let count = 0 22 | let size = 0 23 | let all = 0 24 | for (const s of arr) { 25 | const [s1, s2] = s.trim().split(/\s+/) 26 | if (s1 === 'Count') { 27 | count = parseInt(s2, 10) 28 | all = all + 1 29 | if (all > 1) { 30 | break 31 | } 32 | } else if (s1 === 'Sum') { 33 | all = all + 1 34 | size = parseInt(s2, 10) / 1024 35 | if (all > 1) { 36 | break 37 | } 38 | } 39 | } 40 | return { 41 | count, 42 | size 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/common/create-session-log-file-path.js: -------------------------------------------------------------------------------- 1 | /** 2 | * functions to create ssh log of session 3 | */ 4 | 5 | export const createLogFileName = (id) => { 6 | return `${id}.log` 7 | } 8 | -------------------------------------------------------------------------------- /src/app/common/default-setting.js: -------------------------------------------------------------------------------- 1 | /** 2 | * default setting 3 | */ 4 | 5 | export default { 6 | hotkey: 'Control+2', 7 | sshReadyTimeout: 50000, 8 | scrollback: 3000, 9 | onStartSessions: [], 10 | fontSize: 16, 11 | fontFamily: 'Fira Code, mono, courier-new, courier, monospace', 12 | execWindows: 'System32/WindowsPowerShell/v1.0/powershell.exe', 13 | execMac: 'zsh', 14 | execLinux: 'bash', 15 | execWindowsArgs: [], 16 | execMacArgs: [], 17 | execLinuxArgs: [], 18 | enableGlobalProxy: false, 19 | disableSshHistory: false, 20 | disableTransferHistory: false, 21 | terminalBackgroundImagePath: '', 22 | terminalBackgroundFilterOpacity: 1, 23 | terminalBackgroundFilterBlur: 0, 24 | terminalBackgroundFilterBrightness: 1, 25 | terminalBackgroundFilterGrayscale: 0, 26 | terminalBackgroundFilterContrast: 1, 27 | rendererType: 'canvas', 28 | terminalType: 'xterm-256color', 29 | keepaliveCountMax: 10, 30 | saveTerminalLogToFile: false, 31 | checkUpdateOnStart: true, 32 | cursorBlink: false, 33 | cursorStyle: 'block', 34 | useSystemTitleBar: false, 35 | opacity: 1, 36 | defaultEditor: '', 37 | terminalWordSeparator: './\\()"\'-:,.;<>~!@#$%^&*|+=[]{}`~ ?', 38 | confirmBeforeExit: false, 39 | initDefaultTabOnStart: true, 40 | screenReaderMode: false, 41 | autoRefreshWhenSwitchToSftp: false, 42 | keepaliveInterval: 0, 43 | backspaceMode: '^?', 44 | showHiddenFilesOnSftpStart: true, 45 | terminalInfos: [ 46 | 'uptime', 47 | 'cpu', 48 | 'mem', 49 | 'activities', 50 | 'network', 51 | 'disks' 52 | ], 53 | filePropsEnabled: [ 54 | 'name', 55 | 'size', 56 | 'modifyTime' 57 | ], 58 | hideIP: false, 59 | dataSyncSelected: 'all', 60 | baseURLAI: 'https://api.deepseek.com', 61 | modelAI: 'deepseek-chat', 62 | roleAI: '终端专家,提供不同系统下命令,简要解释用法,用markdown格式', 63 | apiPathAI: '/chat/completions', 64 | sessionLogPath: '', 65 | sshSftpSplitView: false, 66 | showCmdSuggestions: false, 67 | startDirectoryLocal: '' 68 | } 69 | -------------------------------------------------------------------------------- /src/app/common/fs-functions.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'readdirOnly', 3 | 'readdirAndFiles', 4 | 'run', 5 | 'runWinCmd', 6 | 'access', 7 | 'statAsync', 8 | 'lstatAsync', 9 | 'cp', 10 | 'mv', 11 | 'mkdir', 12 | 'touch', 13 | 'chmod', 14 | 'rename', 15 | 'unlink', 16 | 'rmrf', 17 | 'readdirAsync', 18 | 'readFile', 19 | 'readFileAsBase64', 20 | 'writeFile', 21 | 'openFile', 22 | 'zipFolder', 23 | 'unzipFile', 24 | 'readCustom', 25 | 'exists', 26 | 'readdir', 27 | 'mkdir', 28 | 'realpath', 29 | 'statCustom', 30 | 'openCustom', 31 | 'closeCustom', 32 | 'writeCustom', 33 | 'getFolderSize' 34 | ] 35 | -------------------------------------------------------------------------------- /src/app/common/get-json.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | export default (pth) => { 3 | return JSON.parse(readFileSync(pth, 'utf8')) 4 | } 5 | -------------------------------------------------------------------------------- /src/app/common/is-ip.js: -------------------------------------------------------------------------------- 1 | export function isValidIP (input) { 2 | // Check IPv4 format 3 | const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/ 4 | if (ipv4Pattern.test(input)) { 5 | return true 6 | } 7 | 8 | // Check IPv6 format 9 | const ipv6Pattern = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/i 10 | if (ipv6Pattern.test(input)) { 11 | return true 12 | } 13 | 14 | // If input doesn't match IPv4 or IPv6 patterns, it's not a valid IP 15 | return false 16 | } 17 | -------------------------------------------------------------------------------- /src/app/common/log.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import log from 'electron-log' 3 | 4 | config() 5 | log.transports.console.format = '{h}:{i}:{s} {level} › {text}' 6 | 7 | export default log 8 | -------------------------------------------------------------------------------- /src/app/common/pass-enc.js: -------------------------------------------------------------------------------- 1 | export const enc = (str) => { 2 | if (typeof str !== 'string') { 3 | return str 4 | } 5 | return str.split('').map((s, i) => { 6 | return String.fromCharCode((s.charCodeAt(0) + i + 1) % 65536) 7 | }).join('') 8 | } 9 | 10 | export const dec = (str) => { 11 | if (typeof str !== 'string') { 12 | return str 13 | } 14 | return str.split('').map((s, i) => { 15 | return String.fromCharCode((s.charCodeAt(0) - i - 1 + 65536) % 65536) 16 | }).join('') 17 | } 18 | -------------------------------------------------------------------------------- /src/app/common/runtime-constants.js: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | import { resolve } from 'path' 3 | import getJson from './get-json.js' 4 | 5 | export const cwd = process.cwd() 6 | 7 | const platform = os.platform() 8 | const arch = os.arch() 9 | const { NODE_ENV, NODE_TEST } = process.env 10 | export const home = os.homedir() 11 | export const sshKeysPath = resolve( 12 | home, 13 | '.ssh' 14 | ) 15 | export const isWin = platform === 'win32' 16 | export const isMac = platform === 'darwin' 17 | export const isLinux = platform === 'linux' 18 | export const isArm = arch.includes('arm') 19 | export const isDev = NODE_ENV === 'development' 20 | export const iconPath = resolve( 21 | cwd, 22 | isDev 23 | ? 'node_modules/@electerm/electerm-resource/res/imgs/electerm-round-128x128.png' 24 | : 'dist/assets/images/electerm-round-128x128.png' 25 | ) 26 | export const extIconPath = isDev 27 | ? '/node_modules/vscode-icons/icons/' 28 | : '/icons/' 29 | export const defaultUserName = 'default_user' 30 | export const minWindowWidth = 590 31 | export const minWindowHeight = 400 32 | export const defaultLang = 'en_us' 33 | export const tempDir = os.tmpdir() 34 | export const homeOrTmp = os.homedir() || os.tmpdir() 35 | export const packInfo = getJson( 36 | resolve(cwd, 'package.json') 37 | ) 38 | export const isTest = !!NODE_TEST 39 | -------------------------------------------------------------------------------- /src/app/common/time.js: -------------------------------------------------------------------------------- 1 | /** 2 | * time formatter 3 | */ 4 | 5 | import dayjs from 'dayjs' 6 | 7 | export default ( 8 | time = new Date(), 9 | format = 'YYYY-MM-DD HH:mm:ss' 10 | ) => { 11 | return dayjs(time).format(format) 12 | } 13 | -------------------------------------------------------------------------------- /src/app/common/uid.js: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | export default function uid () { 3 | return nanoid(7) 4 | } 5 | -------------------------------------------------------------------------------- /src/app/common/version-compare.js: -------------------------------------------------------------------------------- 1 | /** 2 | * version compare 3 | * @param {string} a 4 | * @param {string} b 5 | * @return {number} 6 | */ 7 | // compare version '1.0.0' '12.0.3' 8 | // return 1 when a > b 9 | // return -1 when a < b 10 | // return 0 when a === b 11 | export default function (a, b) { 12 | const ar = a.split('.').map(n => Number(n.replace('v', ''))) 13 | const br = b.split('.').map(n => Number(n.replace('v', ''))) 14 | let res = 0 15 | for (let i = 0, len = br.length; i < len; i++) { 16 | if (br[i] < ar[i]) { 17 | res = 1 18 | break 19 | } else if (br[i] > ar[i]) { 20 | res = -1 21 | break 22 | } 23 | } 24 | return res 25 | } 26 | -------------------------------------------------------------------------------- /src/app/lib/ai.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AI integration with DeepSeek API 3 | */ 4 | import axios from 'axios' 5 | import log from '../common/log.js' 6 | import defaultSettings from '../common/config-default.js' 7 | import { createProxyAgent } from './proxy-agent.js' 8 | 9 | // Initialize OpenAI with DeepSeek configuration 10 | const createAIClient = (baseURL, apiKey, proxy) => { 11 | const config = { 12 | baseURL, 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | Authorization: `Bearer ${apiKey}` 16 | } 17 | } 18 | 19 | // Add proxy agent if proxy is provided 20 | const agent = proxy ? createProxyAgent(proxy) : null 21 | if (agent) { 22 | config.httpsAgent = agent 23 | config.proxy = false // Disable default proxy behavior when using agent 24 | } 25 | 26 | return axios.create(config) 27 | } 28 | 29 | export const AIchat = async ( 30 | prompt, 31 | model = defaultSettings.modelAI, 32 | role = defaultSettings.roleAI, 33 | baseURL = defaultSettings.baseURLAI, 34 | path = defaultSettings.apiPathAI, 35 | apiKey, 36 | proxy 37 | ) => { 38 | try { 39 | const client = createAIClient(baseURL, apiKey, proxy) 40 | const response = await client.post(path, { 41 | model, 42 | messages: [ 43 | { 44 | role: 'system', 45 | content: role 46 | }, 47 | { 48 | role: 'user', 49 | content: prompt 50 | } 51 | ] 52 | }) 53 | 54 | return { 55 | response: response.data.choices[0].message.content 56 | } 57 | } catch (e) { 58 | log.error('AI chat error') 59 | log.error(e) 60 | return { 61 | error: e.message, 62 | stack: e.stack 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app/lib/build-proxy.js: -------------------------------------------------------------------------------- 1 | export function buildProxyString (obj) { 2 | if (!obj.proxyIp) { 3 | return '' 4 | } 5 | 6 | const proxyTypeMapping = { 7 | 5: 'socks5', 8 | 4: 'socks4', 9 | 0: 'http', 10 | 1: 'https' 11 | } 12 | 13 | const proxyType = proxyTypeMapping[obj.proxyType] || '' 14 | const hasCredentials = obj.proxyUsername && obj.proxyPassword 15 | const credentials = hasCredentials ? `${obj.proxyUsername}:${obj.proxyPassword}@` : '' 16 | 17 | return `${proxyType}://${credentials}${obj.proxyIp}${obj.proxyPort ? `:${obj.proxyPort}` : ''}` 18 | } 19 | -------------------------------------------------------------------------------- /src/app/lib/conf.js: -------------------------------------------------------------------------------- 1 | import { 2 | cwd 3 | } from '../common/runtime-constants.js' 4 | import log from '../common/log.js' 5 | import { 6 | resolve 7 | } from 'path' 8 | 9 | const glob = {} 10 | 11 | export async function getConf () { 12 | if (glob.conf) { 13 | return glob.conf 14 | } 15 | const conf = await import( 16 | resolve(cwd, 'config.js') 17 | ).catch(err => { 18 | if (err.code === 'ERR_MODULE_NOT_FOUND') { 19 | return 20 | } 21 | log.error('read config.js failed', err) 22 | }) 23 | if (conf) { 24 | glob.conf = conf 25 | } 26 | return glob.conf || {} 27 | } 28 | -------------------------------------------------------------------------------- /src/app/lib/db.js: -------------------------------------------------------------------------------- 1 | import { 2 | getConf 3 | } from './conf.js' 4 | 5 | const db = {} 6 | let dbActionRef = null 7 | 8 | const tables = [ 9 | 'bookmarks', 10 | 'history', 11 | 'bookmarkGroups', 12 | 'addressBookmarks', 13 | 'terminalThemes', 14 | 'lastStates', 15 | 'data', 16 | 'quickCommands', 17 | 'log', 18 | 'dbUpgradeLog', 19 | 'profiles' 20 | ] 21 | 22 | export async function getDb () { 23 | if (dbActionRef) { 24 | return dbActionRef 25 | } 26 | const conf = await getConf() 27 | let Db = null 28 | if (conf.Db) { 29 | Db = conf.Db 30 | } else { 31 | Db = await import('./nedb.js').then(d => d.Db) 32 | } 33 | tables.forEach(table => { 34 | const conf = { 35 | tableName: table 36 | } 37 | db[table] = new Db(conf) 38 | }) 39 | dbActionRef = (dbName, op, ...args) => { 40 | return new Promise((resolve, reject) => { 41 | db[dbName][op](...args, (err, result) => { 42 | if (err) { 43 | return reject(err) 44 | } 45 | resolve(result) 46 | }) 47 | }) 48 | } 49 | return dbActionRef 50 | } 51 | 52 | export async function dbAction (...args) { 53 | const func = await getDb() 54 | return func(...args) 55 | } 56 | -------------------------------------------------------------------------------- /src/app/lib/enc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * data encrypt/decrypt 3 | */ 4 | 5 | import crypto from 'crypto' 6 | 7 | const algorithmDefault = 'aes-192-cbc' 8 | 9 | const funcs = {} 10 | 11 | function scryptAsync (...args) { 12 | return new Promise((resolve, reject) => 13 | crypto.scrypt(...args, (err, result) => { 14 | if (err) { 15 | reject(err) 16 | } 17 | resolve(result) 18 | }) 19 | ) 20 | } 21 | 22 | funcs.encrypt = function ( 23 | str = '', 24 | password, 25 | algorithm = algorithmDefault, 26 | iv = Buffer.alloc(16, 0) 27 | ) { 28 | const key = crypto.scryptSync(password, 'salt', 24) 29 | // Use `crypto.randomBytes` to generate a random iv instead of the static iv 30 | const cipher = crypto.createCipheriv(algorithm, key, iv) 31 | let encrypted = cipher.update(str, 'utf8', 'hex') 32 | encrypted += cipher.final('hex') 33 | return encrypted 34 | } 35 | 36 | funcs.decrypt = function ( 37 | encrypted = '', 38 | password, 39 | algorithm = algorithmDefault, 40 | iv = Buffer.alloc(16, 0) 41 | ) { 42 | // Use the async `crypto.scrypt()` instead. 43 | const key = crypto.scryptSync(password, 'salt', 24) 44 | const decipher = crypto.createDecipheriv(algorithm, key, iv) 45 | // Encrypted using same algorithm, key and iv. 46 | let decrypted = decipher.update(encrypted, 'hex', 'utf8') 47 | decrypted += decipher.final('utf8') 48 | return decrypted 49 | } 50 | 51 | export const encryptAsync = async function ( 52 | str = '', 53 | password, 54 | algorithm = algorithmDefault, 55 | iv = Buffer.alloc(16, 0) 56 | ) { 57 | const key = await scryptAsync(password, 'salt', 24) 58 | // Use `crypto.randomBytes` to generate a random iv instead of the static iv 59 | const cipher = crypto.createCipheriv(algorithm, key, iv) 60 | let encrypted = cipher.update(str, 'utf8', 'hex') 61 | encrypted += cipher.final('hex') 62 | return encrypted 63 | } 64 | 65 | export const decryptAsync = async function ( 66 | encrypted = '', 67 | password, 68 | algorithm = algorithmDefault, 69 | iv = Buffer.alloc(16, 0) 70 | ) { 71 | // Use the async `crypto.scrypt()` instead. 72 | const key = await scryptAsync(password, 'salt', 24) 73 | const decipher = crypto.createDecipheriv(algorithm, key, iv) 74 | // Encrypted using same algorithm, key and iv. 75 | let decrypted = decipher.update(encrypted, 'hex', 'utf8') 76 | decrypted += decipher.final('utf8') 77 | return decrypted 78 | } 79 | -------------------------------------------------------------------------------- /src/app/lib/extensions.js: -------------------------------------------------------------------------------- 1 | import { 2 | jwtAuth, 3 | errHandler 4 | } from './jwt.js' 5 | import { 6 | getConf 7 | } from './conf.js' 8 | export async function applyExtensions (app) { 9 | const conf = await getConf() 10 | if (conf && conf.extensions && conf.extensions.length) { 11 | for (const ext of conf.extensions) { 12 | if (ext && ext.appExtend) { 13 | ext.appExtend(app, jwtAuth, errHandler) 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/lib/font-list.js: -------------------------------------------------------------------------------- 1 | /** 2 | * load font list after start 3 | */ 4 | import log from '../common/log.js' 5 | import { getFonts } from 'font-list' 6 | 7 | export const loadFontList = () => { 8 | return getFonts() 9 | .then(fonts => { 10 | return fonts.map(f => f.replace(/"/g, '')) 11 | }) 12 | .catch(err => { 13 | log.error('load font list error') 14 | log.error(err) 15 | return [] 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/app/lib/fs.js: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process' 2 | import fs, { promises as fss } from 'fs' 3 | import log from '../common/log.js' 4 | import { isWin, isMac, tempDir } from '../common/runtime-constants.js' 5 | import path from 'path' 6 | import uid from '../common/uid.js' 7 | import { promisify } from 'util' 8 | import { Bash } from 'node-bash' 9 | import * as tar from 'tar' 10 | import { getSizeCount, getSizeCountWin } from '../common/count-folder-data.js' 11 | 12 | const ROOT_PATH = '/' 13 | const execAsync = promisify( 14 | exec 15 | ) 16 | 17 | // Encoding function 18 | function encodeUint8Array (uint8Arr) { 19 | return Buffer.from(uint8Arr).toString('base64') 20 | } 21 | 22 | // Decoding function 23 | function decodeBase64String (base64String) { 24 | return new Uint8Array(Buffer.from(base64String, 'base64')) 25 | } 26 | 27 | const isWinDrive = function (path) { 28 | return /^\w+:$/.test(path) 29 | } 30 | 31 | /** 32 | * run cmd 33 | * @param {string} cmd 34 | */ 35 | const run = (cmd) => { 36 | const ps = new Bash({ 37 | executableOptions: { 38 | '--login': true 39 | } 40 | }) 41 | return ps.invokeCommand(cmd) 42 | .then(s => s.stdout.toString()) 43 | } 44 | 45 | /** 46 | * run windows cmd 47 | * @param {string} cmd 48 | */ 49 | const runWinCmd = (cmd) => { 50 | return execAsync(`powershell.exe -Command "${cmd}"`) 51 | } 52 | 53 | function getFolderSizeWin (folderPath) { 54 | return runWinCmd( 55 | `Get-ChildItem -Path "${folderPath}" -Recurse | Where-Object { ! $_.PSIsContainer } | Measure-Object -Property Length -Sum` 56 | ).then(res => getSizeCountWin(res.stdout)) 57 | } 58 | 59 | function getFolderSize (folderPath) { 60 | if (isWin) { 61 | return getFolderSizeWin(folderPath) 62 | } 63 | return run(`du -sh "${folderPath}" && find "${folderPath}" -type f | wc -l`) 64 | .then(getSizeCount) 65 | } 66 | 67 | /** 68 | * rm -rf directory 69 | * @param {string} localFolderPath absolute path of directory 70 | */ 71 | const rmrf = (localFolderPath) => { 72 | const cmd = isWin 73 | ? `Remove-Item '${localFolderPath}' -Force -Recurse -ErrorAction SilentlyContinue` 74 | : `rm -rf "${localFolderPath}"` 75 | return isWin ? runWinCmd(cmd) : run(cmd) 76 | } 77 | 78 | /** 79 | * mv from to 80 | * @param {string} localFolderPath absolute path of directory 81 | */ 82 | const mv = (from, to) => { 83 | const cmd = isWin 84 | ? `Move-Item '${(from)}' '${to}'` 85 | : `mv '${from}' '${to}'` 86 | return isWin ? runWinCmd(cmd) : run(cmd) 87 | } 88 | 89 | /** 90 | * cp from to 91 | * @param {string} localFolderPath absolute path of directory 92 | */ 93 | const cp = (from, to) => { 94 | const cmd = isWin 95 | ? `Copy-Item '${from}' -Destination '${to}' -Recurse` 96 | : `cp -r "${from}" "${to}"` 97 | return isWin ? runWinCmd(cmd) : run(cmd) 98 | } 99 | 100 | /** 101 | * touch file 102 | * @param {string} localFolderPath absolute path 103 | */ 104 | const touch = (localFilePath) => { 105 | return fss.writeFile(localFilePath, '') 106 | } 107 | 108 | /** 109 | * open file 110 | * @param {string} localFolderPath absolute path 111 | */ 112 | const openFile = (localFilePath) => { 113 | let cmd 114 | if (isWin) { 115 | cmd = `Invoke-Item '${localFilePath}'` 116 | return runWinCmd(cmd) 117 | } 118 | cmd = (isMac 119 | ? 'open' 120 | : 'xdg-open') + 121 | ` "${localFilePath}"` 122 | return run(cmd) 123 | } 124 | 125 | /** 126 | * zip file 127 | * @param {string} localFolerPath absolute path of a folder 128 | */ 129 | const zipFolder = (localFolerPath) => { 130 | const n = uid() 131 | const p = path.resolve(tempDir, `electerm-temp-${n}.tar`) 132 | const cwd = path.dirname(localFolerPath) 133 | const file = path.basename(localFolerPath) 134 | return tar.c({ 135 | gzip: false, 136 | file: p, 137 | cwd 138 | }, [file]) 139 | .then(() => p) 140 | } 141 | 142 | const handleWindowsDrive = async (localFilePath, targetFolderPath) => { 143 | const tempExtractDir = path.join(tempDir, `electerm-unzip-${uid()}`) 144 | await fss.mkdir(tempExtractDir, { recursive: true }) 145 | 146 | try { 147 | await tar.x({ file: localFilePath, C: tempExtractDir }) 148 | const items = await fss.readdir(tempExtractDir) 149 | 150 | await Promise.all(items.map(async (item) => { 151 | const from = path.join(tempExtractDir, item) 152 | const to = path.join(targetFolderPath, item) 153 | await mv(from, to) 154 | })) 155 | } finally { 156 | await rmrf(tempExtractDir).catch(log.error) 157 | } 158 | } 159 | 160 | /** 161 | * unzip file 162 | * @param {string} localFilePath absolute path of a zip file 163 | * @param {string} targetFolderPath absolute path of unzip target folder 164 | */ 165 | const unzipFile = async (localFilePath, targetFolderPath) => { 166 | if (isWin && isWinDrive(targetFolderPath)) { 167 | await handleWindowsDrive(localFilePath, targetFolderPath) 168 | } else { 169 | await tar.x({ file: localFilePath, C: targetFolderPath }) 170 | } 171 | return 1 172 | } 173 | 174 | async function listWindowsRootPath () { 175 | return new Promise((resolve, reject) => { 176 | const { exec } = require('child_process') 177 | const command = 'powershell.exe -Command "Get-PSDrive -PSProvider FileSystem | Select-Object -ExpandProperty Root"' 178 | 179 | exec(command, { encoding: 'utf8' }, (error, stdout, stderr) => { 180 | if (error) { 181 | reject(error) 182 | return 183 | } 184 | if (stderr) { 185 | reject(new Error(stderr)) 186 | return 187 | } 188 | const drives = stdout.split('\r\n') 189 | .map(line => line.trim()) 190 | // Accept any valid Windows path that ends with backslash 191 | .filter(line => /^[^<>:"/\\|?*]+:\\$/.test(line)) 192 | .map(drive => drive.slice(0, -1)) // Remove trailing backslash 193 | resolve(drives) 194 | }) 195 | }) 196 | } 197 | 198 | const readCustom = (p1, arr, ...args) => { 199 | return new Promise((resolve, reject) => { 200 | fs.read(p1, decodeBase64String(arr), ...args, (err, n, buffer) => { 201 | if (err) { 202 | return reject(err) 203 | } 204 | return resolve({ n, newArr: encodeUint8Array(buffer) }) 205 | }) 206 | }) 207 | } 208 | 209 | const writeCustom = (p1, arr) => { 210 | return new Promise((resolve, reject) => { 211 | const narr = decodeBase64String(arr) 212 | fs.write(p1, narr, (err, n) => { 213 | if (err) { 214 | return reject(err) 215 | } 216 | return resolve(1) 217 | }) 218 | }) 219 | } 220 | 221 | const statCustom = async (...args) => { 222 | const st = await fss.stat(...args) 223 | st.isD = st.isDirectory() 224 | st.isF = st.isFile() 225 | return st 226 | } 227 | 228 | const openCustom = async (...args) => { 229 | return new Promise((resolve, reject) => { 230 | fs.open(...args, (err, n) => { 231 | if (err) { 232 | return reject(err) 233 | } 234 | return resolve(n) 235 | }) 236 | }) 237 | } 238 | 239 | const closeCustom = async (...args) => { 240 | return new Promise((resolve, reject) => { 241 | fs.close(...args, (err) => { 242 | if (err) { 243 | return reject(err) 244 | } 245 | return resolve(true) 246 | }) 247 | }) 248 | } 249 | 250 | const readdirOnly = async (path) => { 251 | const r = await fss.readdir(path, { withFileTypes: true }) 252 | return r.filter(dirent => dirent.isDirectory()) 253 | .map(d => { 254 | return { 255 | name: d.name, 256 | isDirectory: true 257 | } 258 | }) 259 | } 260 | 261 | const readdirAndFiles = async (path) => { 262 | const r = await fss.readdir(path, { withFileTypes: true }) 263 | return r.map(d => { 264 | return { 265 | name: d.name, 266 | isDirectory: d.isDirectory() 267 | } 268 | }) 269 | } 270 | 271 | export const fsExport = Object.assign( 272 | {}, 273 | fss, 274 | { 275 | run, 276 | getFolderSize, 277 | runWinCmd, 278 | rmrf, 279 | touch, 280 | cp, 281 | mv, 282 | openFile, 283 | readCustom, 284 | statCustom, 285 | openCustom, 286 | closeCustom, 287 | writeCustom, 288 | zipFolder, 289 | unzipFile, 290 | readdirOnly, 291 | readdirAndFiles 292 | }, 293 | { 294 | readdirAsync: (_path) => { 295 | if (_path === ROOT_PATH && isWin) { 296 | return listWindowsRootPath() 297 | } 298 | let path = _path 299 | if (isWin && isWinDrive(path)) { 300 | path = path + '\\' 301 | } 302 | return fss.readdir(path) 303 | }, 304 | statAsync: (...args) => { 305 | return fss.stat(...args) 306 | .then(res => { 307 | return { 308 | ...res, 309 | isDirectory: res.isDirectory() 310 | } 311 | }) 312 | }, 313 | lstatAsync: (...args) => { 314 | return fss.lstat(...args) 315 | .then(res => { 316 | return { 317 | ...res, 318 | isDirectory: res.isDirectory(), 319 | isSymbolicLink: res.isSymbolicLink() 320 | } 321 | }) 322 | }, 323 | readFile: (...args) => { 324 | return fss.readFile(...args, 'utf8') 325 | }, 326 | readFileAsBase64: (...args) => { 327 | return fss.readFile(...args) 328 | .then(res => { 329 | return res.toString('base64') 330 | }) 331 | }, 332 | writeFile: (path, txt, mode) => { 333 | return fss.writeFile(path, txt, { mode }) 334 | .then(() => true) 335 | .catch((e) => { 336 | log.error('fs.writeFile', e) 337 | return false 338 | }) 339 | } 340 | } 341 | ) 342 | -------------------------------------------------------------------------------- /src/app/lib/get-constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ipc main 3 | */ 4 | 5 | import * as constants from '../common/runtime-constants.js' 6 | import { transferKeys } from '../server/transfer.js' 7 | import fs from 'fs' 8 | import os from 'os' 9 | import _ from 'lodash' 10 | import { sep } from 'path' 11 | import { getConfig } from './init.js' 12 | import copy from 'json-deep-copy' 13 | 14 | export async function getConstants (req, res) { 15 | const config = await getConfig(true) 16 | const data = { 17 | osInfoData: (() => { 18 | return Object.keys(os).map((k, i) => { 19 | const vf = os[k] 20 | if (!_.isFunction(vf)) { 21 | return null 22 | } 23 | let v 24 | try { 25 | v = vf() 26 | } catch (e) { 27 | return null 28 | } 29 | if (!v) { 30 | return null 31 | } 32 | v = JSON.stringify(v, null, 2) 33 | return { k, v } 34 | }).filter(d => d) 35 | })(), 36 | config, 37 | sep, 38 | fsConstants: fs.constants, 39 | ...constants, 40 | env: process.env, 41 | versions: copy(process.versions), 42 | transferKeys 43 | } 44 | res.send( 45 | data 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/app/lib/global-state.js: -------------------------------------------------------------------------------- 1 | // src/app/lib/global-state.js 2 | 3 | class GlobalState { 4 | constructor () { 5 | this._state = { 6 | win: null, 7 | config: {}, 8 | closeAction: '', 9 | requireAuth: false, 10 | serverInited: false, 11 | langMap: null, 12 | getLang: null, 13 | translate: null, 14 | timer: null, 15 | childPid: null, 16 | app: null, 17 | rawArgs: null, 18 | loadTime: null, 19 | initTime: Date.now(), 20 | watchFilePath: '', 21 | oldRectangle: null, 22 | serverPort: null, 23 | isSecondInstance: false 24 | } 25 | } 26 | 27 | get (key) { 28 | return this._state[key] 29 | } 30 | 31 | set (key, value) { 32 | this._state[key] = value 33 | } 34 | 35 | update (key, updates) { 36 | this._state[key] = { ...this._state[key], ...updates } 37 | } 38 | } 39 | 40 | export default new GlobalState() 41 | -------------------------------------------------------------------------------- /src/app/lib/init.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ipc main 3 | */ 4 | 5 | import defaultSetting from '../common/config-default.js' 6 | import { userConfigId } from '../common/constants.js' 7 | import { isDev } from '../common/runtime-constants.js' 8 | import { dbAction } from './db.js' 9 | import * as langMap from '@electerm/electerm-locales/esm/index.mjs' 10 | 11 | export async function getConfig () { 12 | const userConfig = await dbAction('data', 'findOne', { 13 | _id: userConfigId 14 | }) || {} 15 | delete userConfig._id 16 | delete userConfig.host 17 | delete userConfig.terminalTypes 18 | delete userConfig.tokenElecterm 19 | const config = { 20 | ...defaultSetting, 21 | ...userConfig, 22 | port: process.env.PORT, 23 | host: process.env.HOST, 24 | wsHost: isDev ? process.env.DEV_HOST : process.env.HOST, 25 | wsPort: isDev ? process.env.DEV_PORT : process.env.PORT, 26 | server: process.env.SERVER, 27 | useSystemTitleBar: true 28 | } 29 | return config 30 | } 31 | 32 | export async function init () { 33 | const config = await getConfig(true) 34 | return { 35 | config, 36 | isPortable: true, 37 | langs: Object.keys(langMap).map(id => { 38 | return { 39 | id, 40 | ...langMap[id] 41 | } 42 | }), 43 | langMap 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/lib/iterm-theme.js: -------------------------------------------------------------------------------- 1 | /** 2 | * read themes from https://github.com/mbadolato/iTerm2-Color-Schemes/tree/master/electerm 3 | */ 4 | 5 | import log from '../common/log.js' 6 | 7 | export async function listItermThemes (ws, msg) { 8 | const all = await import('@electerm/electerm-themes/dist/index.mjs').then(d => d.default) 9 | return Promise.all(all).catch(e => { 10 | log.error('list Iterm Themes error', e) 11 | return [] 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /src/app/lib/jwt.js: -------------------------------------------------------------------------------- 1 | import { expressjwt } from 'express-jwt' 2 | import jwtb from 'jsonwebtoken' 3 | 4 | export const jwtAuth = expressjwt({ 5 | secret: process.env.SERVER_SECRET, 6 | algorithms: ['HS256'], 7 | getToken: function fromHeaderOrQuerystring (req) { 8 | return req.headers.token 9 | } 10 | }) 11 | 12 | export const errHandler = function (err, req, res, next) { 13 | if (err && err.name === 'UnauthorizedError') { 14 | res.status(401).send('invalid token...') 15 | } else { 16 | next() 17 | } 18 | } 19 | 20 | export function createToken ( 21 | user = process.env.SERVER_USER, 22 | pass = process.env.SERVER_SECRET, 23 | expire = process.env.TOKEN_EXPIRED_TIME || '120y' 24 | ) { 25 | const x = jwtb.sign({ 26 | id: user 27 | }, pass, { expiresIn: expire }) 28 | return x 29 | } 30 | 31 | export function verify (token) { 32 | return jwtb.verify(token, process.env.SERVER_SECRET) 33 | } 34 | -------------------------------------------------------------------------------- /src/app/lib/login.js: -------------------------------------------------------------------------------- 1 | /** 2 | * simple login with password only 3 | */ 4 | 5 | import { createToken } from './jwt.js' 6 | 7 | const { 8 | SERVER_PASS 9 | } = process.env 10 | 11 | export function login (req, res) { 12 | const { password } = req.body 13 | if (password !== SERVER_PASS) { 14 | return res.status(401).send('pass not right') 15 | } 16 | const token = createToken() 17 | res.send(token) 18 | } 19 | -------------------------------------------------------------------------------- /src/app/lib/lookup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * dns lookup 3 | */ 4 | import dns from 'dns' 5 | 6 | export default (host) => { 7 | const v4 = new Promise((resolve, reject) => { 8 | dns.resolve4(host, function (err, result) { 9 | if (err) { 10 | console.log(`v4 dns lookup error: ${err.message}`) 11 | return resolve([]) 12 | } 13 | resolve(result) 14 | }) 15 | }) 16 | const v6 = new Promise((resolve, reject) => { 17 | dns.resolve6(host, function (err, result) { 18 | if (err) { 19 | console.log(`v6 dns lookup error: ${err.message}`) 20 | return resolve([]) 21 | } 22 | resolve(result) 23 | }) 24 | }) 25 | return Promise.all([v4, v6]).then(result => { 26 | return [...result[0], ...result[1]] 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /src/app/lib/nedb.js: -------------------------------------------------------------------------------- 1 | /** 2 | * nedb api wrapper 3 | */ 4 | 5 | import { defaultUserName, cwd } from '../common/runtime-constants.js' 6 | import { resolve } from 'path' 7 | import Datastore from '@yetzt/nedb' 8 | 9 | const reso = (name) => { 10 | const nedbPath = process.env.DB_PATH || resolve(cwd, 'data/nedb-database') 11 | return resolve(nedbPath, 'users', defaultUserName, `electerm.${name}.nedb`) 12 | } 13 | 14 | export class Db extends Datastore { 15 | constructor (params) { 16 | const conf = { 17 | filename: reso(params.tableName), 18 | autoload: true 19 | } 20 | super(conf) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/lib/proxy-agent.js: -------------------------------------------------------------------------------- 1 | import { HttpsProxyAgent } from 'https-proxy-agent' 2 | import { SocksProxyAgent } from 'socks-proxy-agent' 3 | 4 | // common proxy agent creator 5 | export const createProxyAgent = (url = '') => { 6 | if ( 7 | typeof url !== 'string' || 8 | (!url.startsWith('http') && !url.startsWith('socks')) 9 | ) { 10 | return 11 | } 12 | const Cls = url.startsWith('http') 13 | ? HttpsProxyAgent 14 | : SocksProxyAgent 15 | return new Cls(url, { 16 | keepAlive: true 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/app/lib/run-sync.js: -------------------------------------------------------------------------------- 1 | /** 2 | * serial port lib 3 | */ 4 | import log from '../common/log.js' 5 | import { toCss } from '../lib/style.js' 6 | import { listItermThemes } from '../lib/iterm-theme.js' 7 | import { listSerialPorts } from '../lib/serial-port.js' 8 | import { dbAction } from '../lib/db.js' 9 | import { encryptAsync, decryptAsync } from '../lib/enc.js' 10 | import { loadFontList } from './font-list.js' 11 | import { loadSshConfig } from './ssh-config.js' 12 | import { saveUserConfig } from './user-config.js' 13 | import { checkDbUpgrade, doUpgrade } from '../upgrade/index.js' 14 | import { watchFile, unwatchFile } from './watch-file.js' 15 | import lookup from './lookup.js' 16 | import { init } from './init.js' 17 | import { showItemInFolder } from './show-item-in-folder.js' 18 | import { AIchat } from './ai.js' 19 | import globalState from './global-state.js' 20 | 21 | const globs = { 22 | AIchat, 23 | encryptAsync, 24 | decryptAsync, 25 | showItemInFolder, 26 | dbAction, 27 | lookup, 28 | watchFile, 29 | unwatchFile, 30 | listSerialPorts, 31 | checkDbUpgrade, 32 | doUpgrade, 33 | loadSshConfig, 34 | listItermThemes, 35 | toCss, 36 | init, 37 | initCommandLine: () => Promise.resolve(0), 38 | getInitTime: () => { 39 | return globalState.get('initTime') 40 | }, 41 | loadFontList, 42 | saveUserConfig, 43 | registerDeepLink: () => Promise.resolve(1), 44 | setWindowSize: () => Promise.resolve(1), 45 | getScreenSize: () => Promise.resolve({ width: 1920, height: 1080 }) 46 | } 47 | 48 | export function runSync (ws, msg) { 49 | const { 50 | id, 51 | func, 52 | args = [] 53 | } = msg 54 | globs[func](...args) 55 | .then(data => { 56 | ws.s({ 57 | data, 58 | id: msg.id 59 | }) 60 | }) 61 | .catch(err => { 62 | log.error(id, func, args, err) 63 | ws.s({ 64 | error: err, 65 | id 66 | }) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /src/app/lib/serial-port.js: -------------------------------------------------------------------------------- 1 | /** 2 | * serial port lib 3 | */ 4 | import { SerialPort } from 'serialport' 5 | import log from '../common/log.js' 6 | 7 | export function listSerialPorts () { 8 | return SerialPort.list() 9 | .catch((err) => { 10 | log.error(err) 11 | return [] 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /src/app/lib/show-item-in-folder.js: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process' 2 | import { 3 | isWin, 4 | isMac 5 | } from '../common/runtime-constants.js' 6 | import { dirname, resolve } from 'path' 7 | 8 | export async function showItemInFolder (filePath) { 9 | const itemPath = resolve(filePath) 10 | const folderPath = dirname(itemPath) 11 | let command = '' 12 | 13 | if (isWin) { 14 | // For Windows 15 | command = `explorer.exe /select,"${itemPath}"` 16 | } else if (isMac) { 17 | // For macOS 18 | command = `open -R "${folderPath}"` 19 | } else { 20 | // For Linux or other Unix-like systems 21 | command = `xdg-open "${folderPath}"` 22 | } 23 | 24 | return new Promise((resolve, reject) => { 25 | exec(command, (error, stdout, stderr) => { 26 | if (error) { 27 | reject(new Error(`Failed to show item in folder: ${error.message}`)) 28 | return 29 | } 30 | if (stderr) { 31 | reject(new Error(`Error: ${stderr}`)) 32 | return 33 | } 34 | resolve('Item shown in folder successfully.') 35 | }) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/app/lib/ssh-config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * read ssh config 3 | */ 4 | 5 | import sshConfig from 'ssh-config' 6 | import { resolve } from 'path' 7 | import { home } from '../common/runtime-constants.js' 8 | import log from '../common/log.js' 9 | import fs from 'fs/promises' 10 | 11 | export async function loadSshConfig () { 12 | const defaultPort = 22 13 | let config = [] 14 | try { 15 | const configStr = await fs.readFile( 16 | resolve(home, '.ssh', 'config'), 'utf8' 17 | ) 18 | const sshConf = sshConfig.parse(configStr) 19 | config = sshConf.map((c, i) => { 20 | const { value } = c 21 | const obj = sshConf.compute(value.split(/\s/g)[0]) 22 | const { HostName, User, Port = defaultPort, Host } = obj 23 | if (!Host) { 24 | return null 25 | } 26 | return { 27 | host: HostName, 28 | username: User, 29 | port: Port, 30 | title: value, 31 | type: 'ssh-config', 32 | id: 'ssh' + i 33 | } 34 | }).filter(d => d) 35 | } catch (e) { 36 | log.debug('error parsing $HOME/.ssh/config') 37 | log.debug('maybe no $HOME/.ssh/config, it is ok') 38 | } 39 | return config 40 | } 41 | -------------------------------------------------------------------------------- /src/app/lib/style.js: -------------------------------------------------------------------------------- 1 | /** 2 | * style compiler 3 | * collect all stylus files in src/client and merge into one str 4 | */ 5 | 6 | import { globSync } from 'glob' 7 | import stylus from 'stylus' 8 | import { readFileSync } from 'fs' 9 | import { resolve } from 'path' 10 | import { 11 | packInfo, 12 | isDev, 13 | cwd 14 | } from '../common/runtime-constants.js' 15 | 16 | const { version } = packInfo 17 | 18 | function findFiles (pattern) { 19 | return globSync(pattern) 20 | } 21 | 22 | function removeUnused (str) { 23 | const names = [ 24 | 'contrastColor', 25 | 'main', 26 | 'main-dark', 27 | 'main-light', 28 | 'text', 29 | 'text-light', 30 | 'text-dark', 31 | 'text-disabled', 32 | 'primary', 33 | 'info', 34 | 'success', 35 | 'error', 36 | 'warn' 37 | ] 38 | const lines = str.split('\n').filter(d => { 39 | return d && 40 | !d.startsWith('@require') && 41 | !/^ *\/\//.test(d) && 42 | !/^ *\*/.test(d) && 43 | !/^ *\/\*/.test(d) 44 | }) 45 | const sections = [] 46 | let section = [] 47 | let prevIsHead = false 48 | for (let i = 0; i < lines.length; i++) { 49 | const line = lines[i] 50 | const isIndent = line.startsWith(' ') 51 | if ( 52 | isIndent 53 | ) { 54 | prevIsHead = false 55 | section.push(line) 56 | } else if (!isIndent && (prevIsHead || !section.length)) { 57 | section.push(line) 58 | prevIsHead = true 59 | } else { 60 | sections.push(section.join('\n')) 61 | section = [line] 62 | prevIsHead = true 63 | } 64 | } 65 | if (section.length) { 66 | sections.push(section.join('\n')) 67 | } 68 | return sections.filter(s => { 69 | return names.some(name => s.includes(name)) 70 | }).join('\n') + '\n' 71 | } 72 | 73 | export function loadDevStylus () { 74 | const dir = resolve(cwd, 'src') 75 | const pat = dir + '/**/*.styl' 76 | const arr = findFiles(pat) 77 | const key = 'theme-default.styl' 78 | arr.sort((a, b) => { 79 | const ai = a.includes(key) ? 1 : 0 80 | const bi = b.includes(key) ? 1 : 0 81 | return bi - ai 82 | }) 83 | let all = '' 84 | for (const p of arr) { 85 | const text = readFileSync(p).toString() 86 | if (text.includes(' = ')) { 87 | all = all + text 88 | } else if (text.includes('@require')) { 89 | const after = removeUnused(text) 90 | all = all + after 91 | } 92 | } 93 | // all = all.replace(/@require[^\n]+\n/g, '\n') 94 | return all 95 | } 96 | 97 | function stylus2Css (str) { 98 | return new Promise((resolve, reject) => { 99 | stylus.render(str, (err, css) => { 100 | if (err) { 101 | reject(err) 102 | } else { 103 | resolve(css) 104 | } 105 | }) 106 | }) 107 | } 108 | 109 | export async function toCss (stylus) { 110 | const stylusCss = await stylus2Css(stylus) 111 | // console.log('stylusCss', stylusCss) 112 | return { 113 | stylusCss, 114 | version, 115 | isDev 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/app/lib/user-config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * user-controll.json controll 3 | */ 4 | 5 | import { dbAction } from './db.js' 6 | import { userConfigId } from '../common/constants.js' 7 | 8 | export async function saveUserConfig (userConfig) { 9 | const q = { 10 | _id: userConfigId 11 | } 12 | delete userConfig.host 13 | delete userConfig.terminalTypes 14 | delete userConfig.tokenElecterm 15 | delete userConfig.port 16 | delete userConfig.server 17 | delete userConfig.wsPort 18 | delete userConfig.wsHost 19 | delete userConfig.useSystemTitleBar 20 | await dbAction('data', 'update', q, { 21 | ...q, 22 | ...userConfig 23 | }, { 24 | upsert: true 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /src/app/lib/view.js: -------------------------------------------------------------------------------- 1 | /** 2 | * simple login with password only 3 | */ 4 | 5 | import { 6 | isDev, 7 | isMac, 8 | isWin, 9 | packInfo, 10 | home, 11 | extIconPath 12 | } from '../common/runtime-constants.js' 13 | import fsFunctions from '../common/fs-functions.js' 14 | import { loadDevStylus } from './style.js' 15 | import copy from 'json-deep-copy' 16 | import { createToken } from './jwt.js' 17 | import { logDir } from '../server/session-log.js' 18 | 19 | const stylus = loadDevStylus() 20 | 21 | function buildServer () { 22 | return `http://${process.env.HOST}:${process.env.PORT}` 23 | } 24 | 25 | export function index (req, res) { 26 | const server = process.env.SERVER || (isDev ? buildServer() : '') 27 | const cdn = process.env.CDN || server 28 | const data = { 29 | stylus, 30 | isDev, 31 | isMac, 32 | isWin, 33 | packInfo, 34 | home, 35 | version: packInfo.version, 36 | siteName: packInfo.name, 37 | fsFunctions, 38 | isWebApp: true, 39 | extIconPath: cdn + extIconPath, 40 | cdn, 41 | sessionLogPath: logDir, 42 | query: req.query, 43 | server 44 | } 45 | const { 46 | ENABLE_AUTH 47 | } = process.env 48 | if (!ENABLE_AUTH) { 49 | data.tokenElecterm = createToken() 50 | } 51 | data._global = copy(data) 52 | res.render('index', data) 53 | } 54 | -------------------------------------------------------------------------------- /src/app/lib/watch-file.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import globalState from './global-state.js' 3 | import _ from 'lodash' 4 | 5 | const onWatch = _.debounce(() => { 6 | try { 7 | const filePath = globalState.get('watchFilePath') 8 | if (fs.existsSync(filePath)) { 9 | const text = fs.readFileSync(filePath, 'utf8') 10 | globalState.get('win').webContents.send('file-change', text) 11 | } else { 12 | console.log('Watched file no longer exists') 13 | globalState.get('win').webContents.send('file-deleted') 14 | } 15 | } catch (e) { 16 | console.error('Error reading file:', e) 17 | globalState.get('win').webContents.send('file-read-error', e.message) 18 | } 19 | }, 300, { leading: false, trailing: true }) 20 | 21 | export const watchFile = (path) => { 22 | globalState.set('watchFilePath', path) 23 | fs.watchFile(path, onWatch) 24 | } 25 | 26 | export const unwatchFile = (path) => { 27 | globalState.set('watchFilePath', '') 28 | fs.unwatchFile(path, onWatch) 29 | } 30 | 31 | const cleanWatchFile = () => { 32 | globalState.set('watchFilePath', '') 33 | const filePath = globalState.get('watchFilePath') 34 | if (!filePath) { 35 | return 36 | } 37 | fs.unwatchFile(filePath, onWatch) 38 | } 39 | 40 | process.on('exit', cleanWatchFile) 41 | -------------------------------------------------------------------------------- /src/app/routes/http.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { login } from '../lib/login.js' 3 | import { index } from '../lib/view.js' 4 | import { getConstants } from '../lib/get-constants.js' 5 | import { resolve } from 'path' 6 | import { 7 | cwd, 8 | isDev 9 | } from '../common/runtime-constants.js' 10 | import { 11 | jwtAuth, 12 | errHandler 13 | } from '../lib/jwt.js' 14 | 15 | export function httpRoutes (app) { 16 | app.get('/', index) 17 | app.post('/api/login', login) 18 | app.get('/api/get-constants', jwtAuth, errHandler, getConstants) 19 | if (isDev) { 20 | app.use(express.static( 21 | resolve(cwd, 'node_modules') 22 | )) 23 | app.use(express.static( 24 | resolve(cwd, 'src/client/statics') 25 | )) 26 | } else { 27 | app.use(express.static( 28 | resolve(cwd, 'dist/assets') 29 | )) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/routes/ws.js: -------------------------------------------------------------------------------- 1 | import strip from 'strip-ansi' 2 | import log from '../common/log.js' 3 | import expressWs from 'express-ws' 4 | import { 5 | isWin 6 | } from '../common/runtime-constants.js' 7 | import { verifyWs, initWs } from '../server/dispatch-center.js' 8 | import { 9 | terminals 10 | } from '../server/remote-common.js' 11 | 12 | export function wsRoutes (app) { 13 | expressWs(app, undefined, { 14 | wsOptions: { 15 | perMessageDeflate: { 16 | zlibDeflateOptions: { 17 | // See zlib defaults. 18 | chunkSize: 1024 * 8, 19 | memLevel: 7, 20 | level: 3 21 | }, 22 | zlibInflateOptions: { 23 | chunkSize: 10 * 1024 24 | }, 25 | // Other options settable: 26 | clientNoContextTakeover: true, // Defaults to negotiated value. 27 | serverNoContextTakeover: true, // Defaults to negotiated value. 28 | serverMaxWindowBits: 10, // Defaults to negotiated value. 29 | // Below options specified as default values. 30 | concurrencyLimit: 10, // Limits zlib concurrency for perf. 31 | threshold: 1024 * 8 // Size (in bytes) below which messages 32 | // should not be compressed. 33 | } 34 | } 35 | }) 36 | app.ws('/terminals/:pid', function (ws, req) { 37 | verifyWs(req) 38 | const term = terminals(req.params.pid) 39 | const { pid } = term 40 | log.debug('ws: connected to terminal ->', pid) 41 | 42 | term.on('data', function (data) { 43 | try { 44 | if (term.sessionLogger) { 45 | const dt = term.initOptions.addTimeStampToTermLog 46 | ? `[${new Date()}] ` 47 | : '' 48 | term.sessionLogger.write(`${dt}${strip(data.toString())}`) 49 | } 50 | ws.send(Buffer.from(data)) 51 | } catch (ex) { 52 | console.log('kkk', ex) 53 | // The WebSocket is not open, ignore 54 | } 55 | }) 56 | 57 | function onClose () { 58 | term.kill() 59 | log.debug('Closed terminal ' + pid) 60 | 61 | // Clean things up 62 | ws.close && ws.close() 63 | } 64 | 65 | term.on('close', onClose) 66 | if (term.isLocal && isWin) { 67 | term.on('exit', onClose) 68 | } 69 | ws.on('message', function (msg) { 70 | try { 71 | term.write(msg) 72 | } catch (ex) { 73 | log.error(ex) 74 | } 75 | }) 76 | 77 | ws.on('error', log.error) 78 | 79 | ws.on('close', onClose) 80 | }) 81 | app.ws('/rdp/:pid', function (ws, req) { 82 | const { width, height } = req.query 83 | verifyWs(req) 84 | const term = terminals(req.params.pid) 85 | term.ws = ws 86 | term.start(width, height) 87 | const { pid } = term 88 | log.debug('ws: connected to rdp session ->', pid) 89 | ws.on('error', log.error) 90 | }) 91 | app.ws('/vnc/:pid', function (ws, req) { 92 | const { query } = req 93 | verifyWs(req) 94 | const { pid } = req.params 95 | const term = terminals(pid) 96 | term.ws = ws 97 | term.start(query) 98 | log.debug('ws: connected to vnc session ->', pid) 99 | ws.on('error', log.error) 100 | }) 101 | initWs(app) 102 | } 103 | -------------------------------------------------------------------------------- /src/app/server/dispatch-center.js: -------------------------------------------------------------------------------- 1 | /** 2 | * communication between webview and app 3 | * run functions in seprate process, avoid using electron.remote directly 4 | */ 5 | 6 | import { Sftp } from './session-sftp.js' 7 | import { Ftp } from './session-ftp.js' 8 | import { 9 | sftp, 10 | transfer, 11 | onDestroySftp, 12 | onDestroyTransfer 13 | } from './remote-common.js' 14 | import { Transfer } from './transfer.js' 15 | import { FtpTransfer } from './ftp-transfer.js' 16 | import fs from './fs.js' 17 | import log from '../common/log.js' 18 | import fetch from './fetch.js' 19 | import sync from './sync.js' 20 | import { verify } from '../lib/jwt.js' 21 | import { runSync } from '../lib/run-sync.js' 22 | import { 23 | createTerm, 24 | testTerm, 25 | resize, 26 | runCmd, 27 | toggleTerminalLog, 28 | toggleTerminalLogTimestamp 29 | } from './terminal-api.js' 30 | 31 | const { 32 | SERVER_USER 33 | } = process.env 34 | 35 | /** 36 | * add ws.s function 37 | * @param {*} ws 38 | */ 39 | const wsDec = (ws) => { 40 | ws.s = msg => { 41 | try { 42 | ws.send(JSON.stringify(msg)) 43 | } catch (e) { 44 | log.error('ws send error') 45 | log.error(e) 46 | } 47 | } 48 | ws.on('error', log.error) 49 | ws.once = (callack, id) => { 50 | const func = (evt) => { 51 | const arg = JSON.parse(evt.data) 52 | if (id === arg.id) { 53 | callack(arg) 54 | ws.removeEventListener('message', func) 55 | } 56 | } 57 | ws.addEventListener('message', func) 58 | } 59 | ws._socket.setKeepAlive(true, 30 * 1000) 60 | } 61 | 62 | export function verifyWs (req) { 63 | const { token } = req.query 64 | const data = verify(token) 65 | if (SERVER_USER !== data.id) { 66 | throw new Error('not valid request') 67 | } 68 | } 69 | 70 | export function initWs (app) { 71 | // sftp function 72 | app.ws('/sftp/:id', (ws, req) => { 73 | verifyWs(req) 74 | wsDec(ws) 75 | const { id } = req.params 76 | ws.on('close', () => { 77 | onDestroySftp(id) 78 | }) 79 | ws.on('message', (message) => { 80 | const msg = JSON.parse(message) 81 | const { action } = msg 82 | 83 | if (action === 'sftp-new') { 84 | const { id, terminalId, type } = msg 85 | const Cls = type === 'ftp' ? Ftp : Sftp 86 | sftp(id, new Cls({ 87 | uid: id, 88 | terminalId, 89 | type 90 | })) 91 | } else if (action === 'sftp-func') { 92 | const { id, args, func, uid } = msg 93 | const inst = sftp(id) 94 | if (inst) { 95 | inst[func](...args) 96 | .then(data => { 97 | ws.s({ 98 | id: uid, 99 | data 100 | }) 101 | }) 102 | .catch(err => { 103 | ws.s({ 104 | id: uid, 105 | error: { 106 | message: err.message, 107 | stack: err.stack 108 | } 109 | }) 110 | }) 111 | } 112 | } else if (action === 'sftp-destroy') { 113 | const { id } = msg 114 | ws.close() 115 | onDestroySftp(id) 116 | } 117 | }) 118 | // end 119 | }) 120 | 121 | // transfer function 122 | app.ws('/transfer/:id', (ws, req) => { 123 | verifyWs(req) 124 | wsDec(ws) 125 | const { id } = req.params 126 | const { sftpId } = req.query 127 | ws.on('close', () => { 128 | onDestroyTransfer(id, sftpId) 129 | }) 130 | ws.on('message', (message) => { 131 | const msg = JSON.parse(message) 132 | const { action } = msg 133 | 134 | if (action === 'transfer-new') { 135 | const { sftpId, id, isFtp } = msg 136 | const opts = Object.assign({}, msg, { 137 | sftp: sftp(sftpId).sftp, 138 | sftpId, 139 | ws 140 | }) 141 | const Cls = isFtp ? FtpTransfer : Transfer 142 | transfer(id, sftpId, new Cls(opts)) 143 | } else if (action === 'transfer-func') { 144 | const { id, func, args, sftpId } = msg 145 | if (func === 'destroy') { 146 | return onDestroyTransfer(id, sftpId) 147 | } 148 | transfer(id, sftpId)[func](...args) 149 | } 150 | }) 151 | // end 152 | }) 153 | 154 | // upgrade todo 155 | 156 | // common functions 157 | app.ws('/common/s', (ws, req) => { 158 | verifyWs(req) 159 | wsDec(ws) 160 | ws.on('message', async (message) => { 161 | try { 162 | const msg = JSON.parse(message) 163 | const { action } = msg 164 | if (action === 'fetch') { 165 | fetch(ws, msg) 166 | } else if (action === 'sync') { 167 | sync(ws, msg) 168 | } else if (action === 'fs') { 169 | fs(ws, msg) 170 | } else if (action === 'create-terminal') { 171 | createTerm(ws, msg) 172 | } else if (action === 'test-terminal') { 173 | testTerm(ws, msg) 174 | } else if (action === 'resize-terminal') { 175 | resize(ws, msg) 176 | } else if (action === 'toggle-terminal-log') { 177 | toggleTerminalLog(ws, msg) 178 | } else if (action === 'toggle-terminal-log-timestamp') { 179 | toggleTerminalLogTimestamp(ws, msg) 180 | } else if (action === 'run-cmd') { 181 | runCmd(ws, msg) 182 | } if (action === 'runSync') { 183 | runSync(ws, msg) 184 | } 185 | } catch (e) { 186 | log.error(e) 187 | } 188 | }) 189 | }) 190 | // end 191 | } 192 | -------------------------------------------------------------------------------- /src/app/server/fetch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * node fetch in server side 3 | */ 4 | 5 | import rp from 'axios' 6 | import { createProxyAgent } from '../lib/proxy-agent.js' 7 | 8 | rp.defaults.proxy = false 9 | 10 | function fetch (options) { 11 | return rp(options) 12 | .then((res) => { 13 | return res.data 14 | }) 15 | .catch(error => { 16 | return { 17 | error 18 | } 19 | }) 20 | } 21 | 22 | export default async function wsFetchHandler (ws, msg) { 23 | const { id, options, proxy } = msg 24 | const agent = createProxyAgent(proxy) 25 | if (agent) { 26 | options.httpsAgent = agent 27 | } else { 28 | options.proxy = false 29 | } 30 | const res = await fetch(options) 31 | if (res.error) { 32 | console.log(res.error) 33 | ws.s({ 34 | error: res.error.message, 35 | id 36 | }) 37 | } else { 38 | ws.s({ 39 | data: res, 40 | id 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/server/fs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * fs in child process 3 | */ 4 | 5 | import { fsExport as fs } from '../lib/fs.js' 6 | 7 | export default function handleFs (ws, msg) { 8 | const { id, args, func } = msg 9 | fs[func](...args) 10 | .then(data => { 11 | ws.s({ 12 | id, 13 | data 14 | }) 15 | }) 16 | .catch(err => { 17 | ws.s({ 18 | id, 19 | error: { 20 | message: err.message, 21 | stack: err.stack 22 | } 23 | }) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/server/ftp-file.js: -------------------------------------------------------------------------------- 1 | import { Readable, Writable } from 'stream' 2 | 3 | export async function readRemoteFile (client, remotePath) { 4 | return new Promise((resolve, reject) => { 5 | let data = '' 6 | const writable = new Writable({ 7 | write (chunk, encoding, callback) { 8 | data += chunk.toString() 9 | callback() 10 | } 11 | }) 12 | 13 | client.downloadTo(writable, remotePath) 14 | .then(() => resolve(data)) 15 | .catch(reject) 16 | }) 17 | } 18 | 19 | export async function writeRemoteFile (client, remotePath, str) { 20 | const readable = new Readable({ 21 | read () { 22 | this.push(str) 23 | this.push(null) 24 | } 25 | }) 26 | 27 | return client.uploadFrom(readable, remotePath) 28 | } 29 | -------------------------------------------------------------------------------- /src/app/server/ftp-transfer.js: -------------------------------------------------------------------------------- 1 | // ftp-transfer.js 2 | /** 3 | * ftp transfer class 4 | * Note: basic-ftp only supports one active transfer per client connection 5 | */ 6 | 7 | export class FtpTransfer { 8 | constructor ({ 9 | remotePath, 10 | localPath, 11 | options = {}, 12 | id, 13 | type = 'download', 14 | sftp, 15 | sftpId, 16 | ws 17 | }) { 18 | this.id = id 19 | this.ftpClient = sftp 20 | this.srcPath = type === 'download' ? remotePath : localPath 21 | this.dstPath = type === 'download' ? localPath : remotePath 22 | this.isUpload = type !== 'download' 23 | this.ws = ws 24 | this.pausing = false 25 | this.onDestroy = false 26 | this.total = 0 27 | this.src = type === 'download' ? sftp : null 28 | this.dst = type === 'download' ? null : sftp 29 | this.start() 30 | } 31 | 32 | handleProgress = (info) => { 33 | if (this.pausing) return 34 | const chunk = info.bytes - this.total 35 | this.total = info.bytes 36 | this.onData(this.total, chunk) 37 | } 38 | 39 | onData = (total, chunk) => { 40 | if (this.pausing) return 41 | this.ws?.s({ 42 | id: `transfer:data:${this.id}`, 43 | data: total 44 | }) 45 | } 46 | 47 | onEnd = () => { 48 | this.ws?.s({ 49 | id: `transfer:end:${this.id}`, 50 | data: null 51 | }) 52 | } 53 | 54 | onError = (err) => { 55 | if (!err) { 56 | return this.onEnd() 57 | } 58 | this.ws?.s({ 59 | id: `transfer:err:${this.id}`, 60 | error: { 61 | message: err.message, 62 | stack: err.stack 63 | } 64 | }) 65 | } 66 | 67 | trackProgress = () => { 68 | this.total = 0 69 | this.ftpClient?.trackProgress(this.handleProgress) 70 | } 71 | 72 | async start () { 73 | try { 74 | if (this.onDestroy) { 75 | return 76 | } 77 | this.trackProgress() 78 | if (!this.isUpload) { 79 | await this.ftpClient.downloadTo(this.dstPath, this.srcPath) 80 | } else { 81 | await this.ftpClient.uploadFrom(this.srcPath, this.dstPath) 82 | } 83 | this.onEnd() 84 | } catch (err) { 85 | this.onError(err) 86 | } 87 | } 88 | 89 | pause () { 90 | this.pausing = true 91 | } 92 | 93 | resume () { 94 | this.pausing = false 95 | } 96 | 97 | destroy () { 98 | this.onDestroy = true 99 | if (this.ftpClient) { 100 | this.ftpClient.trackProgress() // Remove progress tracking 101 | } 102 | this.ftpClient = null 103 | this.src = null 104 | this.dst = null 105 | if (this.ws) { 106 | this.ws.close() 107 | this.ws = null 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/app/server/global-state.js: -------------------------------------------------------------------------------- 1 | // global-state.js 2 | class GlobalState { 3 | #sessions = {} 4 | 5 | // Sessions management 6 | getSession (id) { 7 | return this.#sessions[id] 8 | } 9 | 10 | setSession (id, data) { 11 | this.#sessions[id] = data 12 | } 13 | 14 | removeSession (id) { 15 | delete this.#sessions[id] 16 | } 17 | } 18 | 19 | // Export a singleton instance 20 | export default new GlobalState() 21 | -------------------------------------------------------------------------------- /src/app/server/remote-common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * common functions for remote process handling, 3 | * for sftp, terminal and transfer 4 | */ 5 | 6 | // const _ = require('loadsh') 7 | import globalState from './global-state.js' 8 | 9 | export function session (id) { 10 | return globalState.getSession(id) 11 | } 12 | 13 | export function sftp (id, inst) { 14 | if (inst) { 15 | globalState.setSession(id, inst) 16 | return inst 17 | } 18 | return globalState.getSession(id) 19 | } 20 | 21 | export function terminals (id, inst) { 22 | if (inst) { 23 | globalState.setSession(id, inst) 24 | return inst 25 | } 26 | return globalState.getSession(id) 27 | } 28 | 29 | export function transfer (id, sftpId, inst) { 30 | const ss = sftp(sftpId) 31 | if (!ss) { 32 | return 33 | } 34 | if (inst) { 35 | ss.transfers[id] = inst 36 | return inst 37 | } 38 | return ss.transfers[id] 39 | } 40 | 41 | export function onDestroySftp (id) { 42 | const inst = sftp(id) 43 | inst && inst.kill && inst.kill() 44 | } 45 | 46 | export function onDestroyTerminal (id) { 47 | onDestroySftp(id) 48 | } 49 | 50 | export function onDestroyTransfer (id, sftpId) { 51 | const sftpInst = sftp(sftpId) 52 | const inst = transfer(id, sftpId) 53 | inst && inst.destroy && inst.destroy() 54 | sftpInst && delete sftpInst.transfers[id] 55 | } 56 | -------------------------------------------------------------------------------- /src/app/server/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { wsRoutes } from '../routes/ws.js' 3 | import { httpRoutes } from '../routes/http.js' 4 | import { applyExtensions } from '../lib/extensions.js' 5 | import morgan from 'morgan' 6 | import { 7 | isDev, 8 | cwd 9 | } from '../common/runtime-constants.js' 10 | import { resolve } from 'path' 11 | 12 | export async function createApp () { 13 | const app = express() 14 | // parse application/x-www-form-urlencoded 15 | app.use(express.urlencoded({ extended: true })) 16 | 17 | // parse application/json 18 | app.use(express.json()) 19 | 20 | app.use(morgan( 21 | ':method :url :status :res[content-length] - :response-time ms' 22 | )) 23 | app.set('view engine', 'pug') 24 | app.set( 25 | 'views', 26 | process.env.VIEW_FOLDER || 27 | ( 28 | !isDev 29 | ? resolve(cwd, 'dist/views') 30 | : resolve(cwd, 'src/app/views') 31 | ) 32 | ) 33 | app.set('x-powered-by', false) 34 | 35 | httpRoutes(app) 36 | wsRoutes(app) 37 | await applyExtensions(app) 38 | return app 39 | } 40 | -------------------------------------------------------------------------------- /src/app/server/session-base.js: -------------------------------------------------------------------------------- 1 | /** 2 | * terminal/sftp/serial class 3 | */ 4 | import uid from '../common/uid.js' 5 | import { createLogFileName } from '../common/create-session-log-file-path.js' 6 | import { SessionLog } from './session-log.js' 7 | import globalState from './global-state.js' 8 | 9 | export class TerminalBase { 10 | constructor (initOptions, ws, isTest) { 11 | this.type = initOptions.termType || initOptions.type 12 | this.pid = initOptions.uid || uid() 13 | this.initOptions = initOptions 14 | if (initOptions.saveTerminalLogToFile) { 15 | this.sessionLogger = new SessionLog({ 16 | logDir: initOptions.sessionLogPath, 17 | fileName: createLogFileName(initOptions.logName) 18 | }) 19 | } 20 | if (ws) { 21 | this.ws = ws 22 | } 23 | if (isTest) { 24 | this.isTest = isTest 25 | } 26 | } 27 | 28 | toggleTerminalLogTimestamp () { 29 | this.initOptions.addTimeStampToTermLog = !this.initOptions.addTimeStampToTermLog 30 | } 31 | 32 | toggleTerminalLog () { 33 | if (this.sessionLogger) { 34 | this.sessionLogger.destroy() 35 | delete this.sessionLogger 36 | } else { 37 | this.sessionLogger = new SessionLog({ 38 | logDir: this.initOptions.sessionLogPath, 39 | fileName: createLogFileName(this.initOptions.logName) 40 | }) 41 | } 42 | } 43 | 44 | onEndConn () { 45 | const { 46 | pid 47 | } = this 48 | const inst = globalState.getSession(pid) 49 | if (!inst) { 50 | return 51 | } 52 | if (this.ws) { 53 | delete this.ws 54 | } 55 | if (this.server && this.server.end) { 56 | this.server.end() 57 | } 58 | globalState.removeSession(pid) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/server/session-common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * terminal/sftp/serial class 3 | */ 4 | 5 | export function commonExtends (Cls) { 6 | Cls.prototype.customEnv = function (envs) { 7 | if (!envs) { 8 | return {} 9 | } 10 | return envs.split(' ').reduce((p, k) => { 11 | const [key, value] = k.split('=') 12 | if (key && value) { 13 | p[key] = value 14 | } 15 | return p 16 | }, {}) 17 | } 18 | 19 | Cls.prototype.getEnv = function (initOptions = this.initOptions) { 20 | return { 21 | LANG: initOptions.envLang || 'en_US.UTF-8', 22 | ...this.customEnv(initOptions.setEnv) 23 | } 24 | } 25 | 26 | Cls.prototype.getExecOpts = function () { 27 | return { 28 | env: this.getEnv() 29 | } 30 | } 31 | 32 | Cls.prototype.runCmd = function (cmd, conn) { 33 | return new Promise((resolve, reject) => { 34 | const client = conn || this.conn || this.client 35 | client.exec(cmd, this.getExecOpts(), (err, stream) => { 36 | if (err) reject(err) 37 | if (stream) { 38 | let r = '' 39 | stream 40 | .on('data', function (data) { 41 | const d = data.toString() 42 | r = r + d 43 | }) 44 | .on('close', (code, signal) => { 45 | resolve(r) 46 | }) 47 | } else { 48 | resolve('') 49 | } 50 | }) 51 | }) 52 | } 53 | return Cls 54 | } 55 | -------------------------------------------------------------------------------- /src/app/server/session-ftp.js: -------------------------------------------------------------------------------- 1 | import ftp from 'basic-ftp' 2 | import { TerminalBase } from './session-base.js' 3 | import { commonExtends } from './session-common.js' 4 | import { readRemoteFile, writeRemoteFile } from './ftp-file.js' 5 | import { Readable } from 'stream' 6 | import globalState from './global-state.js' 7 | 8 | class FtpClient extends TerminalBase { 9 | constructor (initOptions) { 10 | super({ 11 | ...initOptions, 12 | type: 'ftp' // Explicitly set the type 13 | }) 14 | this.transfers = {} 15 | } 16 | 17 | async connect (initOptions) { 18 | this.client = new ftp.Client() 19 | this.client.ftp.verbose = initOptions.debug 20 | await this.client.access({ 21 | host: initOptions.host, 22 | port: initOptions.port || 21, 23 | user: initOptions.user, 24 | password: initOptions.password, 25 | secure: initOptions.secure 26 | }) 27 | globalState.setSession(this.pid, this) 28 | this.sftp = this.client // For API compatibility 29 | return 'ok' 30 | } 31 | 32 | kill () { 33 | Object.values(this.transfers).forEach(transfer => { 34 | transfer?.destroy?.() 35 | }) 36 | this.transfers = {} 37 | this.client?.close() 38 | delete this.sftp 39 | super.onEndConn() 40 | } 41 | 42 | async getHomeDir () { 43 | return this.client.pwd() 44 | } 45 | 46 | async rmdir (remotePath) { 47 | await this.removeDirectoryRecursively(remotePath) 48 | return 1 49 | } 50 | 51 | async removeDirectoryRecursively (remotePath) { 52 | const contents = await this.list(remotePath) 53 | for (const item of contents) { 54 | const itemPath = `${remotePath}/${item.name}` 55 | if (item.type === 'd') { 56 | await this.removeDirectoryRecursively(itemPath) 57 | } else { 58 | await this.rm(itemPath) 59 | } 60 | } 61 | await this.rmFolder(remotePath) 62 | } 63 | 64 | async touch (remotePath) { 65 | const emptyStream = new Readable({ 66 | read () { 67 | this.push(null) 68 | } 69 | }) 70 | await this.client.uploadFrom(emptyStream, remotePath) 71 | return 1 72 | } 73 | 74 | async mkdir (remotePath) { 75 | await this.client.ensureDir(remotePath) 76 | return 1 77 | } 78 | 79 | async stat (remotePath) { 80 | const pathParts = remotePath.split('/') 81 | const fileName = pathParts.pop() 82 | const parentPath = pathParts.join('/') || '/' 83 | const list = await this.client.list(parentPath) 84 | if (!list || !list.length) { 85 | throw new Error('stat failed: parent directory listing empty') 86 | } 87 | 88 | const item = list.find(item => item.name === fileName) 89 | if (!item) { 90 | throw new Error(`stat failed: ${fileName} not found in ${parentPath}`) 91 | } 92 | return { 93 | size: item.size, 94 | accessTime: new Date(item.modifiedAt).getTime(), 95 | modifyTime: new Date(item.modifiedAt).getTime(), 96 | mode: 0o777, // Default permissions since FTP doesn't provide this 97 | isDirectory: item.type === 2 98 | } 99 | } 100 | 101 | async readlink (remotePath) { 102 | return remotePath 103 | } 104 | 105 | async realpath (remotePath) { 106 | const currentPath = await this.client.pwd() 107 | await this.client.cd(remotePath) 108 | const realPath = await this.client.pwd() 109 | await this.client.cd(currentPath) 110 | return realPath 111 | } 112 | 113 | async lstat (remotePath) { 114 | return this.stat(remotePath) 115 | } 116 | 117 | async chmod () { 118 | // FTP doesn't support chmod 119 | return 1 120 | } 121 | 122 | async rename (remotePath, remotePathNew) { 123 | await this.client.rename(remotePath, remotePathNew) 124 | return 1 125 | } 126 | 127 | async rmFolder (remotePath) { 128 | await this.client.removeDir(remotePath) 129 | return 1 130 | } 131 | 132 | async rm (remotePath) { 133 | await this.client.remove(remotePath) 134 | return 1 135 | } 136 | 137 | async list (remotePath) { 138 | const list = await this.client.list(remotePath) 139 | return list.map(item => ({ 140 | type: item.type === 2 ? 'd' : '-', 141 | name: item.name, 142 | size: item.size, 143 | modifyTime: new Date(item.modifiedAt).getTime(), 144 | accessTime: new Date(item.modifiedAt).getTime(), 145 | mode: 0o777, // Default permissions since FTP doesn't provide this 146 | rights: { 147 | user: 'rwx', 148 | group: 'rwx', 149 | other: 'rwx' 150 | }, 151 | owner: 'owner', 152 | group: 'group' 153 | })) 154 | } 155 | 156 | async readFile (remotePath) { 157 | return readRemoteFile(this.client, remotePath) 158 | } 159 | 160 | async writeFile (remotePath, str, mode) { 161 | return writeRemoteFile(this.client, remotePath, str, mode) 162 | } 163 | 164 | async getFolderSize (folderPath) { 165 | let size = 0 166 | let count = 0 167 | const processDir = async (dirPath) => { 168 | const list = await this.list(dirPath) 169 | for (const item of list) { 170 | if (item.type === 'd') { 171 | await processDir(`${dirPath}/${item.name}`) 172 | } else { 173 | size += item.size 174 | count++ 175 | } 176 | } 177 | } 178 | await processDir(folderPath) 179 | return { size, count } 180 | } 181 | } 182 | 183 | export const Ftp = commonExtends(FtpClient) 184 | -------------------------------------------------------------------------------- /src/app/server/session-local.js: -------------------------------------------------------------------------------- 1 | /** 2 | * terminal/sftp/serial class 3 | */ 4 | import pty from 'node-pty' 5 | import { resolve as pathResolve } from 'path' 6 | import log from '../common/log.js' 7 | import globalState from './global-state.js' 8 | import { TerminalBase } from './session-base.js' 9 | 10 | class TerminalLocal extends TerminalBase { 11 | init () { 12 | const { 13 | cols, 14 | rows, 15 | execWindows, 16 | execMac, 17 | execLinux, 18 | execWindowsArgs, 19 | execMacArgs, 20 | execLinuxArgs, 21 | termType, 22 | term 23 | } = this.initOptions 24 | this.isLocal = true 25 | const { platform } = process 26 | const exec = platform.startsWith('win') 27 | ? pathResolve( 28 | process.env.windir, 29 | execWindows 30 | ) 31 | : platform === 'darwin' ? execMac : execLinux 32 | const arg = platform.startsWith('win') 33 | ? execWindowsArgs 34 | : platform === 'darwin' ? execMacArgs : execLinuxArgs 35 | const cwd = process.env[platform === 'win32' ? 'USERPROFILE' : 'HOME'] 36 | const argv = platform.startsWith('darwin') ? ['--login', ...arg] : arg 37 | this.term = pty.spawn(exec, argv, { 38 | name: term, 39 | encoding: null, 40 | cols: cols || 80, 41 | rows: rows || 24, 42 | cwd, 43 | env: process.env 44 | }) 45 | this.term.termType = termType 46 | globalState.setSession(this.pid, this) 47 | return Promise.resolve(this) 48 | } 49 | 50 | resize (cols, rows) { 51 | this.term.resize(cols, rows) 52 | } 53 | 54 | on (event, cb) { 55 | this.term.on(event, cb) 56 | } 57 | 58 | write (data) { 59 | try { 60 | this.term.write(data) 61 | if (this.sessionLogger) { 62 | this.sessionLogger.write(data) 63 | } 64 | } catch (e) { 65 | log.error(e) 66 | } 67 | } 68 | 69 | kill () { 70 | if (this.sessionLogger) { 71 | this.sessionLogger.destroy() 72 | } 73 | this.term && this.term.kill() 74 | this.onEndConn() 75 | } 76 | } 77 | 78 | export const terminalLocal = function (initOptions, ws) { 79 | return (new TerminalLocal(initOptions, ws)).init() 80 | } 81 | 82 | /** 83 | * test ssh connection 84 | * @param {object} options 85 | */ 86 | export const testConnectionLocal = (initOptions) => { 87 | return Promise.resolve(true) 88 | } 89 | -------------------------------------------------------------------------------- /src/app/server/session-log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * log ssh output to file 3 | */ 4 | 5 | import { resolve, dirname } from 'path' 6 | import { createWriteStream, existsSync, mkdirSync } from 'fs' 7 | import { cwd } from '../common/runtime-constants.js' 8 | 9 | function mkdirP (resolvedPath) { 10 | if (!existsSync(resolvedPath)) { 11 | mkdirP(dirname(resolvedPath)) 12 | mkdirSync(resolvedPath) 13 | } 14 | } 15 | 16 | const { DB_PATH } = process.env 17 | const dataPath = DB_PATH || resolve(cwd, 'data') 18 | 19 | export const logDir = resolve(dataPath, 'electerm_session_logs') 20 | 21 | export class SessionLog { 22 | constructor (options) { 23 | const { logDir } = options 24 | const logPath = resolve(logDir, options.fileName) 25 | mkdirP(logDir) 26 | this.stream = createWriteStream(logPath) 27 | } 28 | 29 | write (text) { 30 | this.stream.write(text) 31 | } 32 | 33 | destroy () { 34 | this.stream.destroy() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/server/session-rdp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * terminal/sftp/serial class 3 | */ 4 | import _ from 'lodash' 5 | import log from '../common/log.js' 6 | import rdp from '@electerm/rdpjs' 7 | import { TerminalBase } from './session-base.js' 8 | import { isDev } from '../common/runtime-constants.js' 9 | import globalState from './global-state.js' 10 | 11 | class TerminalRdp extends TerminalBase { 12 | init = async () => { 13 | globalState.setSession(this.pid, this) 14 | return Promise.resolve(this) 15 | } 16 | 17 | start = async (width, height) => { 18 | if (this.isRunning) { 19 | return 20 | } 21 | this.isRunning = true 22 | if (this.channel) { 23 | this.channel.close() 24 | delete this.channel 25 | } 26 | const { 27 | host, 28 | port, 29 | ...rest 30 | } = this.initOptions 31 | const opts = { 32 | ...rest, 33 | logLevel: isDev ? 'DEBUG' : 'ERROR', 34 | screen: { 35 | width, 36 | height 37 | } 38 | } 39 | if (!opts.domain) { 40 | opts.domain = host 41 | } 42 | const channel = rdp.createClient(opts) 43 | .on('error', this.onError) 44 | .on('connect', this.onConnect) 45 | .on('bitmap', this.onBitmap) 46 | .on('end', this.kill) 47 | .connect(host, port) 48 | this.channel = channel 49 | this.width = width 50 | this.height = height 51 | } 52 | 53 | resize () { 54 | 55 | } 56 | 57 | onError = (err) => { 58 | if (err.message.includes('read ECONNRESET')) { 59 | this.ws && this.start( 60 | this.width, 61 | this.height 62 | ) 63 | } else { 64 | log.error('rdp error', err) 65 | } 66 | } 67 | 68 | test = async () => { 69 | return new Promise((resolve, reject) => { 70 | const { 71 | host, 72 | port, 73 | ...rest 74 | } = this.initOptions 75 | const client = rdp.createClient(rest) 76 | .on('error', (err) => { 77 | log.error(err) 78 | reject(err) 79 | }) 80 | .on('connect', () => { 81 | resolve(client) 82 | }) 83 | .connect(host, port) 84 | }) 85 | } 86 | 87 | onConnect = () => { 88 | this.isRunning = false 89 | if (this.ws) { 90 | if (!this.isWsEventRegistered) { 91 | this.ws.on('message', this.onAction) 92 | this.ws.on('close', this.kill) 93 | this.isWsEventRegistered = true 94 | } 95 | this.ws.send( 96 | JSON.stringify( 97 | { 98 | action: 'session-rdp-connected', 99 | ..._.pick(this.initOptions, [ 100 | 'tabId' 101 | ]) 102 | } 103 | ) 104 | ) 105 | } 106 | } 107 | 108 | onBitmap = (bitmap) => { 109 | this.ws && this.ws.send(JSON.stringify( 110 | bitmap 111 | )) 112 | } 113 | 114 | // action: 'sendPointerEvent', params: x, y, button, isPressed 115 | // action: 'sendWheelEvent', params: x, y, step, isNegative, isHorizontal 116 | // action: 'sendKeyEventScancode', params: code, isPressed 117 | // action: 'sendKeyEventUnicode', params: code, isPressed 118 | onAction = (_data) => { 119 | if (!this.channel || this.isRunning) { 120 | return 121 | } 122 | const data = JSON.parse(_data) 123 | const { 124 | action, 125 | params 126 | } = data 127 | if (action === 'reload') { 128 | this.start( 129 | ...params 130 | ) 131 | } else if ( 132 | [ 133 | 'sendPointerEvent', 134 | 'sendWheelEvent', 135 | 'sendKeyEventScancode', 136 | 'sendKeyEventUnicode' 137 | ].includes(action) 138 | ) { 139 | this.channel[action](...params) 140 | } else { 141 | log.error('invalid action', action) 142 | } 143 | } 144 | 145 | kill = () => { 146 | log.debug('Closed rdp session ' + this.pid) 147 | if (this.ws) { 148 | this.ws.close() 149 | delete this.ws 150 | } 151 | this.channel && this.channel.close() 152 | if (this.sessionLogger) { 153 | this.sessionLogger.destroy() 154 | } 155 | const { 156 | pid 157 | } = this 158 | const inst = globalState.getSession(pid) 159 | if (!inst) { 160 | return 161 | } 162 | globalState.removeSession(pid) 163 | } 164 | } 165 | 166 | export const terminalRdp = async function (initOptions, ws) { 167 | const term = new TerminalRdp(initOptions, ws) 168 | await term.init() 169 | return term 170 | } 171 | 172 | /** 173 | * test ssh connection 174 | * @param {object} options 175 | */ 176 | export const testConnectionRdp = (options) => { 177 | return (new TerminalRdp(options, undefined, true)) 178 | .test() 179 | .then((res) => { 180 | res.close() 181 | return true 182 | }) 183 | .catch(() => { 184 | return false 185 | }) 186 | } 187 | -------------------------------------------------------------------------------- /src/app/server/session-serial.js: -------------------------------------------------------------------------------- 1 | /** 2 | * terminal/sftp/serial class 3 | */ 4 | import { TerminalBase } from './session-base.js' 5 | import log from '../common/log.js' 6 | import { SerialPort } from 'serialport' 7 | import globalState from './global-state.js' 8 | // const { MockBinding } = require('@serialport/binding-mock') 9 | // MockBinding.createPort('/dev/ROBOT', { echo: true, record: true }) 10 | 11 | class TerminalSerial extends TerminalBase { 12 | async init () { 13 | // https://serialport.io/docs/api-stream 14 | const { 15 | autoOpen = true, 16 | baudRate = 9600, 17 | dataBits = 8, 18 | lock = true, 19 | stopBits = 1, 20 | parity = 'none', 21 | rtscts = false, 22 | xon = false, 23 | xoff = false, 24 | xany = false, 25 | path 26 | } = this.initOptions 27 | await new Promise((resolve, reject) => { 28 | this.port = new SerialPort({ 29 | // binding: MockBinding, 30 | path, 31 | autoOpen, 32 | baudRate, 33 | dataBits, 34 | lock, 35 | stopBits, 36 | parity, 37 | rtscts, 38 | xon, 39 | xoff, 40 | xany 41 | }, (err) => { 42 | if (err) { 43 | reject(err) 44 | } else { 45 | resolve('ok') 46 | } 47 | }) 48 | }) 49 | if (this.isTest) { 50 | this.kill() 51 | return true 52 | } 53 | globalState.setSession(this.pid, this) 54 | } 55 | 56 | resize () { 57 | 58 | } 59 | 60 | on (event, cb) { 61 | this.port.on(event, cb) 62 | } 63 | 64 | write (data) { 65 | try { 66 | this.port.write(data) 67 | if (this.sessionLogger) { 68 | this.sessionLogger.write(data) 69 | } 70 | } catch (e) { 71 | log.error(e) 72 | } 73 | } 74 | 75 | kill () { 76 | if (this.sessionLogger) { 77 | this.sessionLogger.destroy() 78 | } 79 | this.port && this.port.isOpen && this.port.close() 80 | delete this.port 81 | this.onEndConn() 82 | } 83 | } 84 | 85 | export async function terminalSerial (initOptions, ws) { 86 | const term = new TerminalSerial(initOptions, ws) 87 | await term.init() 88 | return term 89 | } 90 | 91 | /** 92 | * test ssh connection 93 | * @param {object} options 94 | */ 95 | export function testConnectionSerial (initOptions) { 96 | return (new TerminalSerial(initOptions, undefined, true)) 97 | .init() 98 | .then(() => true) 99 | .catch(() => { 100 | return false 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /src/app/server/session-telnet.js: -------------------------------------------------------------------------------- 1 | /** 2 | * terminal/sftp/serial class 3 | */ 4 | import _ from 'lodash' 5 | import log from '../common/log.js' 6 | import { Telnet } from './telnet.js' 7 | import { TerminalBase } from './session-base.js' 8 | import globalState from './global-state.js' 9 | 10 | class TerminalTelnet extends TerminalBase { 11 | async init () { 12 | const connection = new Telnet() 13 | 14 | const { initOptions } = this 15 | const shellOpts = { 16 | highWaterMark: 64 * 1024 * 16 17 | } 18 | const params = _.pick( 19 | initOptions, 20 | [ 21 | 'host', 22 | 'port', 23 | 'timeout', 24 | 'username', 25 | 'password', 26 | 'terminalWidth', 27 | 'terminalHeight' 28 | ] 29 | ) 30 | Object.assign( 31 | params, 32 | { 33 | negotiationMandatory: false, 34 | // terminalWidth: initOptions.cols, 35 | // terminalHeight: initOptions.rows, 36 | timeout: initOptions.readyTimeout, 37 | sendTimeout: initOptions.readyTimeout, 38 | socketConnectOptions: shellOpts 39 | } 40 | ) 41 | await connection.connect(params) 42 | this.port = connection.shell(shellOpts) 43 | this.channel = connection 44 | if (this.isTest) { 45 | this.kill() 46 | return true 47 | } 48 | globalState.setSession(this.pid, this) 49 | } 50 | 51 | resize (cols, rows) { 52 | Object.assign(this.channel.options, { 53 | terminalWidth: cols, 54 | terminalHeight: rows 55 | }) 56 | this.channel.sendWindowSize() 57 | } 58 | 59 | on (event, cb) { 60 | this.port.on(event, cb) 61 | } 62 | 63 | write (data) { 64 | try { 65 | this.port.write(data) 66 | if (this.sessionLogger) { 67 | this.sessionLogger.write(data) 68 | } 69 | } catch (e) { 70 | log.error(e) 71 | } 72 | } 73 | 74 | kill = () => { 75 | this.channel && this.channel.end() 76 | if (this.sessionLogger) { 77 | this.sessionLogger.destroy() 78 | } 79 | globalState.removeSession(this.pid) 80 | } 81 | } 82 | 83 | export const terminalTelnet = async function (initOptions, ws) { 84 | const term = new TerminalTelnet(initOptions, ws) 85 | await term.init() 86 | return term 87 | } 88 | 89 | /** 90 | * test ssh connection 91 | * @param {object} options 92 | */ 93 | export const testConnectionTelnet = (options) => { 94 | return (new TerminalTelnet(options, undefined, true)) 95 | .init() 96 | .then(() => true) 97 | .catch(() => { 98 | return false 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /src/app/server/session-vnc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * terminal/sftp/serial class 3 | */ 4 | 5 | import log from '../common/log.js' 6 | import { TerminalBase } from './session-base.js' 7 | import net from 'net' 8 | import proxySock from './socks.js' 9 | import uid from '../common/uid.js' 10 | import { terminalSsh } from './session-ssh.js' 11 | import globalState from './global-state.js' 12 | 13 | function getPort (fromPort = 120023) { 14 | return new Promise((resolve, reject) => { 15 | require('find-free-port')(fromPort, '127.0.0.1', function (err, freePort) { 16 | if (err) { 17 | reject(err) 18 | } else { 19 | resolve(freePort) 20 | } 21 | }) 22 | }) 23 | } 24 | 25 | class TerminalVnc extends TerminalBase { 26 | init = async () => { 27 | globalState.setSession(this.pid, this) 28 | return Promise.resolve(this) 29 | } 30 | 31 | start = async (width, height) => { 32 | if (this.isRunning) { 33 | return 34 | } 35 | this.isRunning = true 36 | if (this.channel) { 37 | this.channel.close() 38 | delete this.channel 39 | } 40 | const { 41 | host, 42 | port 43 | } = this.initOptions 44 | const info = await this.hop() 45 | const target = net.createConnection({ 46 | port, 47 | host, 48 | ...info 49 | }) 50 | this.channel = target 51 | target.on('data', this.onData) 52 | target.on('end', this.kill) 53 | target.on('error', this.onError) 54 | 55 | this.ws.on('message', this.onMsg) 56 | this.ws.on('close', this.kill) 57 | this.width = width 58 | this.height = height 59 | } 60 | 61 | hop = async () => { 62 | const { 63 | host, 64 | port, 65 | proxy, 66 | readyTimeout, 67 | connectionHoppings 68 | } = this.initOptions 69 | if (!connectionHoppings || !connectionHoppings.length) { 70 | return proxy 71 | ? await proxySock({ 72 | readyTimeout, 73 | host, 74 | port, 75 | proxy 76 | }) 77 | : undefined 78 | } 79 | const hop = connectionHoppings.pop() 80 | const fp = await getPort(12023) 81 | const initOpts = { 82 | connectionHoppings, 83 | hasHopping: true, 84 | ...hop, 85 | cols: 80, 86 | rows: 24, 87 | term: 'xterm-256color', 88 | saveTerminalLogToFile: false, 89 | id: uid(), 90 | enableSsh: true, 91 | encode: 'utf-8', 92 | envLang: 'en_US.UTF-8', 93 | proxy, 94 | sshTunnels: [ 95 | { 96 | sshTunnel: 'dynamicForward', 97 | sshTunnelLocalHost: '127.0.0.1', 98 | sshTunnelLocalPort: fp, 99 | id: uid() 100 | } 101 | ] 102 | } 103 | this.ssh = await terminalSsh(initOpts) 104 | const proxyA = `socks5://127.0.0.1:${fp}` 105 | return proxySock({ 106 | readyTimeout, 107 | host, 108 | port, 109 | proxy: proxyA 110 | }) 111 | } 112 | 113 | onMsg = (msg) => { 114 | this.channel.write(msg) 115 | } 116 | 117 | onData = (data) => { 118 | try { 119 | this.ws?.send(data) 120 | } catch (e) { 121 | log.error('vnc connection send data error', e) 122 | } 123 | } 124 | 125 | resize () { 126 | 127 | } 128 | 129 | onError = (err) => { 130 | log.error('vnc error', err) 131 | this.kill() 132 | } 133 | 134 | test = async () => { 135 | return new Promise((resolve, reject) => { 136 | const { 137 | host, 138 | port 139 | } = this.initOptions 140 | return this.hop() 141 | .then(info => { 142 | net.createConnection({ 143 | port, 144 | host, 145 | ...info 146 | }, () => { 147 | resolve(true) 148 | }) 149 | }) 150 | .catch(err => reject(err)) 151 | }) 152 | } 153 | 154 | kill = () => { 155 | log.debug('Closed vnc session ' + this.pid) 156 | if (this.ws) { 157 | this.ws.close() 158 | delete this.ws 159 | } 160 | if (this.ssh) { 161 | this.ssh.kill() 162 | delete this.ssh 163 | } 164 | this.channel && this.channel.end() 165 | if (this.sessionLogger) { 166 | this.sessionLogger.destroy() 167 | } 168 | globalState.removeSession(this.pid) 169 | } 170 | } 171 | 172 | export const terminalVnc = async function (initOptions, ws) { 173 | const term = new TerminalVnc(initOptions, ws) 174 | await term.init() 175 | return term 176 | } 177 | 178 | /** 179 | * test ssh connection 180 | * @param {object} options 181 | */ 182 | export const testConnectionVnc = (options) => { 183 | const inst = new TerminalVnc(options, undefined, true) 184 | return inst.test() 185 | .then(() => { 186 | inst.kill() 187 | return true 188 | }) 189 | .catch(() => { 190 | inst.kill() 191 | return false 192 | }) 193 | } 194 | -------------------------------------------------------------------------------- /src/app/server/session.js: -------------------------------------------------------------------------------- 1 | import { 2 | terminalTelnet, 3 | testConnectionTelnet 4 | } from './session-telnet.js' 5 | 6 | import { 7 | terminalSsh, 8 | testConnectionSsh 9 | } from './session-ssh.js' 10 | 11 | import { 12 | terminalLocal, 13 | testConnectionLocal 14 | } from './session-local.js' 15 | 16 | import { 17 | terminalSerial, 18 | testConnectionSerial 19 | } from './session-serial.js' 20 | 21 | import { 22 | terminalRdp, 23 | testConnectionRdp 24 | } from './session-rdp.js' 25 | 26 | import { 27 | terminalVnc, 28 | testConnectionVnc 29 | } from './session-vnc.js' 30 | 31 | export const terminal = async function (initOptions, ws) { 32 | const type = initOptions.termType || initOptions.type 33 | if (type === 'telnet') { 34 | return terminalTelnet(initOptions, ws) 35 | } else if (type === 'rdp') { 36 | return terminalRdp(initOptions, ws) 37 | } else if (type === 'vnc') { 38 | return terminalVnc(initOptions, ws) 39 | } else if (type === 'serial') { 40 | return terminalSerial(initOptions, ws) 41 | } else if (type === 'local') { 42 | return terminalLocal(initOptions, ws) 43 | } else { 44 | return terminalSsh(initOptions, ws) 45 | } 46 | } 47 | 48 | /** 49 | * test ssh connection 50 | * @param {object} options 51 | */ 52 | export const testConnection = (initOptions) => { 53 | const type = initOptions.termType || initOptions.type 54 | if (type === 'telnet') { 55 | return testConnectionTelnet(initOptions) 56 | } else if (type === 'rdp') { 57 | return testConnectionRdp(initOptions) 58 | } else if (type === 'vnc') { 59 | return testConnectionVnc(initOptions) 60 | } else if (type === 'local') { 61 | return testConnectionLocal(initOptions) 62 | } else if (type === 'serial') { 63 | return testConnectionSerial(initOptions) 64 | } else { 65 | return testConnectionSsh(initOptions) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/server/sftp-file.js: -------------------------------------------------------------------------------- 1 | /** 2 | * sftp read/write file 3 | */ 4 | 5 | import { Readable, Writable } from 'stream' 6 | 7 | function createReadStreamFromString (str) { 8 | const s = new Readable() 9 | s._read = () => {} 10 | s.push(str) 11 | s.push(null) 12 | return s 13 | } 14 | 15 | class FakeWrite extends Writable { 16 | constructor (opts) { 17 | super(opts) 18 | this.opts = opts 19 | } 20 | 21 | _write (data, encoding, done) { 22 | this.opts.onData(data) 23 | done() 24 | } 25 | } 26 | 27 | export function writeRemoteFile (sftp, path, str, mode) { 28 | return new Promise((resolve, reject) => { 29 | const writeStream = sftp.createWriteStream(path, { 30 | highWaterMark: 64 * 1024 * 4 * 4, 31 | mode 32 | }) 33 | writeStream.on('close', () => { 34 | resolve('ok') 35 | }) 36 | writeStream.on('error', (e) => { 37 | reject(e) 38 | }) 39 | createReadStreamFromString(str).pipe(writeStream) 40 | }) 41 | } 42 | 43 | export function readRemoteFile (sftp, path) { 44 | return new Promise((resolve, reject) => { 45 | let final = Buffer.alloc(0) 46 | const writeStream = new FakeWrite({ 47 | onData: data => { 48 | final = Buffer.concat( 49 | [final, data] 50 | ) 51 | } 52 | }) 53 | writeStream.on('finish', () => { 54 | resolve(final.toString()) 55 | }) 56 | writeStream.on('error', (e) => { 57 | reject(e) 58 | }) 59 | sftp.createReadStream(path, { 60 | highWaterMark: 64 * 1024 * 4 * 4 61 | }).pipe(writeStream) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /src/app/server/socks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * socks proxy wrapper 3 | */ 4 | import { SocksClient } from 'socks' 5 | import { request } from 'http' 6 | 7 | function isValidIP (input) { 8 | // Check IPv4 format 9 | const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/ 10 | if (ipv4Pattern.test(input)) { 11 | return true 12 | } 13 | 14 | // Check IPv6 format 15 | const ipv6Pattern = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/i 16 | if (ipv6Pattern.test(input)) { 17 | return true 18 | } 19 | 20 | // If input doesn't match IPv4 or IPv6 patterns, it's not a valid IP 21 | return false 22 | } 23 | 24 | function parseUrl (str) { 25 | try { 26 | return new URL(str) 27 | } catch (e) { 28 | console.log(`parse url error: ${e.message}, url: ${str}`) 29 | } 30 | } 31 | 32 | export default (initOptions) => { 33 | const { 34 | readyTimeout, 35 | host, 36 | port, 37 | proxy 38 | } = initOptions 39 | const proxyURL = parseUrl(proxy) 40 | if (!proxyURL) { 41 | throw new Error('proxy format not right:', proxy) 42 | } 43 | // use http proxy 44 | const { 45 | protocol, 46 | hostname, 47 | username, 48 | password 49 | } = proxyURL 50 | const proxyPort = Number(proxyURL.port) 51 | const proxyHost = proxyURL.host 52 | if (protocol === 'http:' || protocol === 'https:') { 53 | return new Promise((resolve, reject) => { 54 | const opts = { 55 | agent: false, 56 | protocol, 57 | hostname, 58 | port: proxyPort, 59 | host: proxyHost, 60 | path: `${host}:${port}`, 61 | method: 'CONNECT', 62 | timeout: readyTimeout 63 | } 64 | if (username) { 65 | opts.auth = `${username}:${password}` 66 | } 67 | request(opts) 68 | .on('error', (e) => { 69 | console.error(`fail to connect proxy: ${e.message}`) 70 | reject(e) 71 | }) 72 | .on('connect', (res, socket) => { 73 | resolve({ socket }) 74 | }) 75 | .end() 76 | }) 77 | } 78 | const type = protocol.includes('5') ? 5 : 4 79 | const isIp = isValidIP(hostname) 80 | const options = { 81 | proxy: { 82 | port: proxyPort, 83 | type, 84 | userId: username, 85 | password 86 | }, 87 | 88 | command: 'connect', 89 | timeout: readyTimeout, 90 | 91 | destination: { 92 | host, 93 | port 94 | } 95 | } 96 | if (isIp) { 97 | options.proxy.ipaddress = hostname 98 | } else { 99 | options.proxy.host = hostname 100 | } 101 | 102 | // use socks proxy 103 | return SocksClient.createConnection(options) 104 | } 105 | -------------------------------------------------------------------------------- /src/app/server/ssh-tunnel.js: -------------------------------------------------------------------------------- 1 | import log from '../common/log.js' 2 | import socks from 'socksv5' 3 | import net from 'net' 4 | 5 | export function forwardRemoteToLocal ({ 6 | conn, 7 | sshTunnelRemotePort, 8 | sshTunnelLocalPort, 9 | sshTunnelRemoteHost = '127.0.0.1', 10 | sshTunnelLocalHost = '127.0.0.1' 11 | }) { 12 | return new Promise((resolve, reject) => { 13 | const result = `remote:${sshTunnelRemoteHost}:${sshTunnelRemotePort} => local:${sshTunnelLocalHost}:${sshTunnelLocalPort}` 14 | let server = null 15 | conn.on('tcp connection', (info, accept, reject) => { 16 | const srcStream = accept() // Source stream for forwarding 17 | conn.emit('forwardIn', srcStream) 18 | }).on('forwardIn', (srcStream) => { 19 | // Connect the local machine source stream to the local port 20 | server = net.connect(sshTunnelLocalPort, sshTunnelLocalHost) 21 | srcStream.pipe(server).pipe(srcStream) 22 | }).on('close', () => { 23 | server && server.close && server.close() 24 | log.log('SSH connection closed') 25 | }) 26 | // Forward the remote server's port to the local machine's port 27 | conn.forwardIn(sshTunnelRemoteHost, sshTunnelRemotePort, (err) => { 28 | if (err) { 29 | log.error('Error forwarding port:', err) 30 | return reject(err) 31 | } 32 | log.log(`Port forwarded: ${result}`) 33 | resolve(1) 34 | }) 35 | }) 36 | } 37 | 38 | export function forwardLocalToRemote ({ 39 | conn, 40 | sshTunnelRemotePort, 41 | sshTunnelLocalPort, 42 | sshTunnelRemoteHost = '127.0.0.1', 43 | sshTunnelLocalHost = '127.0.0.1' 44 | }) { 45 | return new Promise((resolve, reject) => { 46 | const localServer = net.createServer((socket) => { 47 | conn.forwardOut(sshTunnelLocalHost, sshTunnelLocalPort, sshTunnelRemoteHost, sshTunnelRemotePort, (err, remoteSocket) => { 48 | if (err) { 49 | log.error('Error forwarding connection:', err) 50 | socket.end() 51 | return reject(err) 52 | } 53 | socket.pipe(remoteSocket).pipe(socket) 54 | }) 55 | }) 56 | // Start listening for local connections 57 | localServer.listen(sshTunnelLocalPort, sshTunnelLocalHost, () => { 58 | log.log(`Local server listening on port ${sshTunnelLocalPort}`) 59 | resolve(1) 60 | }) 61 | localServer.on('error', (err) => { 62 | log.error('Error listening for local connections:', err) 63 | reject(err) 64 | }) 65 | conn.on('close', () => { 66 | localServer && localServer.close() 67 | }) 68 | }) 69 | } 70 | 71 | export function dynamicForward ({ 72 | conn, 73 | sshTunnelLocalPort, 74 | sshTunnelLocalHost = '127.0.0.1' 75 | }) { 76 | return new Promise((resolve, reject) => { 77 | const dproxyServer = socks.createServer((info, accept, deny) => { 78 | conn.forwardOut( 79 | info.srcAddr, 80 | info.srcPort, 81 | info.dstAddr, 82 | info.dstPort, 83 | (err, stream) => { 84 | if (err) { 85 | deny() 86 | return reject(err) 87 | } 88 | const clientSocket = accept(true) 89 | if (clientSocket) { 90 | stream.pipe(clientSocket).pipe(stream) 91 | } 92 | }) 93 | }) 94 | dproxyServer.listen(sshTunnelLocalPort, sshTunnelLocalHost, () => { 95 | log.log(`SOCKS server listening on ${sshTunnelLocalHost}:${sshTunnelLocalPort}`) 96 | resolve(1) 97 | }).useAuth(socks.auth.None()) 98 | dproxyServer.on('error', (err) => { 99 | log.error('Error listening for local connections:', err) 100 | reject(err) 101 | }) 102 | // close socks proxy when ssh connection is closed. 103 | conn.on('close', () => { 104 | dproxyServer && dproxyServer.close() 105 | }) 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /src/app/server/ssh2-alg.js: -------------------------------------------------------------------------------- 1 | /** 2 | * all supported ssh2 algorithms config 3 | */ 4 | 5 | import nodeCrypto from 'crypto' 6 | import browserDH from 'diffie-hellman/browser.js' 7 | 8 | nodeCrypto.createDiffieHellmanGroup = browserDH.createDiffieHellmanGroup 9 | nodeCrypto.createDiffieHellman = browserDH.createDiffieHellman 10 | nodeCrypto.ddd = 1 11 | 12 | export const algDefault = () => ({ 13 | kex: [ 14 | 'curve25519-sha256', // (node v13.9.0 or newer) 15 | 'curve25519-sha256@libssh.org', // (node v13.9.0 or newer) 16 | 'diffie-hellman-group14-sha256', 17 | 'diffie-hellman-group15-sha512', 18 | 'diffie-hellman-group16-sha512', 19 | 'diffie-hellman-group17-sha512', 20 | 'diffie-hellman-group18-sha512', 21 | 'ecdh-sha2-nistp256', 22 | 'ecdh-sha2-nistp384', 23 | 'ecdh-sha2-nistp521', 24 | 'diffie-hellman-group-exchange-sha256', 25 | 'diffie-hellman-group14-sha1', 26 | 'diffie-hellman-group-exchange-sha1', 27 | 'diffie-hellman-group1-sha1' 28 | ], 29 | hmac: [ 30 | 'hmac-sha2-256', 31 | 'hmac-sha2-512', 32 | 'hmac-sha1', 33 | 'hmac-md5', 34 | 'hmac-sha2-256-96', 35 | 'hmac-sha2-512-96', 36 | 'hmac-ripemd160', 37 | 'hmac-sha1-96', 38 | 'hmac-md5-96', 39 | 'hmac-sha2-256-etm@openssh.com', 40 | 'hmac-sha2-512-etm@openssh.com', 41 | 'hmac-sha1-etm@openssh.com' 42 | ], 43 | compress: [ 44 | 'zlib@openssh.com', 45 | 'zlib', 46 | 'none' 47 | ] 48 | }) 49 | 50 | export const algAlt = () => ({ 51 | ...exports.algDefault(), 52 | cipher: [ 53 | // 'chacha20-poly1305@openssh.com', 54 | 'aes128-ctr', 55 | 'aes192-ctr', 56 | 'aes256-ctr', 57 | 'aes128-gcm', 58 | 'aes128-gcm@openssh.com', 59 | 'aes256-gcm', 60 | 'aes256-gcm@openssh.com', 61 | 'aes256-cbc', 62 | 'aes192-cbc', 63 | 'aes128-cbc', 64 | 'aes128-ctr', 65 | 'aes192-ctr', 66 | 'aes256-ctr', 67 | 'blowfish-cbc', 68 | '3des-cbc', 69 | 'arcfour256', 70 | 'arcfour128', 71 | // 'cast128-cbc', 72 | 'arcfour' 73 | ], 74 | serverHostKey: [ 75 | 'ssh-rsa', 76 | 'ssh-ed25519', 77 | 'ecdsa-sha2-nistp256', 78 | 'ecdsa-sha2-nistp384', 79 | 'ecdsa-sha2-nistp521', 80 | 'ssh-dss', 81 | 'rsa-sha2-512', 82 | 'rsa-sha2-256' 83 | ] 84 | }) 85 | -------------------------------------------------------------------------------- /src/app/server/sync.js: -------------------------------------------------------------------------------- 1 | /** 2 | * handle sync with github/gitee 3 | */ 4 | 5 | import GitHubOri from 'gist-wrapper' 6 | import GiteeOri from 'gitee-client' 7 | import customSync from 'electerm-sync' 8 | import log from '../common/log.js' 9 | import { createProxyAgent } from '../lib/proxy-agent.js' 10 | 11 | class Gitee extends GiteeOri { 12 | create (data, conf) { 13 | return this.post('/v5/gists', data, conf) 14 | } 15 | 16 | update (gistId, data, conf) { 17 | return this.patch(`/v5/gists/${gistId}`, data, conf) 18 | } 19 | 20 | getOne (gistId, conf) { 21 | return this.get(`/v5/gists/${gistId}`, conf) 22 | } 23 | 24 | delOne (gistId, conf) { 25 | return this.delete(`/gists/${gistId}`, conf) 26 | } 27 | 28 | test (conf) { 29 | return this.get('/v5/gists?page=1&per_page=1', conf) 30 | } 31 | } 32 | 33 | class GitHub extends GitHubOri { 34 | test (conf) { 35 | return this.get('/gists?page=1&per_page=1', conf) 36 | } 37 | } 38 | 39 | const dist = { 40 | gitee: Gitee, 41 | github: GitHub, 42 | custom: customSync, 43 | cloud: customSync 44 | } 45 | 46 | async function doSync (type, func, args, token, proxy) { 47 | const argsArr = [...args] 48 | const inst = new dist[type](token) 49 | if (type === 'cloud' && func === 'getOne') { 50 | argsArr[0] = '' 51 | } 52 | const agent = createProxyAgent(proxy) 53 | const conf = agent 54 | ? { 55 | httpsAgent: agent 56 | } 57 | : { 58 | proxy: false 59 | } 60 | return inst[func](...argsArr, conf) 61 | .then(r => r.data) 62 | .catch(e => { 63 | log.error('sync error') 64 | log.error(e) 65 | return { 66 | error: e 67 | } 68 | }) 69 | } 70 | 71 | export default async function wsSyncHandler (ws, msg) { 72 | const { id, type, args, func, token, proxy } = msg 73 | const res = await doSync(type, func, args, token, proxy) 74 | if (res.error) { 75 | ws.s({ 76 | error: res.error, 77 | id 78 | }) 79 | } else { 80 | ws.s({ 81 | data: res, 82 | id 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/app/server/telnet.js: -------------------------------------------------------------------------------- 1 | // used code from https://github.com/Eugeny/tabby/blob/master/tabby-telnet/src/session.ts and from https://github.com/mkozjak/node-telnet-client 2 | 3 | import { EventEmitter } from 'events' 4 | import { Socket } from 'net' 5 | import { Duplex } from 'stream' 6 | 7 | const TelnetCommands = { 8 | SUBOPTION_END: 240, 9 | GA: 249, 10 | SUBOPTION: 250, 11 | WILL: 251, 12 | WONT: 252, 13 | DO: 253, 14 | DONT: 254, 15 | IAC: 255 16 | } 17 | 18 | const TelnetOptions = { 19 | ECHO: 1, 20 | SUPPRESS_GO_AHEAD: 3, 21 | STATUS: 5, 22 | TERMINAL_TYPE: 24, 23 | NEGO_WINDOW_SIZE: 31, 24 | NEGO_TERMINAL_SPEED: 32, 25 | REMOTE_FLOW_CONTROL: 33, 26 | X_DISPLAY_LOCATION: 35, 27 | NEW_ENVIRON: 39 28 | } 29 | 30 | class Stream extends Duplex { 31 | constructor (socket, options) { 32 | super(options) 33 | this.socket = socket 34 | this.socket.on('data', data => this.push(data)) 35 | } 36 | 37 | _write (data, encoding, callback) { 38 | if (!this.socket.writable && callback) { 39 | callback(new Error('socket not writable')) 40 | return 41 | } 42 | this.socket.write(data, encoding, callback) 43 | } 44 | 45 | _read () {} 46 | } 47 | 48 | export class Telnet extends EventEmitter { 49 | constructor (options = {}) { 50 | super() 51 | this.options = { 52 | host: '127.0.0.1', 53 | port: 23, 54 | timeout: 5000, 55 | negotiationMandatory: false, 56 | username: '', 57 | password: '', 58 | terminalWidth: 80, 59 | terminalHeight: 24, 60 | loginPrompt: /login[: ]*$/i, 61 | passwordPrompt: /password[: ]*$/i, 62 | failedLoginMatch: /failed|incorrect|denied/i, 63 | ...options 64 | } 65 | this.socket = null 66 | this.telnetProtocol = false 67 | this.state = 'init' 68 | this.buffer = Buffer.alloc(0) 69 | this.dataBuffer = '' 70 | this.authenticated = false 71 | this.loginAttempted = false 72 | this.passwordAttempted = false 73 | } 74 | 75 | connect (options = {}) { 76 | return new Promise((resolve, reject) => { 77 | Object.assign(this.options, options) 78 | 79 | if (this.options.sock) { 80 | this.socket = this.options.sock 81 | } else { 82 | this.socket = new Socket() 83 | } 84 | 85 | this.socket.setTimeout(this.options.timeout || 0) 86 | 87 | this.socket.on('connect', () => { 88 | this.state = 'connected' 89 | this.emit('connect') 90 | if (!this.options.negotiationMandatory) { 91 | resolve() 92 | } 93 | }) 94 | 95 | this.socket.on('timeout', () => { 96 | this.emit('timeout') 97 | reject(new Error('Connection timeout')) 98 | }) 99 | 100 | this.socket.on('error', (error) => { 101 | this.emit('error', error) 102 | reject(error) 103 | }) 104 | 105 | this.socket.on('end', () => { 106 | this.emit('end') 107 | }) 108 | 109 | this.socket.on('close', () => { 110 | this.emit('close') 111 | }) 112 | 113 | this.socket.on('data', (data) => { 114 | const processedData = this.processData(data) 115 | if (processedData && processedData.length > 0) { 116 | this.handleLoginSequence(processedData) 117 | } 118 | }) 119 | 120 | if (!this.options.sock) { 121 | this.socket.connect({ 122 | host: this.options.host, 123 | port: this.options.port 124 | }) 125 | } 126 | 127 | this.once('telnetProtocol', () => { 128 | this.emitTelnet(TelnetCommands.DO, TelnetOptions.SUPPRESS_GO_AHEAD) 129 | this.emitTelnet(TelnetCommands.WILL, TelnetOptions.TERMINAL_TYPE) 130 | this.emitTelnet(TelnetCommands.WILL, TelnetOptions.NEGO_WINDOW_SIZE) 131 | if (this.options.negotiationMandatory) { 132 | resolve() 133 | } 134 | }) 135 | }) 136 | } 137 | 138 | handleLoginSequence (data) { 139 | if (this.authenticated) { 140 | this.emit('data', data) 141 | return 142 | } 143 | 144 | const str = data.toString() 145 | this.dataBuffer += str 146 | 147 | // Check for failed login 148 | if (this.options.failedLoginMatch.test(this.dataBuffer)) { 149 | this.emit('failedlogin') 150 | this.dataBuffer = '' 151 | return 152 | } 153 | 154 | // Check for login prompt 155 | if (!this.loginAttempted && 156 | this.options.username && 157 | this.options.loginPrompt.test(this.dataBuffer)) { 158 | setTimeout(() => { 159 | this.socket.write(this.options.username + '\n') 160 | }, 100) 161 | this.loginAttempted = true 162 | this.dataBuffer = '' 163 | return 164 | } 165 | 166 | // Check for password prompt 167 | if (!this.passwordAttempted && 168 | this.options.password && 169 | this.options.passwordPrompt.test(this.dataBuffer)) { 170 | setTimeout(() => { 171 | this.socket.write(this.options.password + '\n') 172 | }, 100) 173 | this.passwordAttempted = true 174 | this.dataBuffer = '' 175 | return 176 | } 177 | 178 | // If both login and password were attempted, consider it authenticated 179 | if (this.loginAttempted && this.passwordAttempted) { 180 | this.authenticated = true 181 | this.emit('data', data) 182 | } 183 | 184 | // Keep only last chunk in buffer for prompt detection 185 | if (this.dataBuffer.length > 1024) { 186 | this.dataBuffer = this.dataBuffer.slice(-1024) 187 | } 188 | } 189 | 190 | processData (data) { 191 | if (!this.telnetProtocol && data[0] === TelnetCommands.IAC) { 192 | this.telnetProtocol = true 193 | this.emit('telnetProtocol') 194 | } 195 | 196 | if (this.telnetProtocol) { 197 | data = this.processTelnetProtocol(data) 198 | } 199 | 200 | if (data && data.length > 0) { 201 | return data 202 | } 203 | return null 204 | } 205 | 206 | processTelnetProtocol (data) { 207 | let position = 0 208 | let resultBuffer = Buffer.alloc(0) 209 | 210 | while (position < data.length) { 211 | if (data[position] === TelnetCommands.IAC) { 212 | if (position + 1 >= data.length) { 213 | this.buffer = data.slice(position) 214 | return Buffer.concat([resultBuffer, data.slice(0, position)]) 215 | } 216 | 217 | const command = data[position + 1] 218 | 219 | if (command === TelnetCommands.IAC) { 220 | resultBuffer = Buffer.concat([resultBuffer, Buffer.from([TelnetCommands.IAC])]) 221 | position += 2 222 | } else if ([TelnetCommands.WILL, TelnetCommands.WONT, TelnetCommands.DO, TelnetCommands.DONT].includes(command)) { 223 | if (position + 2 >= data.length) { 224 | this.buffer = data.slice(position) 225 | return Buffer.concat([resultBuffer, data.slice(0, position)]) 226 | } 227 | 228 | const option = data[position + 2] 229 | this.handleTelnetCommand(command, option) 230 | position += 3 231 | } else if (command === TelnetCommands.SUBOPTION) { 232 | let endPos = position + 2 233 | while (endPos < data.length - 1) { 234 | if (data[endPos] === TelnetCommands.IAC && data[endPos + 1] === TelnetCommands.SUBOPTION_END) { 235 | break 236 | } 237 | endPos++ 238 | } 239 | 240 | if (endPos >= data.length - 1) { 241 | this.buffer = data.slice(position) 242 | return Buffer.concat([resultBuffer, data.slice(0, position)]) 243 | } 244 | 245 | this.handleSuboption(data.slice(position + 2, endPos)) 246 | position = endPos + 2 247 | } else { 248 | position += 2 249 | } 250 | } else { 251 | const nextIAC = data.indexOf(TelnetCommands.IAC, position) 252 | if (nextIAC === -1) { 253 | resultBuffer = Buffer.concat([resultBuffer, data.slice(position)]) 254 | break 255 | } else { 256 | resultBuffer = Buffer.concat([resultBuffer, data.slice(position, nextIAC)]) 257 | position = nextIAC 258 | } 259 | } 260 | } 261 | 262 | return resultBuffer 263 | } 264 | 265 | handleTelnetCommand (command, option) { 266 | switch (command) { 267 | case TelnetCommands.WILL: 268 | if ([TelnetOptions.SUPPRESS_GO_AHEAD, TelnetOptions.ECHO].includes(option)) { 269 | this.emitTelnet(TelnetCommands.DO, option) 270 | } else { 271 | this.emitTelnet(TelnetCommands.DONT, option) 272 | } 273 | break 274 | 275 | case TelnetCommands.DO: 276 | if (option === TelnetOptions.NEGO_WINDOW_SIZE) { 277 | this.emitTelnet(TelnetCommands.WILL, option) 278 | this.sendWindowSize() 279 | } else if (option === TelnetOptions.TERMINAL_TYPE) { 280 | this.emitTelnet(TelnetCommands.WILL, option) 281 | } else { 282 | this.emitTelnet(TelnetCommands.WONT, option) 283 | } 284 | break 285 | 286 | case TelnetCommands.WONT: 287 | case TelnetCommands.DONT: 288 | // Do nothing 289 | break 290 | } 291 | } 292 | 293 | handleSuboption (data) { 294 | const option = data[0] 295 | if (option === TelnetOptions.TERMINAL_TYPE) { 296 | if (data[1] === 1) { // SEND 297 | this.emitTelnetSuboption(TelnetOptions.TERMINAL_TYPE, 298 | Buffer.from([0, ...Buffer.from('xterm')])) 299 | } 300 | } 301 | } 302 | 303 | emitTelnet (command, option) { 304 | this.socket.write(Buffer.from([TelnetCommands.IAC, command, option])) 305 | } 306 | 307 | emitTelnetSuboption (option, value) { 308 | this.socket.write(Buffer.from([ 309 | TelnetCommands.IAC, 310 | TelnetCommands.SUBOPTION, 311 | option, 312 | ...value, 313 | TelnetCommands.IAC, 314 | TelnetCommands.SUBOPTION_END 315 | ])) 316 | } 317 | 318 | sendWindowSize () { 319 | const { terminalWidth, terminalHeight } = this.options 320 | this.emitTelnetSuboption(TelnetOptions.NEGO_WINDOW_SIZE, Buffer.from([ 321 | terminalWidth >> 8, terminalWidth & 0xff, 322 | terminalHeight >> 8, terminalHeight & 0xff 323 | ])) 324 | } 325 | 326 | shell (options = {}) { 327 | return new Stream(this.socket, options) 328 | } 329 | 330 | end () { 331 | if (this.socket) { 332 | this.socket.end() 333 | } 334 | } 335 | 336 | destroy () { 337 | if (this.socket) { 338 | this.socket.destroy() 339 | } 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/app/server/terminal-api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * run cmd with terminal 3 | */ 4 | 5 | import { terminals } from './remote-common.js' 6 | import { terminal, testConnection } from './session.js' 7 | 8 | export async function runCmd (ws, msg) { 9 | const { id, pid, cmd } = msg 10 | const term = terminals(pid) 11 | let txt = '' 12 | if (term) { 13 | txt = await term.runCmd(cmd) 14 | } 15 | ws.s({ 16 | id, 17 | data: txt 18 | }) 19 | } 20 | 21 | export function resize (ws, msg) { 22 | const { id, pid, cols, rows } = msg 23 | const term = terminals(pid) 24 | if (term) { 25 | term.resize(cols, rows) 26 | } 27 | ws.s({ 28 | id, 29 | data: 'ok' 30 | }) 31 | } 32 | 33 | export function toggleTerminalLog (ws, msg) { 34 | const { id, pid } = msg 35 | const term = terminals(pid) 36 | if (term) { 37 | term.toggleTerminalLog() 38 | } 39 | ws.s({ 40 | id, 41 | data: 'ok' 42 | }) 43 | } 44 | 45 | export function toggleTerminalLogTimestamp (ws, msg) { 46 | const { id, pid } = msg 47 | const term = terminals(pid) 48 | if (term) { 49 | term.toggleTerminalLogTimestamp() 50 | } 51 | ws.s({ 52 | id, 53 | data: 'ok' 54 | }) 55 | } 56 | 57 | export function createTerm (ws, msg) { 58 | const { id, body } = msg 59 | terminal(body, ws) 60 | .then(data => { 61 | ws.s({ 62 | id, 63 | data: data.pid 64 | }) 65 | }) 66 | .catch(err => { 67 | ws.s({ 68 | id, 69 | error: { 70 | message: err.message, 71 | stack: err.stack 72 | } 73 | }) 74 | }) 75 | } 76 | 77 | export function testTerm (ws, msg) { 78 | const { id, body } = msg 79 | testConnection(body) 80 | .then(data => { 81 | if (data) { 82 | ws.s({ 83 | id, 84 | data 85 | }) 86 | } else { 87 | ws.s({ 88 | id, 89 | error: { 90 | message: 'test failed', 91 | stack: 'test failed' 92 | } 93 | }) 94 | } 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /src/app/server/transfer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * transfer class 3 | */ 4 | 5 | import fs from 'fs' 6 | import _ from 'lodash' 7 | import log from '../common/log.js' 8 | 9 | export class Transfer { 10 | constructor ({ 11 | remotePath, 12 | localPath, 13 | options = {}, 14 | id, 15 | type = 'download', 16 | sftp, 17 | sftpId, 18 | ws 19 | }) { 20 | this.id = id 21 | const isd = type === 'download' 22 | this.src = isd ? sftp : fs 23 | this.dst = isd ? fs : sftp 24 | this.sftpId = sftpId 25 | this.srcPath = isd ? remotePath : localPath 26 | this.dstPath = !isd ? remotePath : localPath 27 | this.pausing = false 28 | this.hadError = false 29 | this.isUpload = isd 30 | this.options = options 31 | this.concurrency = options.concurrency || 64 32 | this.chunkSize = options.chunkSize || 32768 33 | this.mode = options.mode 34 | this.onData = _.throttle((count) => { 35 | ws.s({ 36 | id: 'transfer:data:' + id, 37 | data: count 38 | }) 39 | }, 3000) 40 | this.timers = {} 41 | 42 | this.ws = ws 43 | this.fastXfer(options, type) 44 | } 45 | 46 | tryCreateBuffer = (size) => { 47 | try { 48 | return Buffer.allocUnsafe(size) 49 | } catch (ex) { 50 | return ex 51 | } 52 | } 53 | 54 | // from https://github.com/mscdex/ssh2-streams/blob/master/lib/sftp.js 55 | fastXfer = () => { 56 | const { src, srcPath } = this 57 | src.open(srcPath, 'r', this.onSrcOpen) 58 | } 59 | 60 | onSrcOpen = (err, sourceHandle) => { 61 | if (err) { 62 | return this.onError(err) 63 | } 64 | if (this.onDestroy) { 65 | return 66 | } 67 | const { src } = this 68 | const th = this 69 | 70 | th.srcHandle = sourceHandle 71 | 72 | src.fstat(th.srcHandle, this.tryStat) 73 | } 74 | 75 | tryStat = (err, attrs) => { 76 | const { src, dst, srcPath, dstPath } = this 77 | const th = this 78 | if (err) { 79 | if (src !== fs) { 80 | // Try stat() for sftp servers that may not support fstat() for 81 | // whatever reason 82 | src.stat(srcPath, (err_, attrs_) => { 83 | if (err_) { 84 | return th.onError(err_) 85 | } 86 | this.tryStat(null, attrs_) 87 | }) 88 | return 89 | } 90 | return th.onError(err) 91 | } 92 | this.fsize = attrs.size 93 | dst.open(dstPath, 'w', this.onDstOpen) 94 | } 95 | 96 | onDstOpen = (err, destHandle) => { 97 | if (err) { 98 | return this.onError(err) 99 | } 100 | 101 | if (this.onDestroy) { 102 | return 103 | } 104 | 105 | let { 106 | concurrency, 107 | chunkSize, 108 | mode 109 | } = this 110 | const onstep = this.onData 111 | const { src, dst, dstPath } = this 112 | const th = this 113 | 114 | // internal state variables 115 | let pdst = 0 116 | let total = 0 117 | let bufsize = chunkSize * concurrency 118 | 119 | const { fsize } = this 120 | 121 | th.dstHandle = destHandle 122 | 123 | if (fsize <= 0) { 124 | return th.onError() 125 | } 126 | 127 | // Use less memory where possible 128 | while (bufsize > fsize) { 129 | if (concurrency === 1) { 130 | bufsize = fsize 131 | break 132 | } 133 | bufsize -= chunkSize 134 | --concurrency 135 | } 136 | 137 | const readbuf = th.tryCreateBuffer(bufsize) 138 | if (readbuf instanceof Error) { 139 | return th.onError(readbuf) 140 | } 141 | 142 | if (mode !== undefined) { 143 | dst.fchmod(th.dstHandle, mode, function tryAgain (err) { 144 | if (err) { 145 | // Try chmod() for sftp servers that may not support fchmod() for 146 | // whatever reason 147 | dst.chmod(dstPath, mode, function (err_) { 148 | tryAgain() 149 | }) 150 | return 151 | } 152 | startReads() 153 | }) 154 | } else { 155 | startReads() 156 | } 157 | 158 | function onread (err, nb, data, dstpos, datapos, origChunkLen) { 159 | if (err) { 160 | return th.onError(err) 161 | } 162 | 163 | if (th.onDestroy) { 164 | return 165 | } 166 | 167 | datapos = datapos || 0 168 | 169 | dst.write(th.dstHandle, readbuf, datapos, nb, dstpos, writeCb) 170 | 171 | function writeCb (err) { 172 | if (err) { 173 | return th.onError(err) 174 | } 175 | 176 | total += nb 177 | onstep && onstep(total, nb, fsize) 178 | 179 | if (nb < origChunkLen) { 180 | return singleRead(datapos, dstpos + nb, origChunkLen - nb) 181 | } 182 | 183 | if (total === fsize) { 184 | dst.close(th.dstHandle, (err) => { 185 | th.dstHandle = undefined 186 | if (err) { 187 | return th.onError(err) 188 | } 189 | src.close(th.srcHandle, (err) => { 190 | th.srcHandle = undefined 191 | if (err) { 192 | return th.onError(err) 193 | } 194 | th.onError() 195 | }) 196 | }) 197 | return 198 | } 199 | 200 | if (pdst >= fsize) { 201 | return 202 | } 203 | 204 | const chunk = (pdst + chunkSize > fsize ? fsize - pdst : chunkSize) 205 | singleRead(datapos, pdst, chunk) 206 | pdst += chunk 207 | } 208 | } 209 | 210 | function makeCb (psrc, pdst, chunk) { 211 | return function (err, nb, data) { 212 | onread(err, nb, data, pdst, psrc, chunk) 213 | } 214 | } 215 | 216 | function singleRead (psrc, pdst, chunk) { 217 | if (th.onDestroy) { 218 | return 219 | } 220 | if (th.pausing) { 221 | th.timers[psrc + ':' + pdst] = setTimeout(() => { 222 | singleRead(psrc, pdst, chunk) 223 | }, 2) 224 | return 225 | } 226 | src.read( 227 | th.srcHandle, 228 | readbuf, 229 | psrc, 230 | chunk, 231 | pdst, 232 | makeCb(psrc, pdst, chunk) 233 | ) 234 | } 235 | 236 | function startReads () { 237 | let reads = 0 238 | let psrc = 0 239 | while (pdst < fsize && reads < concurrency) { 240 | const chunk = (pdst + chunkSize > fsize ? fsize - pdst : chunkSize) 241 | singleRead(psrc, pdst, chunk) 242 | psrc += chunk 243 | pdst += chunk 244 | ++reads 245 | } 246 | } 247 | } 248 | 249 | onEnd = (id = this.id, ws = this.ws) => { 250 | ws.s({ 251 | id: 'transfer:end:' + id, 252 | data: null 253 | }) 254 | } 255 | 256 | onError = (err = '', id = this.id, ws = this.ws) => { 257 | if (!err) { 258 | return this.onEnd() 259 | } 260 | ws && ws.s({ 261 | id: 'transfer:err:' + id, 262 | error: { 263 | message: err.message, 264 | stack: err.stack 265 | } 266 | }) 267 | } 268 | 269 | pause = () => { 270 | this.pausing = true 271 | } 272 | 273 | resume = () => { 274 | this.pausing = false 275 | } 276 | 277 | kill = () => { 278 | if (this.src && this.srcHandle) { 279 | this.src.close(this.srcHandle, log.error) 280 | } 281 | if (this.dst && this.dstHandle) { 282 | this.dst.close(this.dstHandle, log.error) 283 | } 284 | this.src = null 285 | this.dst = null 286 | this.srcHandle = null 287 | this.dstHandle = null 288 | } 289 | 290 | destroy = () => { 291 | this.onDestroy = true 292 | setTimeout(this.kill, 200) 293 | if (this.ws) { 294 | this.ws.close() 295 | this.ws = null 296 | } 297 | if (this.timers) { 298 | Object.keys(this.timers).forEach(k => { 299 | clearTimeout(this.timers[k]) 300 | this.timers[k] = null 301 | }) 302 | this.timers = null 303 | } 304 | } 305 | 306 | // end 307 | } 308 | 309 | export const transferKeys = [ 310 | 'pause', 311 | 'resume', 312 | 'destroy' 313 | ] 314 | -------------------------------------------------------------------------------- /src/app/upgrade/db-defaults.js: -------------------------------------------------------------------------------- 1 | /** 2 | * database default should init 3 | */ 4 | 5 | function parsor (themeTxt) { 6 | return themeTxt.split('\n').reduce((prev, line) => { 7 | let [key = '', value = ''] = line.split('=') 8 | key = key.trim() 9 | value = value.trim() 10 | if (!key || !value) { 11 | return prev 12 | } 13 | prev[key] = value 14 | return prev 15 | }, {}) 16 | } 17 | 18 | const defaultTheme = parsor(` 19 | main = #141314 20 | main-dark = #000 21 | main-light = #2E3338 22 | text = #ddd 23 | text-light = #fff 24 | text-dark = #888 25 | text-disabled = #777 26 | primary = #08c 27 | info = #FFD166 28 | success = #06D6A0 29 | error = #EF476F 30 | warn = #E55934 31 | `) 32 | const defaultThemeLight = parsor(` 33 | main=#ededed 34 | main-dark=#cccccc 35 | main-light=#fefefe 36 | text=#555 37 | text-light=#777 38 | text-dark=#444 39 | text-disabled=#888 40 | primary=#08c 41 | info=#FFD166 42 | success=#06D6A0 43 | error=#EF476F 44 | warn=#E55934 45 | `) 46 | const defaultThemeLightTerminal = parsor(` 47 | foreground=#333333 48 | background=#ededed 49 | cursor=#b5bd68 50 | cursorAccent=#1d1f21 51 | selectionBackground=rgba(0, 0, 0, 0.3) 52 | black=#575757 53 | red=#FF2C6D 54 | green=#19f9d8 55 | yellow=#FFB86C 56 | blue=#45A9F9 57 | magenta=#FF75B5 58 | cyan=#B084EB 59 | white=#CDCDCD 60 | brightBlack=#757575 61 | brightRed=#FF2C6D 62 | brightGreen=#19f9d8 63 | brightYellow=#FFCC95 64 | brightBlue=#6FC1FF 65 | brightMagenta=#FF9AC1 66 | brightCyan=#BCAAFE 67 | brightWhite=#E6E6E6 68 | `) 69 | 70 | const defaultThemeTerminal = { 71 | foreground: '#bbbbbb', 72 | background: '#141314', 73 | cursor: '#b5bd68', 74 | cursorAccent: '#1d1f21', 75 | selectionBackground: 'rgba(255, 255, 255, 0.3)', 76 | black: '#575757', 77 | red: '#FF2C6D', 78 | green: '#19f9d8', 79 | yellow: '#FFB86C', 80 | blue: '#45A9F9', 81 | magenta: '#FF75B5', 82 | cyan: '#B084EB', 83 | white: '#CDCDCD', 84 | brightBlack: '#757575', 85 | brightRed: '#FF2C6D', 86 | brightGreen: '#19f9d8', 87 | brightYellow: '#FFCC95', 88 | brightBlue: '#6FC1FF', 89 | brightMagenta: '#FF9AC1', 90 | brightCyan: '#BCAAFE', 91 | brightWhite: '#E6E6E6' 92 | } 93 | 94 | export default [ 95 | { 96 | db: 'terminalThemes', 97 | data: [ 98 | { 99 | _id: 'default', 100 | name: 'default', 101 | themeConfig: defaultThemeTerminal, 102 | uiThemeConfig: defaultTheme 103 | }, 104 | { 105 | _id: 'defaultLight', 106 | name: 'default light', 107 | themeConfig: defaultThemeLightTerminal, 108 | uiThemeConfig: defaultThemeLight 109 | } 110 | ] 111 | }, 112 | { 113 | db: 'bookmarkGroups', 114 | data: [ 115 | { 116 | _id: 'default', 117 | title: 'default', 118 | bookmarkIds: [], 119 | bookmarkGroupIds: [] 120 | } 121 | ] 122 | } 123 | ] 124 | -------------------------------------------------------------------------------- /src/app/upgrade/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * common data upgrade process 3 | * It will check current version in db and check version in package.json, 4 | * run every upgrade script one by one 5 | */ 6 | 7 | import { packInfo } from '../common/runtime-constants.js' 8 | import { resolve, dirname } from 'path' 9 | import fs from 'fs' 10 | import log from '../common/log.js' 11 | import compare from '../common/version-compare.js' 12 | import { dbAction } from '../lib/db.js' 13 | import _ from 'lodash' 14 | import initData from './init-nedb.js' 15 | import { updateDBVersion } from './version-upgrade.js' 16 | import { fileURLToPath } from 'url' 17 | 18 | const __filename = fileURLToPath(import.meta.url) 19 | const __dirname = dirname(__filename) 20 | 21 | const { version: packVersion } = packInfo 22 | const emptyVersion = '0.0.0' 23 | const versionQuery = { 24 | _id: 'version' 25 | } 26 | 27 | async function getDBVersion () { 28 | const version = await dbAction('data', 'findOne', versionQuery) 29 | .then(doc => { 30 | return doc ? doc.value : emptyVersion 31 | }) 32 | .catch(e => { 33 | log.error(e) 34 | return emptyVersion 35 | }) 36 | return version 37 | } 38 | 39 | /** 40 | * get upgrade versions should be run as version upgrade 41 | */ 42 | async function getUpgradeVersionList () { 43 | const version = await getDBVersion() 44 | const list = fs.readdirSync(__dirname) 45 | return list.filter(f => { 46 | const vv = f.replace('.js', '').replace('v', '') 47 | return /^v\d/.test(f) && compare(vv, version) > 0 && compare(vv, packVersion) <= 0 48 | }).sort((a, b) => { 49 | return compare(a, b) 50 | }) 51 | } 52 | 53 | async function versionShouldUpgrade () { 54 | const dbVersion = await getDBVersion() 55 | log.info('database version:', dbVersion) 56 | return compare(dbVersion, packVersion) < 0 57 | } 58 | 59 | export async function checkDbUpgrade () { 60 | const shouldUpgradeVersion = await versionShouldUpgrade() 61 | if (!shouldUpgradeVersion) { 62 | return false 63 | } 64 | const dbVersion = await getDBVersion() 65 | log.info('dbVersion', dbVersion) 66 | if (dbVersion === emptyVersion) { 67 | await initData() 68 | await updateDBVersion(packVersion) 69 | return false 70 | } 71 | const list = await getUpgradeVersionList() 72 | if (_.isEmpty(list)) { 73 | await updateDBVersion(packVersion) 74 | return false 75 | } 76 | return { 77 | dbVersion, 78 | packVersion 79 | } 80 | } 81 | 82 | export async function doUpgrade () { 83 | const list = await getUpgradeVersionList() 84 | log.info('Upgrading...') 85 | for (const v of list) { 86 | const p = resolve(__dirname, v) 87 | const run = import(p).then(d => d.default) 88 | await run() 89 | } 90 | log.info('Upgrade end') 91 | } 92 | -------------------------------------------------------------------------------- /src/app/upgrade/init-nedb.js: -------------------------------------------------------------------------------- 1 | /** 2 | * for new user, they do not have old json db 3 | * just need init db 4 | */ 5 | 6 | import { dbAction } from '../lib/db.js' 7 | import log from '../common/log.js' 8 | import defaults from './db-defaults.js' 9 | 10 | export default async function initData () { 11 | log.info('start: init db') 12 | for (const conf of defaults) { 13 | const { 14 | db, data 15 | } = conf 16 | await dbAction(db, 'insert', data).catch(log.error) 17 | } 18 | log.info('end: init db') 19 | } 20 | -------------------------------------------------------------------------------- /src/app/upgrade/version-upgrade.js: -------------------------------------------------------------------------------- 1 | /** 2 | * upgrade db version 3 | */ 4 | 5 | /** 6 | * common data upgrade process 7 | * It will check current version in db and check version in package.json, 8 | * run every upgrade script one by one 9 | */ 10 | 11 | import log from '../common/log.js' 12 | import { dbAction } from '../lib/db.js' 13 | 14 | export async function updateDBVersion (toVersion) { 15 | const versionQuery = { 16 | _id: 'version' 17 | } 18 | log.info('upgrade db version to', toVersion) 19 | await dbAction('data', 'update', versionQuery, { 20 | ...versionQuery, 21 | value: toVersion 22 | }, { 23 | upsert: true 24 | }) 25 | .catch(e => { 26 | log.error(e) 27 | log.error('upgrade db version error', toVersion) 28 | }) 29 | await dbAction('dbUpgradeLog', 'insert', { 30 | time: Date.now(), 31 | toVersion 32 | }) 33 | .catch(e => { 34 | log.error(e) 35 | log.error('insert dbUpgradeLog error', toVersion) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/app/views/index.pug: -------------------------------------------------------------------------------- 1 | 2 | doctype html 3 | html 4 | head 5 | meta(charset='UTF-8') 6 | meta(http-equiv='x-ua-compatible' content='IE=edge') 7 | meta(name='viewport', content='width=device-width, initial-scale=1, shrink-to-fit=no') 8 | title #{siteName} 9 | style. 10 | body { 11 | background: #000; 12 | } 13 | #content-loading { 14 | position: fixed; 15 | left: 0; 16 | top: 0; 17 | width: 100%; 18 | height: 100%; 19 | background: #141314 50% 50% no-repeat url("./images/electerm-watermark.png"); 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: center; 23 | align-items: center; 24 | } 25 | .electerm-logo-bg { 26 | background: transparent 50% 50% no-repeat url("./images/electerm-watermark.png"); 27 | } 28 | - if (!isDev) 29 | link(rel='stylesheet', href=cdn + '/css/' + version + '-basic.css') 30 | link(rel='stylesheet', href=cdn + '/css/' + version + '-electerm.css') 31 | style(id='theme-css'). 32 | style(id='custom-css'). 33 | body 34 | - if (isDev) 35 | style(id='theme-css'). 36 | style(id='custom-css'). 37 | #container 38 | #content-loading 39 | .morph-shape.iblock.pd3 40 | img.iblock.logo-filter(src='images/electerm.png', alt='', height=80) 41 | script. 42 | window.et = !{JSON.stringify(_global)} 43 | - if (tokenElecterm) 44 | script. 45 | window.localStorage.setItem('tokenElecterm', window.et.tokenElecterm) 46 | - var url = '/src/client/entry-web/basic.js' 47 | - var url1 = '/src/client/entry-web/rle.js' 48 | - if (isDev) 49 | //- script(src='/external/react.development.js?' + version) 50 | //- script(src='/external/react-dom.development.js?' + version) 51 | script(type='module'). 52 | import RefreshRuntime from '/@react-refresh' 53 | RefreshRuntime.injectIntoGlobalHook(window) 54 | window.$RefreshReg$ = () => {} 55 | window.$RefreshSig$ = () => (type) => type 56 | window.__vite_plugin_react_preamble_installed__ = true 57 | script(src='/@vite/client', type='module') 58 | script(src=url1, type='module') 59 | script(src=url, type='module') 60 | - else 61 | //- script(src='/external/react.production.min.js?' + version) 62 | //- script(src='/external/react-dom.production.min.js?' + version) 63 | - var url = src=cdn + '/js/basic-' + version + '.js' 64 | - var url2 = src=cdn + '/js/rle-' + version + '.js' 65 | script(src=url2, type='module') 66 | script(src=url, type='module') 67 | 68 | -------------------------------------------------------------------------------- /src/client/entry-web/basic.js: -------------------------------------------------------------------------------- 1 | /** 2 | * init app data then write main script to html body 3 | */ 4 | import '../electerm-react/css/basic.styl' 5 | import '../web-components/style-overide.styl' 6 | import '../electerm-react/css/mobile.styl' 7 | import '../web-components/web-api.js' 8 | import '../web-components/web-pre.js' 9 | import { get as _get } from 'lodash-es' 10 | 11 | const { isDev, version, cdn } = window.et 12 | 13 | window.et.buildWsUrl = ( 14 | host, 15 | port, 16 | tokenElecterm, 17 | id 18 | ) => { 19 | const ss = isDev ? window.et.server : window.location.href 20 | const s = ss 21 | ? ss.replace(/https?:\/\//, '').replace(/\/$/, '') 22 | : `${host}:${port}` 23 | const pre = ss.startsWith('https') ? 'wss' : 'ws' 24 | return `${pre}://${s}/terminals/${id}?token=${tokenElecterm}` 25 | } 26 | 27 | async function loadWorker () { 28 | return new Promise((resolve) => { 29 | const url = !isDev ? cdn + `/js/worker-${version}.js` : cdn + '/js/worker.js' 30 | window.worker = new window.Worker(url) 31 | function onInit (e) { 32 | if (!e || !e.data) { 33 | return false 34 | } 35 | const { 36 | action 37 | } = e.data 38 | if (action === 'worker-init') { 39 | window.worker.removeEventListener('message', onInit) 40 | resolve(1) 41 | } 42 | } 43 | window.worker.addEventListener('message', onInit) 44 | }) 45 | } 46 | 47 | async function load () { 48 | window.capitalizeFirstLetter = (string) => { 49 | return string.charAt(0).toUpperCase() + string.slice(1) 50 | } 51 | function loadScript () { 52 | const rcs = document.createElement('script') 53 | const url = !isDev ? cdn + `/js/electerm-${version}.js` : cdn + '/js/electerm.js' 54 | rcs.src = url 55 | rcs.type = 'module' 56 | document.body.appendChild(rcs) 57 | } 58 | window.getLang = (lang = window.store?.config.language || 'en_us') => { 59 | return _get(window.langMap, `[${lang}].lang`) 60 | } 61 | window.translate = txt => { 62 | const lang = window.getLang() 63 | const str = _get(lang, `[${txt}]`) || txt 64 | return window.capitalizeFirstLetter(str) 65 | } 66 | await loadWorker() 67 | if (!window.et.isDev) { 68 | window.worker.postMessage({ 69 | action: 'init-url', 70 | url: window.location.href 71 | }) 72 | } 73 | loadScript() 74 | document.body.removeChild(document.getElementById('content-loading')) 75 | } 76 | 77 | // window.addEventListener('load', load) 78 | load() 79 | -------------------------------------------------------------------------------- /src/client/entry-web/electerm.jsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import '../../../node_modules/antd/dist/reset.css' 3 | import '../../../node_modules/@xterm/xterm/css/xterm.css' 4 | import '../electerm-react/common/trzsz' 5 | import Main from '../web-components/web-main' 6 | 7 | const rootElement = document.getElementById('container') 8 | const root = createRoot(rootElement) 9 | 10 | root.render(
) 11 | -------------------------------------------------------------------------------- /src/client/entry-web/worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * web worker 3 | */ 4 | 5 | self.insts = {} 6 | 7 | function createWs ( 8 | type, 9 | id, 10 | sftpId = '', 11 | config 12 | ) { 13 | // init gloabl ws 14 | const { host, port, tokenElecterm, server = '' } = config 15 | const ss = self.currentUrl || server 16 | const s = ss 17 | ? ss.replace(/https?:\/\//, '').replace(/\/$/, '') 18 | : `${host}:${port}` 19 | const pre = ss.startsWith('https') ? 'wss' : 'ws' 20 | const wsUrl = `${pre}://${s}/${type}/${id}?sftpId=${sftpId}&token=${tokenElecterm}` 21 | const ws = new WebSocket(wsUrl) 22 | ws.s = msg => { 23 | ws.send(JSON.stringify(msg)) 24 | } 25 | ws.id = id 26 | ws.once = (callack, id) => { 27 | const func = (evt) => { 28 | const arg = JSON.parse(evt.data) 29 | if (id === arg.id) { 30 | callack(arg) 31 | ws.removeEventListener('message', func) 32 | } 33 | } 34 | ws.addEventListener('message', func) 35 | } 36 | ws.onclose = () => { 37 | if (ws.dup) { 38 | return 39 | } 40 | send({ 41 | id: ws.id, 42 | action: 'close' 43 | }) 44 | delete self.insts[ws.id] 45 | } 46 | return new Promise((resolve) => { 47 | ws.onopen = () => { 48 | if (self.insts[ws.id]) { 49 | ws.dup = true 50 | ws.close() 51 | resolve(null) 52 | } else { 53 | resolve(ws) 54 | } 55 | } 56 | }) 57 | } 58 | 59 | function send (data) { 60 | self.postMessage(data) 61 | } 62 | 63 | async function onMsg (e) { 64 | const { 65 | id, 66 | wsId, 67 | args, 68 | action, 69 | type, 70 | persist, 71 | url 72 | } = e.data 73 | if (action === 'init-url') { 74 | self.currentUrl = url 75 | return false 76 | } 77 | if (action === 'create') { 78 | const inst = self.insts[id] 79 | if (inst instanceof WebSocket) { 80 | return send({ 81 | action, 82 | id, 83 | persist 84 | }, '*') 85 | } else if (inst) { 86 | return false 87 | } else { 88 | const ws = await createWs(...args) 89 | if (ws) { 90 | self.insts[id] = ws 91 | } 92 | } 93 | send({ 94 | action, 95 | persist, 96 | id 97 | }, '*') 98 | } else if (action === 'once') { 99 | const ws = self.insts[wsId] 100 | if (ws) { 101 | const cb = (data) => { 102 | send({ 103 | id, 104 | wsId, 105 | data 106 | }) 107 | } 108 | ws.once(cb, id) 109 | } 110 | } else if (action === 'close') { 111 | const ws = self.insts[wsId] 112 | if (ws) { 113 | ws.close() 114 | } 115 | } else if (action === 's') { 116 | const ws = self.insts[wsId] 117 | if (ws) { 118 | ws.s(...args) 119 | } 120 | } else if (action === 'addEventListener') { 121 | const ws = self.insts[wsId] 122 | if (ws) { 123 | ws.cb = (e) => { 124 | send({ 125 | wsId, 126 | id, 127 | data: { 128 | data: e.data 129 | } 130 | }) 131 | } 132 | ws.addEventListener(type, ws.cb) 133 | } 134 | } else if (action === 'removeEventListener') { 135 | const ws = self.insts[wsId] 136 | if (ws) { 137 | ws.removeEventListener(type, ws.cb) 138 | delete ws.cb 139 | } 140 | } 141 | } 142 | 143 | self.addEventListener('message', onMsg) 144 | setTimeout(() => { 145 | send({ 146 | action: 'worker-init' 147 | }) 148 | }, 10) 149 | -------------------------------------------------------------------------------- /src/client/file-select-dialog/file-item.jsx: -------------------------------------------------------------------------------- 1 | import FileIcon from '../electerm-react/components/sftp/file-icon' 2 | import classNames from 'classnames' 3 | export default function FileItem (props) { 4 | const { 5 | file, 6 | selected 7 | } = props 8 | const handleClick = () => { 9 | props.onClick(file) 10 | } 11 | const handleDbClick = () => { 12 | props.onDbClick(file) 13 | } 14 | const cls = classNames( 15 | 'dialog-file-item elli', 16 | { 17 | selected: selected?.name === file.name 18 | } 19 | ) 20 | return ( 21 |
26 | 29 | {file.name} 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/client/file-select-dialog/file-select-dialog.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * file/folder select dialog component 3 | */ 4 | 5 | import { Component } from 'react' 6 | import { 7 | Modal, 8 | Spin, 9 | notification, 10 | Pagination, 11 | Button, 12 | ConfigProvider 13 | } from 'antd' 14 | import FileItem from './file-item' 15 | import AddressBar from '../electerm-react/components/sftp/address-bar' 16 | import isValidPath from '../electerm-react/common/is-valid-path' 17 | import { 18 | typeMap 19 | } from '../electerm-react/common/constants' 20 | import { resolve } from '../web-components/path' 21 | import './file-select-dialog.styl' 22 | 23 | const s = window.translate 24 | 25 | export default class FileSelectDialog extends Component { 26 | constructor (props) { 27 | super(props) 28 | const p = window.localStorage.getItem(this.lsKey) || window.et.home 29 | this.state = { 30 | opts: null, 31 | loading: false, 32 | page: 1, 33 | localShowHiddenFile: false, 34 | localPathHistory: [], 35 | fileSelected: null, 36 | pageSize: 100, 37 | localInputFocus: false, 38 | list: [], 39 | localPathTemp: p, 40 | localPath: p 41 | } 42 | } 43 | 44 | componentDidMount () { 45 | window.addEventListener('message', this.handleMsg) 46 | } 47 | 48 | componentWillUnmount () { 49 | window.removeEventListener('message', this.handleMsg) 50 | } 51 | 52 | lsKey = 'dialog-start-path' 53 | 54 | handleMsg = (e) => { 55 | if (e?.data?.type === 'openDialog') { 56 | this.setState({ opts: e.data.data }, this.localList) 57 | } 58 | } 59 | 60 | handlePageChange = (page, pageSize) => { 61 | this.setState({ page, pageSize }) 62 | } 63 | 64 | handlePageSizeChange = (k, pageSize) => { 65 | this.setState({ pageSize }) 66 | } 67 | 68 | handleLocalPathChange = (e) => { 69 | this.setState({ localPath: e.target.value }) 70 | } 71 | 72 | handleClose = () => { 73 | window.postMessage({ 74 | type: 'closeDialog' 75 | }, '*') 76 | this.setState({ opts: null }) 77 | } 78 | 79 | handleSubmit = () => { 80 | const { fileSelected, localPath } = this.state 81 | const p = fileSelected 82 | ? resolve(localPath, fileSelected.name) 83 | : localPath 84 | this.setState({ 85 | opts: null 86 | }) 87 | window.postMessage({ 88 | type: 'handleDialog', 89 | data: [p] 90 | }, '*') 91 | } 92 | 93 | localList = async () => { 94 | this.setState({ loading: true, fileSelected: null }) 95 | const { 96 | localPath, 97 | opts 98 | } = this.state 99 | const func = opts.properties.includes('openDirectory') 100 | ? window.fs.readdirOnly 101 | : window.fs.readdirAndFiles 102 | const list = await func(localPath) 103 | .catch((err) => { 104 | console.log(err) 105 | return [] 106 | }) 107 | this.updateLs(localPath) 108 | this.setState({ list, loading: false, page: 1 }) 109 | } 110 | 111 | onChange = e => { 112 | this.setState({ 113 | localPathTemp: e.target.value 114 | }) 115 | } 116 | 117 | onInputBlur = (type) => { 118 | this.inputFocus = false 119 | this.timer4 = setTimeout(() => { 120 | this.setState({ 121 | [type + 'InputFocus']: false 122 | }) 123 | }, 200) 124 | } 125 | 126 | onInputFocus = (type) => { 127 | this.setState({ 128 | [type + 'InputFocus']: true 129 | }) 130 | this.inputFocus = true 131 | } 132 | 133 | onGoto = (type, e) => { 134 | e && e.preventDefault() 135 | const n = `${type}Path` 136 | const nt = n + 'Temp' 137 | const np = this.state[nt] 138 | if (!isValidPath(np)) { 139 | return notification.warning({ 140 | message: 'path not valid' 141 | }) 142 | } 143 | this.updateLs(np) 144 | this.setState({ 145 | [n]: np 146 | }, this[`${type}List`]) 147 | } 148 | 149 | updateLs = (np = this.state.localPath) => { 150 | window.localStorage.setItem(this.lsKey, np) 151 | } 152 | 153 | toggleShowHiddenFile = type => { 154 | const prop = `${type}ShowHiddenFile` 155 | const b = this.state[prop] 156 | this.setState({ 157 | [prop]: !b 158 | }) 159 | } 160 | 161 | onClickHistory = (type, path) => { 162 | const n = `${type}Path` 163 | this.setState({ 164 | [n]: path, 165 | [`${n}Temp`]: path 166 | }, this[`${type}List`]) 167 | } 168 | 169 | goParent = (type) => { 170 | const n = `${type}Path` 171 | const p = this.state[n] 172 | const np = resolve(p, '..') 173 | if (np !== p) { 174 | this.updateLs(np) 175 | this.setState({ 176 | [n]: np, 177 | [n + 'Temp']: np 178 | }, this[`${type}List`]) 179 | } 180 | } 181 | 182 | handleClickFile = item => { 183 | this.setState({ 184 | fileSelected: item 185 | }) 186 | } 187 | 188 | handleDbClickFile = (item) => { 189 | if (!item.isDirectory) { 190 | return false 191 | } 192 | const { localPath } = this.state 193 | const np = resolve(localPath, item.name) 194 | this.setState({ 195 | localPath: np, 196 | localPathTemp: np 197 | }, this.localList) 198 | } 199 | 200 | renderHeader () { 201 | const { 202 | localPath, 203 | localPathTemp, 204 | loading, 205 | localPathHistory, 206 | localInputFocus, 207 | localShowHiddenFile 208 | } = this.state 209 | const props = { 210 | type: typeMap.local, 211 | onChange: this.onChange, 212 | onInputBlur: this.onInputBlur, 213 | onInputFocus: this.onInputFocus, 214 | onGoto: this.onGoto, 215 | localInputFocus, 216 | localPath, 217 | localShowHiddenFile, 218 | toggleShowHiddenFile: this.toggleShowHiddenFile, 219 | localPathTemp, 220 | onClickHistory: this.onClickHistory, 221 | goParent: this.goParent, 222 | localPathHistory, 223 | loadingSftp: loading 224 | } 225 | return ( 226 |
227 | 230 |
231 | ) 232 | } 233 | 234 | renderFooter () { 235 | const { 236 | properties 237 | } = this.state.opts 238 | const { 239 | fileSelected 240 | } = this.state 241 | const disabled = properties.includes('openFile') && !fileSelected 242 | return ( 243 |
244 |
245 | {this.renderPager()} 246 |
247 |
248 | 255 | 263 |
264 |
265 | ) 266 | } 267 | 268 | renderList () { 269 | const { list, fileSelected, page, pageSize } = this.state 270 | const all = list.slice((page - 1) * pageSize, page * pageSize) 271 | return ( 272 |
273 | { 274 | all.map((item, i) => { 275 | return ( 276 | 283 | ) 284 | }) 285 | } 286 |
287 | ) 288 | } 289 | 290 | renderPager () { 291 | const { 292 | page, 293 | pageSize, 294 | list 295 | } = this.state 296 | const len = list.length 297 | if (len <= pageSize) { 298 | return null 299 | } 300 | return ( 301 | 309 | ) 310 | } 311 | 312 | renderContent = () => { 313 | const { 314 | opts, 315 | loading 316 | } = this.state 317 | const props = { 318 | maskClosable: false, 319 | open: true, 320 | width: '80%', 321 | okText: s('submit'), 322 | title: opts.title, 323 | footer: this.renderFooter(), 324 | onOk: this.handleSubmit, 325 | onCancel: this.handleClose 326 | } 327 | return ( 328 | 329 | 330 | 331 | {this.renderHeader()} 332 | {this.renderList()} 333 | 334 | 335 | 336 | ) 337 | } 338 | 339 | render () { 340 | const { 341 | opts 342 | } = this.state 343 | if (!opts) { 344 | return null 345 | } 346 | return this.renderContent() 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/client/file-select-dialog/file-select-dialog.styl: -------------------------------------------------------------------------------- 1 | @require '../electerm-react/css/includes/theme-default' 2 | 3 | .dialog-file-item 4 | padding 6px 10px 5 | &:hover 6 | background-color primary 7 | color contrastColor(primary) 8 | &.selected 9 | background-color darken(primary, 30%) 10 | color contrastColor(primary) 11 | .file-dialog-list-wrap 12 | height 70vh 13 | overflow-y scroll 14 | .file-dialog-header 15 | .sftp-title 16 | .anticon-eye-invisible 17 | display none -------------------------------------------------------------------------------- /src/client/simple-auth/logout.jsx: -------------------------------------------------------------------------------- 1 | import { auto } from 'manate/react' 2 | import { 3 | LogoutOutlined 4 | } from '@ant-design/icons' 5 | import './logout.styl' 6 | 7 | export default auto(function Logout (props) { 8 | const handleLogout = () => { 9 | window.localStorage.removeItem('tokenElecterm') 10 | props.store.logined = false 11 | } 12 | 13 | if (window.et.tokenElecterm) { 14 | return null 15 | } 16 | 17 | return ( 18 |
19 | 23 |
24 | ) 25 | }) 26 | -------------------------------------------------------------------------------- /src/client/simple-auth/logout.styl: -------------------------------------------------------------------------------- 1 | .logout-icon 2 | position fixed 3 | left 0 4 | bottom 0 5 | z-index 100 6 | width 43px 7 | height 48px 8 | text-align center -------------------------------------------------------------------------------- /src/client/simple-auth/web-login.jsx: -------------------------------------------------------------------------------- 1 | import { auto } from 'manate/react' 2 | import { useState, useEffect, useRef } from 'react' 3 | import LogoElem from '../electerm-react/components/common/logo-elem.jsx' 4 | import { 5 | Input, 6 | message, 7 | Spin 8 | } from 'antd' 9 | import { 10 | ArrowRightOutlined, 11 | Loading3QuartersOutlined 12 | } from '@ant-design/icons' 13 | import Main from '../electerm-react/components/main/main.jsx' 14 | 15 | const f = window.translate 16 | 17 | export default auto(function Login ({ store }) { 18 | const [pass, setPass] = useState('') 19 | const submitting = useRef(false) 20 | 21 | useEffect(() => { 22 | store.getConstants() 23 | }, []) 24 | 25 | const handlePassChange = e => { 26 | setPass(e.target.value) 27 | } 28 | 29 | const handleSubmit = async () => { 30 | if (!pass) { 31 | return message.warning('password required') 32 | } else if (submitting.current) { 33 | return 34 | } 35 | submitting.current = true 36 | await store.login(pass) 37 | submitting.current = false 38 | } 39 | 40 | const renderUnchecked = () => { 41 | return ( 42 | 43 |
44 | 45 |
46 | 47 |
48 | 49 |
50 | ) 51 | } 52 | 53 | const renderAfter = () => { 54 | return ( 55 | 59 | ) 60 | } 61 | 62 | const renderLogin = () => { 63 | const { 64 | logining, 65 | fetchingUser 66 | } = store 67 | 68 | return ( 69 | 70 |
71 | 72 |
73 | 81 |
82 | 83 |
84 | 87 |
88 | 89 |
90 | ) 91 | } 92 | 93 | if (!store.authChecked) { 94 | return renderUnchecked() 95 | } else if (!store.logined) { 96 | return renderLogin() 97 | } 98 | 99 | return ( 100 |
103 | ) 104 | }) 105 | -------------------------------------------------------------------------------- /src/client/statics/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electerm/electerm-web/3306f962d7b40693753dd673316520b099d21cc7/src/client/statics/favicon.ico -------------------------------------------------------------------------------- /src/client/statics/rle.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electerm/electerm-web/3306f962d7b40693753dd673316520b099d21cc7/src/client/statics/rle.wasm -------------------------------------------------------------------------------- /src/client/web-components/path.js: -------------------------------------------------------------------------------- 1 | export function join (...parts) { 2 | const { isWin } = window.et 3 | const separator = isWin ? '\\' : '/' 4 | const joined = parts.join(separator) 5 | const regex = new RegExp(`${separator}{2,}`, 'g') 6 | return joined.replace(regex, separator) 7 | } 8 | 9 | export function resolve (...paths) { 10 | const { isWin } = window.et 11 | const separator = isWin ? '\\' : '/' 12 | const resolved = [] 13 | 14 | let root = '' 15 | if (paths[0].startsWith(separator)) { 16 | root = separator 17 | paths[0] = paths[0].slice(1) 18 | } else if (paths[0].match(/^[a-zA-Z]+:/)) { 19 | root = paths.shift() + separator 20 | } 21 | const len = paths.length 22 | if (paths[len - 1].endsWith(separator)) { 23 | paths[len - 1] = paths[len - 1].slice(0, -1) 24 | } 25 | 26 | for (const path of paths) { 27 | if (typeof path !== 'string') { 28 | throw new TypeError(`Invalid argument type: ${typeof path}`) 29 | } 30 | 31 | const parts = path.split(separator).filter(d => d) 32 | 33 | for (const part of parts) { 34 | if (part === '') { 35 | resolved.length = 0 36 | break 37 | } else if (part === '.') { 38 | continue 39 | } else if (part === '..') { 40 | resolved.pop() 41 | } else { 42 | resolved.push(part) 43 | } 44 | } 45 | } 46 | 47 | return `${root}${resolved.join(separator)}` 48 | } 49 | 50 | export function basename (path, ext) { 51 | const { isWin } = window.et 52 | const separator = isWin ? '\\' : '/' 53 | const parts = path.split(separator).filter(d => d) 54 | const lastPart = parts[parts.length - 1] 55 | const basename = ext ? lastPart.slice(0, -ext.length) : lastPart 56 | return basename 57 | } 58 | -------------------------------------------------------------------------------- /src/client/web-components/store-login.js: -------------------------------------------------------------------------------- 1 | import Fetch from '../electerm-react/common/fetch.jsx' 2 | import { initWsCommon } from '../electerm-react/common/fetch-from-server.js' 3 | 4 | export default Store => { 5 | Store.prototype.getConstants = async function () { 6 | const { store } = window 7 | store.fetchingUser = true 8 | const res = await Fetch.get('/api/get-constants', null, { 9 | handleErr: console.log 10 | }) 11 | if (res) { 12 | Object.assign(window.pre, res) 13 | window.reqs.fs.constants = window.pre.fsConstants 14 | store.updateConfig(res.config) 15 | await initWsCommon() 16 | Object.assign(store, { 17 | logined: true, 18 | authChecked: true, 19 | fetchingUser: false, 20 | logining: false 21 | }) 22 | return true 23 | } else { 24 | console.log('getConstants err') 25 | store.authChecked = true 26 | Object.assign(store, { 27 | authChecked: true, 28 | logined: false, 29 | fetchingUser: false 30 | }) 31 | return false 32 | } 33 | } 34 | Store.prototype.login = async function (password) { 35 | const { store } = window 36 | store.logining = true 37 | const res = await Fetch.post('/api/login', { 38 | password 39 | }) 40 | if (res) { 41 | store.updateConfig({ 42 | tokenElecterm: res 43 | }) 44 | window.localStorage.setItem('tokenElecterm', res) 45 | store.getConstants() 46 | } 47 | Object.assign(store, { 48 | logining: false 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/client/web-components/style-overide.styl: -------------------------------------------------------------------------------- 1 | @-moz-document url-prefix() 2 | .tabs-inner 3 | overflow-x hidden !important 4 | -------------------------------------------------------------------------------- /src/client/web-components/web-api.js: -------------------------------------------------------------------------------- 1 | // window preload 2 | // import { message } from 'antd' 3 | 4 | window.api = { 5 | getZoomFactor: () => 1, 6 | setZoomFactor: (nl) => { 7 | // message.info('Set ZoomFactor not supported') 8 | }, 9 | openDialog: (opts) => { 10 | return new Promise((resolve, reject) => { 11 | window.et.handleDialogEvent = (e) => { 12 | if (e?.data?.type === 'handleDialog') { 13 | window.removeEventListener('message', window.et.handleDialogEvent) 14 | delete window.et.handleDialogEvent 15 | resolve(e.data.data) 16 | } else if (e?.data?.type === 'closeDialog') { 17 | resolve(false) 18 | } 19 | } 20 | window.addEventListener('message', window.et.handleDialogEvent) 21 | window.postMessage({ 22 | type: 'openDialog', 23 | data: opts 24 | }, '*') 25 | }) 26 | }, 27 | ipcOnEvent: (event, cb) => { 28 | 29 | }, 30 | ipcOffEvent: (event, cb) => { 31 | 32 | }, 33 | runGlobalAsync: async (func, ...args) => { 34 | if (func === 'initCommandLine') { 35 | try { 36 | const { init } = window.et.query 37 | return init ? JSON.parse(window.et.query.init) : null 38 | } catch (err) { 39 | console.log('initCommandLine error:', err) 40 | } 41 | } else if (func === 'setTitle') { 42 | document.title = args[0] 43 | return 44 | } else if (func === 'openNewInstance') { 45 | return window.open(args[0], '_blank') 46 | } else if (func === 'closeApp') { 47 | return window.close() 48 | } else if (func === 'restart') { 49 | return window.location.reload() 50 | } else if (func === 'init') { 51 | const d = await window.wsFetch({ 52 | action: 'runSync', 53 | args, 54 | func 55 | }) 56 | d.config.tokenElecterm = window.localStorage.getItem('tokenElecterm') || '' 57 | return d 58 | } 59 | return window.wsFetch({ 60 | action: 'runSync', 61 | args, 62 | func 63 | }) 64 | }, 65 | runSync: (func, ...args) => { 66 | if (func === 'isMaximized') { 67 | return false 68 | } else if (func === 'isSecondInstance') { 69 | return false 70 | } else if (func === 'windowMove') { 71 | return false 72 | } else if (func === 'getLoadTime' || func === 'setLoadTime') { 73 | return 0 74 | } else if (func === 'getInitTime') { 75 | if (window.et.initTime !== undefined) { 76 | return window.et.initTime 77 | } else { 78 | window.et.initTime = Date.now() 79 | return window.et.initTime 80 | } 81 | } 82 | return window.wsFetch({ 83 | action: 'runSync', 84 | args, 85 | func 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/client/web-components/web-main.jsx: -------------------------------------------------------------------------------- 1 | import ErrorBoundary from '../electerm-react/components/main/error-wrapper' 2 | import Login from '../simple-auth/web-login' 3 | import store from './web-store' 4 | import FileSelectDialog from '../file-select-dialog/file-select-dialog' 5 | import Logout from '../simple-auth/logout' 6 | export default function MainEntry () { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/client/web-components/web-pre.js: -------------------------------------------------------------------------------- 1 | import * as path from './path.js' 2 | import { message } from 'antd' 3 | 4 | const { 5 | ipcOnEvent, 6 | ipcOffEvent, 7 | runGlobalAsync, 8 | getZoomFactor, 9 | setZoomFactor, 10 | runSync 11 | } = window.api 12 | 13 | // Encoding function 14 | function encodeUint8Array (uint8Array) { 15 | let str = '' 16 | const len = uint8Array.byteLength 17 | 18 | for (let i = 0; i < len; i++) { 19 | str += String.fromCharCode(uint8Array[i]) 20 | } 21 | 22 | return btoa(str) 23 | } 24 | 25 | // Decoding function 26 | function decodeBase64String (base64String) { 27 | const str = atob(base64String) 28 | const len = str.length 29 | 30 | const uint8Array = new Uint8Array(len) 31 | 32 | for (let i = 0; i < len; i++) { 33 | uint8Array[i] = str.charCodeAt(i) 34 | } 35 | 36 | return uint8Array 37 | } 38 | 39 | window.log = window.console 40 | 41 | window.pre = { 42 | resolve: (...args) => { 43 | return path.resolve(...args.map(d => d || '')) 44 | }, 45 | transferKeys: [ 46 | 'pause', 47 | 'resume', 48 | 'destroy' 49 | ], 50 | osInfo: () => { return window.pre.osInfoData }, 51 | extIconPath: window.et.extIconPath, 52 | readClipboard: () => { 53 | return window.et.clipboard || '' 54 | }, 55 | 56 | writeClipboard: str => { 57 | window.et.clipboard = str 58 | if (!navigator.clipboard) { 59 | message.error('Clipboard API not available') 60 | return 61 | } 62 | try { 63 | return navigator.clipboard.writeText(str) 64 | } catch (err) { 65 | message.error('Failed to copy text: ' + err) 66 | } 67 | }, 68 | readClipboardSync: function readClipboard () { 69 | if (!navigator.clipboard) { 70 | message.error('Clipboard API not available') 71 | return '' 72 | } 73 | try { 74 | return navigator.clipboard.readText() 75 | } catch (err) { 76 | message.error('Failed to read clipboard: ' + err.message) 77 | } 78 | }, 79 | 80 | // writeClipboard: function writeClipboard (str) { 81 | // if (!navigator.clipboard) { 82 | // message.error('Clipboard API not available') 83 | // return 84 | // } 85 | // try { 86 | // return navigator.clipboard.writeText(str) 87 | // } catch (err) { 88 | // message.error('Failed to copy text: ' + err) 89 | // } 90 | // }, 91 | showItemInFolder: (href) => runSync('showItemInFolder', href), 92 | ipcOnEvent, 93 | ipcOffEvent, 94 | getZoomFactor, 95 | setZoomFactor, 96 | openExternal: (url) => { 97 | window.open(url, '_blank') 98 | }, 99 | runSync, 100 | runGlobalAsync 101 | } 102 | 103 | const fs = { 104 | stat: (path, cb) => { 105 | window.fs.statCustom(path) 106 | .catch(err => cb(err)) 107 | .then(obj => { 108 | obj.isDirectory = () => obj.isD 109 | obj.isFile = () => obj.isF 110 | cb(undefined, obj) 111 | }) 112 | }, 113 | access: (...args) => { 114 | const cb = args.pop() 115 | window.fs.access(...args) 116 | .then((data) => cb(undefined, data)) 117 | .catch((err) => cb(err)) 118 | }, 119 | open: (...args) => { 120 | const cb = args.pop() 121 | window.fs.openCustom(...args) 122 | .then((data) => cb(undefined, data)) 123 | .catch((err) => cb(err)) 124 | }, 125 | read: (p1, arr, ...args) => { 126 | const cb = args.pop() 127 | window.fs.readCustom( 128 | p1, 129 | encodeUint8Array(arr), 130 | ...args 131 | ) 132 | .then((data) => { 133 | const { n, newArr } = data 134 | const newArr1 = decodeBase64String(newArr) 135 | cb(undefined, n, newArr1) 136 | }) 137 | .catch(err => cb(err)) 138 | }, 139 | close: (fd, cb) => { 140 | window.fs.closeCustom(fd) 141 | .then((data) => cb(undefined, data)) 142 | .catch((err) => cb(err)) 143 | }, 144 | readdir: (p, cb) => { 145 | window.fs.readdir(p) 146 | .then((data) => cb(undefined, data)) 147 | .catch((err) => cb(err)) 148 | }, 149 | mkdir: (...args) => { 150 | const cb = args.pop() 151 | window.fs.mkdir(...args) 152 | .then((data) => cb(undefined, data)) 153 | .catch((err) => cb(err)) 154 | }, 155 | write: (p1, buf, cb) => { 156 | window.fs.writeCustom(p1, encodeUint8Array(buf)) 157 | .then((data) => cb(undefined, data)) 158 | .catch((err) => cb(err)) 159 | }, 160 | realpath: (p, cb) => { 161 | window.fs.realpath(p) 162 | .then((data) => cb(undefined, data)) 163 | .catch((err) => cb(err)) 164 | } 165 | } 166 | 167 | window.reqs = { 168 | path, 169 | fs 170 | } 171 | 172 | function require (name) { 173 | return window.reqs[name] 174 | } 175 | 176 | require.resolve = name => name 177 | 178 | window.require = require 179 | -------------------------------------------------------------------------------- /src/client/web-components/web-store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * central state store powered by manate - https://github.com/tylerlong/manate 3 | */ 4 | 5 | import { manage } from 'manate' 6 | import initState from '../electerm-react/store/init-state' 7 | import { StateStore } from '../electerm-react/store/store' 8 | import loginExtend from './store-login' 9 | 10 | class Store extends StateStore { 11 | constructor () { 12 | super() 13 | Object.assign( 14 | this, 15 | initState, 16 | { 17 | logined: false, 18 | authChecked: false, 19 | fetchingUser: false, 20 | logining: false, 21 | height: window.innerHeight, 22 | _config: { 23 | tokenElecterm: window.localStorage.getItem('tokenElecterm') || '' 24 | } 25 | } 26 | ) 27 | } 28 | } 29 | 30 | loginExtend(Store) 31 | 32 | const store = manage(new Store()) 33 | 34 | window.store = store 35 | export default store 36 | --------------------------------------------------------------------------------