├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── CHANGELOG_CURRENT.md ├── package-lock.json ├── package.json ├── packages ├── app │ ├── .bashrc │ ├── .gitignore │ ├── .npmrc │ ├── electron.builder.json │ ├── index.ts │ ├── package.json │ ├── readme.md │ ├── script.js │ ├── src │ │ ├── logger.core.ts │ │ ├── logger.ts │ │ ├── scripts │ │ │ ├── automation │ │ │ │ ├── common │ │ │ │ │ └── index.ts │ │ │ │ └── wk │ │ │ │ │ ├── cx.ts │ │ │ │ │ ├── icve.ts │ │ │ │ │ ├── zhs.ts │ │ │ │ │ └── zjy.ts │ │ │ ├── collector │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── interface.ts │ │ │ ├── script.ts │ │ │ └── utils │ │ │ │ └── index.ts │ │ ├── store.ts │ │ ├── tasks │ │ │ ├── auto.launch.ts │ │ │ ├── error.handler.ts │ │ │ ├── global.listener.ts │ │ │ ├── init.chrome.ts │ │ │ ├── init.store.ts │ │ │ ├── remote.register.ts │ │ │ ├── startup.server.ts │ │ │ └── updater.ts │ │ ├── utils │ │ │ ├── index.ts │ │ │ └── ocr.ts │ │ ├── window.ts │ │ └── worker │ │ │ └── index.ts │ ├── tsconfig.json │ └── types.ts ├── common │ ├── .gitignore │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── api.ts │ │ ├── interface.ts │ │ └── utils │ │ │ ├── string.ts │ │ │ └── valid.browser.ts │ └── tsconfig.json └── web │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── package.json │ ├── public │ ├── favicon.icns │ ├── favicon.ico │ ├── favicon.png │ └── js │ │ ├── acro.font.js │ │ └── iconfont.js │ ├── src │ ├── App.vue │ ├── assets │ │ ├── css │ │ │ ├── bootstrap.min.css │ │ │ ├── common.css │ │ │ ├── container.css │ │ │ ├── markdown-text.css │ │ │ ├── style.css │ │ │ └── video.css │ │ └── less │ │ │ ├── common.less │ │ │ ├── container.less │ │ │ └── markdown-text.less │ ├── components │ │ ├── BrowserList.vue │ │ ├── BrowserPanelOperators.vue │ │ ├── Card.vue │ │ ├── CommonEditActionDropdown.vue │ │ ├── CommonSelector.vue │ │ ├── Description.vue │ │ ├── Entity.vue │ │ ├── EntityOperator.vue │ │ ├── Icon.vue │ │ ├── MarkdownText.vue │ │ ├── OCSConfigs.vue │ │ ├── Path.vue │ │ ├── ScriptList.vue │ │ ├── Setup.vue │ │ ├── Tags.vue │ │ ├── Title.vue │ │ ├── TitleLink.vue │ │ ├── XTerm.vue │ │ ├── browsers │ │ │ ├── BrowserOperators.vue │ │ │ ├── BrowserPanel.vue │ │ │ ├── FileBreadcrumb.vue │ │ │ ├── FileFilters.vue │ │ │ ├── FileMultipleOperators.vue │ │ │ └── FileOperators.vue │ │ ├── playwright-scripts │ │ │ ├── PlaywrightScriptList.vue │ │ │ ├── PlaywrightScriptSelector.vue │ │ │ ├── PlaywrightScriptTable.vue │ │ │ └── index.ts │ │ └── setting │ │ │ └── BrowserPath.vue │ ├── config │ │ └── index.ts │ ├── env.d.ts │ ├── fs │ │ ├── browser.ts │ │ ├── entity.ts │ │ ├── folder.ts │ │ ├── index.ts │ │ └── interface.ts │ ├── main.ts │ ├── pages │ │ ├── bookmarks.vue │ │ ├── browsers │ │ │ └── index.vue │ │ ├── dashboard │ │ │ └── index.vue │ │ ├── index.vue │ │ ├── resources │ │ │ └── index.vue │ │ ├── setting │ │ │ └── index.vue │ │ └── user-scripts │ │ │ └── index.vue │ ├── route │ │ └── index.ts │ ├── store │ │ └── index.ts │ ├── types │ │ ├── search.ts │ │ └── user.script.ts │ └── utils │ │ ├── browser.ts │ │ ├── entity.ts │ │ ├── extension.ts │ │ ├── index.ts │ │ ├── ipc.ts │ │ ├── markdown.container.ts │ │ ├── markdown.ts │ │ ├── node.ts │ │ ├── notify.ts │ │ ├── os.ts │ │ ├── process.ts │ │ ├── remote.ts │ │ ├── resources.loader.ts │ │ ├── user-scripts.ts │ │ └── xterm.ts │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── readme.md └── scripts ├── build-app.js ├── chrome.install.js ├── release.sh ├── tsc.js └── utils.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | lib 4 | package-lock.json 5 | pnpm-lock.yaml 6 | public 7 | stats.html 8 | tests/* 9 | .md 10 | .chrome-temp/* 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "commonjs": true, 5 | "browser": true, 6 | "es6": true, 7 | "vue/setup-compiler-macros": true 8 | }, 9 | "extends": ["standard", "plugin:vue/vue3-recommended", "prettier"], 10 | "parserOptions": { 11 | "ecmaVersion": "latest", 12 | "parser": "@typescript-eslint/parser", 13 | "sourceType": "module" 14 | }, 15 | "plugins": ["vue", "@typescript-eslint", "eslint-plugin-vue"], 16 | "rules": { 17 | "max-len": [ 18 | 2, 19 | { 20 | "code": 120, 21 | "tabWidth": 2, 22 | "ignoreComments": true, 23 | "ignoreStrings": true 24 | } 25 | ], 26 | "indent": "off", 27 | "camelcase": "off", 28 | "vue/multi-word-component-names": "off", 29 | "vue/valid-attribute-name": "off", 30 | "space-before-function-paren": "off", 31 | "func-call-spacing": "off", 32 | "no-redeclare": "off" 33 | }, 34 | 35 | "globals": { 36 | "core": "readonly", 37 | "scripts": "readonly" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build/release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | os: [macos-latest, ubuntu-latest, windows-latest] 15 | 16 | steps: 17 | - name: Check out Git repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Install Node.js, NPM and Yarn 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: '18' 24 | - name: Npm Install 25 | run: | 26 | npm i pnpm -g && 27 | pnpm install 28 | - name: Build 29 | run: npm run build 30 | env: 31 | USE_HARD_LINKS: false 32 | - name: Create Release 33 | uses: softprops/action-gh-release@v1 34 | if: startsWith(github.ref, 'refs/tags/') 35 | with: 36 | body_path: './CHANGELOG_CURRENT.md' 37 | files: './packages/app/dist/**.exe,./packages/app/dist/**.dmg,./packages/app/dist/**.AppImage,./packages/app/dist/**.zip,./packages/app/dist/**.deb,./packages/app/dist/**.rpm' 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # customize 2 | 3 | **/*.zip 4 | 5 | **/*/.DS_Store 6 | 7 | .chrome-temp/ 8 | bin/ 9 | 10 | # tsc 11 | 12 | lib/ 13 | 14 | # Logs 15 | logs 16 | *.log 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | lerna-debug.log* 21 | .pnpm-debug.log* 22 | 23 | # Diagnostic reports (https://nodejs.org/api/report.html) 24 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 25 | 26 | # Runtime data 27 | pids 28 | *.pid 29 | *.seed 30 | *.pid.lock 31 | 32 | # Directory for instrumented libs generated by jscoverage/JSCover 33 | lib-cov 34 | 35 | # Coverage directory used by tools like istanbul 36 | coverage 37 | *.lcov 38 | 39 | # nyc test coverage 40 | .nyc_output 41 | 42 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | bower_components 47 | 48 | # node-waf configuration 49 | .lock-wscript 50 | 51 | # Compiled binary addons (https://nodejs.org/api/addons.html) 52 | build/Release 53 | 54 | # Dependency directories 55 | node_modules/ 56 | jspm_packages/ 57 | 58 | # Snowpack dependency directory (https://snowpack.dev/) 59 | web_modules/ 60 | 61 | # TypeScript cache 62 | *.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | .npm 66 | 67 | # Optional eslint cache 68 | .eslintcache 69 | 70 | # Optional stylelint cache 71 | .stylelintcache 72 | 73 | # Microbundle cache 74 | .rpt2_cache/ 75 | .rts2_cache_cjs/ 76 | .rts2_cache_es/ 77 | .rts2_cache_umd/ 78 | 79 | # Optional REPL history 80 | .node_repl_history 81 | 82 | # Output of 'npm pack' 83 | *.tgz 84 | 85 | # Yarn Integrity file 86 | .yarn-integrity 87 | 88 | # dotenv environment variable files 89 | .env 90 | .env.development.local 91 | .env.test.local 92 | .env.production.local 93 | .env.local 94 | 95 | # parcel-bundler cache (https://parceljs.org/) 96 | .cache 97 | .parcel-cache 98 | 99 | # Next.js build output 100 | .next 101 | out 102 | 103 | # Nuxt.js build / generate output 104 | .nuxt 105 | dist/ 106 | 107 | # Gatsby files 108 | .cache/ 109 | # Comment in the public line in if your project uses Gatsby and not Next.js 110 | # https://nextjs.org/blog/next-9-1#public-directory-support 111 | # public 112 | 113 | # vuepress build output 114 | .vuepress/dist 115 | 116 | # vuepress v2.x temp and cache directory 117 | .temp 118 | .cache 119 | 120 | # Docusaurus cache and generated files 121 | .docusaurus 122 | 123 | # Serverless directories 124 | .serverless/ 125 | 126 | # FuseBox cache 127 | .fusebox/ 128 | 129 | # DynamoDB Local files 130 | .dynamodb/ 131 | 132 | # TernJS port file 133 | .tern-port 134 | 135 | # Stores VSCode versions used for testing VSCode extensions 136 | .vscode-test 137 | 138 | # yarn v2 139 | .yarn/cache 140 | .yarn/unplugged 141 | .yarn/build-state.yml 142 | .yarn/install-state.gz 143 | .pnp.* -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | node-linker=hoisted 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | lib 4 | package-lock.json 5 | pnpm-lock.yaml 6 | public 7 | stats.html 8 | .md 9 | .chrome-temp/* 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc.json", 3 | "semi": true, 4 | "singleQuote": true, 5 | "bracketSpacing": true, 6 | "bracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 120, 9 | "tabWidth": 2, 10 | "useTabs": true, 11 | "vueIndentScriptAndStyle": false, 12 | "arrowParens": "always", 13 | "proseWrap": "preserve", 14 | "htmlWhitespaceSensitivity": "css", 15 | "endOfLine": "auto", 16 | "trailingComma": "none", 17 | "singleAttributePerLine": true 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "greasyfork", 4 | "ICVE", 5 | "ocsjs", 6 | "scriptcat", 7 | "userscript", 8 | "userscripts" 9 | ], 10 | "eslint.validate": [ 11 | "javascript", 12 | "javascriptreact", 13 | "vue" 14 | ], 15 | "editor.formatOnSave": true, 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll.eslint": "explicit" 18 | }, 19 | "editor.defaultFormatter": "esbenp.prettier-vscode", 20 | /** 样式自动生成 */ 21 | "less.compile": { 22 | "compress": false, 23 | "sourceMap": false, 24 | "out": "../css/" 25 | }, 26 | /** 鼠标中键代码缩进 */ 27 | "editor.mouseWheelZoom": true, 28 | /** 鼠标滚动速度 */ 29 | "editor.mouseWheelScrollSensitivity": 2, 30 | /** 不生成 @return 的 jsdoc */ 31 | "typescript.suggest.jsdoc.generateReturns": false, 32 | "[typescript]": { 33 | "editor.defaultFormatter": "esbenp.prettier-vscode" 34 | }, 35 | "[jsonc]": { 36 | "editor.defaultFormatter": "vscode.json-language-features" 37 | }, 38 | "editor.wordBasedSuggestions": "off" 39 | } -------------------------------------------------------------------------------- /CHANGELOG_CURRENT.md: -------------------------------------------------------------------------------- 1 | ## [2.8.21](https://github.com/ocsjs/ocs-desktop/compare/2.8.19...2.8.21) (2025-09-30) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * 优化脚本主页和链接打开方式 ([e7903de](https://github.com/ocsjs/ocs-desktop/commit/e7903ded373eed467445d8fa665eb1f6c6290ea2)) 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ocs-desktop", 3 | "version": "2.8.21", 4 | "description": "desktop for userscript", 5 | "files": [ 6 | "lib", 7 | "dist" 8 | ], 9 | "scripts": { 10 | "build": "npm run tsc && gulp -f ./scripts/build-app.js", 11 | "release": "sh ./scripts/release.sh", 12 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md --same-file -r 0", 13 | "changelog:current": "conventional-changelog -p angular -o CHANGELOG_CURRENT.md -r 1", 14 | "tsc": "pnpm lint && gulp -f ./scripts/tsc.js", 15 | "lint": "pnpm format && eslint ./packages --ext .ts,.tsx,.js,.jsx,.vue --fix", 16 | "format": "prettier -c ./.prettierrc.json **/*.ts **/*.js **/*.vue **/*.css --write", 17 | "init-commitizen": "commitizen init cz-conventional-changelog --save --save-exact" 18 | }, 19 | "devDependencies": { 20 | "@html-eslint/eslint-plugin": "^0.15.0", 21 | "@puppeteer/browsers": "^2.10.5", 22 | "@types/node": "^17.0.16", 23 | "@typescript-eslint/eslint-plugin": "^5.19.0", 24 | "@typescript-eslint/parser": "^5.19.0", 25 | "browser-env": "^3.3.0", 26 | "conventional-changelog-cli": "^2.2.2", 27 | "cz-conventional-changelog": "^3.3.0", 28 | "del": "^6.0.0", 29 | "eslint": "^7.32.0", 30 | "eslint-config-prettier": "^8.5.0", 31 | "eslint-config-standard": "^16.0.3", 32 | "eslint-plugin-import": "^2.22.1", 33 | "eslint-plugin-node": "^11.1.0", 34 | "eslint-plugin-prettier": "^4.0.0", 35 | "eslint-plugin-promise": "^4.2.1", 36 | "eslint-plugin-vue": "^9.8.0", 37 | "gulp": "^4.0.2", 38 | "gulp-cli": "^2.3.0", 39 | "gulp-zip": "^5.1.0", 40 | "prettier": "^2.8.8", 41 | "typescript": "^4.5.5" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/ocsjs/ocs-desktop.git" 46 | }, 47 | "keywords": [ 48 | "ocs", 49 | "script", 50 | "playwright", 51 | "puppeteer", 52 | "electron", 53 | "vue", 54 | "ant-design-vue", 55 | "typescript" 56 | ], 57 | "author": "enncy", 58 | "license": "MIT", 59 | "bugs": { 60 | "url": "https://github.com/ocsjs/ocs-desktop/issues" 61 | }, 62 | "homepage": "https://github.com/ocsjs/ocs-desktop#readme", 63 | "config": { 64 | "commitizen": { 65 | "path": "./node_modules/cz-conventional-changelog" 66 | } 67 | }, 68 | "dependencies": { 69 | "@types/minimatch": "^6.0.0", 70 | "minimatch": "^10.0.3" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/app/.bashrc: -------------------------------------------------------------------------------- 1 | USE_HARD_LINKS=false -------------------------------------------------------------------------------- /packages/app/.gitignore: -------------------------------------------------------------------------------- 1 | # customize 2 | 3 | pnpm-lock.yaml 4 | package-lock.json 5 | public/ 6 | bin/ 7 | tests/ 8 | 9 | # rollup-plugin-visualizer 10 | stats.html 11 | 12 | 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | lerna-debug.log* 20 | .pnpm-debug.log* 21 | 22 | # Diagnostic reports (https://nodejs.org/api/report.html) 23 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 24 | 25 | # Runtime data 26 | pids 27 | *.pid 28 | *.seed 29 | *.pid.lock 30 | 31 | # Directory for instrumented libs generated by jscoverage/JSCover 32 | lib-cov 33 | 34 | # Coverage directory used by tools like istanbul 35 | coverage 36 | *.lcov 37 | 38 | # nyc test coverage 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | .grunt 43 | 44 | # Bower dependency directory (https://bower.io/) 45 | bower_components 46 | 47 | # node-waf configuration 48 | .lock-wscript 49 | 50 | # Compiled binary addons (https://nodejs.org/api/addons.html) 51 | build/Release 52 | 53 | # Dependency directories 54 | node_modules/ 55 | jspm_packages/ 56 | 57 | # Snowpack dependency directory (https://snowpack.dev/) 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | *.tsbuildinfo 62 | 63 | # Optional npm cache directory 64 | .npm 65 | 66 | # Optional eslint cache 67 | .eslintcache 68 | 69 | # Optional stylelint cache 70 | .stylelintcache 71 | 72 | # Microbundle cache 73 | .rpt2_cache/ 74 | .rts2_cache_cjs/ 75 | .rts2_cache_es/ 76 | .rts2_cache_umd/ 77 | 78 | # Optional REPL history 79 | .node_repl_history 80 | 81 | # Output of 'npm pack' 82 | *.tgz 83 | 84 | # Yarn Integrity file 85 | .yarn-integrity 86 | 87 | # dotenv environment variable files 88 | .env 89 | .env.development.local 90 | .env.test.local 91 | .env.production.local 92 | .env.local 93 | 94 | # parcel-bundler cache (https://parceljs.org/) 95 | .cache 96 | .parcel-cache 97 | 98 | # Next.js build output 99 | .next 100 | out 101 | 102 | # Nuxt.js build / generate output 103 | .nuxt 104 | dist 105 | 106 | # Gatsby files 107 | .cache/ 108 | # Comment in the public line in if your project uses Gatsby and not Next.js 109 | # https://nextjs.org/blog/next-9-1#public-directory-support 110 | # public 111 | 112 | # vuepress build output 113 | .vuepress/dist 114 | 115 | # vuepress v2.x temp and cache directory 116 | .temp 117 | .cache 118 | 119 | # Docusaurus cache and generated files 120 | .docusaurus 121 | 122 | # Serverless directories 123 | .serverless/ 124 | 125 | # FuseBox cache 126 | .fusebox/ 127 | 128 | # DynamoDB Local files 129 | .dynamodb/ 130 | 131 | # TernJS port file 132 | .tern-port 133 | 134 | # Stores VSCode versions used for testing VSCode extensions 135 | .vscode-test 136 | 137 | # yarn v2 138 | .yarn/cache 139 | .yarn/unplugged 140 | .yarn/build-state.yml 141 | .yarn/install-state.gz 142 | .pnp.* -------------------------------------------------------------------------------- /packages/app/.npmrc: -------------------------------------------------------------------------------- 1 | electron_mirror=https://npmmirror.com/mirrors/electron/ -------------------------------------------------------------------------------- /packages/app/electron.builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/electron-builder.json", 3 | "appId": "ocs.enncy.cn", 4 | "extraMetadata": { 5 | "name": "OCS Desktop", 6 | "description": "OCS 浏览器自动化神器,一键浏览器多开,用户脚本环境一键配置。" 7 | }, 8 | "productName": "OCS Desktop", 9 | "asar": false, 10 | "copyright": "Copyright © 2021 ${author}", 11 | "nsis": { 12 | "oneClick": false, 13 | "perMachine": true, 14 | "allowToChangeInstallationDirectory": true 15 | }, 16 | "win": { 17 | "icon": "public/favicon.png", 18 | "artifactName": "ocs-${version}-setup-${os}-${arch}.${ext}", 19 | "target": [ 20 | { 21 | "target": "nsis", 22 | "arch": ["x64", "ia32", "arm64"] 23 | } 24 | ], 25 | "requestedExecutionLevel": "highestAvailable", 26 | "extraResources": [ 27 | { 28 | "from": "../../bin/", 29 | "to": "bin", 30 | "filter": ["**/*.zip"] 31 | } 32 | ] 33 | }, 34 | "mac": { 35 | "icon": "public/favicon.icns", 36 | "artifactName": "ocs-${version}-setup-${os}-${arch}.${ext}", 37 | "target": [ 38 | { 39 | "target": "zip", 40 | "arch": ["x64", "arm64"] 41 | }, 42 | { 43 | "target": "dmg", 44 | "arch": ["x64", "arm64"] 45 | } 46 | ], 47 | "extraResources": [ 48 | { 49 | "from": "../../bin/", 50 | "to": "bin", 51 | "filter": ["**/*.zip"] 52 | } 53 | ] 54 | }, 55 | "linux": { 56 | "icon": "public/favicon.png", 57 | "artifactName": "ocs-${version}-setup-${os}-${arch}.${ext}", 58 | "target": [ 59 | { 60 | "target": "AppImage", 61 | "arch": ["x64", "arm64"] 62 | } 63 | ], 64 | "extraResources": [ 65 | { 66 | "from": "../../bin/", 67 | "to": "bin", 68 | "filter": ["**/*.zip"] 69 | } 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/app/index.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import { remoteRegister } from './src/tasks/remote.register'; 3 | import { initStore } from './src/tasks/init.store'; 4 | import { autoLaunch } from './src/tasks/auto.launch'; 5 | import { createWindow } from './src/window'; 6 | import { globalListenerRegister } from './src/tasks/global.listener'; 7 | import { task } from './src/utils'; 8 | import { handleError } from './src/tasks/error.handler'; 9 | import { updater } from './src/tasks/updater'; 10 | import { startupServer } from './src/tasks/startup.server'; 11 | import { initChrome } from './src/tasks/init.chrome'; 12 | import { store } from './src/store'; 13 | 14 | app.setName('ocs'); 15 | 16 | // 防止软件崩溃以及兼容 17 | app.commandLine.appendSwitch('no-sandbox'); 18 | app.commandLine.appendSwitch('disable-gpu'); 19 | app.commandLine.appendSwitch('disable-software-rasterizer'); 20 | app.commandLine.appendSwitch('disable-gpu-compositing'); 21 | app.commandLine.appendSwitch('disable-gpu-rasterization'); 22 | app.commandLine.appendSwitch('disable-gpu-sandbox'); 23 | app.commandLine.appendSwitch('--no-sandbox'); 24 | app.disableHardwareAcceleration(); 25 | 26 | /** 获取单进程锁 */ 27 | const gotTheLock = app.requestSingleInstanceLock(); 28 | if (!gotTheLock) { 29 | app.quit(); 30 | } else { 31 | bootstrap(); 32 | } 33 | 34 | /** 启动渲染进程 */ 35 | function bootstrap() { 36 | task('OCS启动程序', () => 37 | Promise.all([ 38 | task('初始化错误处理', () => handleError()), 39 | task('初始化本地设置', () => { 40 | initStore(); 41 | task('启动接口服务', () => startupServer()); 42 | }), 43 | task('初始化自动启动', () => autoLaunch()), 44 | task('启动渲染进程', async () => { 45 | // 设置 webrtc 的影像帧率比例,最高100,太高会造成卡顿,参数默认50 46 | app.commandLine.appendSwitch( 47 | 'webrtc-max-cpu-consumption-percentage', 48 | (store.store.app?.video_frame_rate ?? 1).toString() 49 | ); 50 | 51 | await app.whenReady(); 52 | const window = createWindow(); 53 | await task('初始化谷歌浏览器', () => initChrome(window)); 54 | 55 | app.on('quit', (e) => { 56 | e.preventDefault(); 57 | // 交给渲染层去关闭浏览器 58 | window.webContents.send('close'); 59 | }); 60 | 61 | window.on('close', (e) => { 62 | e.preventDefault(); 63 | window.webContents.send('close'); 64 | }); 65 | 66 | window.webContents.once('did-finish-load', () => { 67 | setTimeout(() => { 68 | // 因为需要对渲染进程发送信息,所以要在显示完成后开始监听 69 | if (app.isPackaged) { 70 | task('软件更新', () => updater()); 71 | } 72 | }, 1000); 73 | }); 74 | 75 | task('初始化远程通信模块', () => remoteRegister(window)); 76 | task('注册app事件监听器', () => globalListenerRegister(window)); 77 | 78 | if (app.isPackaged) { 79 | await window.loadFile('./public/index.html'); 80 | } else { 81 | await window.loadURL('http://localhost:3000'); 82 | window.webContents.openDevTools(); 83 | } 84 | 85 | // 加载完成显示,解决一系列的显示/黑屏问题 86 | window.show(); 87 | }) 88 | ]) 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocs-desktop/app", 3 | "version": "2.8.21", 4 | "description": "desktop for userscript", 5 | "main": "./lib/index.js", 6 | "types": "./lib/types.d.ts", 7 | "scripts": { 8 | "dev": "tsc && chcp 65001 && electron .", 9 | "dev:mac": "tsc && electron .", 10 | "pack": "tsc && electron-builder --config ./electron.builder.json --dir --publish never", 11 | "dist": "tsc && electron-builder --config ./electron.builder.json --publish never", 12 | "proxy": "HTTPS_PROXY=127.0.0.1:7890" 13 | }, 14 | "devDependencies": { 15 | "@types/adm-zip": "^0.5.5", 16 | "@types/axios": "^0.14.0", 17 | "@types/lodash": "^4.17.16", 18 | "@types/node-forge": "^1.0.0", 19 | "@types/semver": "^7.3.9", 20 | "@types/unzipper": "^0.10.9", 21 | "electron": "35.0.0", 22 | "electron-builder": "^23.1.0" 23 | }, 24 | "dependencies": { 25 | "@ocs-desktop/common": "workspace:^0.0.1", 26 | "@types/express": "^4.17.13", 27 | "@types/minimatch": "^6.0.0", 28 | "adm-zip": "^0.5.12", 29 | "axios": "^0.25.0", 30 | "chalk": "4.1.0", 31 | "dayjs": "^1.10.7", 32 | "electron-store": "^8.0.1", 33 | "express": "^4.18.1", 34 | "glob": "^11.0.2", 35 | "lodash": "^4.17.21", 36 | "node-forge": "1.0.0", 37 | "playwright-core": "^1.52.0", 38 | "semver": "^7.3.5", 39 | "systeminformation": "^5.17.10", 40 | "unzipper": "^0.10.14", 41 | "xlsx": "^0.17.5" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/ocsjs/ocs-desktop.git" 46 | }, 47 | "keywords": [ 48 | "ocs", 49 | "script", 50 | "playwright", 51 | "puppeteer", 52 | "electron", 53 | "vue", 54 | "ant-design-vue", 55 | "typescript" 56 | ], 57 | "author": { 58 | "name": "enncy", 59 | "email": "enncyemail@qq.com" 60 | }, 61 | "license": "MIT", 62 | "bugs": { 63 | "url": "https://github.com/ocsjs/ocs-desktop/issues" 64 | }, 65 | "homepage": "https://github.com/ocsjs/ocs-desktop#readme" 66 | } 67 | -------------------------------------------------------------------------------- /packages/app/readme.md: -------------------------------------------------------------------------------- 1 | # 初始化项目 2 | 3 | 开发时需要在 web 项目中 `npm run build` 打包一次,否则会导致浏览器启动后 OCS 导航页无法访问。 4 | -------------------------------------------------------------------------------- /packages/app/script.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const { ScriptWorker } = require('./lib/src/worker/index'); 4 | 5 | const worker = new ScriptWorker(); 6 | 7 | // 监听消息 8 | process.on( 9 | 'message', 10 | ( 11 | /** @type {{event: any, args: any}} */ 12 | message 13 | ) => { 14 | /** 根据 event 名直接调用方法 */ 15 | worker[message.event](...message.args); 16 | } 17 | ); 18 | -------------------------------------------------------------------------------- /packages/app/src/logger.core.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { join, dirname } from 'path'; 3 | import { inspect } from 'util'; 4 | import { existsSync, mkdirSync, appendFileSync } from 'fs'; 5 | 6 | function formatDate() { 7 | const date = new Date(); 8 | return [ 9 | date.getFullYear(), 10 | String(date.getMonth() + 1).padStart(2, '0'), 11 | date.getDate().toString().padStart(2, '0') 12 | ].join('-'); 13 | } 14 | 15 | /** 16 | * 日志对象 17 | * ```js 18 | * const l = new LoggerCore(app.getPath("logs"), true, 'test') // create `${logs}/YYYY-MM-DD/test.log` 19 | * const l2 = new LoggerCore(app.getPath("logs"), true,'project','error','1') // create `${logs}/YYYY-MM-DD/project/error/1.log` 20 | * ``` 21 | */ 22 | export class LoggerCore { 23 | basePath; 24 | withConsole; 25 | dest; 26 | constructor(basePath: string, withConsole = true, ...name: string[]) { 27 | this.basePath = basePath; 28 | this.withConsole = withConsole; 29 | this.dest = join(this.basePath, '/', formatDate(), '/', name.join('/') + '.log'); 30 | } 31 | 32 | log = (...msg: any[]) => this._log(this.dest, '信息', ...msg); 33 | info = (...msg: any[]) => this._log(this.dest, '信息', ...msg); 34 | error = (...msg: any[]) => this._log(this.dest, '错误', ...msg); 35 | debug = (...msg: any[]) => this._log(this.dest, '调试', ...msg); 36 | warn = (...msg: any[]) => this._log(this.dest, '警告', ...msg); 37 | 38 | _log(dest: string, level: string, ...msg: string[]) { 39 | const data = msg 40 | .map((s) => { 41 | if (typeof s === 'object' || typeof s === 'function') { 42 | s = inspect(s); 43 | } 44 | return s; 45 | }) 46 | .join(' '); 47 | const txt = `[${level}] ${new Date().toLocaleString()} \t ` + data; 48 | 49 | if (this.withConsole) { 50 | console.log(txt); 51 | } 52 | 53 | return new Promise((resolve) => { 54 | if (!existsSync(dirname(dest))) { 55 | mkdirSync(dirname(dest), { recursive: true }); 56 | } 57 | appendFileSync(dest, txt + '\n'); 58 | resolve(); 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/app/src/logger.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { LoggerCore } from './logger.core'; 3 | import { app } from 'electron'; 4 | 5 | export function Logger(...name: any[]) { 6 | return new LoggerCore(app.getPath('logs'), true, ...name); 7 | } 8 | -------------------------------------------------------------------------------- /packages/app/src/scripts/automation/common/index.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightScript } from '../../script'; 2 | 3 | export const NewPageScript = new PlaywrightScript( 4 | { 5 | url: { 6 | label: '网页链接', 7 | value: '' 8 | } 9 | }, 10 | { 11 | name: '通用-新建页面', 12 | async run(page, configs) { 13 | if (configs.url.startsWith('http')) { 14 | await page.goto(configs.url); 15 | } else { 16 | throw new Error('网页链接格式不正确,请输入 http 开头的链接。'); 17 | } 18 | } 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /packages/app/src/scripts/automation/wk/icve.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { PlaywrightScript } from '../../script'; 3 | 4 | export const ICVELoginScript = new PlaywrightScript( 5 | { 6 | username: { 7 | label: '账号', 8 | value: '' 9 | }, 10 | password: { 11 | label: '密码', 12 | value: '' 13 | } 14 | }, 15 | { 16 | name: '智慧职教-账号密码登录', 17 | async run(page, configs) { 18 | try { 19 | if (await isNotLogin(page)) { 20 | await page.fill('[placeholder="请输入账号"]', configs.username); 21 | await page.fill('[placeholder="请输入密码"]', configs.password); 22 | // 同意协议 23 | await page.click('.agreement label.el-checkbox'); 24 | await page.click('form .login'); 25 | } 26 | } catch (err) { 27 | ICVELoginScript.emit('script-error', String(err)); 28 | } 29 | } 30 | } 31 | ); 32 | 33 | async function isNotLogin(page: Page) { 34 | await page.goto('https://sso.icve.com.cn/sso/auth?mode=simple&source=2&redirect=https://mooc.icve.com.cn/cms/'); 35 | await page.waitForTimeout(2000); 36 | return page.url().includes('sso/auth'); 37 | } 38 | -------------------------------------------------------------------------------- /packages/app/src/scripts/automation/wk/zhs.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { breakSliderVerify, getBase64, slowType } from '../../utils'; 3 | import { PlaywrightScript } from '../../script'; 4 | 5 | export const ZHSUnitLoginScript = new PlaywrightScript( 6 | { 7 | schoolname: { 8 | label: '学校', 9 | value: '' 10 | }, 11 | id: { 12 | label: '学号', 13 | value: '' 14 | }, 15 | password: { 16 | label: '密码', 17 | value: '' 18 | } 19 | }, 20 | { 21 | name: '智慧树-学校登录', 22 | async run( 23 | page, 24 | configs, 25 | options?: { 26 | ocrApiUrl?: string; 27 | detTargetKey?: string; 28 | detBackgroundKey?: string; 29 | } 30 | ) { 31 | try { 32 | if (await isNotLogin(page)) { 33 | await page.click('#qStudentID'); 34 | // 取消我知道了弹窗 35 | await page.waitForTimeout(1000); 36 | await page.evaluate('userindex.closeStuCodeFirstLoginTip();'); 37 | 38 | await slowType(page, '#quickSearch', configs.schoolname); 39 | // 显示学校列表 40 | await Promise.all([ 41 | /** 为防止页面未加载学校数据,所以这里即可能为远程加载或者缓存读取学校记录 */ 42 | Promise.race([ 43 | /** 等待请求完成 */ 44 | page.waitForResponse(/getAllSchool/), 45 | /** 等待元素出现 */ 46 | page.waitForSelector('#schoolListCode li') 47 | ]), 48 | page.evaluate('userindex.selectSchoolByName();') 49 | ]); 50 | // 单击第一个匹配的学校 51 | await page.click('#schoolListCode li'); 52 | await page.fill('#clCode', configs.id); 53 | await page.fill('#clPassword', configs.password); 54 | await page.waitForTimeout(3000); 55 | await page.click('.wall-sub-btn'); 56 | 57 | if (options?.ocrApiUrl && options?.detTargetKey && options?.detBackgroundKey) { 58 | let count = 5; 59 | while (await isNotVerified(page)) { 60 | if (count > 0) { 61 | count--; 62 | await verify(page, { 63 | ocrApiUrl: options.ocrApiUrl, 64 | detTargetKey: options.detTargetKey, 65 | detBackgroundKey: options.detBackgroundKey 66 | }); 67 | 68 | await page.waitForTimeout(2000); 69 | } else { 70 | throw new Error('滑块识别失败,请手动登录。'); 71 | } 72 | } 73 | } 74 | } 75 | } catch (err) { 76 | ZHSUnitLoginScript.emit('script-error', String(err)); 77 | } 78 | } 79 | } 80 | ); 81 | 82 | export const ZHSPhoneLoginScript = new PlaywrightScript( 83 | { 84 | phone: { 85 | label: '手机号', 86 | value: '' 87 | }, 88 | password: { 89 | label: '密码', 90 | value: '' 91 | } 92 | }, 93 | { 94 | name: '智慧树-手机密码登录', 95 | async run( 96 | page, 97 | configs, 98 | options?: { 99 | ocrApiUrl?: string; 100 | detTargetKey?: string; 101 | detBackgroundKey?: string; 102 | } 103 | ) { 104 | try { 105 | if (await isNotLogin(page)) { 106 | await page.click('#qSignin'); 107 | await page.fill('#lUsername', configs.phone); 108 | await page.fill('#lPassword', configs.password); 109 | await page.waitForTimeout(3000); 110 | await page.click('.wall-sub-btn'); 111 | 112 | if (options?.ocrApiUrl && options?.detTargetKey && options?.detBackgroundKey) { 113 | let count = 5; 114 | while ((await isNotVerified(page)) && count > 0) { 115 | count--; 116 | await verify(page, { 117 | ocrApiUrl: options.ocrApiUrl, 118 | detTargetKey: options.detTargetKey, 119 | detBackgroundKey: options.detBackgroundKey 120 | }); 121 | 122 | await page.waitForTimeout(2000); 123 | } 124 | } 125 | } 126 | } catch (err) { 127 | ZHSPhoneLoginScript.emit('script-error', String(err)); 128 | } 129 | } 130 | } 131 | ); 132 | 133 | /** 134 | * 滑块验证 135 | */ 136 | async function verify(page: Page, opts: { ocrApiUrl: string; detTargetKey: string; detBackgroundKey: string }) { 137 | // 删除yidun遮挡 138 | await page.evaluate(() => 139 | document.querySelectorAll('.yidun_cover-frame,.yidun_popup__mask').forEach((el) => el.remove()) 140 | ); 141 | 142 | const det_slider_el = await page.$('.yidun_slider'); 143 | const det_target_el = await page.$('[alt="验证码滑块"]'); 144 | const det_bg_el = await page.$('[alt="验证码背景"]'); 145 | 146 | if (det_target_el && det_slider_el && det_bg_el) { 147 | const det_target_src = await det_target_el.getAttribute('src'); 148 | const det_bg_src = await det_bg_el.getAttribute('src'); 149 | if (det_target_src && det_bg_src) { 150 | await breakSliderVerify(page, det_slider_el, await getBase64(det_target_src), await getBase64(det_bg_src), { 151 | ...opts, 152 | offset: 10 153 | }); 154 | } 155 | } 156 | } 157 | 158 | /** 是否未通过验证 */ 159 | async function isNotVerified(page: Page) { 160 | await page.waitForTimeout(2000); 161 | 162 | const errors = await page.evaluate(() => 163 | Array.from(document.querySelectorAll('.switch-wrap-signin.active .form-ipt-error.is-visible')) 164 | .map((e) => e.textContent || '') 165 | .filter(Boolean) 166 | ); 167 | 168 | if (errors.length) { 169 | throw new Error(errors.join('\n')); 170 | } 171 | 172 | return page.url().includes('passport.zhihuishu.com'); 173 | } 174 | 175 | async function isNotLogin(page: Page) { 176 | await page.goto('https://www.zhihuishu.com/'); 177 | await page.waitForTimeout(2000); 178 | const loginBtnNotDisplay = await page.evaluate( 179 | () => (document.querySelector('#login') as HTMLElement)?.style.display === 'none' 180 | ); 181 | if (loginBtnNotDisplay) { 182 | await page.click('#notLogin'); 183 | } else { 184 | await page.click('#login'); 185 | } 186 | 187 | await page.waitForTimeout(2000); 188 | return loginBtnNotDisplay; 189 | } 190 | -------------------------------------------------------------------------------- /packages/app/src/scripts/automation/wk/zjy.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { PlaywrightScript } from '../../script'; 3 | 4 | export const ZJYLoginScript = new PlaywrightScript( 5 | { 6 | username: { 7 | label: '账号', 8 | value: '' 9 | }, 10 | password: { 11 | label: '密码', 12 | value: '' 13 | } 14 | }, 15 | { 16 | name: '职教云-账号密码登录', 17 | async run(page, configs) { 18 | try { 19 | await page.goto('https://zjy2.icve.com.cn/study/index'); 20 | if (await isNotLogin(page)) { 21 | await page.fill('[placeholder="请输入账号"]', configs.username); 22 | await page.fill('[placeholder="请输入密码"]', configs.password); 23 | await page.click('.agreement .el-checkbox__input'); 24 | await page.click('.ri .login', { position: { x: 10, y: 10 } }); 25 | await page.waitForTimeout(1000); 26 | if (await isNotLogin(page)) { 27 | const errors = await page.evaluate(() => 28 | Array.from(document.querySelectorAll('.xcConfirm .txtBox')) 29 | .map((e) => e.textContent || '') 30 | .filter(Boolean) 31 | ); 32 | 33 | if (errors.length) { 34 | throw new Error(errors.join('\n')); 35 | } 36 | } 37 | } 38 | } catch (err) { 39 | ZJYLoginScript.emit('script-error', String(err)); 40 | } 41 | } 42 | } 43 | ); 44 | 45 | /** 是否未登录 */ 46 | async function isNotLogin(page: Page) { 47 | return page.url().includes('/sso/auth'); 48 | } 49 | -------------------------------------------------------------------------------- /packages/app/src/scripts/collector/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ocsjs/ocs-desktop/5e4c4be6ea4533d67d807aa00e3439d67448c3fb/packages/app/src/scripts/collector/index.ts -------------------------------------------------------------------------------- /packages/app/src/scripts/index.ts: -------------------------------------------------------------------------------- 1 | import { CXPhoneLoginScript, CXUnitLoginScript } from './automation/wk/cx'; 2 | import { ZHSPhoneLoginScript, ZHSUnitLoginScript } from './automation/wk/zhs'; 3 | import { ICVELoginScript } from './automation/wk/icve'; 4 | import { ZJYLoginScript } from './automation/wk/zjy'; 5 | import { NewPageScript } from './automation/common'; 6 | 7 | export const scripts = [ 8 | CXPhoneLoginScript, 9 | CXUnitLoginScript, 10 | ZHSPhoneLoginScript, 11 | ZHSUnitLoginScript, 12 | ZJYLoginScript, 13 | ICVELoginScript, 14 | NewPageScript 15 | ]; 16 | -------------------------------------------------------------------------------- /packages/app/src/scripts/interface.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import EventEmitter from 'events'; 4 | 5 | export type EventsRecord = Record void>; 6 | export type EventName = T extends string | symbol ? T : any; 7 | 8 | export class TypedEventEmitter extends EventEmitter { 9 | override emit(eventName: EventName, ...args: Parameters): boolean { 10 | return super.emit(eventName, ...args); 11 | } 12 | 13 | override on(eventName: EventName, listener: E[T]): this { 14 | return super.on(eventName, listener); 15 | } 16 | 17 | override once(eventName: EventName, listener: E[T]): this { 18 | return super.once(eventName, listener); 19 | } 20 | 21 | override off(eventName: EventName, listener: E[T]): this { 22 | return super.off(eventName, listener); 23 | } 24 | 25 | override removeListener(eventName: EventName, listener: E[T]): this { 26 | return super.removeListener(eventName, listener); 27 | } 28 | 29 | override removeAllListeners(event?: EventName | undefined): this { 30 | return super.removeAllListeners(event); 31 | } 32 | 33 | override addListener(eventName: EventName, listener: E[T]): this { 34 | return super.addListener(eventName, listener); 35 | } 36 | 37 | override listeners(eventName: EventName): Function[] { 38 | return super.listeners(eventName); 39 | } 40 | 41 | override prependListener(eventName: EventName, listener: E[T]): this { 42 | return super.prependListener(eventName, listener); 43 | } 44 | 45 | override prependOnceListener(eventName: EventName, listener: E[T]): this { 46 | return super.prependOnceListener(eventName, listener); 47 | } 48 | } 49 | 50 | export type BaseAutomationEvents = { 51 | 'script-error': (...msg: string[]) => void; 52 | 'script-data': (...msg: string[]) => void; 53 | }; 54 | 55 | export interface Config { 56 | label: string; 57 | value: any; 58 | hide?: boolean; 59 | } 60 | 61 | export type ConfigValueRecord> = { 62 | [K in keyof C]: C[K]['value']; 63 | }; 64 | -------------------------------------------------------------------------------- /packages/app/src/scripts/script.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { BaseAutomationEvents, Config, ConfigValueRecord, EventsRecord, TypedEventEmitter } from './interface'; 3 | 4 | export type RunFunction = (...args: any[]) => any; 5 | 6 | export interface ScriptOptions { 7 | name: string; 8 | run: Run; 9 | } 10 | 11 | export abstract class Script 12 | extends TypedEventEmitter 13 | implements ScriptOptions 14 | { 15 | name: string; 16 | run: Run; 17 | 18 | constructor(opts: ScriptOptions) { 19 | super(); 20 | this.name = opts.name; 21 | this.run = opts.run; 22 | } 23 | } 24 | 25 | /** 收集脚本 */ 26 | export abstract class CollectorScript< 27 | RF extends RunFunction = RunFunction, 28 | E extends EventsRecord = EventsRecord 29 | > extends Script {} 30 | 31 | /** 自动化脚本 */ 32 | export abstract class AutomationScript< 33 | RF extends RunFunction = RunFunction, 34 | E extends EventsRecord = EventsRecord 35 | > extends Script {} 36 | 37 | export class ConfigsRequiredAutomationScript< 38 | C extends Record = Record, 39 | RF extends RunFunction = RunFunction, 40 | E extends EventsRecord = EventsRecord 41 | > extends AutomationScript { 42 | name: string; 43 | configs: C; 44 | 45 | constructor(configs: C, options: ScriptOptions) { 46 | super(options); 47 | this.name = options.name; 48 | this.run = options.run; 49 | this.configs = configs; 50 | } 51 | } 52 | 53 | /** 脚本运行方法类型声明 */ 54 | export type PlaywrightScriptRunFunction = Record> = { 55 | // 第一个,和第二个参数类型固定,剩下的参数类型由实例化时方法的参数类型决定 56 | (page: Page, configs: ConfigValueRecord, ...args: any[]): void | Promise; 57 | }; 58 | 59 | /** 自动化PW脚本 */ 60 | export class PlaywrightScript< 61 | C extends Record = Record, 62 | RF extends PlaywrightScriptRunFunction = PlaywrightScriptRunFunction 63 | > extends ConfigsRequiredAutomationScript {} 64 | -------------------------------------------------------------------------------- /packages/app/src/scripts/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { ElementHandle, Page } from 'playwright-core'; 2 | import axios from 'axios'; 3 | 4 | /** 缓慢输入 */ 5 | export function slowType(page: Page, selector: string, text: string) { 6 | return page.type(selector, text, { delay: 100 }); 7 | } 8 | 9 | /** 验证码破解 */ 10 | export async function breakVerifyCode( 11 | page: Page, 12 | imageElement: ElementHandle, 13 | inputElement: ElementHandle, 14 | options: { ocrApiUrl: string; ocrApiImageKey: string } 15 | ) { 16 | const box = await imageElement.boundingBox(); 17 | if (box) { 18 | /** 请求验证码破解接口 */ 19 | const body = Object.create([]); 20 | const buffer = await page.screenshot({ clip: box }); 21 | Reflect.set(body, options.ocrApiImageKey, buffer.toString('base64')); 22 | const { 23 | data: { code, canOCR, error } 24 | } = await axios.post(options.ocrApiUrl, body); 25 | if (canOCR) { 26 | /** 破解验证码 */ 27 | if (code) { 28 | await inputElement.fill(code); 29 | } else if (error) { 30 | throw new Error(error); 31 | } 32 | } else { 33 | throw new Error('未检测到图片验证码识别模块, 请手动输入验证码,或在软件左侧应用中心安装识别模块后重启浏览器。。'); 34 | } 35 | } 36 | } 37 | 38 | /** 滑块验证码破解 */ 39 | export async function breakSliderVerify( 40 | page: Page, 41 | /** 42 | * 滑块目标元素 43 | */ 44 | det_slider_el: ElementHandle, 45 | /** 46 | * 拼图元素 47 | */ 48 | det_target_base64: string, 49 | /** 50 | * 滑块背景元素 51 | */ 52 | det_bg_base64: string, 53 | opts: { ocrApiUrl: string; detTargetKey: string; detBackgroundKey: string; offset?: number } 54 | ) { 55 | const body = Object.create({}); 56 | Reflect.set(body, opts.detTargetKey, det_target_base64); 57 | Reflect.set(body, opts.detBackgroundKey, det_bg_base64); 58 | 59 | const data = await axios.post(opts.ocrApiUrl, body); 60 | console.log('slider ocr', JSON.stringify(data?.data)); 61 | 62 | if (data?.data?.error) { 63 | console.error(data.data.error); 64 | } else { 65 | if (data?.data?.canOCR) { 66 | /** 破解滑块验证码 */ 67 | const result: { target_y: number; target: number[] } = data?.data?.det; 68 | 69 | if (result) { 70 | const bg_rect = await det_slider_el.evaluate((node) => node.getBoundingClientRect()); 71 | const x1 = bg_rect.x; 72 | const y1 = bg_rect.y; 73 | const x2 = bg_rect.x + result.target[0] + (opts.offset ?? 0); 74 | const y2 = bg_rect.y; 75 | 76 | console.log('slider ocr', { x1, y1, x2, y2, offset: opts.offset ?? 0 }); 77 | 78 | await page.mouse.move(x1, y1); 79 | await page.mouse.down(); 80 | await page.mouse.down(); 81 | await page.mouse.move(x2, y2, { steps: 10 }); 82 | await page.mouse.up(); 83 | 84 | try { 85 | await page.waitForNavigation({ timeout: 3000, waitUntil: 'domcontentloaded' }); 86 | } catch {} 87 | } else { 88 | console.error(`OCR_DET: `, { 89 | data, 90 | opts, 91 | det_target_base64: det_target_base64.length, 92 | det_bg_base64: det_bg_base64.length 93 | }); 94 | throw new Error('滑块验证识别失败,请尝试手动登录。'); 95 | } 96 | } else { 97 | throw new Error('未检测到图片验证码识别模块, 请手动输入验证码,或在软件左侧应用中心安装识别模块后重启浏览器。'); 98 | } 99 | } 100 | } 101 | 102 | export function getBase64(url: string) { 103 | return axios 104 | .get(url, { 105 | responseType: 'arraybuffer' 106 | }) 107 | .then((response) => Buffer.from(response.data, 'binary').toString('base64')); 108 | } 109 | -------------------------------------------------------------------------------- /packages/app/src/store.ts: -------------------------------------------------------------------------------- 1 | import { app, safeStorage } from 'electron'; 2 | import path from 'path'; 3 | import Store from 'electron-store'; 4 | 5 | // IO操作只能在 app.getPath('userData') 下进行,否则会有权限问题。 6 | 7 | export const OriginalAppStore = { 8 | name: app.getName(), 9 | version: app.getVersion(), 10 | /** 路径数据 */ 11 | paths: { 12 | 'app-path': app.getAppPath(), 13 | 'user-data-path': app.getPath('userData'), 14 | 'exe-path': app.getPath('exe'), 15 | 'logs-path': app.getPath('logs'), 16 | 'config-path': path.resolve(app.getPath('userData'), './config.json'), 17 | /** 浏览器用户数据文件夹 */ 18 | userDataDirsFolder: '', 19 | /** 浏览器下载文件夹 */ 20 | downloadFolder: path.resolve(app.getPath('userData'), './downloads'), 21 | /** 加载拓展路径 */ 22 | extensionsFolder: path.resolve(app.getPath('userData'), './downloads/extensions') 23 | }, 24 | /** 软件设置 */ 25 | app: { 26 | video_frame_rate: 1, 27 | data_encryption: false 28 | }, 29 | /** 窗口设置 */ 30 | window: { 31 | /** 开机自启 */ 32 | alwaysOnTop: false, 33 | autoLaunch: false 34 | }, 35 | /** 本地服务器数据 */ 36 | server: { 37 | port: 15319, 38 | authToken: '' 39 | }, 40 | /** 渲染进程数据 */ 41 | render: {} as { [x: string]: any } 42 | }; 43 | 44 | /** 45 | * - electron 本地存储对象 46 | * - 可以使用 store.store 访问 47 | * - 设置数据请使用 store.set('key', value) 48 | */ 49 | export const store = new Store(); 50 | 51 | /** 52 | * 获取解密后的渲染进程数据 53 | */ 54 | export function getDecryptedRenderData(): (typeof OriginalAppStore)['render'] { 55 | if (typeof store?.store?.render === 'string') { 56 | return JSON.parse(safeStorage.decryptString(Buffer.from(store?.store?.render, 'base64'))); 57 | } 58 | return store?.store?.render || {}; 59 | } 60 | -------------------------------------------------------------------------------- /packages/app/src/tasks/auto.launch.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import { store } from '../store'; 3 | 4 | /** 配置自动启动 */ 5 | export function autoLaunch() { 6 | if (app.isPackaged) { 7 | app.setLoginItemSettings({ 8 | openAtLogin: store.store.window.autoLaunch 9 | }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/app/src/tasks/error.handler.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import { Logger } from '../logger'; 3 | 4 | /** 5 | * 处理错误 6 | */ 7 | export function handleError() { 8 | const logger = Logger('error'); 9 | 10 | app.on('render-process-gone', (e, c, details) => { 11 | logger.error('render-process-gone', details); 12 | process.exit(0); 13 | }); 14 | app.on('child-process-gone', (e, details) => { 15 | logger.error('child-process-gone', details); 16 | process.exit(0); 17 | }); 18 | 19 | process.on('uncaughtException', (e) => { 20 | logger.error('rejectionHandled', e); 21 | }); 22 | process.on('unhandledRejection', (e) => { 23 | logger.error('unhandledRejection', e); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /packages/app/src/tasks/global.listener.ts: -------------------------------------------------------------------------------- 1 | import { createWindow } from '../window'; 2 | import { app, BrowserWindow } from 'electron'; 3 | import { existsSync, statSync } from 'fs'; 4 | 5 | export function globalListenerRegister(win: BrowserWindow) { 6 | app.on('second-instance', (e, argv) => { 7 | if (win && process.platform === 'win32') { 8 | if (win.isMinimized()) { 9 | win.restore(); 10 | } 11 | if (win.isVisible()) { 12 | win.focus(); 13 | } else { 14 | win.show(); 15 | } 16 | const file = getFileInArguments(argv); 17 | win.webContents.send('open-file', file); 18 | } 19 | }); 20 | 21 | app.on('activate', () => { 22 | if (BrowserWindow.getAllWindows().length === 0) { 23 | createWindow(); 24 | } 25 | }); 26 | } 27 | 28 | /** 29 | * 30 | * 获取命令行参数中的url信息 31 | * @param {string[]} argv 32 | * @returns 33 | */ 34 | export function getFileInArguments(argv: any[]) { 35 | argv.shift(); 36 | for (const arg of argv) { 37 | if (!arg.startsWith('-')) { 38 | if (existsSync(arg) && statSync(arg).isFile()) { 39 | return arg; 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/app/src/tasks/init.chrome.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, dialog } from 'electron'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import { sleep, unzip } from '../utils'; 5 | import { Logger } from '../logger'; 6 | import { glob } from 'glob'; 7 | const logger = Logger('chrome-init'); 8 | 9 | export async function initChrome(win: BrowserWindow) { 10 | try { 11 | // 解压浏览器内核 12 | const chromePath = path.join(process.resourcesPath, 'bin', 'chrome'); 13 | if (!fs.existsSync(chromePath)) { 14 | logger.error(`内置浏览器目录不存在: ${chromePath}`); 15 | return; 16 | } 17 | 18 | const chrome_filename = 19 | process.platform === 'win32' 20 | ? 'chrome.exe' 21 | : process.platform === 'linux' 22 | ? 'chrome' 23 | : 'Google Chrome for Testing'; 24 | 25 | if (fs.existsSync(path.join(chromePath, 'chrome', chrome_filename))) { 26 | logger.log(`内置浏览器已存在,无需初始化`); 27 | return; 28 | } 29 | 30 | const ab = new AbortController(); 31 | dialog.showMessageBox(win, { 32 | title: app.name, 33 | message: '正在初始化资源...,请稍等', 34 | type: 'info', 35 | noLink: true, 36 | signal: ab.signal 37 | }); 38 | try { 39 | await unzip(path.join(chromePath, 'chrome.zip'), path.join(chromePath, 'chrome_temp')); 40 | const chrome_file = await glob('**/*/' + chrome_filename, { 41 | nodir: true, 42 | absolute: true, 43 | cwd: path.join(chromePath, 'chrome_temp') 44 | }); 45 | if (!chrome_file || chrome_file.length === 0) { 46 | throw new Error('浏览器压缩包数据错误'); 47 | } 48 | fs.renameSync(path.dirname(chrome_file[0]), path.join(chromePath, 'chrome')); 49 | fs.rmdirSync(path.join(chromePath, 'chrome_temp'), { recursive: true }); 50 | 51 | dialog.showMessageBox(win, { 52 | title: app.name, 53 | message: '内置浏览器初始化完成,即将重启...', 54 | type: 'info', 55 | noLink: true 56 | }); 57 | await sleep(1000); 58 | app.relaunch(); 59 | app.quit(); 60 | } catch (e) { 61 | logger.error('初始化谷歌浏览器失败', e); 62 | dialog.showErrorBox('初始化谷歌浏览器失败', String(e)); 63 | } finally { 64 | ab.abort(); 65 | } 66 | } catch (e) { 67 | logger.error('初始化谷歌浏览器失败', e); 68 | dialog.showErrorBox('初始化谷歌浏览器失败', String(e)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/app/src/tasks/init.store.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { app } from 'electron'; 4 | import { existsSync, mkdirSync } from 'fs'; 5 | import { OriginalAppStore, store } from '../store'; 6 | import { valid, coerce, clean, gt, SemVer } from 'semver'; 7 | import defaultsDeep from 'lodash/defaultsDeep'; 8 | import { Logger } from '../logger'; 9 | import path from 'path'; 10 | 11 | const logger = Logger('store-init'); 12 | 13 | /** 14 | * 初始化配置 15 | */ 16 | export function initStore() { 17 | const version = store.store.version; 18 | logger.log('version', version); 19 | 20 | if (typeof version === 'string') { 21 | // 当前app版本 22 | const appVersion = parseVersion(app.getVersion()); 23 | // 本地存储的app版本 24 | const originVersion = parseVersion(version); 25 | // 是否需要更新设置 26 | if (gt(appVersion, originVersion)) { 27 | store.store = defaultsDeep(store.store, OriginalAppStore); 28 | } 29 | } 30 | // 初始化 31 | else { 32 | const render = store.store.render ? JSON.parse(JSON.stringify(store.store.render)) : {}; 33 | OriginalAppStore.render = render; 34 | // 初始化设置 35 | store.store = OriginalAppStore; 36 | 37 | logger.log('store', store.store); 38 | } 39 | 40 | /** 41 | * 如果浏览器缓存为空,则初始化,如果不为空那就是用户自己设置了 42 | */ 43 | if (!store.store.paths.userDataDirsFolder) { 44 | OriginalAppStore.paths.userDataDirsFolder = path.resolve(app.getPath('userData'), './userDataDirs'); 45 | } else { 46 | OriginalAppStore.paths.userDataDirsFolder = store.store.paths.userDataDirsFolder; 47 | } 48 | 49 | // 强制更新路径 50 | store.set('paths', OriginalAppStore.paths); 51 | 52 | if (!existsSync(store.store.paths.userDataDirsFolder)) { 53 | mkdirSync(store.store.paths.userDataDirsFolder, { recursive: true }); 54 | } 55 | if (!existsSync(store.store.paths.extensionsFolder)) { 56 | mkdirSync(store.store.paths.extensionsFolder, { recursive: true }); 57 | } 58 | if (!existsSync(store.store.paths.downloadFolder)) { 59 | mkdirSync(store.store.paths.downloadFolder, { recursive: true }); 60 | } 61 | } 62 | 63 | /** 字符串转换成版本对象 */ 64 | function parseVersion(version: string) { 65 | return new SemVer(valid(coerce(clean(version, { loose: true }))) || '0.0.0'); 66 | } 67 | -------------------------------------------------------------------------------- /packages/app/src/tasks/remote.register.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain, app, dialog, BrowserWindow, desktopCapturer, safeStorage } from 'electron'; 2 | import { Logger } from '../logger'; 3 | import { autoLaunch } from './auto.launch'; 4 | import axios, { AxiosRequestConfig } from 'axios'; 5 | import { downloadFile, moveWindowToTop, unzip, zip } from '../utils'; 6 | import fs from 'fs'; 7 | import path from 'path'; 8 | import os from 'os'; 9 | import crypto from 'crypto'; 10 | import { OCSApi, getValidBrowsers } from '@ocs-desktop/common'; 11 | import si from 'systeminformation'; 12 | import { store } from '../store'; 13 | import { exportExcel } from '../utils/index'; 14 | import { readdir, stat } from 'fs/promises'; 15 | import { updateApp } from './updater'; 16 | import { scripts } from '../scripts'; 17 | import { PlaywrightScript } from '../scripts/script'; 18 | 19 | export type RawPlaywrightScript = Pick; 20 | 21 | /** 22 | * 注册主进程远程通信事件 23 | * @param name 事件前缀名称 24 | * @param target 事件目标 25 | */ 26 | function registerRemoteEvent(name: string, target: any) { 27 | const logger = Logger('remote'); 28 | try { 29 | ipcMain 30 | .on(name + '-get', (event, [property]) => { 31 | try { 32 | // logger.info({ event: name + '-get', args: [property] }); 33 | event.returnValue = target[property]; 34 | } catch (e) { 35 | event.returnValue = { error: e }; 36 | } 37 | }) 38 | .on(name + '-set', (event, [property, value]) => { 39 | try { 40 | // logger.info({ event: name + '-set', args: [property, value] }); 41 | event.returnValue = target[property] = value; 42 | } catch (e) { 43 | event.returnValue = { error: e }; 44 | } 45 | }) 46 | 47 | /** 异步调用 */ 48 | .on( 49 | name + '-call', 50 | async ( 51 | event, 52 | [ 53 | /** 回调id */ 54 | respondChannel, 55 | property, 56 | ...args 57 | ] 58 | ) => { 59 | // logger.info({ event: name + '-call', args }); 60 | try { 61 | const result = await target[property](...args); 62 | event.reply(respondChannel, { data: result }); 63 | } catch (e) { 64 | event.reply(respondChannel, { error: e }); 65 | } 66 | } 67 | ) 68 | 69 | /** 同步调用 */ 70 | .on(name + '-call-sync', (event, [property, ...args]) => { 71 | // logger.info({ event: name + '-call-sync', args: [property] }); 72 | try { 73 | const result = target[property](...args); 74 | event.returnValue = { data: result }; 75 | } catch (e) { 76 | event.returnValue = { error: e }; 77 | } 78 | }); 79 | } catch (err) { 80 | logger.error(err); 81 | } 82 | } 83 | 84 | let win: BrowserWindow | undefined; 85 | 86 | /** 需远程共享的方法 */ 87 | const methods = { 88 | autoLaunch, 89 | get: (url: string, config?: AxiosRequestConfig | undefined) => axios.get(url, config).then((res) => res.data), 90 | post: (url: string, config?: AxiosRequestConfig | undefined) => axios.post(url, config).then((res) => res.data), 91 | download: (channel: string, url: string, dest: string) => { 92 | /** 下载文件 */ 93 | return downloadFile(url, dest, (rate: any, totalLength: any, chunkLength: any) => { 94 | win?.webContents?.send('download', channel, rate, totalLength, chunkLength); 95 | }); 96 | }, 97 | zip: zip, 98 | unzip: unzip, 99 | getValidBrowsers: getValidBrowsers, 100 | systemProcesses: () => si.processes(), 101 | exportExcel: exportExcel, 102 | statisticFolderSize: statisticFolderSize, 103 | getPlatform: () => process.platform, 104 | updateApp: updateApp, 105 | moveWindowToTop: moveWindowToTop, 106 | encryptString: (text: string) => safeStorage.encryptString(text).toString('base64'), 107 | decryptString: (text: string) => { 108 | return safeStorage.decryptString(Buffer.from(text, 'base64')); 109 | }, 110 | isEncryptionAvailable: () => { 111 | return safeStorage.isEncryptionAvailable(); 112 | }, 113 | getRawScripts: () => JSON.parse(JSON.stringify(scripts)) as RawPlaywrightScript[] 114 | }; 115 | 116 | /** 117 | * 初始化远程通信 118 | */ 119 | export function remoteRegister(_win: BrowserWindow) { 120 | win = _win; 121 | registerRemoteEvent('electron-store', store); 122 | registerRemoteEvent('fs', fs); 123 | registerRemoteEvent('os', os); 124 | registerRemoteEvent('path', path); 125 | registerRemoteEvent('crypto', crypto); 126 | registerRemoteEvent('OCSApi', OCSApi); 127 | 128 | registerRemoteEvent('win', _win); 129 | registerRemoteEvent('webContents', _win.webContents); 130 | registerRemoteEvent('app', app); 131 | registerRemoteEvent('dialog', dialog); 132 | registerRemoteEvent('methods', methods); 133 | registerRemoteEvent('logger', Logger('render')); 134 | registerRemoteEvent('desktopCapturer', desktopCapturer); 135 | } 136 | 137 | export type RemoteMethods = typeof methods; 138 | 139 | const _registerRemoteEvent = registerRemoteEvent; 140 | export { _registerRemoteEvent as registerRemoteEvent }; 141 | 142 | async function statisticFolderSize(dir: string) { 143 | const files = await readdir(dir, { withFileTypes: true }); 144 | 145 | const paths: Promise[] = files.map(async (file) => { 146 | const _path = path.join(dir, file.name); 147 | if (file.isDirectory()) return await statisticFolderSize(_path); 148 | 149 | if (file.isFile()) { 150 | const { size } = await stat(_path); 151 | return size; 152 | } 153 | return 0; 154 | }); 155 | 156 | return (await Promise.all(paths)).flat().reduce((i, size) => i + size, 0); 157 | } 158 | -------------------------------------------------------------------------------- /packages/app/src/tasks/startup.server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { Logger } from '../logger'; 3 | import path from 'path'; 4 | import axios from 'axios'; 5 | import { getDecryptedRenderData, store } from '../store'; 6 | import { getCurrentWebContents, getProjectPath, moveWindowToTop } from '../utils'; 7 | import { canOCR, det, ocr } from '../utils/ocr'; 8 | import { randomUUID } from 'crypto'; 9 | const logger = Logger('server'); 10 | 11 | export async function startupServer() { 12 | const app = express(); 13 | 14 | store.set('server', { 15 | port: 15319, 16 | authToken: randomUUID().replace(/-/g, '') 17 | }); 18 | 19 | app.use((req, res, next) => { 20 | res.setHeader('Access-Control-Allow-Origin', req.headers.origin || 'unknown'); 21 | res.setHeader('Access-Control-Allow-Credentials', 'true'); 22 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, request-id, if-none-match'); 23 | res.setHeader('Access-Control-Allow-Methods', '*'); 24 | if (req.method === 'OPTIONS') { 25 | res.sendStatus(204); 26 | return; 27 | } 28 | next(); 29 | }); 30 | // 解析 post 数据 31 | app.use(express.urlencoded({ extended: false, limit: '10mb' })); 32 | app.use(express.json({ limit: '10mb' })); 33 | 34 | app.get('/state', (req, res) => { 35 | res.json({ 36 | public: path.join(getProjectPath(), './public'), 37 | project: getProjectPath() 38 | }); 39 | }); 40 | 41 | app.get('/ocs-global-setting', (req, res) => { 42 | const render = getDecryptedRenderData(); 43 | res.json(render.setting.ocs); 44 | }); 45 | 46 | /** 获取 browser 数据 */ 47 | app.get('/browser', (req, res) => { 48 | const render = getDecryptedRenderData(); 49 | // 如果开启了同步配置,就返回,否则返回空对象 50 | res.json(render?.setting?.ocs?.openSync ? render?.setting?.ocs?.store : {}); 51 | }); 52 | 53 | /** 脚本操作 */ 54 | app.get('/ocs-script-actions', (req, res) => { 55 | res.json({ allow: true }); 56 | }); 57 | 58 | app.get('/get-actions-key', (req, res) => { 59 | res.send(store.store.server.authToken); 60 | }); 61 | 62 | /** 请求转发 */ 63 | app.post('/proxy', async (req, res) => { 64 | const { method, url, data, headers } = req.body || {}; 65 | axios 66 | .request({ 67 | method, 68 | url, 69 | data, 70 | headers 71 | }) 72 | .then(({ data }) => { 73 | res.send(data); 74 | }) 75 | .catch((err) => { 76 | res.send(err); 77 | }); 78 | }); 79 | 80 | app.get(/\/ocs-action_.+/, (req, res) => { 81 | res.send('正在执行脚本 : ' + req.path + ' 请勿操作。'); 82 | }); 83 | 84 | // ocr 验证码破解 85 | app.post('/ocr', async (req, res) => { 86 | const base64 = req.body.image?.toString(); 87 | const det_target = req.body.det_target?.toString(); 88 | const det_bg = req.body.det_bg?.toString(); 89 | 90 | if (canOCR()) { 91 | try { 92 | if (base64) { 93 | res.json({ canOCR: true, code: await ocr(base64) }); 94 | } else if (det_target && det_bg) { 95 | res.json({ canOCR: true, det: await det(det_target, det_bg) }); 96 | } else { 97 | res.send({ error: '参数缺失!' }); 98 | } 99 | } catch (err) { 100 | res.json({ canOCR: true, error: err }); 101 | } 102 | } else { 103 | res.json({ canOCR: false }); 104 | } 105 | }); 106 | 107 | app.get('/api/bookmark/show-browser-in-app', (req, res) => { 108 | moveWindowToTop(); 109 | // 显示浏览器文件 110 | getCurrentWebContents().send('show-browser-in-app', req.query.uid); 111 | }); 112 | 113 | // 静态资源 114 | app.use(express.static(path.join(getProjectPath(), './public'))); 115 | 116 | const server = app.listen(store.store.server.port, () => { 117 | const address = server.address(); 118 | if (address && typeof address === 'object') { 119 | // 存储本次服务的端口 120 | logger.info(`OCS服务启动成功 => ${address.port}`); 121 | } 122 | }); 123 | } 124 | -------------------------------------------------------------------------------- /packages/app/src/tasks/updater.ts: -------------------------------------------------------------------------------- 1 | import { app, dialog, clipboard } from 'electron'; 2 | import { gt } from 'semver'; 3 | import { Logger } from '../logger'; 4 | import AdmZip from 'adm-zip'; 5 | import { join } from 'path'; 6 | import { downloadFile, getCurrentWebContents, moveWindowToTop } from '../utils'; 7 | import { OCSApi, UpdateInformationResource } from '@ocs-desktop/common'; 8 | import { writeFileSync, rmSync } from 'fs'; 9 | 10 | const logger = Logger('updater'); 11 | 12 | export async function updater() { 13 | const infos = await OCSApi.getInfos(); 14 | 15 | const versions = infos.versions || []; 16 | 17 | const newVersion = versions.find((version) => gt(version.tag, app.getVersion())); 18 | 19 | logger.info('updater', { versions, newVersion }); 20 | 21 | /** 更新 */ 22 | if (newVersion) { 23 | moveWindowToTop(); 24 | getCurrentWebContents().send('detect-new-app-version', newVersion); 25 | } 26 | } 27 | 28 | export async function updateApp(newVersion: UpdateInformationResource) { 29 | logger.info('更新 : ' + JSON.stringify(newVersion)); 30 | const { tag, url } = newVersion; 31 | const appPath = app.getAppPath(); 32 | /** 日志路径 */ 33 | const logPath = join(appPath, `../update-${tag}.log`); 34 | /** 安装路径 */ 35 | const dest = join(appPath, `../app-${tag}.zip`); 36 | /** 解压路径 */ 37 | const unzipDest = join(appPath, './'); 38 | /** 删除app */ 39 | rmSync(unzipDest, { recursive: true, force: true }); 40 | 41 | /** 添加日志 */ 42 | writeFileSync(logPath, JSON.stringify(Object.assign(newVersion, { dest, unzipDest }), null, 4)); 43 | 44 | logger.info('更新文件 : ' + dest); 45 | logger.info('解压路径 : ' + unzipDest); 46 | try { 47 | /** 下载最新版本 */ 48 | await downloadFile(url, dest, (rate: any, totalLength: any, chunkLength: any) => { 49 | getCurrentWebContents().send('update-download', rate, totalLength, chunkLength); 50 | }); 51 | 52 | /** 解压缩 */ 53 | const zip = new AdmZip(dest); 54 | 55 | new Promise((resolve) => { 56 | zip.extractAllTo(unzipDest, true); 57 | resolve(); 58 | }) 59 | .then(() => { 60 | // @ts-ignore 61 | dialog.showMessageBox(null, { 62 | title: 'OCS更新程序', 63 | message: '更新完毕,即将重启软件...', 64 | type: 'warning', 65 | noLink: true 66 | }); 67 | setTimeout(() => { 68 | app.relaunch(); 69 | app.quit(); 70 | }, 1000); 71 | }) 72 | .catch((err) => logger.error('更新失败', err)); 73 | } catch (e) { 74 | logger.error('更新失败', e); 75 | // @ts-ignore 76 | const { response } = await dialog.showMessageBox(null, { 77 | title: 'OCS更新程序', 78 | message: 'OCS更新失败:\n' + e, 79 | type: 'error', 80 | noLink: true, 81 | defaultId: 1, 82 | buttons: ['继续使用', '复制错误日志'] 83 | }); 84 | if (response === 1) { 85 | clipboard.writeText(String(e)); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/app/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, app, dialog } from 'electron'; 2 | import path from 'path'; 3 | import AdmZip from 'adm-zip'; 4 | import axios from 'axios'; 5 | import { createWriteStream, existsSync, mkdirSync } from 'fs'; 6 | import { finished } from 'stream/promises'; 7 | import { Logger } from '../logger'; 8 | import xlsx from 'xlsx'; 9 | import unzipper from 'unzipper'; 10 | 11 | const taskLogger = Logger('task'); 12 | const logger = Logger('utils'); 13 | 14 | export async function task(name: string, func: any) { 15 | const time = Date.now(); 16 | const res = await func(); 17 | taskLogger.info(name, ' 耗时:', Date.now() - time); 18 | return res; 19 | } 20 | 21 | /** 22 | * 下载文件 23 | */ 24 | export async function downloadFile(fileURL: string, outputURL: string, rateHandler: any) { 25 | logger.info('downloadFile', fileURL, outputURL); 26 | 27 | const { data, headers } = await axios.get(fileURL, { 28 | responseType: 'stream' 29 | }); 30 | const totalLength = parseInt(headers['content-length']); 31 | 32 | let chunkLength = 0; 33 | data.on('data', (chunk: any) => { 34 | chunkLength += String(chunk).length; 35 | const rate = ((chunkLength / totalLength) * 100).toFixed(2); 36 | rateHandler(parseFloat(rate), totalLength, chunkLength); 37 | }); 38 | 39 | // 创建文件夹 40 | if (existsSync(path.dirname(outputURL)) === false) { 41 | mkdirSync(path.dirname(outputURL), { recursive: true }); 42 | } 43 | 44 | const writer = createWriteStream(outputURL); 45 | data.pipe(writer); 46 | await finished(writer); 47 | rateHandler(100, totalLength, totalLength); 48 | } 49 | 50 | /** 51 | * 压缩文件 52 | */ 53 | 54 | export function zip(input: string, output: string) { 55 | return new Promise((resolve, reject) => { 56 | const zip = new AdmZip(); 57 | zip.addLocalFile(input, './'); 58 | zip.writeZip(output, (err: any) => { 59 | if (err) { 60 | reject(err); 61 | } else { 62 | resolve(); 63 | } 64 | }); 65 | }); 66 | } 67 | 68 | /** 69 | * 解压文件 70 | */ 71 | 72 | export async function unzip(input: string, output: string) { 73 | const directory = await unzipper.Open.file(input); 74 | await directory.extract({ path: output }); 75 | } 76 | 77 | export function getProjectPath() { 78 | /** 这里多退出一层是因为打包后是运行在 ./lib 下面的 */ 79 | return app.isPackaged ? app.getAppPath() : path.resolve('./'); 80 | } 81 | 82 | /** 83 | * 导出excel 84 | */ 85 | export function exportExcel(excel: { sheetName: string; list: any[] }[], filename: string) { 86 | dialog 87 | .showSaveDialog({ 88 | title: '导出Excel', 89 | defaultPath: filename 90 | }) 91 | .then(({ canceled, filePath }) => { 92 | if (!canceled && filePath) { 93 | const book = xlsx.utils.book_new(); 94 | for (const item of excel) { 95 | xlsx.utils.book_append_sheet(book, xlsx.utils.json_to_sheet(item.list), item.sheetName); 96 | } 97 | xlsx.writeFile(book, filePath); 98 | } 99 | }); 100 | } 101 | 102 | export function moveWindowToTop() { 103 | const win = BrowserWindow.getAllWindows()[0]; 104 | // 置顶应用 105 | const onTop = win.isAlwaysOnTop(); 106 | win.setAlwaysOnTop(true); 107 | win.setAlwaysOnTop(onTop); 108 | return win; 109 | } 110 | 111 | export function getCurrentWebContents() { 112 | return BrowserWindow.getAllWindows()[0].webContents; 113 | } 114 | 115 | export function sleep(ms: number) { 116 | return new Promise((resolve) => { 117 | setTimeout(() => { 118 | resolve(true); 119 | }, ms); 120 | }); 121 | } 122 | -------------------------------------------------------------------------------- /packages/app/src/utils/ocr.ts: -------------------------------------------------------------------------------- 1 | import { writeFile, mkdirSync, existsSync, rmSync, writeFileSync } from 'fs'; 2 | import path from 'path'; 3 | import { randomUUID } from 'crypto'; 4 | import child_process from 'child_process'; 5 | import { Logger } from '../logger'; 6 | import { store } from '../store'; 7 | 8 | const getOcrFolder = () => path.join(store.store.paths.downloadFolder, './apps/ocr'); 9 | 10 | const logger = Logger('ocr'); 11 | 12 | /** 13 | * 使用 ddddocr 进行验证码识别 14 | * @param base64 图片 base64 15 | */ 16 | export function ocr(base64: string) { 17 | return new Promise((resolve, reject) => { 18 | const uuid = randomUUID(); 19 | const img_cache = path.join(getOcrFolder(), './img_cache'); 20 | if (!existsSync(img_cache)) { 21 | mkdirSync(img_cache, { recursive: true }); 22 | } 23 | const img = path.join(img_cache, uuid + '.png'); 24 | writeFile(img, base64, 'base64', () => { 25 | // 要使用 "" 去包裹路径,防止出现空格 26 | const cmd = [`".${path.join(getOcrFolder(), getOCRFileName())}"`, '--ocr', `"${img}"`].join(' '); 27 | logger.log('cmd', cmd); 28 | 29 | child_process.exec(cmd, (err, stdout, stderr) => { 30 | if (err || stderr) { 31 | reject(err || stderr); 32 | } else { 33 | resolve(stdout.trim()); 34 | } 35 | // 删除图片 36 | if (existsSync(img)) { 37 | rmSync(img); 38 | } 39 | }); 40 | }); 41 | }); 42 | } 43 | 44 | /** 45 | * 使用 ddddocr 进行滑块识别 46 | * 47 | * @param det_target_base64 滑块图片 48 | * @param det_bg_base64 滑块背景图片 49 | * 50 | * target_y: 滑块高度 51 | * target: [x1,y1,x2,y2] 52 | */ 53 | export function det(det_target_base64: string, det_bg_base64: string) { 54 | return new Promise<{ 55 | target_y: number; 56 | target: [number, number, number, number]; 57 | }>((resolve, reject) => { 58 | const img_cache = path.join(getOcrFolder(), './img_cache'); 59 | if (!existsSync(img_cache)) { 60 | mkdirSync(img_cache, { recursive: true }); 61 | } 62 | const img1 = path.join(img_cache, randomUUID() + '.png'); 63 | const img2 = path.join(img_cache, randomUUID() + '.png'); 64 | writeFileSync(img1, det_target_base64, 'base64'); 65 | writeFileSync(img2, det_bg_base64, 'base64'); 66 | 67 | const cmd = [ 68 | `"${path.join(getOcrFolder(), getOCRFileName())}"`, 69 | '--det-target', 70 | `"${img1}"`, 71 | '--det-bg', 72 | `"${img2}"` 73 | ].join(' '); 74 | 75 | child_process.exec(cmd, (err, stdout, stderr) => { 76 | if (err || stderr) { 77 | reject(err || stderr); 78 | } else { 79 | resolve(JSON.parse(stdout.trim().replace(/'/g, '"'))); 80 | } 81 | // 删除图片 82 | if (existsSync(img1)) { 83 | rmSync(img1); 84 | } 85 | if (existsSync(img2)) { 86 | rmSync(img2); 87 | } 88 | }); 89 | }); 90 | } 91 | 92 | /** 判断是否能够进行验证码识别 */ 93 | export function canOCR() { 94 | return existsSync(path.join(getOcrFolder(), getOCRFileName())); 95 | } 96 | 97 | function getOCRFileName() { 98 | return process.platform === 'win32' ? './ocr.exe' : './ocr'; 99 | } 100 | -------------------------------------------------------------------------------- /packages/app/src/window.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { BrowserWindow, shell } from 'electron'; 3 | import path from 'path'; 4 | 5 | export function createWindow() { 6 | const win = new BrowserWindow({ 7 | title: 'ocs', 8 | icon: path.resolve('./public/favicon.ico'), 9 | minWidth: 700, 10 | minHeight: 400, 11 | width: 900, 12 | height: 600, 13 | center: true, 14 | hasShadow: true, 15 | autoHideMenuBar: true, 16 | titleBarStyle: 'hidden', 17 | titleBarOverlay: { 18 | color: 'white', 19 | symbolColor: 'black' 20 | }, 21 | frame: false, 22 | show: false, 23 | webPreferences: { 24 | zoomFactor: 1, 25 | // 关闭拼写矫正 26 | spellcheck: false, 27 | webSecurity: true, 28 | // 开启node 29 | nodeIntegration: true, 30 | contextIsolation: false 31 | } 32 | }); 33 | 34 | win.webContents.on('will-navigate', (event, url) => { 35 | event.preventDefault(); 36 | shell.openExternal(url); 37 | }); 38 | 39 | win.webContents.setWindowOpenHandler((detail) => { 40 | shell.openExternal(detail.url); 41 | return { 42 | action: 'deny' 43 | }; 44 | }); 45 | 46 | return win; 47 | } 48 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "target": "es2016", 5 | "module": "commonjs", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "outDir": "./lib", 11 | "declaration": true 12 | }, 13 | "include": ["**/*.ts"], 14 | "exclude": ["node_modules", "dist", "extensions", "**/*.spec.ts", "**/*.d.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /packages/app/types.ts: -------------------------------------------------------------------------------- 1 | import { OriginalAppStore } from './src/store'; 2 | import { ScriptWorker } from './src/worker'; 3 | 4 | export { RemoteMethods } from './src/tasks/remote.register'; 5 | export type AppStore = typeof OriginalAppStore; 6 | 7 | export { ScriptWorker }; 8 | 9 | export interface UserScripts { 10 | id: number; 11 | /** 用户脚本链接 */ 12 | url: string; 13 | /** 启动自动安装脚本 */ 14 | enable: boolean; 15 | /** 16 | * 脚本信息 17 | */ 18 | info: any; 19 | /** 是否为本地脚本 */ 20 | isLocalScript: boolean; 21 | /** 是否为网络链接加载的脚本 */ 22 | isInternetLinkScript: boolean; 23 | } 24 | -------------------------------------------------------------------------------- /packages/common/.gitignore: -------------------------------------------------------------------------------- 1 | # customize 2 | 3 | ## 油猴拓展文件 4 | extensions/** 5 | extensions 6 | *.local.test.js 7 | 8 | pnpm-lock.yaml 9 | package-lock.json 10 | 11 | # vue build 12 | 13 | public 14 | 15 | # electron build 16 | 17 | dist 18 | 19 | # Logs 20 | logs 21 | *.log 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | lerna-debug.log* 26 | .pnpm-debug.log* 27 | 28 | # Diagnostic reports (https://nodejs.org/api/report.html) 29 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 30 | 31 | # Runtime data 32 | pids 33 | *.pid 34 | *.seed 35 | *.pid.lock 36 | 37 | # Directory for instrumented libs generated by jscoverage/JSCover 38 | lib-cov 39 | 40 | # Coverage directory used by tools like istanbul 41 | coverage 42 | *.lcov 43 | 44 | # nyc test coverage 45 | .nyc_output 46 | 47 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 48 | .grunt 49 | 50 | # Bower dependency directory (https://bower.io/) 51 | bower_components 52 | 53 | # node-waf configuration 54 | .lock-wscript 55 | 56 | # Compiled binary addons (https://nodejs.org/api/addons.html) 57 | build/Release 58 | 59 | # Dependency directories 60 | node_modules/ 61 | jspm_packages/ 62 | 63 | # Snowpack dependency directory (https://snowpack.dev/) 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | *.tsbuildinfo 68 | 69 | # Optional npm cache directory 70 | .npm 71 | 72 | # Optional eslint cache 73 | .eslintcache 74 | 75 | # Optional stylelint cache 76 | .stylelintcache 77 | 78 | # Microbundle cache 79 | .rpt2_cache/ 80 | .rts2_cache_cjs/ 81 | .rts2_cache_es/ 82 | .rts2_cache_umd/ 83 | 84 | # Optional REPL history 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | *.tgz 89 | 90 | # Yarn Integrity file 91 | .yarn-integrity 92 | 93 | # dotenv environment variable files 94 | .env 95 | .env.development.local 96 | .env.test.local 97 | .env.production.local 98 | .env.local 99 | 100 | # parcel-bundler cache (https://parceljs.org/) 101 | .cache 102 | .parcel-cache 103 | 104 | # Next.js build output 105 | .next 106 | out 107 | 108 | # Nuxt.js build / generate output 109 | .nuxt 110 | dist 111 | 112 | # Gatsby files 113 | .cache/ 114 | # Comment in the public line in if your project uses Gatsby and not Next.js 115 | # https://nextjs.org/blog/next-9-1#public-directory-support 116 | # public 117 | 118 | # vuepress build output 119 | .vuepress/dist 120 | 121 | # vuepress v2.x temp and cache directory 122 | .temp 123 | .cache 124 | 125 | # Docusaurus cache and generated files 126 | .docusaurus 127 | 128 | # Serverless directories 129 | .serverless/ 130 | 131 | # FuseBox cache 132 | .fusebox/ 133 | 134 | # DynamoDB Local files 135 | .dynamodb/ 136 | 137 | # TernJS port file 138 | .tern-port 139 | 140 | # Stores VSCode versions used for testing VSCode extensions 141 | .vscode-test 142 | 143 | # yarn v2 144 | .yarn/cache 145 | .yarn/unplugged 146 | .yarn/build-state.yml 147 | .yarn/install-state.gz 148 | .pnp.* -------------------------------------------------------------------------------- /packages/common/index.ts: -------------------------------------------------------------------------------- 1 | export { getValidBrowsers } from './src/utils/valid.browser'; 2 | export * from './src/api'; 3 | export { StringUtils } from './src/utils/string'; 4 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocs-desktop/common", 3 | "version": "0.0.1", 4 | "description": "common package of ocs-desktop", 5 | "main": "./lib/index.js", 6 | "types": "./lib/index.d.ts", 7 | "scripts": {}, 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/ocsjs/ocs-desktop.git" 11 | }, 12 | "keywords": [ 13 | "ocs", 14 | "script", 15 | "playwright", 16 | "puppeteer", 17 | "electron", 18 | "vue", 19 | "ant-design-vue", 20 | "typescript" 21 | ], 22 | "author": "enncy", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/ocsjs/ocs-desktop/issues" 26 | }, 27 | "homepage": "https://github.com/ocsjs/ocs-desktop#readme", 28 | "dependencies": { 29 | "axios": "^0.25.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/common/src/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | /** 资源文件 */ 4 | export interface ResourceFile { 5 | /** 唯一ID */ 6 | id: string; 7 | name: string; 8 | url: string; 9 | description?: string; 10 | icon?: string; 11 | homepage?: string; 12 | platforms?: { 13 | // eslint-disable-next-line no-undef 14 | platform: NodeJS.Platform; 15 | url: string; 16 | }[]; 17 | } 18 | 19 | /** 资源组 */ 20 | export interface ResourceGroup { 21 | /** 资源分组名,全英文,用于本地下载时文件夹分组 */ 22 | name: string; 23 | /** 资源组描述 */ 24 | description: string; 25 | /** 是否显示在应用中心页面 */ 26 | showInResourcePage: boolean; 27 | /** 文件列表 */ 28 | files: ResourceFile[]; 29 | } 30 | 31 | export interface ResourceLoaderOptions { 32 | /** 本地资源下载根目录 */ 33 | resourceRootPath: string; 34 | } 35 | 36 | /** 通知信息 */ 37 | export interface NotifyResource { 38 | id: string; 39 | content: string[]; 40 | } 41 | 42 | /** 版本更新信息 */ 43 | export interface UpdateInformationResource { 44 | tag: string; 45 | description: Record<'feat' | 'fix' | 'other', string[]>; 46 | url: string; 47 | } 48 | 49 | /** 官方书签信息 */ 50 | export interface BookmarkResource { 51 | values: { 52 | name: string; 53 | url: string; 54 | description?: string; 55 | icon?: string; 56 | }[]; 57 | group: string; 58 | } 59 | 60 | export interface Infos { 61 | userjs: { 62 | ocsjs: string; 63 | }; 64 | resourceGroups: ResourceGroup[]; 65 | bookmark: BookmarkResource[]; 66 | notify: NotifyResource[]; 67 | versions: UpdateInformationResource[]; 68 | } 69 | 70 | export class OCSApi { 71 | static async getInfos(): Promise { 72 | const { data } = await axios.get('https://cdn.ocsjs.com/api/ocs-app-infos.json?t=' + Date.now()); 73 | return data; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/common/src/interface.ts: -------------------------------------------------------------------------------- 1 | export interface ValidBrowser { 2 | name: string; 3 | path: string; 4 | } 5 | -------------------------------------------------------------------------------- /packages/common/src/utils/string.ts: -------------------------------------------------------------------------------- 1 | export class StringUtils { 2 | _text: string; 3 | constructor(_text: string) { 4 | this._text = _text; 5 | } 6 | 7 | /** 删除换行符 */ 8 | static nowrap(str?: string) { 9 | return str?.replace(/\n/g, '') || ''; 10 | } 11 | 12 | nowrap() { 13 | this._text = StringUtils.nowrap(this._text); 14 | return this; 15 | } 16 | 17 | /** 删除特殊字符 */ 18 | static noSpecialChar(str?: string) { 19 | return str?.replace(/[^\w\s]/gi, '') || ''; 20 | } 21 | 22 | noSpecialChar() { 23 | this._text = StringUtils.noSpecialChar(this._text); 24 | return this; 25 | } 26 | 27 | /** 最大长度,剩余显示省略号 */ 28 | static max(str: string, len: number) { 29 | return str.length > len ? str.substring(0, len) + '...' : str; 30 | } 31 | 32 | max(len: number) { 33 | this._text = StringUtils.max(this._text, len); 34 | return this; 35 | } 36 | 37 | /** 隐藏字符串 */ 38 | static hide(str: string, start: number, end: number, replacer: string = '*') { 39 | // 从 start 到 end 中间的字符串全部替换成 replacer 40 | return str.substring(0, start) + str.substring(start, end).replace(/./g, replacer) + str.substring(end); 41 | } 42 | 43 | hide(start: number, end: number, replacer: string = '*') { 44 | this._text = StringUtils.hide(this._text, start, end, replacer); 45 | return this; 46 | } 47 | 48 | static of(text: string) { 49 | return new StringUtils(text); 50 | } 51 | 52 | toString() { 53 | return this._text; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/common/src/utils/valid.browser.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { ValidBrowser } from '../interface'; 4 | import os from 'os'; 5 | import 'electron'; 6 | 7 | // 获取可用浏览器路径 8 | export function getValidBrowsers(): ValidBrowser[] { 9 | switch (os.platform()) { 10 | case 'darwin': { 11 | return []; 12 | } 13 | case 'win32': { 14 | return [ 15 | { 16 | name: '软件内置-谷歌浏览器(Chrome)', 17 | path: resolveBrowserPath('bin\\chrome\\chrome\\chrome.exe') 18 | }, 19 | { 20 | name: '微软浏览器(Microsoft Edge)', 21 | path: resolveBrowserPath('Microsoft\\Edge\\Application\\msedge.exe') 22 | }, 23 | { 24 | name: '谷歌浏览器(Chrome)', 25 | path: resolveBrowserPath('Google\\Chrome\\Application\\chrome.exe') 26 | } 27 | ].filter((b) => b.path) as ValidBrowser[]; 28 | } 29 | default: { 30 | return []; 31 | } 32 | } 33 | } 34 | 35 | function resolveBrowserPath(commonPath: string) { 36 | return [ 37 | join(process.resourcesPath, commonPath), 38 | // @ts-ignore 39 | join(process.env.ProgramFiles, commonPath), 40 | // @ts-ignore 41 | join(process.env['ProgramFiles(x86)'], commonPath), 42 | join('C:\\Program Files', commonPath), 43 | join('C:\\Program Files (x86)', commonPath) 44 | ].find((p) => existsSync(p)); 45 | } 46 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "strict": true, 6 | "outDir": "./lib", 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "declaration": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/web/.gitignore: -------------------------------------------------------------------------------- 1 | # customize 2 | 3 | pnpm-lock.yaml 4 | package-lock.json 5 | 6 | # rollup-plugin-visualizer 7 | stats.html 8 | 9 | 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | .pnpm-debug.log* 18 | 19 | # Diagnostic reports (https://nodejs.org/api/report.html) 20 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | lib-cov 30 | 31 | # Coverage directory used by tools like istanbul 32 | coverage 33 | *.lcov 34 | 35 | # nyc test coverage 36 | .nyc_output 37 | 38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | bower_components 43 | 44 | # node-waf configuration 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | build/Release 49 | 50 | # Dependency directories 51 | node_modules/ 52 | jspm_packages/ 53 | 54 | # Snowpack dependency directory (https://snowpack.dev/) 55 | web_modules/ 56 | 57 | # TypeScript cache 58 | *.tsbuildinfo 59 | 60 | # Optional npm cache directory 61 | .npm 62 | 63 | # Optional eslint cache 64 | .eslintcache 65 | 66 | # Optional stylelint cache 67 | .stylelintcache 68 | 69 | # Microbundle cache 70 | .rpt2_cache/ 71 | .rts2_cache_cjs/ 72 | .rts2_cache_es/ 73 | .rts2_cache_umd/ 74 | 75 | # Optional REPL history 76 | .node_repl_history 77 | 78 | # Output of 'npm pack' 79 | *.tgz 80 | 81 | # Yarn Integrity file 82 | .yarn-integrity 83 | 84 | # dotenv environment variable files 85 | .env 86 | .env.development.local 87 | .env.test.local 88 | .env.production.local 89 | .env.local 90 | 91 | # parcel-bundler cache (https://parceljs.org/) 92 | .cache 93 | .parcel-cache 94 | 95 | # Next.js build output 96 | .next 97 | out 98 | 99 | # Nuxt.js build / generate output 100 | .nuxt 101 | dist 102 | 103 | # Gatsby files 104 | .cache/ 105 | # Comment in the public line in if your project uses Gatsby and not Next.js 106 | # https://nextjs.org/blog/next-9-1#public-directory-support 107 | # public 108 | 109 | # vuepress build output 110 | .vuepress/dist 111 | 112 | # vuepress v2.x temp and cache directory 113 | .temp 114 | .cache 115 | 116 | # Docusaurus cache and generated files 117 | .docusaurus 118 | 119 | # Serverless directories 120 | .serverless/ 121 | 122 | # FuseBox cache 123 | .fusebox/ 124 | 125 | # DynamoDB Local files 126 | .dynamodb/ 127 | 128 | # TernJS port file 129 | .tern-port 130 | 131 | # Stores VSCode versions used for testing VSCode extensions 132 | .vscode-test 133 | 134 | # yarn v2 135 | .yarn/cache 136 | .yarn/unplugged 137 | .yarn/build-state.yml 138 | .yarn/install-state.gz 139 | .pnp.* -------------------------------------------------------------------------------- /packages/web/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + Typescript + Vite 2 | 3 | This template should help get you started developing with Vue 3 and Typescript in Vite. The template uses Vue 3 ` 27 | 28 | 29 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocs-desktop/web", 3 | "version": "0.0.1", 4 | "description": "desktop for userscript", 5 | "scripts": { 6 | "dev": "vite --port 3000", 7 | "build": "vue-tsc --noEmit && vite build --emptyOutDir", 8 | "build:watch": "vite build --watch", 9 | "serve": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@ocs-desktop/app": "workspace:^2.1.3", 13 | "@ocs-desktop/common": "workspace:^0.0.1", 14 | "@ocsjs/scripts": "^4.0.1", 15 | "@types/minimatch": "^6.0.0", 16 | "axios": "^0.25.0", 17 | "chalk": "4.1.0", 18 | "colorpicker-v3": "^2.10.2", 19 | "dayjs": "^1.10.7", 20 | "easy-us": "^0.0.11", 21 | "events": "^3.3.0", 22 | "markdown-it": "^13.0.1", 23 | "markdown-it-anchor": "^8.6.7", 24 | "markdown-it-container": "^3.0.0", 25 | "markdown-it-emoji": "^2.0.2", 26 | "marked": "^4.2.12", 27 | "material-icons": "^1.13.1", 28 | "ocsjs": "^4.8.13", 29 | "playwright-core": "^1.28.0", 30 | "video.js": "^8.0.4", 31 | "vue": "^3.3.2", 32 | "vue-router": "^4.0.12", 33 | "xlsx": "^0.17.5", 34 | "xterm": "^5.1.0", 35 | "xterm-addon-fit": "^0.7.0" 36 | }, 37 | "devDependencies": { 38 | "@arco-design/web-vue": "^2.42.0", 39 | "@types/markdown-it": "^12.2.3", 40 | "@types/markdown-it-container": "^2.0.5", 41 | "@types/markdown-it-emoji": "^2.0.2", 42 | "@types/marked": "^4.0.8", 43 | "@types/video.js": "^7.3.51", 44 | "@vitejs/plugin-vue": "^4.2.3", 45 | "@vue/compiler-sfc": "^3.2.6", 46 | "less": "^4.1.2", 47 | "less-loader": "^10.2.0", 48 | "rollup-plugin-visualizer": "^5.6.0", 49 | "typescript": "^4.3.2", 50 | "vite": "^3.2.6", 51 | "vite-plugin-commonjs": "^0.6.1", 52 | "vue-tsc": "^1.0.24" 53 | }, 54 | "repository": { 55 | "type": "git", 56 | "url": "git+https://github.com/ocsjs/ocs-desktop.git" 57 | }, 58 | "keywords": [ 59 | "ocs", 60 | "script", 61 | "playwright", 62 | "puppeteer", 63 | "electron", 64 | "vue", 65 | "ant-design-vue", 66 | "typescript" 67 | ], 68 | "author": "enncy", 69 | "license": "MIT", 70 | "bugs": { 71 | "url": "https://github.com/ocsjs/ocs-desktop/issues" 72 | }, 73 | "homepage": "https://github.com/ocsjs/ocs-desktop#readme" 74 | } 75 | -------------------------------------------------------------------------------- /packages/web/public/favicon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ocsjs/ocs-desktop/5e4c4be6ea4533d67d807aa00e3439d67448c3fb/packages/web/public/favicon.icns -------------------------------------------------------------------------------- /packages/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ocsjs/ocs-desktop/5e4c4be6ea4533d67d807aa00e3439d67448c3fb/packages/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ocsjs/ocs-desktop/5e4c4be6ea4533d67d807aa00e3439d67448c3fb/packages/web/public/favicon.png -------------------------------------------------------------------------------- /packages/web/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 23 | -------------------------------------------------------------------------------- /packages/web/src/assets/css/common.css: -------------------------------------------------------------------------------- 1 | body, 2 | html, 3 | #app { 4 | margin: 0; 5 | padding: 0; 6 | width: 100%; 7 | height: 100%; 8 | max-width: 100%; 9 | max-height: 100%; 10 | min-width: 500px; 11 | min-height: 300px; 12 | } 13 | /** 兼容 win11 以下的软件不会自动带有阴影和边框 */ 14 | html.window-frame { 15 | border: 1px solid #e1e1e1; 16 | overflow: hidden; 17 | box-shadow: 0px 0px 12px #d3d3d3; 18 | } 19 | #app { 20 | font-family: Avenir, Helvetica, Arial, sans-serif; 21 | -webkit-font-smoothing: antialiased; 22 | -moz-osx-font-smoothing: grayscale; 23 | } 24 | .pointer { 25 | cursor: pointer; 26 | } 27 | /* 设置滚动条的样式 */ 28 | ::-webkit-scrollbar { 29 | width: 10px; 30 | height: 6px; 31 | } 32 | /* 滚动槽 */ 33 | ::-webkit-scrollbar-track { 34 | background: #ffffff00; 35 | } 36 | /* 滚动条滑块 */ 37 | ::-webkit-scrollbar-thumb { 38 | border-radius: 2px; 39 | background: rgba(0, 0, 0, 0.1); 40 | box-shadow: inset006pxrgba(0, 0, 0, 0.3); 41 | } 42 | /** 通知栏样式 */ 43 | .arco-notification { 44 | /** 主容器 */ 45 | /** 底部按钮 */ 46 | } 47 | .arco-notification .arco-notification-notice { 48 | padding: 12px; 49 | } 50 | .arco-notification .arco-notification-notice-btn { 51 | margin-top: 8px; 52 | } 53 | .arco-notification .arco-notification-notice-with-icon .arco-notification-notice-description { 54 | margin-left: 0; 55 | font-size: 12px; 56 | } 57 | .arco-notification .arco-notification-notice-with-icon .arco-notification-notice-message { 58 | margin-left: 36px; 59 | } 60 | .arco-notification .arco-notification-notice-description { 61 | overflow: auto; 62 | } 63 | .anticon { 64 | vertical-align: 0px !important; 65 | } 66 | .markdown { 67 | font-size: 14px; 68 | border: 1px solid #e6e6e6; 69 | border-radius: 4px; 70 | padding: 4px; 71 | } 72 | code { 73 | margin: 0px 2px !important; 74 | padding: 2px 4px !important; 75 | font-size: 90% !important; 76 | color: #323030; 77 | background-color: #eaeaea; 78 | border-radius: 4px; 79 | } 80 | em { 81 | background-color: yellow; 82 | font-weight: bold; 83 | } 84 | body .arco-tree li span.arco-tree-iconEle { 85 | vertical-align: unset; 86 | } 87 | body .arco-tree li .arco-tree-node-content-wrapper { 88 | padding: 0; 89 | } 90 | body .arco-tree li { 91 | padding: 0px 8px 0px 0px !important; 92 | text-overflow: hidden; 93 | overflow: hidden; 94 | white-space: nowrap; 95 | } 96 | body .arco-tree-title { 97 | padding: 4px 8px 4px 0px !important; 98 | } 99 | body .arco-tree * { 100 | animation-duration: 0s !important; 101 | animation: none !important; 102 | transition: none !important; 103 | } 104 | body .arco-tree-iconEle.arco-tree-icon__customize { 105 | width: 18px; 106 | } 107 | body .arco-tree li span.arco-tree-switcher { 108 | width: 18px; 109 | } 110 | body[arco-theme='dark'] { 111 | color: #cccccc; 112 | background-color: #2c2c2c; 113 | /** browser-panel toc */ 114 | /* 滚动槽 */ 115 | /* 滚动条滑块 */ 116 | } 117 | body[arco-theme='dark'] .bp-toc { 118 | color: #cccccc; 119 | background-color: #2c2c2c; 120 | } 121 | body[arco-theme='dark'] .text-secondary { 122 | color: #aaaaaa !important; 123 | } 124 | body[arco-theme='dark'] .title { 125 | color: #cccccc; 126 | background-color: #2c2c2c; 127 | border-bottom: 1px solid #4b4848; 128 | } 129 | body[arco-theme='dark'] #title ul li { 130 | color: #cccccc; 131 | } 132 | body[arco-theme='dark'] ::-webkit-scrollbar-track { 133 | background: #9693933a; 134 | } 135 | body[arco-theme='dark'] ::-webkit-scrollbar-thumb { 136 | background: #c9bfbf98; 137 | } 138 | body[arco-theme='dark'] a, 139 | body[arco-theme='dark'] a.link { 140 | color: #61a5e4 !important; 141 | } 142 | body[arco-theme='dark'] .border-end, 143 | body[arco-theme='dark'] .border-start, 144 | body[arco-theme='dark'] .border-bottom, 145 | body[arco-theme='dark'] .border-top { 146 | border-color: #4b4848 !important; 147 | } 148 | body[arco-theme='dark'] * { 149 | box-shadow: none !important; 150 | } 151 | body[arco-theme='dark'] .entity:hover { 152 | background: #3b3b3b !important; 153 | } 154 | body[arco-theme='dark'] .screenshot-item { 155 | border: 1px solid; 156 | border-color: #4b4848 !important; 157 | } 158 | body[arco-theme='dark'] .breadcrumb { 159 | background-color: #2c2c2c; 160 | border: 1px solid #4b4848; 161 | } 162 | body[arco-theme='dark'] .path-item { 163 | color: #cccccc; 164 | } 165 | body[arco-theme='dark'] .entity { 166 | border-bottom: 1px solid #4b4848; 167 | } 168 | body[arco-theme='dark'] .entity-properties { 169 | background-color: #4b4b4b; 170 | color: #aaaaaa; 171 | } 172 | body[arco-theme='dark'] .sider-item.active { 173 | background-color: #4b4b4b; 174 | } 175 | body[arco-theme='dark'] .sider { 176 | border-right: 1px solid #4b4848; 177 | } 178 | body[arco-theme='dark'] .sider .sider-item { 179 | border-left: 6px solid #2c2c2c; 180 | } 181 | body[arco-theme='dark'] .sider .sider-item.active { 182 | background-color: #4b4b4b; 183 | border-left: 6px solid #4898e2; 184 | } 185 | body[arco-theme='dark'] .ps-table .ps-body-cell { 186 | border: 1px solid #4b4848; 187 | } 188 | body[arco-theme='dark'] .ps-table .ps-body-cell .arco-input-wrapper { 189 | background: #4b4b4b; 190 | } 191 | body[arco-theme='dark'] .ps-table .arco-table-tr .ps-table-index { 192 | background-color: #4b4b4b; 193 | color: #aaaaaa; 194 | } 195 | .arco-trigger-popup { 196 | animation-duration: 0s !important; 197 | animation: none !important; 198 | transition: none !important; 199 | } 200 | .arco-trigger-popup * { 201 | animation-duration: 0s !important; 202 | animation: none !important; 203 | transition: none !important; 204 | } 205 | .arco-dropdown-option-content { 206 | font-size: 12px; 207 | line-height: 28px; 208 | } 209 | /** 简单模式下不要有头部间隔 */ 210 | .arco-modal-simple .arco-modal-header { 211 | margin-bottom: 0px; 212 | } 213 | /** 防止被标题栏遮挡 */ 214 | .arco-drawer-container, 215 | .arco-modal-container { 216 | top: 32px; 217 | } 218 | .arco-notification-list, 219 | .arco-message-list { 220 | top: 52px; 221 | } 222 | -------------------------------------------------------------------------------- /packages/web/src/assets/css/container.css: -------------------------------------------------------------------------------- 1 | .container-info { 2 | border-radius: 2px; 3 | border-left: 8px solid #1890ff; 4 | background-color: rgba(98, 180, 255, 0.19); 5 | padding: 1px 1px 1px 20px; 6 | margin: 12px 0px; 7 | } 8 | .container-warning { 9 | border-radius: 2px; 10 | border-left: 8px solid #faad14; 11 | background-color: rgba(252, 199, 96, 0.19); 12 | padding: 1px 1px 1px 20px; 13 | margin: 12px 0px; 14 | } 15 | .container-success { 16 | border-radius: 2px; 17 | border-left: 8px solid #52c41a; 18 | background-color: rgba(138, 215, 100, 0.19); 19 | padding: 1px 1px 1px 20px; 20 | margin: 12px 0px; 21 | } 22 | .container-error { 23 | border-radius: 2px; 24 | border-left: 8px solid #f5222d; 25 | background-color: rgba(248, 105, 113, 0.19); 26 | padding: 1px 1px 1px 20px; 27 | margin: 12px 0px; 28 | } 29 | .container-title { 30 | font-size: 18px; 31 | font-weight: bold; 32 | } 33 | .container-body { 34 | line-height: 5px; 35 | font-weight: 300; 36 | } 37 | -------------------------------------------------------------------------------- /packages/web/src/assets/css/markdown-text.css: -------------------------------------------------------------------------------- 1 | .markdown-text { 2 | /** 3 | 以下是 table 样式 4 | */ 5 | } 6 | .markdown-text details { 7 | border-radius: 2px; 8 | padding: 30px 0px 30px 20px; 9 | border-left: 8px solid #d4d4d4; 10 | background-color: rgba(222, 222, 222, 0.115); 11 | } 12 | .markdown-text img { 13 | max-width: 100%; 14 | } 15 | .markdown-text h3, 16 | .markdown-text h4, 17 | .markdown-text h5, 18 | .markdown-text h6 { 19 | font-weight: bold; 20 | } 21 | .markdown-text h1 code, 22 | .markdown-text h2 code, 23 | .markdown-text h3 code, 24 | .markdown-text h4 code, 25 | .markdown-text h5 code, 26 | .markdown-text h6 code { 27 | padding: 0.4rem 0.6rem; 28 | background-color: #f0f0f0; 29 | border-radius: 6px; 30 | font-size: 1.5rem; 31 | font-weight: bold; 32 | } 33 | .markdown-text h1:hover .header-anchor, 34 | .markdown-text h2:hover .header-anchor, 35 | .markdown-text h3:hover .header-anchor, 36 | .markdown-text h4:hover .header-anchor, 37 | .markdown-text h5:hover .header-anchor, 38 | .markdown-text h6:hover .header-anchor { 39 | display: inline-block; 40 | } 41 | .markdown-text p code, 42 | .markdown-text a code, 43 | .markdown-text span code, 44 | .markdown-text li code { 45 | padding: 0.2rem 0.5rem; 46 | background-color: #f0f0f0; 47 | border-radius: 6px; 48 | font-size: 1rem; 49 | font-weight: bold; 50 | } 51 | .markdown-text pre:not([class]) > code:not([class]) { 52 | display: block; 53 | padding: 20px; 54 | background-color: #f0f0f0; 55 | font-family: monospace; 56 | margin: 0px; 57 | } 58 | .markdown-text pre.hljs { 59 | padding: 20px; 60 | } 61 | .markdown-text .block-code:hover .line-suffix .code-lang { 62 | transition: all 0.3s ease-out; 63 | transform: translate(0px, 12px); 64 | opacity: 1; 65 | visibility: visible; 66 | } 67 | .markdown-text .block-code:hover .line-suffix .code-copy { 68 | transition: all 0.3s ease-out; 69 | transform: translate(0px, 12px); 70 | opacity: 1; 71 | visibility: visible; 72 | } 73 | .markdown-text .block-code .line-suffix { 74 | display: flex; 75 | width: 100%; 76 | justify-content: flex-end; 77 | height: 0px; 78 | } 79 | .markdown-text .block-code .line-suffix .code-lang { 80 | height: 14px; 81 | transition: all 0.3s ease-in; 82 | opacity: 0; 83 | visibility: hidden; 84 | background-color: #81818159; 85 | } 86 | .markdown-text .block-code .line-suffix .code-copy { 87 | height: 14px; 88 | transition: all 0.3s ease-in; 89 | opacity: 0; 90 | visibility: hidden; 91 | background-color: #81818159; 92 | } 93 | .markdown-text .block-code .line-suffix span { 94 | color: white; 95 | padding: 4px 4px 4px 4px; 96 | margin: 0px 10px 0px 10px; 97 | border-radius: 4px; 98 | cursor: pointer; 99 | -moz-user-select: none; 100 | -webkit-user-select: none; 101 | -ms-user-select: none; 102 | -khtml-user-select: none; 103 | user-select: none; 104 | } 105 | .markdown-text .block-code pre { 106 | margin-top: 4px; 107 | padding: 0px 20px 10px 0px; 108 | display: flex; 109 | } 110 | .markdown-text .block-code pre code { 111 | max-width: 100%; 112 | padding: 20px; 113 | padding-bottom: 10px; 114 | overflow: auto; 115 | } 116 | .markdown-text .block-code pre .line-count { 117 | padding: 20px; 118 | color: #9e9e9e; 119 | border-right: 1px solid #000000; 120 | -moz-user-select: none; 121 | -webkit-user-select: none; 122 | -ms-user-select: none; 123 | -khtml-user-select: none; 124 | user-select: none; 125 | } 126 | .markdown-text .header-anchor { 127 | display: none; 128 | } 129 | .markdown-text ::selection { 130 | color: white; 131 | background-color: #3368f4; 132 | } 133 | .markdown-text blockquote { 134 | border-radius: 2px; 135 | padding: 4px 0px 4px 20px; 136 | margin: 12px 0px; 137 | font-size: 14px; 138 | color: rgba(0, 0, 0, 0.65); 139 | border-left: 8px solid rgba(0, 0, 0, 0.25); 140 | background-color: rgba(222, 222, 222, 0.115); 141 | } 142 | .markdown-text blockquote p { 143 | margin-bottom: 0px; 144 | } 145 | .markdown-text table { 146 | border-spacing: 0px; 147 | text-indent: unset; 148 | box-sizing: unset; 149 | border-collapse: unset; 150 | } 151 | .markdown-text table tr th { 152 | font-weight: 700; 153 | background-color: #e5e7eb; 154 | } 155 | .markdown-text table tr:nth-child(even) { 156 | background-color: #f5f5f5; 157 | } 158 | .markdown-text th, 159 | .markdown-text td { 160 | border: 1px solid #ddd; 161 | padding: 10px; 162 | } 163 | .markdown-text hr { 164 | background: #e8e8e8; 165 | margin: 24px 0px 24px 0px; 166 | padding: 0px; 167 | border: unset; 168 | height: 1.5px; 169 | } 170 | .markdown-text a::selection { 171 | color: blue; 172 | } 173 | .markdown-text a { 174 | color: #2f9bff; 175 | } 176 | .markdown-text ul { 177 | margin-block-start: 0.5em; 178 | margin-block-end: 1em; 179 | padding-inline-start: 18px; 180 | } 181 | .markdown-text ul li { 182 | line-height: 26px; 183 | } 184 | .markdown-text .toc-li { 185 | display: block; 186 | } 187 | .markdown-text .none-select { 188 | -moz-user-select: none; 189 | -webkit-user-select: none; 190 | -ms-user-select: none; 191 | -khtml-user-select: none; 192 | user-select: none; 193 | } 194 | -------------------------------------------------------------------------------- /packages/web/src/assets/less/common.less: -------------------------------------------------------------------------------- 1 | body, 2 | html, 3 | #app { 4 | margin: 0; 5 | padding: 0; 6 | width: 100%; 7 | height: 100%; 8 | max-width: 100%; 9 | max-height: 100%; 10 | min-width: 500px; 11 | min-height: 300px; 12 | } 13 | 14 | /** 兼容 win11 以下的软件不会自动带有阴影和边框 */ 15 | html.window-frame { 16 | border: 1px solid #e1e1e1; 17 | overflow: hidden; 18 | box-shadow: 0px 0px 12px #d3d3d3; 19 | } 20 | 21 | #app { 22 | font-family: Avenir, Helvetica, Arial, sans-serif; 23 | -webkit-font-smoothing: antialiased; 24 | -moz-osx-font-smoothing: grayscale; 25 | } 26 | 27 | .pointer { 28 | cursor: pointer; 29 | } 30 | 31 | /* 设置滚动条的样式 */ 32 | ::-webkit-scrollbar { 33 | width: 10px; 34 | height: 6px; 35 | } 36 | 37 | /* 滚动槽 */ 38 | ::-webkit-scrollbar-track { 39 | background: #ffffff00; 40 | } 41 | 42 | /* 滚动条滑块 */ 43 | ::-webkit-scrollbar-thumb { 44 | border-radius: 2px; 45 | background: rgba(0, 0, 0, 0.1); 46 | box-shadow: inset006pxrgba(0, 0, 0, 0.3); 47 | } 48 | 49 | /** 通知栏样式 */ 50 | .arco-notification { 51 | /** 主容器 */ 52 | .arco-notification-notice { 53 | padding: 12px; 54 | } 55 | 56 | /** 底部按钮 */ 57 | .arco-notification-notice-btn { 58 | margin-top: 8px; 59 | } 60 | 61 | .arco-notification-notice-with-icon .arco-notification-notice-description { 62 | margin-left: 0; 63 | font-size: 12px; 64 | } 65 | 66 | .arco-notification-notice-with-icon .arco-notification-notice-message { 67 | margin-left: 36px; 68 | } 69 | 70 | .arco-notification-notice-description { 71 | overflow: auto; 72 | } 73 | } 74 | 75 | .anticon { 76 | vertical-align: 0px !important; 77 | } 78 | 79 | .markdown { 80 | font-size: 14px; 81 | border: 1px solid #e6e6e6; 82 | border-radius: 4px; 83 | padding: 4px; 84 | } 85 | 86 | code { 87 | margin: 0px 2px !important; 88 | padding: 2px 4px !important; 89 | font-size: 90% !important; 90 | color: #323030; 91 | background-color: #eaeaea; 92 | border-radius: 4px; 93 | } 94 | 95 | em { 96 | background-color: yellow; 97 | font-weight: bold; 98 | } 99 | 100 | body { 101 | .arco-tree li span.arco-tree-iconEle { 102 | vertical-align: unset; 103 | } 104 | 105 | .arco-tree li .arco-tree-node-content-wrapper { 106 | padding: 0; 107 | } 108 | 109 | .arco-tree li { 110 | padding: 0px 8px 0px 0px !important; 111 | text-overflow: hidden; 112 | overflow: hidden; 113 | white-space: nowrap; 114 | } 115 | 116 | .arco-tree-title { 117 | padding: 4px 8px 4px 0px !important; 118 | } 119 | 120 | .arco-tree * { 121 | animation-duration: 0s !important; 122 | animation: none !important; 123 | transition: none !important; 124 | } 125 | 126 | .arco-tree-iconEle.arco-tree-icon__customize { 127 | width: 18px; 128 | } 129 | 130 | .arco-tree li span.arco-tree-switcher { 131 | width: 18px; 132 | } 133 | } 134 | 135 | @night-color-primary: #4898e2; 136 | @night-color: #cccccc; 137 | @night-color-secondary: #aaaaaa; 138 | @night-bg-color: #2c2c2c; 139 | @night-bg-color-secondary: #4b4b4b; 140 | @night-active: #61a5e4; 141 | @night-border-color: #4b4848; 142 | 143 | body[arco-theme='dark'] { 144 | color: @night-color; 145 | background-color: @night-bg-color; 146 | 147 | /** browser-panel toc */ 148 | .bp-toc { 149 | color: @night-color; 150 | background-color: @night-bg-color; 151 | } 152 | 153 | .text-secondary { 154 | color: @night-color-secondary !important; 155 | } 156 | 157 | .title { 158 | color: @night-color; 159 | background-color: @night-bg-color; 160 | border-bottom: 1px solid @night-border-color; 161 | } 162 | 163 | #title { 164 | ul li { 165 | color: @night-color; 166 | } 167 | } 168 | 169 | /* 滚动槽 */ 170 | ::-webkit-scrollbar-track { 171 | background: #9693933a; 172 | } 173 | 174 | /* 滚动条滑块 */ 175 | ::-webkit-scrollbar-thumb { 176 | background: #c9bfbf98; 177 | } 178 | 179 | a, 180 | a.link { 181 | color: @night-active !important; 182 | } 183 | 184 | // 边框颜色 185 | .border-end, 186 | .border-start, 187 | .border-bottom, 188 | .border-top { 189 | border-color: @night-border-color !important; 190 | } 191 | 192 | // 取消所有悬浮效果 193 | * { 194 | box-shadow: none !important; 195 | } 196 | 197 | // 鼠标选择 198 | .entity:hover { 199 | background: #3b3b3b !important; 200 | } 201 | 202 | // 截图边框 203 | .screenshot-item { 204 | border: 1px solid; 205 | border-color: @night-border-color !important; 206 | } 207 | 208 | // 目录路径栏 209 | .breadcrumb { 210 | background-color: @night-bg-color; 211 | border: 1px solid @night-border-color; 212 | } 213 | .path-item { 214 | color: @night-color; 215 | } 216 | 217 | // 实体 218 | .entity { 219 | border-bottom: 1px solid @night-border-color; 220 | } 221 | 222 | .entity-properties { 223 | background-color: @night-bg-color-secondary; 224 | color: @night-color-secondary; 225 | } 226 | 227 | // 侧边栏 228 | .sider-item.active { 229 | background-color: @night-bg-color-secondary; 230 | } 231 | 232 | .sider { 233 | border-right: 1px solid @night-border-color; 234 | 235 | .sider-item { 236 | border-left: 6px solid @night-bg-color; 237 | } 238 | .sider-item.active { 239 | background-color: @night-bg-color-secondary; 240 | border-left: 6px solid @night-color-primary; 241 | } 242 | } 243 | 244 | // 批量创建表格 245 | .ps-table { 246 | .ps-body-cell { 247 | border: 1px solid @night-border-color; 248 | 249 | .arco-input-wrapper { 250 | background: @night-bg-color-secondary; 251 | } 252 | } 253 | 254 | .arco-table-tr { 255 | .ps-table-index { 256 | background-color: @night-bg-color-secondary; 257 | 258 | color: @night-color-secondary; 259 | } 260 | } 261 | } 262 | } 263 | 264 | .arco-trigger-popup { 265 | animation-duration: 0s !important; 266 | animation: none !important; 267 | transition: none !important; 268 | 269 | * { 270 | animation-duration: 0s !important; 271 | animation: none !important; 272 | transition: none !important; 273 | } 274 | } 275 | 276 | .arco-dropdown-option-content { 277 | font-size: 12px; 278 | line-height: 28px; 279 | } 280 | 281 | /** 简单模式下不要有头部间隔 */ 282 | .arco-modal-simple .arco-modal-header { 283 | margin-bottom: 0px; 284 | } 285 | 286 | /** 防止被标题栏遮挡 */ 287 | .arco-drawer-container, 288 | .arco-modal-container { 289 | top: 32px; 290 | } 291 | 292 | .arco-notification-list, 293 | .arco-message-list { 294 | top: 52px; 295 | } 296 | -------------------------------------------------------------------------------- /packages/web/src/assets/less/container.less: -------------------------------------------------------------------------------- 1 | @primary-color: #1890ff; // 全局主色 2 | @link-color: #1890ff; // 链接色 3 | @success-color: #52c41a; // 成功色 4 | @warning-color: #faad14; // 警告色 5 | @error-color: #f5222d; // 错误色 6 | @heading-color: rgba(0, 0, 0, 0.85); // 标题色 7 | @text-color: rgba(0, 0, 0, 0.65); // 主文本色 8 | @text-color-secondary: rgba(0, 0, 0, 0.45); // 次文本色 9 | @disabled-color: rgba(0, 0, 0, 0.25); // 失效色 10 | @border-color-base: #d9d9d9; // 边框色 11 | 12 | 13 | .container{ 14 | .create(info,@primary-color); 15 | .create(warning,@warning-color); 16 | .create(success,@success-color); 17 | .create(error,@error-color); 18 | 19 | 20 | .create(@name,@color){ 21 | &-@{name}{ 22 | border-radius: 2px; 23 | border-left: 8px solid @color; 24 | background-color: mix(@color,rgba(255,255,255,0.1),10%); 25 | padding: 1px 1px 1px 20px; 26 | margin: 12px 0px; 27 | } 28 | } 29 | 30 | &-title{ 31 | font-size: 18px; 32 | font-weight: bold; 33 | } 34 | 35 | &-body { 36 | line-height: 5px; 37 | font-weight: 300; 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /packages/web/src/assets/less/markdown-text.less: -------------------------------------------------------------------------------- 1 | @text-color: rgba(0, 0, 0, 0.65); // 主文本色 2 | @text-color-secondary: rgba(0, 0, 0, 0.45); // 次文本色 3 | @disabled-color: rgba(0, 0, 0, 0.25); // 失效色 4 | @primary-color: #1890ff; // 全局主色 5 | 6 | .markdown-text { 7 | details { 8 | border-radius: 2px; 9 | padding: 30px 0px 30px 20px; 10 | border-left: 8px solid rgb(212, 212, 212); 11 | background-color: mix(@disabled-color, rgba(255, 255, 255, 0.1), 10%); 12 | } 13 | 14 | img { 15 | max-width: 100%; 16 | } 17 | 18 | h3, 19 | h4, 20 | h5, 21 | h6 { 22 | font-weight: bold; 23 | } 24 | 25 | // 标题中的 inline code 26 | h1, 27 | h2, 28 | h3, 29 | h4, 30 | h5, 31 | h6 { 32 | code { 33 | padding: 0.4rem 0.6rem; 34 | background-color: #f0f0f0; 35 | border-radius: 6px; 36 | font-size: 1.5rem; 37 | font-weight: bold; 38 | } 39 | 40 | &:hover .header-anchor { 41 | display: inline-block; 42 | } 43 | } 44 | 45 | // Inline code 46 | p, 47 | a, 48 | span, 49 | li { 50 | code { 51 | padding: 0.2rem 0.5rem; 52 | background-color: #f0f0f0; 53 | border-radius: 6px; 54 | font-size: 1rem; 55 | font-weight: bold; 56 | } 57 | } 58 | 59 | // Indented code 60 | pre:not([class]) > code:not([class]) { 61 | display: block; 62 | padding: 20px; 63 | background-color: #f0f0f0; 64 | font-family: monospace; 65 | margin: 0px; 66 | } 67 | 68 | // Block code 69 | pre.hljs { 70 | padding: 20px; 71 | } 72 | 73 | // Block code 74 | .block-code { 75 | &:hover { 76 | .line-suffix { 77 | .code-lang { 78 | transition: all 0.3s ease-out; 79 | transform: translate(0px, 12px); 80 | opacity: 1; 81 | visibility: visible; 82 | } 83 | .code-copy { 84 | transition: all 0.3s ease-out; 85 | transform: translate(0px, 12px); 86 | opacity: 1; 87 | visibility: visible; 88 | } 89 | } 90 | } 91 | 92 | .line-suffix { 93 | display: flex; 94 | width: 100%; 95 | justify-content: flex-end; 96 | height: 0px; 97 | .code-lang { 98 | height: 14px; 99 | transition: all 0.3s ease-in; 100 | opacity: 0; 101 | visibility: hidden; 102 | background-color: #81818159; 103 | } 104 | 105 | .code-copy { 106 | height: 14px; 107 | transition: all 0.3s ease-in; 108 | opacity: 0; 109 | visibility: hidden; 110 | background-color: #81818159; 111 | } 112 | 113 | span { 114 | color: white; 115 | padding: 4px 4px 4px 4px; 116 | margin: 0px 10px 0px 10px; 117 | border-radius: 4px; 118 | cursor: pointer; 119 | .none-select(); 120 | } 121 | } 122 | 123 | pre { 124 | margin-top: 4px; 125 | padding: 0px 20px 10px 0px; 126 | display: flex; 127 | 128 | code { 129 | max-width: 100%; 130 | padding: 20px; 131 | padding-bottom: 10px; 132 | overflow: auto; 133 | } 134 | 135 | // 行数 136 | .line-count { 137 | padding: 20px; 138 | color: #9e9e9e; 139 | border-right: 1px solid rgb(0, 0, 0); 140 | .none-select(); 141 | } 142 | } 143 | } 144 | 145 | // 标签钩子样式 146 | .header-anchor { 147 | display: none; 148 | } 149 | 150 | // 文本选择样式 151 | ::selection { 152 | color: white; 153 | background-color: #3368f4; 154 | } 155 | 156 | // > 157 | blockquote { 158 | border-radius: 2px; 159 | padding: 4px 0px 4px 20px; 160 | margin: 12px 0px; 161 | font-size: 14px; 162 | color: @text-color; 163 | border-left: 8px solid @disabled-color; 164 | background-color: mix(@disabled-color, rgba(255, 255, 255, 0.1), 10%); 165 | 166 | p { 167 | margin-bottom: 0px; 168 | } 169 | } 170 | 171 | /** 172 | 以下是 table 样式 173 | */ 174 | table { 175 | border-spacing: 0px; 176 | text-indent: unset; 177 | box-sizing: unset; 178 | border-collapse: unset; 179 | } 180 | 181 | // 标题样式 182 | table tr th { 183 | font-weight: 700; 184 | background-color: #e5e7eb; 185 | } 186 | 187 | // 偶数样式 188 | table tr:nth-child(even) { 189 | background-color: #f5f5f5; 190 | } 191 | 192 | th, 193 | td { 194 | border: 1px solid #ddd; 195 | padding: 10px; 196 | } 197 | 198 | // 分割线样式 199 | hr { 200 | background: #e8e8e8; 201 | margin: 24px 0px 24px 0px; 202 | padding: 0px; 203 | border: unset; 204 | height: 1.5px; 205 | } 206 | 207 | a::selection { 208 | color: blue; 209 | } 210 | 211 | a { 212 | color: mix(@primary-color, rgb(255, 255, 255), 90%); 213 | } 214 | 215 | ul { 216 | li { 217 | line-height: 26px; 218 | } 219 | margin-block-start: 0.5em; 220 | margin-block-end: 1em; 221 | padding-inline-start: 18px; 222 | } 223 | 224 | .toc-li { 225 | display: block; 226 | } 227 | 228 | .none-select { 229 | -moz-user-select: none; 230 | -webkit-user-select: none; 231 | -ms-user-select: none; 232 | -khtml-user-select: none; 233 | user-select: none; 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /packages/web/src/components/BrowserList.vue: -------------------------------------------------------------------------------- 1 | 104 | 105 | 120 | 121 | 132 | -------------------------------------------------------------------------------- /packages/web/src/components/BrowserPanelOperators.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /packages/web/src/components/Card.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 28 | 29 | 39 | -------------------------------------------------------------------------------- /packages/web/src/components/CommonEditActionDropdown.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 100 | 101 | 112 | -------------------------------------------------------------------------------- /packages/web/src/components/CommonSelector.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 100 | 101 | 114 | -------------------------------------------------------------------------------- /packages/web/src/components/Description.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 39 | 40 | 51 | -------------------------------------------------------------------------------- /packages/web/src/components/Entity.vue: -------------------------------------------------------------------------------- 1 | 93 | 94 | 135 | 176 | -------------------------------------------------------------------------------- /packages/web/src/components/EntityOperator.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /packages/web/src/components/Icon.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 39 | 40 | 45 | -------------------------------------------------------------------------------- /packages/web/src/components/MarkdownText.vue: -------------------------------------------------------------------------------- 1 | 2 | 8 | 52 | 53 | -------------------------------------------------------------------------------- /packages/web/src/components/OCSConfigs.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 205 | 206 | 212 | -------------------------------------------------------------------------------- /packages/web/src/components/Path.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /packages/web/src/components/Tags.vue: -------------------------------------------------------------------------------- 1 | 93 | 179 | 180 | 192 | -------------------------------------------------------------------------------- /packages/web/src/components/TitleLink.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /packages/web/src/components/XTerm.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 46 | 47 | 58 | -------------------------------------------------------------------------------- /packages/web/src/components/browsers/BrowserOperators.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 98 | 99 | 104 | -------------------------------------------------------------------------------- /packages/web/src/components/browsers/FileBreadcrumb.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 22 | 23 | 38 | -------------------------------------------------------------------------------- /packages/web/src/components/browsers/FileFilters.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /packages/web/src/components/browsers/FileOperators.vue: -------------------------------------------------------------------------------- 1 | 93 | 94 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /packages/web/src/components/playwright-scripts/PlaywrightScriptList.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 42 | 43 | 58 | -------------------------------------------------------------------------------- /packages/web/src/components/playwright-scripts/PlaywrightScriptSelector.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 74 | 75 | 88 | -------------------------------------------------------------------------------- /packages/web/src/components/playwright-scripts/index.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightScript } from '@ocs-desktop/app/src/scripts/script'; 2 | 3 | export type RawPlaywrightScript = Pick; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/setting/BrowserPath.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /packages/web/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import { DefineComponent } from 'vue'; 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any>; 7 | 8 | export default component; 9 | } 10 | 11 | declare module '*.svg'; 12 | declare module '*.png'; 13 | declare module '*.jpg'; 14 | declare module '*.jpeg'; 15 | declare module '*.gif'; 16 | declare module '*.bmp'; 17 | declare module '*.tiff'; 18 | -------------------------------------------------------------------------------- /packages/web/src/fs/browser.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from 'vue'; 2 | import { store } from '../store'; 3 | import { Process, processes } from '../utils/process'; 4 | import { resetSearch } from '../utils/entity'; 5 | import { router } from '../route'; 6 | import { Entity } from './entity'; 7 | import { Folder, root } from './folder'; 8 | import { BrowserOptions, BrowserOperateHistory, Tag, BrowserType, EntityOptions } from './interface'; 9 | import { remote } from '../utils/remote'; 10 | import { RawPlaywrightScript } from '../components/playwright-scripts'; 11 | import { child_process } from '../utils/node'; 12 | 13 | export class Browser extends Entity implements BrowserOptions { 14 | type: BrowserType; 15 | tags: Tag[]; 16 | notes: string; 17 | checked: boolean; 18 | cachePath: string; 19 | histories: BrowserOperateHistory[]; 20 | parent: string; 21 | playwrightScripts: RawPlaywrightScript[]; 22 | 23 | constructor(opts: BrowserOptions & EntityOptions) { 24 | super(opts); 25 | this.type = 'browser'; 26 | this.tags = opts.tags; 27 | this.notes = opts.notes; 28 | this.checked = opts.checked; 29 | this.histories = opts.histories; 30 | this.parent = opts.parent; 31 | this.playwrightScripts = opts.playwrightScripts; 32 | this.cachePath = opts.cachePath; 33 | } 34 | 35 | /** 36 | * 获取浏览器文件 37 | */ 38 | static from(uid: string) { 39 | return root().find('browser', uid); 40 | } 41 | 42 | /** 启动浏览器 */ 43 | async launch() { 44 | const process = new Process(this, { 45 | executablePath: store.render.setting.launchOptions.executablePath, 46 | headless: false 47 | }); 48 | processes.push(process); 49 | const reactiveProcess = Process.from(this.uid); 50 | if (reactiveProcess) { 51 | await reactiveProcess.init(console.log); 52 | await reactiveProcess.launch(); 53 | } 54 | 55 | this.histories.unshift({ action: '运行', time: Date.now() }); 56 | } 57 | 58 | /** 59 | * 仅启动浏览器,不执行其他操作 60 | * 适用于模拟更真实的浏览器环境 61 | */ 62 | async onlyLaunch() { 63 | const extensionPaths: string[] = []; 64 | // @ts-ignore 65 | const paths: string[] = await remote.fs.call('readdirSync', store.paths.extensionsFolder); 66 | 67 | for (const file of paths) { 68 | extensionPaths.push(await remote.path.call('join', store.paths.extensionsFolder, file)); 69 | } 70 | const cmd = ` "${store.render.setting.launchOptions.executablePath}" ${[ 71 | '--window-position=0,0', 72 | '--no-first-run', 73 | '--no-default-browser-check', 74 | `--user-data-dir="${this.cachePath}"` 75 | ] 76 | .concat(formatExtensionArguments(extensionPaths)) 77 | .join(' ')} http://localhost:${store.server.port || 15319}/index.html#/bookmarks`; 78 | console.log(cmd); 79 | child_process.exec(cmd); 80 | } 81 | 82 | /** 重启浏览器 */ 83 | async relaunch() { 84 | const process = Process.from(this.uid); 85 | await process?.close(); 86 | /** 87 | * 因为 process.close 会杀死进程,并删除 process 实例 88 | * 所以重新创建 process 实例并启动 89 | */ 90 | await this.launch(); 91 | } 92 | 93 | /** 关闭浏览器 */ 94 | async close() { 95 | const process = Process.from(this.uid); 96 | await process?.close(); 97 | this.histories.unshift({ action: '关闭', time: Date.now() }); 98 | } 99 | 100 | /** 置顶浏览器 */ 101 | bringToFront() { 102 | const process = Process.from(this.uid); 103 | process?.bringToFront(); 104 | } 105 | 106 | location(): void { 107 | // 进入列表页 108 | router.push('/'); 109 | // 关闭搜索模式 110 | resetSearch(); 111 | // 设置当前文件夹 112 | store.render.browser.currentFolderUid = this.parent; 113 | nextTick(() => { 114 | store.render.browser.currentBrowserUid = this.uid; 115 | this.select(); 116 | }); 117 | } 118 | 119 | select(): void { 120 | store.render.browser.currentBrowserUid = this.uid; 121 | } 122 | 123 | async remove() { 124 | // 如果在运行,关闭当前浏览器 125 | const process = Process.from(this.uid); 126 | if (process) { 127 | await process.close(); 128 | } 129 | 130 | const parent = Folder.from(this.parent); 131 | Reflect.deleteProperty(parent?.children || {}, this.uid); 132 | 133 | const exists = await remote.fs.call('existsSync', this.cachePath); 134 | if (exists) { 135 | // 删除本地缓存 136 | await remote.fs.call('rmSync', this.cachePath, { recursive: true }); 137 | } 138 | } 139 | 140 | rename(name: string): void { 141 | if (this.name !== name) { 142 | this.histories.unshift({ action: '改名', content: `${this.name} => ${name}`, time: Date.now() }); 143 | } 144 | this.name = name; 145 | this.renaming = false; 146 | } 147 | 148 | async cleanCache() { 149 | try { 150 | await remote.fs.call('rmSync', this.cachePath, { recursive: true, force: true }); 151 | } catch (err) { 152 | console.log(err); 153 | } 154 | } 155 | } 156 | 157 | function formatExtensionArguments(extensionPaths: string[]) { 158 | const paths = extensionPaths 159 | .filter((f) => f.includes('.DS_Store') === false) 160 | .map((p) => p.replace(/\\/g, '/')) 161 | .join(','); 162 | return [`--load-extension="${paths}"`]; 163 | } 164 | -------------------------------------------------------------------------------- /packages/web/src/fs/entity.ts: -------------------------------------------------------------------------------- 1 | import { EntityOptions, EntityTypes } from './interface'; 2 | 3 | export abstract class Entity implements EntityOptions { 4 | /** 原响应式对象 */ 5 | abstract type: EntityTypes; 6 | uid: string; 7 | name: string; 8 | createTime: number; 9 | renaming: boolean; 10 | 11 | constructor(opts: EntityOptions) { 12 | this.uid = opts.uid; 13 | this.name = opts.name; 14 | this.createTime = opts.createTime; 15 | this.renaming = opts.renaming; 16 | } 17 | 18 | static uuid() { 19 | return uuid().replace(/-/g, ''); 20 | } 21 | 22 | /** 定位 */ 23 | abstract location(...args: any[]): void; 24 | /** 重命名 */ 25 | abstract rename(...args: any[]): void; 26 | /** 删除 */ 27 | abstract remove(...args: any[]): void; 28 | /** 选择 */ 29 | abstract select(...args: any[]): void; 30 | } 31 | 32 | function uuid() { 33 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 34 | const r = (Math.random() * 16) | 0; 35 | const v = c === 'x' ? r : (r & 0x3) | 0x8; 36 | return v.toString(16); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /packages/web/src/fs/folder.ts: -------------------------------------------------------------------------------- 1 | import { Browser } from './browser'; 2 | import { router } from '../route'; 3 | import { store } from '../store'; 4 | import { resetSearch } from '../utils/entity'; 5 | import { Entity } from './entity'; 6 | import { FolderOptions, BrowserType, EntityTypes, FolderType, EntityOptions } from './interface'; 7 | import { reactive, watch } from 'vue'; 8 | 9 | export class Folder extends Entity implements FolderOptions { 10 | type: T; 11 | parent: T extends 'root' ? undefined : string; 12 | children: Record = {}; 13 | 14 | constructor(opts: FolderOptions & EntityOptions) { 15 | super(opts); 16 | this.type = opts.type; 17 | this.parent = opts.parent; 18 | this.children = opts.children; 19 | 20 | for (const key in this.children) { 21 | if (Object.prototype.hasOwnProperty.call(this.children, key)) { 22 | const child = this.children[key]; 23 | if (child.type === 'browser') { 24 | this.children[key] = new Browser(child); 25 | } else { 26 | this.children[key] = new Folder(child); 27 | } 28 | } 29 | } 30 | } 31 | 32 | listChildren(): (Browser | Folder)[] { 33 | return Object.values(this.children); 34 | } 35 | 36 | /** 37 | * 获取文件夹 38 | */ 39 | static from(uid: string) { 40 | return uid === root().uid ? root() : root().find('folder', uid); 41 | } 42 | 43 | /** 递归查找 */ 44 | find(type: T, uid: string): T extends BrowserType ? Browser : Folder { 45 | let target = this.children[uid]; 46 | if (target && target.type === type) { 47 | return target as any; 48 | } else { 49 | for (const key in this.children) { 50 | if (Object.prototype.hasOwnProperty.call(this.children, key)) { 51 | const child = this.children[key]; 52 | if (child.type === 'folder') { 53 | const res = child.find(type, uid); 54 | target = target || res; 55 | } 56 | } 57 | } 58 | } 59 | 60 | return target as any; 61 | } 62 | 63 | findAll(handler: (entity: E) => boolean): E[] { 64 | const list: E[] = []; 65 | 66 | for (const key in this.children) { 67 | if (Object.prototype.hasOwnProperty.call(this.children, key)) { 68 | const child = this.children[key]; 69 | if (handler(child as any)) { 70 | list.push(child as any); 71 | } 72 | if (child.type === 'folder') { 73 | list.push(...child.findAll(handler)); 74 | } 75 | } 76 | } 77 | 78 | return list; 79 | } 80 | 81 | /** 返回当前以及所以父文件夹 */ 82 | flatParents(): Folder[] { 83 | let parents; 84 | if (this.parent) { 85 | const parent = Folder.from(this.parent); 86 | if (parent) { 87 | parents = parent.flatParents(); 88 | } 89 | } 90 | return [...(parents || []), this]; 91 | } 92 | 93 | location(): void { 94 | // 进入列表页 95 | router.push('/'); 96 | // 关闭搜索模式 97 | resetSearch(); 98 | // 设置当前文件夹 99 | store.render.browser.currentFolderUid = this.uid; 100 | } 101 | 102 | select(): void { 103 | store.render.browser.currentFolderUid = this.uid; 104 | } 105 | 106 | remove(): void { 107 | if (this.parent) { 108 | const parent = Folder.from(this.parent); 109 | 110 | if (parent) { 111 | Reflect.deleteProperty(parent.children, this.uid); 112 | } 113 | } 114 | } 115 | 116 | rename(name: string): void { 117 | this.name = name; 118 | this.renaming = false; 119 | } 120 | } 121 | 122 | let _root: Folder | undefined; 123 | 124 | /** 根目录 */ 125 | export const root = () => { 126 | if (_root) { 127 | return _root; 128 | } else { 129 | return (_root = reactive(new Folder(store.render.browser.root))); 130 | } 131 | }; 132 | 133 | /** 实时存储文件树 */ 134 | watch( 135 | () => _root, 136 | () => { 137 | store.render.browser.root = JSON.parse(JSON.stringify(root)); 138 | } 139 | ); 140 | -------------------------------------------------------------------------------- /packages/web/src/fs/index.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue'; 2 | import { store } from '../store'; 3 | import { root } from './folder'; 4 | 5 | /** 当前所在的文件夹 */ 6 | export const currentFolder = computed(() => root().find('folder', store.render.browser.currentFolderUid) || root()); 7 | 8 | /** 当前文件夹的子文件 */ 9 | export const currentEntities = computed(() => currentFolder.value.listChildren()); 10 | 11 | /** 当前编辑的浏览器 */ 12 | export const currentBrowser = computed(() => root().find('browser', store.render.browser.currentBrowserUid)); 13 | 14 | /** 当前搜索到的实体 */ 15 | export const currentSearchedEntities = computed(() => { 16 | if (store.render.browser.search.value === '' && store.render.browser.search.tags.length === 0) { 17 | return undefined; 18 | } else { 19 | return root().findAll( 20 | (e) => 21 | (store.render.browser.search.value !== '' && 22 | // 判断文件名 23 | (e.name.indexOf(store.render.browser.search.value) !== -1 || 24 | // 判断备注 25 | (e.type === 'browser' && e.notes.indexOf(store.render.browser.search.value) !== -1))) || 26 | (store.render.browser.search.tags.length !== 0 && 27 | // 判断标签 28 | store.render.browser.search.tags.some((t) => e.type === 'browser' && e.tags.some((et) => et.name === t))) 29 | ); 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /packages/web/src/fs/interface.ts: -------------------------------------------------------------------------------- 1 | import { RawPlaywrightScript } from '../components/playwright-scripts'; 2 | 3 | export type FolderType = 'folder' | 'root'; 4 | export type BrowserType = 'browser'; 5 | export type EntityTypes = BrowserType | FolderType; 6 | 7 | /** 实体 */ 8 | export interface EntityOptions { 9 | type: EntityTypes; 10 | /** 实体id */ 11 | uid: string; 12 | /** 实体名 */ 13 | name: string; 14 | /** 创建时间 */ 15 | createTime: number; 16 | /** 是否重命名中 */ 17 | renaming: boolean; 18 | } 19 | 20 | export interface Tag { 21 | /** 标签名字 */ 22 | name: string; 23 | /** 颜色 */ 24 | color: string; 25 | } 26 | 27 | /** 28 | * 浏览器操作历史记录 29 | */ 30 | export interface BrowserOperateHistory { 31 | action: '运行' | '重启' | '关闭' | '创建' | '改名' | '添加标签' | '删除标签' | '备注'; 32 | content?: string; 33 | time: number; 34 | } 35 | 36 | /** 浏览器 */ 37 | export interface BrowserOptions extends EntityOptions { 38 | type: BrowserType; 39 | parent: string; 40 | /** 浏览器标签 */ 41 | tags: Tag[]; 42 | /** 浏览器备注 */ 43 | notes: string; 44 | /** 是否选中 */ 45 | checked: boolean; 46 | /** 缓存路径 */ 47 | cachePath: string; 48 | renaming: boolean; 49 | /** 历史 */ 50 | histories: BrowserOperateHistory[]; 51 | /** 自动化脚本列表 */ 52 | playwrightScripts: RawPlaywrightScript[]; 53 | } 54 | 55 | /** 56 | * 浏览器文件夹 57 | */ 58 | export interface FolderOptions extends EntityOptions { 59 | type: T; 60 | parent: T extends 'root' ? undefined : string; 61 | children: Record; 62 | } 63 | -------------------------------------------------------------------------------- /packages/web/src/main.ts: -------------------------------------------------------------------------------- 1 | import { remote } from './utils/remote'; 2 | import { createApp } from 'vue'; 3 | import ArcoVue, { Icon } from '@arco-design/web-vue'; 4 | import ArcoVueIcon from '@arco-design/web-vue/es/icon'; 5 | import App from './App.vue'; 6 | import '@arco-design/web-vue/dist/arco.css'; 7 | import { router } from './route'; 8 | import { notify } from './utils/notify'; 9 | import 'material-icons/iconfont/material-icons.css'; 10 | 11 | window.addEventListener('error', function (e) { 12 | console.error(e); 13 | if (e instanceof ErrorEvent) { 14 | if (errorFilter(e?.message || String(e) || '')) { 15 | return; 16 | } 17 | } 18 | 19 | remote.logger.call('error', '未知的错误', e); 20 | notify('未知的错误', e, 'render-error', { 21 | type: 'error', 22 | copy: true 23 | }); 24 | }); 25 | 26 | window.addEventListener('unhandledrejection', function (e) { 27 | e.promise.catch((e) => { 28 | console.error(e); 29 | try { 30 | if (errorFilter(e?.message || String(e) || '')) { 31 | return; 32 | } 33 | remote.logger.call('error', '未捕获的异步错误', e?.stack || e?.message || e || ''); 34 | notify('未捕获的异步错误', e, 'render-error', { 35 | type: 'error', 36 | copy: true 37 | }); 38 | } catch (e) { 39 | console.error(e); 40 | } 41 | }); 42 | }); 43 | 44 | function errorFilter(str: string) { 45 | // arco design 问题,暂时无需处理,复现方式,鼠标重复经过 tooltip 或者 dropdown , 打开 modal 都会出现 46 | if (str.includes('ResizeObserver loop')) { 47 | return true; 48 | } 49 | // operation not permitted, stat xxxxx CrashpadMetrics.pma , 这个是 playwright 问题,暂时无需处理 50 | if (str.includes('CrashpadMetrics')) { 51 | return true; 52 | } 53 | } 54 | 55 | createApp(App) 56 | .use(router) 57 | .use(ArcoVue) 58 | .use(ArcoVueIcon) 59 | .component('IconFont', Icon.addFromIconFontCn({ src: 'js/acro.font.js' })) 60 | .directive('focus', { 61 | mounted(el) { 62 | el.focus(); 63 | } 64 | }) 65 | .mount('#app'); 66 | -------------------------------------------------------------------------------- /packages/web/src/pages/browsers/index.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 85 | 86 | 105 | -------------------------------------------------------------------------------- /packages/web/src/route/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'; 2 | import { config } from '../config'; 3 | 4 | /** 5 | * 根据配置生成路由 6 | */ 7 | 8 | export type CustomRouteType = RouteRecordRaw & { 9 | name: string; 10 | path: string; 11 | component: any; 12 | meta: { 13 | icon: string; 14 | title: string; 15 | hideInMenu: boolean; 16 | tutorial: { 17 | step: number; 18 | placement: string; 19 | tooltip: string; 20 | }; 21 | }; 22 | }; 23 | 24 | export const routes = config.routes as CustomRouteType[]; 25 | 26 | export const router = createRouter({ 27 | history: createWebHashHistory(), 28 | routes 29 | }); 30 | 31 | router.beforeEach(() => { 32 | return true; 33 | }); 34 | -------------------------------------------------------------------------------- /packages/web/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue'; 2 | import { remote } from '../utils/remote'; 3 | import defaultsDeep from 'lodash/defaultsDeep'; 4 | import type { AppStore, UserScripts } from '@ocs-desktop/app'; 5 | import { CommonUserScript } from '../types/user.script'; 6 | import { FolderOptions } from '../fs/interface'; 7 | import { Browser } from '../fs/browser'; 8 | import { Folder } from '../fs/folder'; 9 | import { inBrowser } from '../utils/node'; 10 | 11 | export type StoreUserScript = { info?: CommonUserScript } & Omit; 12 | 13 | export type WebStore = { 14 | scripts: StoreUserScript[]; 15 | notifies: any[]; 16 | 17 | browser: { 18 | currentFolderUid: string; 19 | currentBrowserUid: string; 20 | /** 根目录 */ 21 | root: FolderOptions<'root', Browser | Folder>; 22 | tags: Record; 23 | search: { 24 | /** 名字或者备注搜素 */ 25 | value: string; 26 | tags: string[]; 27 | }; 28 | }; 29 | dashboard: { 30 | /** 显示标签和备注 */ 31 | details: { 32 | tags: boolean; 33 | notes: boolean; 34 | }; 35 | /** 列数控制 */ 36 | num: number; 37 | /** 视频设置 */ 38 | video: { 39 | /** 横纵比 */ 40 | aspectRatio?: number; 41 | }; 42 | }; 43 | setting: { 44 | browserType: 'diy' | 'local' | 'setup'; 45 | /** 是否显示侧边栏文字 */ 46 | showSideBarText: boolean; 47 | /** 浏览器启动参数 */ 48 | launchOptions: { 49 | executablePath: string; 50 | }; 51 | /** 当前的主题 */ 52 | theme: { 53 | dark: boolean; 54 | }; 55 | /** ocs 特殊配置 */ 56 | ocs: { 57 | /** 当前配置名 */ 58 | currentProjectName: string; 59 | /** 全局配置 */ 60 | store: any; 61 | /** 是否同步OCS配置 */ 62 | openSync: boolean; 63 | }; 64 | browser: { 65 | /** 浏览器缓存大小预警阈值(GB) */ 66 | cachesSizeWarningPoint: number; 67 | /** 是否启用浏览器原版对话框 */ 68 | enableDialog: boolean; 69 | /** 是否强制更新/安装脚本 */ 70 | forceUpdateScript: boolean; 71 | }; 72 | }; 73 | 74 | langs: Record; 75 | state: { 76 | /** 是否第一次打开 */ 77 | first: boolean; 78 | /** 是否展示一键安装 */ 79 | setup: boolean; 80 | mini: boolean; 81 | responsive: 'mini' | 'small'; 82 | height: number; 83 | /** 已读的提示记录 */ 84 | read_record: Record; 85 | }; 86 | }; 87 | 88 | const _store: AppStore & { render: WebStore } = defaultsDeep( 89 | inBrowser ? JSON.parse(localStorage.getItem('ocs-app-store') || '{}') : remote['electron-store'].get('store'), 90 | { 91 | render: { 92 | scripts: [], 93 | notifies: [], 94 | browser: { 95 | currentFolderUid: '', 96 | currentBrowserUid: '', 97 | root: { 98 | name: '根目录', 99 | parent: undefined, 100 | createTime: Date.now(), 101 | type: 'root', 102 | uid: 'root-folder', 103 | children: {}, 104 | renaming: false 105 | }, 106 | tags: {}, 107 | search: { 108 | value: '', 109 | tags: [], 110 | results: undefined 111 | } 112 | }, 113 | dashboard: { 114 | details: { 115 | tags: false, 116 | notes: false 117 | }, 118 | num: 4, 119 | video: { 120 | aspectRatio: 0 121 | } 122 | }, 123 | setting: { 124 | browserType: 'diy', 125 | showSideBarText: true, 126 | launchOptions: { 127 | executablePath: '' 128 | }, 129 | theme: { 130 | dark: false 131 | }, 132 | ocs: { 133 | currentProjectName: '', 134 | store: {}, 135 | openSync: false 136 | }, 137 | browser: { 138 | cachesSizeWarningPoint: 10, 139 | enableDialog: false, 140 | forceUpdateScript: false 141 | } 142 | }, 143 | langs: {}, 144 | state: { 145 | first: true, 146 | setup: true, 147 | mini: false, 148 | responsive: 'small', 149 | height: document.documentElement.clientHeight, 150 | read_record: {} 151 | } 152 | } as WebStore 153 | } 154 | ); 155 | 156 | // 解密数据 157 | if (typeof _store.render === 'string') { 158 | try { 159 | const data = JSON.parse( 160 | remote.methods.callSync( 161 | 'decryptString', 162 | // @ts-ignore 163 | _store.render 164 | ) 165 | ); 166 | console.log(data); 167 | // 解密 168 | Reflect.set(_store, 'render', data); 169 | } catch (e) { 170 | console.error('数据解密失败:' + e); 171 | } 172 | } 173 | 174 | /** 数据存储对象 */ 175 | export const store: AppStore & { render: WebStore } = reactive(_store); 176 | 177 | console.log('store', store); 178 | // @ts-ignore 179 | window.store = store; 180 | 181 | /** 根目录 */ 182 | export const files = reactive([]); 183 | 184 | /** 打开的文件 */ 185 | export const openedFiles = reactive(new Map()); 186 | 187 | export function lang(key: string, def?: string, params?: Record) { 188 | let text = store.render.langs[key] || key; 189 | if (!text && def) { 190 | text = def; 191 | } 192 | if (params) { 193 | Object.keys(params).forEach((k) => { 194 | text = text.replace(new RegExp(`{{${k}}}`, 'g'), params[k]); 195 | }); 196 | } 197 | return text; 198 | } 199 | -------------------------------------------------------------------------------- /packages/web/src/types/search.ts: -------------------------------------------------------------------------------- 1 | import { CommonUserScript, ScriptSearchEngineType, ScriptVersionProvider } from './user.script'; 2 | 3 | export interface ScriptSearchEngine { 4 | type: ScriptSearchEngineType; 5 | name: string; 6 | homepage: string; 7 | search: (keyword: string, page: number, size: number) => Promise; 8 | infoGetter: (script: CommonUserScript) => Promise; 9 | transformToCommonByInfo: (script: CommonUserScript, info: T) => CommonUserScript; 10 | versionProvider: ScriptVersionProvider; 11 | } 12 | -------------------------------------------------------------------------------- /packages/web/src/types/user.script.ts: -------------------------------------------------------------------------------- 1 | import { StoreUserScript } from '../store'; 2 | 3 | export type ScriptSearchEngineType = 'greasyfork' | 'scriptcat'; 4 | export type ScriptSourceType = 'unknown' | 'official' | ScriptSearchEngineType; 5 | 6 | export interface CommonUserScript { 7 | id: number; 8 | url: string; 9 | name: string; 10 | code_url: string; 11 | license: string; 12 | version: string; 13 | description: string; 14 | authors: { 15 | name: string; 16 | url: string; 17 | avatar?: string; 18 | }[]; 19 | /** 评分 */ 20 | ratings: number; 21 | daily_installs: number; 22 | total_installs: number; 23 | create_time: number; 24 | update_time: number; 25 | } 26 | 27 | export interface GreasyForkUserScript { 28 | id: number; 29 | created_at: string; 30 | daily_installs: number; 31 | total_installs: number; 32 | code_updated_at: string; 33 | bad_ratings: number; 34 | ok_ratings: number; 35 | good_ratings: number; 36 | users: { 37 | id: number; 38 | name: string; 39 | url: string; 40 | }[]; 41 | name: string; 42 | description: string; 43 | url: string; 44 | code_url: string; 45 | license: string; 46 | version: string; 47 | } 48 | 49 | export interface ScriptCatUserScript { 50 | username: string; 51 | avatar: string; 52 | user_id: number; 53 | score: number; 54 | score_num?: number; 55 | script: { 56 | meta: string; 57 | meta_json: Record; 58 | script_id: number; 59 | version: string; 60 | }; 61 | id: number; 62 | name: string; 63 | description: string; 64 | today_install: number; 65 | total_install: number; 66 | createtime: number; 67 | updatetime: number; 68 | } 69 | 70 | export interface LocalUserScript { 71 | path: string; 72 | filename: string; 73 | createtime: number; 74 | } 75 | 76 | export interface ScriptVersion { 77 | version: string; 78 | url: string; 79 | code_url: string; 80 | create_time: number; 81 | } 82 | 83 | export type ScriptVersionProvider = (script: StoreUserScript) => Promise; 84 | -------------------------------------------------------------------------------- /packages/web/src/utils/entity.ts: -------------------------------------------------------------------------------- 1 | import { store } from '../store'; 2 | 3 | export function resetSearch() { 4 | store.render.browser.search.value = ''; 5 | store.render.browser.search.tags = []; 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/src/utils/extension.ts: -------------------------------------------------------------------------------- 1 | import { Message, Modal } from '@arco-design/web-vue'; 2 | import { remote } from './remote'; 3 | import { resourceLoader } from './resources.loader'; 4 | import { notify } from './notify'; 5 | import { ResourceFile } from '@ocs-desktop/common/src/api'; 6 | 7 | type Extension = ResourceFile & { 8 | installed?: boolean; 9 | }; 10 | 11 | // 下载拓展 12 | export async function installExtensions(extensions: Extension[], extension: Extension) { 13 | if (extensions.filter((e) => e.installed).length > 0) { 14 | Message.success({ 15 | content: `脚本管理器 ${extension.name} 已下载`, 16 | duration: 10 * 1000 17 | }); 18 | } else { 19 | await resourceLoader.download('extensions', extension); 20 | 21 | notify('文件解压', `${extension.name} 解压中...`, 'download-file-' + extension.name, { 22 | type: 'info', 23 | duration: 0 24 | }); 25 | 26 | await resourceLoader.unzip('extensions', extension); 27 | 28 | notify('文件下载', `${extension.name} 下载完成!`, 'download-file-' + extension.name, { 29 | type: 'success', 30 | duration: 3000 31 | }); 32 | 33 | extension.installed = true; 34 | 35 | Modal.confirm({ 36 | title: '提示', 37 | content: '安装脚本管理器后需要重启才能生效。', 38 | okText: '立刻重启', 39 | cancelText: '稍等自行重启', 40 | onOk: async () => { 41 | await remote.app.call('relaunch'); 42 | await remote.app.call('exit', 0); 43 | } 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/web/src/utils/ipc.ts: -------------------------------------------------------------------------------- 1 | import { UpdateInformationResource } from '@ocs-desktop/common'; 2 | import { store } from '../store'; 3 | import { electron } from '../utils/node'; 4 | import { closeAllBrowser } from './browser'; 5 | import { notify } from './notify'; 6 | import { Modal } from '@arco-design/web-vue'; 7 | import { h } from 'vue'; 8 | import { remote } from './remote'; 9 | const { ipcRenderer } = electron; 10 | 11 | export function activeIpcRenderListener() { 12 | ipcRenderer.on('close', () => closeAllBrowser(true)); 13 | 14 | /** 如果正在更新的话,获取更新进度 */ 15 | ipcRenderer.on('update-download', (e, rate, totalLength, chunkLength) => { 16 | notify( 17 | 'OCS更新程序', 18 | `更新中: ${(chunkLength / 1024 / 1024).toFixed(2)}MB/${(totalLength / 1024 / 1024).toFixed(2)}MB`, 19 | 'updater', 20 | { 21 | type: 'info', 22 | duration: 0, 23 | close: false 24 | } 25 | ); 26 | }); 27 | 28 | // 显示浏览器 29 | ipcRenderer.on('show-browser-in-app', (e, uid) => { 30 | store.render.browser.currentBrowserUid = uid; 31 | }); 32 | 33 | // 检测到新版本 34 | ipcRenderer.on('detect-new-app-version', (e, new_version: UpdateInformationResource) => { 35 | console.log('detect-new-app-version', new_version); 36 | if (!new_version) { 37 | return; 38 | } 39 | 40 | Modal.confirm({ 41 | title: '🎉检测到版本更新🎉', 42 | okText: '确认更新', 43 | cancelText: '下次一定', 44 | maskClosable: false, 45 | width: 500, 46 | async onOk() { 47 | await remote.methods.call('updateApp', new_version); 48 | }, 49 | content: () => 50 | h('div', [ 51 | h('div', '新版本 : ✨' + new_version.tag), 52 | h('div', '版本更新内容如下: '), 53 | h('div', [ 54 | ...(new_version.description.feat?.length 55 | ? [ 56 | h('div', '新增:'), 57 | h( 58 | 'ul', 59 | new_version.description.feat.map((feature) => h('li', feature)) 60 | ) 61 | ] 62 | : []), 63 | ...(new_version.description.fix?.length 64 | ? [ 65 | h('div', '修复:'), 66 | h( 67 | 'ul', 68 | new_version.description.fix.map((feature) => h('li', feature)) 69 | ) 70 | ] 71 | : []), 72 | ...(new_version.description.other?.length 73 | ? [ 74 | h('div', '其他:'), 75 | h( 76 | 'ul', 77 | new_version.description.other.map((feature) => h('li', feature)) 78 | ) 79 | ] 80 | : []) 81 | ]) 82 | ]) 83 | }); 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /packages/web/src/utils/markdown.container.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import MarkdownIt from 'markdown-it'; 3 | import container from 'markdown-it-container'; 4 | import Token from 'markdown-it/lib/token'; 5 | 6 | export enum CommonContainerNames { 7 | INFO = 'info', 8 | WARNING = 'warning', 9 | SUCCESS = 'success', 10 | ERROR = 'error' 11 | } 12 | 13 | export function markdownContainer(md: MarkdownIt) { 14 | createContainer({ 15 | md, 16 | name: 'code', 17 | renders: { 18 | open() { 19 | return '
点击查看代码\n'; 20 | }, 21 | close() { 22 | return '
\n'; 23 | } 24 | } 25 | }); 26 | 27 | // 创建全局通用容器 28 | createCommonContainer(md, CommonContainerNames.INFO); 29 | createCommonContainer(md, CommonContainerNames.WARNING); 30 | createCommonContainer(md, CommonContainerNames.SUCCESS); 31 | createCommonContainer(md, CommonContainerNames.ERROR); 32 | createContainer({ 33 | md, 34 | name: 'video', 35 | renders: { 36 | open(tokens: Token[], idx: number) { 37 | const [input, src] = tokens[idx].info.trim().match(/^video (.*)$/) || []; 38 | console.log(tokens[idx].info, [input, src]); 39 | 40 | return ``; 43 | }, 44 | close() { 45 | return ``; 46 | } 47 | } 48 | }); 49 | } 50 | 51 | /** 52 | * 创建通用容器的模板函数 53 | * @param name 容器名 54 | */ 55 | export function createCommonContainer(md: MarkdownIt, name: string | CommonContainerNames) { 56 | createContainer({ 57 | md, 58 | name, 59 | renders: { 60 | open(tokens: Token[], idx: number) { 61 | const m = tokens[idx].info.trim().match(RegExp(`^${name}\\s+(.*)$`)); 62 | return ` 63 |
64 |

${m?.[1] || ''}

65 |
66 | `; 67 | }, 68 | close() { 69 | return '
'; 70 | } 71 | } 72 | }); 73 | } 74 | 75 | export interface RenderOptions { 76 | open: (tokens: Token[], idx: number) => string; 77 | close: (tokens: Token[], idx: number) => string; 78 | } 79 | 80 | // 容器创建 81 | export function createContainer({ 82 | md, 83 | name, 84 | renders, 85 | maker = ':' 86 | }: { 87 | md: MarkdownIt; 88 | name: string | CommonContainerNames; 89 | renders: RenderOptions; 90 | maker?: string; 91 | }) { 92 | md.use(container, name, { 93 | maker, 94 | validate: (params: string) => RegExp(name).test(params.trim()), 95 | render: (tokens: Token[], idx: number) => { 96 | if (tokens[idx].nesting === 1) { 97 | // open tag 98 | return renders.open(tokens, idx); 99 | } else { 100 | // closing tag 101 | return renders.close(tokens, idx); 102 | } 103 | } 104 | }); 105 | } 106 | -------------------------------------------------------------------------------- /packages/web/src/utils/markdown.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | import Emoji from 'markdown-it-emoji'; 3 | import { markdownContainer } from '../utils/markdown.container'; 4 | 5 | // @ts-ignore full options list (defaults) 6 | export const markdownIt: MarkdownIt = MarkdownIt({ 7 | html: true, 8 | xhtmlOut: false, 9 | breaks: true, 10 | langPrefix: 'language-', 11 | linkify: true, 12 | typographer: true, 13 | quotes: '“”‘’' 14 | }); 15 | 16 | markdownIt 17 | // emoji 表情 18 | .use(Emoji) 19 | // 自定义 container 20 | .use(markdownContainer); 21 | -------------------------------------------------------------------------------- /packages/web/src/utils/node.ts: -------------------------------------------------------------------------------- 1 | import type Electron from 'electron'; 2 | import type childProcess from 'child_process'; 3 | 4 | // @ts-ignore 5 | export const inBrowser = typeof global === 'undefined'; 6 | 7 | if (inBrowser === false) { 8 | // @ts-ignore 9 | window.electron = require('electron'); 10 | // @ts-ignore 11 | window.child_process = require('child_process'); 12 | } 13 | 14 | // @ts-ignore 15 | export const electron: typeof Electron = window.electron || { 16 | ipcRenderer: { 17 | sendSync: () => {}, 18 | send: () => {}, 19 | on: () => {}, 20 | once: () => {} 21 | } 22 | }; 23 | // @ts-ignore 24 | export const child_process: typeof childProcess = window.child_process || {}; 25 | -------------------------------------------------------------------------------- /packages/web/src/utils/notify.ts: -------------------------------------------------------------------------------- 1 | import { Button, Notification } from '@arco-design/web-vue'; 2 | import { h, VNodeChild } from 'vue'; 3 | import { electron } from './node'; 4 | import { StringUtils } from '@ocs-desktop/common/src/utils/string'; 5 | const { clipboard } = electron; 6 | 7 | interface NotifyOptions { 8 | type?: 'error' | 'success' | 'info' | 'warning'; 9 | duration?: number; 10 | btn?: VNodeChild | undefined; 11 | copy?: boolean; 12 | close?: boolean; 13 | max_length?: number; 14 | } 15 | 16 | export function notify(title: string, msg: any, key: string, options?: NotifyOptions) { 17 | return Notification[options?.type || 'info']({ 18 | id: key, 19 | title, 20 | closable: true, 21 | style: { width: '400px' }, 22 | content: () => 23 | h('div', { 24 | title: msg?.stack || msg?.message || msg || '', 25 | innerHTML: StringUtils.max(String(msg), options?.max_length ?? 999) 26 | }), 27 | duration: options?.duration ?? (options?.type === 'error' ? 6000 : 3000), 28 | footer: () => 29 | options?.btn || 30 | h('div', [ 31 | options?.copy ? cerateCopyButton(title, msg, key, options) : '', 32 | options?.close ? createCloseButton(key) : '' 33 | ]) 34 | }); 35 | } 36 | 37 | /** 38 | * 创建关闭按钮 39 | */ 40 | function createCloseButton(key: string) { 41 | return h( 42 | Button, 43 | { 44 | type: 'primary', 45 | size: 'small', 46 | onClick: () => { 47 | Notification.remove(key); 48 | } 49 | }, 50 | '关闭' 51 | ); 52 | } 53 | 54 | /** 55 | * 创建复制信息按钮 56 | */ 57 | function cerateCopyButton(title: string, msg: any, key: string, options?: NotifyOptions) { 58 | return h( 59 | Button, 60 | { 61 | type: 'primary', 62 | size: 'small', 63 | onClick: () => { 64 | clipboard.writeText(title + '\n' + String(msg)); 65 | 66 | notify(title, msg, key, { 67 | ...options, 68 | ...{ 69 | btn: h( 70 | Button, 71 | { 72 | type: 'primary', 73 | size: 'small', 74 | disabled: true 75 | }, 76 | '复制成功√' 77 | ) 78 | } 79 | }); 80 | } 81 | }, 82 | '复制信息' 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /packages/web/src/utils/os.ts: -------------------------------------------------------------------------------- 1 | import { remote } from './remote'; 2 | 3 | /** 获取 windows 版本号 */ 4 | export async function getWindowsRelease() { 5 | const release = await remote.os.call('release'); 6 | 7 | if (release.startsWith('6.1')) { 8 | return 'win7'; 9 | } else if (parseInt(release.split('.').at(-1) || '0') > 22000) { 10 | return 'win11'; 11 | } else { 12 | return 'win10'; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/src/utils/remote.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, App, Dialog, WebContents, DesktopCapturer } from 'electron'; 2 | import { notify } from './notify'; 3 | import type { RemoteMethods } from '@ocs-desktop/app'; 4 | import type fs from 'fs'; 5 | import type os from 'os'; 6 | import type path from 'path'; 7 | import type crypto from 'crypto'; 8 | import type Store from 'electron-store'; 9 | import { electron } from './node'; 10 | import type { OCSApi } from '@ocs-desktop/common'; 11 | const { ipcRenderer } = electron; 12 | 13 | /** 14 | * 注册渲染进程和主进程的远程通信 15 | * @param eventName 16 | * @returns 17 | */ 18 | function registerRemote(eventName: string) { 19 | function sendSync(channel: string, ...args: any[]): any { 20 | const res = ipcRenderer.sendSync(channel, ...args); 21 | if (res?.error) { 22 | console.log(res); 23 | if (errorFilter(res.error)) { 24 | return; 25 | } 26 | notify('remote 模块错误', res.error, 'remote', { copy: true, type: 'error' }); 27 | } 28 | return res; 29 | } 30 | 31 | function send(channel: string, args: any[]): Promise { 32 | return new Promise((resolve, reject) => { 33 | ipcRenderer.once(args[0], (e: any, ...respondArgs) => { 34 | if (respondArgs[0].error) { 35 | console.log({ respondArgs, channel, args }); 36 | if (!errorFilter(respondArgs[0].error)) { 37 | notify('remote 模块错误', respondArgs[0].error, 'remote', { copy: true, type: 'error' }); 38 | } 39 | reject(String(respondArgs[0].error)); 40 | } else { 41 | resolve(respondArgs[0].data); 42 | } 43 | // console.log(args[1], args[2], respondArgs[0].data); 44 | }); 45 | ipcRenderer.send(channel, JSON.parse(JSON.stringify(args))); 46 | }); 47 | } 48 | 49 | return { 50 | /** 获取远程变量 */ 51 | get(property: K): T[K] extends { (...args: any[]): any } ? ReturnType : any { 52 | return sendSync(eventName + '-get', [property]); 53 | }, 54 | /** 设置远程变量 */ 55 | set(property: K, value: any): T[K] extends { (...args: any[]): any } ? ReturnType : any { 56 | return sendSync(eventName + '-set', [property, value]); 57 | }, 58 | 59 | /** 异步调用远程方法 */ 60 | call( 61 | property: K, 62 | ...args: T[K] extends { (...args: any[]): any } ? Parameters : any[] 63 | ): Promise : any>> { 64 | // 回调名 65 | const respondChannel = getRespondChannelId(property.toString()); 66 | return send(eventName + '-call', [respondChannel, property, ...args]); 67 | }, 68 | 69 | /** 同步调用远程方法 */ 70 | callSync( 71 | property: K, 72 | ...args: T[K] extends { (...args: any[]): any } ? Parameters : any[] 73 | ): T[K] extends { (...args: any[]): any } ? ReturnType : any { 74 | const response = sendSync(eventName + '-call-sync', [property, ...args]); 75 | if (response?.error) { 76 | notify('remote 模块错误', response.error, 'remote', { copy: true, type: 'error' }); 77 | throw new Error(response.error); 78 | } 79 | return response.data; 80 | } 81 | }; 82 | } 83 | 84 | function getRespondChannelId(property: string) { 85 | return `${property}-${(Math.random() * 1000).toFixed(0)}-${Date.now()}`; 86 | } 87 | 88 | export const remote = { 89 | // nodejs 90 | 'electron-store': registerRemote('electron-store'), 91 | fs: registerRemote('fs'), 92 | path: registerRemote('path'), 93 | os: registerRemote('os'), 94 | crypto: registerRemote('crypto'), 95 | 96 | // 公共 api 97 | OCSApi: registerRemote('OCSApi'), 98 | 99 | // 注册 window 通信 100 | win: registerRemote('win'), 101 | // 注册 window 通信 102 | webContents: registerRemote('webContents'), 103 | // 注册 app 通信 104 | app: registerRemote('app'), 105 | // 注册 dialog 通信 106 | dialog: registerRemote('dialog'), 107 | // 暴露方法 108 | methods: registerRemote('methods'), 109 | // 日志 110 | // eslint-disable-next-line no-undef 111 | logger: registerRemote('logger'), 112 | // 截屏录像 113 | desktopCapturer: registerRemote('desktopCapturer') 114 | }; 115 | 116 | function errorFilter(str: string) { 117 | // operation not permitted, stat xxxxx CrashpadMetrics.pma , 这个是 playwright 问题,暂时无需处理 118 | if (String(str).includes('CrashpadMetrics')) { 119 | return true; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /packages/web/src/utils/resources.loader.ts: -------------------------------------------------------------------------------- 1 | import { store } from '../store'; 2 | import { remote } from './remote'; 3 | import { ResourceFile, ResourceLoaderOptions } from '@ocs-desktop/common/src/api'; 4 | 5 | export interface LocalResourceFile { 6 | groupname: string; 7 | filename: string; 8 | path: string; 9 | } 10 | 11 | /** 12 | * 13 | * 资源加载器 14 | * 15 | */ 16 | export class ResourceLoader { 17 | resourceRootPath: string; 18 | constructor(options: ResourceLoaderOptions) { 19 | this.resourceRootPath = options.resourceRootPath; 20 | } 21 | 22 | /** 返回本地绝对路径 */ 23 | async getPath(group_name: string, file: ResourceFile) { 24 | return await remote.path.call('join', this.resourceRootPath, group_name, file.id); 25 | } 26 | 27 | /** 判断是否存在 */ 28 | async isExists(group_name: string, file: ResourceFile) { 29 | const path = await this.getPath(group_name, file); 30 | return await remote.fs.call('existsSync', path); 31 | } 32 | 33 | /** 是否为压缩包文件 */ 34 | isZipFile(file: ResourceFile) { 35 | return /\.(zip|rar|7z)$/.test(file.url); 36 | } 37 | 38 | /** 获取解压缩后的文件路径 */ 39 | async getUnzippedPath(group_name: string, file: ResourceFile) { 40 | return await remote.path.call('join', this.resourceRootPath, group_name, file.id); 41 | } 42 | 43 | /** 判断压缩包是否存在 */ 44 | async isZipFileExists(group_name: string, file: ResourceFile) { 45 | if (this.isZipFile(file)) { 46 | const path = await this.getUnzippedPath(group_name, file); 47 | return await remote.fs.call('existsSync', path); 48 | } 49 | return false; 50 | } 51 | 52 | /** 下载资源 */ 53 | async download(group_name: string, file: ResourceFile) { 54 | const downloadPath = await remote.path.call( 55 | 'join', 56 | this.resourceRootPath, 57 | group_name, 58 | this.isZipFile(file) ? file.id + '.zip' : file.id 59 | ); 60 | 61 | // 判断文件是否存在,如果存在则删除 62 | if (await remote.fs.call('existsSync', downloadPath)) { 63 | await remote.fs.call('unlinkSync', downloadPath); 64 | } 65 | const platform = await remote.methods.call('getPlatform'); 66 | const url = 67 | platform === 'win32' 68 | ? file.url 69 | : // 优先安装对应平台的资源。如果没有对应平台的资源,则使用默认资源 70 | file.platforms?.find((p) => p.platform === platform)?.url || file.url; 71 | if (!url) { 72 | throw new Error('资源下载失败,路径为空'); 73 | } 74 | // 下载 75 | await remote.methods.call('download', 'download-file-' + file.id, url, downloadPath); 76 | } 77 | 78 | /** 解压资源 */ 79 | async unzip(group_name: string, file: ResourceFile) { 80 | const targetPath = await remote.path.call( 81 | 'join', 82 | this.resourceRootPath, 83 | group_name, 84 | this.isZipFile(file) ? file.id + '.zip' : file.id 85 | ); 86 | 87 | const to = await remote.path.call('join', this.resourceRootPath, group_name, file.id); 88 | // 判断文件夹是否存在,如果存在则删除 89 | if (await remote.fs.call('existsSync', to)) { 90 | await remote.fs.call('rmSync', to, { 91 | recursive: true 92 | }); 93 | } 94 | await remote.methods.call('unzip', targetPath, to); 95 | // 删除压缩包 96 | await remote.fs.call('unlinkSync', targetPath); 97 | } 98 | 99 | /** 删除资源 */ 100 | async remove(group_name: string, file: ResourceFile) { 101 | if (this.isZipFile(file)) { 102 | const path = await this.getUnzippedPath(group_name, file); 103 | // 删除文件夹所有文件 104 | await remote.fs.call('rmSync', path, { 105 | recursive: true 106 | }); 107 | } else { 108 | const path = await this.getPath(group_name, file); 109 | await remote.fs.call('unlinkSync', path); 110 | } 111 | } 112 | 113 | /** 114 | * 获取资源列表 115 | */ 116 | async list() { 117 | const files: LocalResourceFile[] = []; 118 | // @ts-ignore 119 | const groupnames: string[] = await remote.fs.call('readdirSync', this.resourceRootPath); 120 | // TODO 检测是否是文件夹,如果不是则忽略 121 | for (const groupname of groupnames.filter((g) => g !== '.DS_Store')) { 122 | const folder = await remote.path.call('join', this.resourceRootPath, groupname); 123 | if (await remote.fs.call('existsSync', folder)) { 124 | try { 125 | // @ts-ignore 126 | const filenames: string[] = await remote.fs.call('readdirSync', folder); 127 | for (const filename of filenames) { 128 | const path = await remote.path.call('join', folder, filename); 129 | files.push({ groupname, filename, path }); 130 | } 131 | } catch (e) { 132 | console.error(e); 133 | } 134 | } 135 | } 136 | return files; 137 | } 138 | } 139 | 140 | /** 资源加载器 */ 141 | export const resourceLoader = new ResourceLoader({ 142 | // 这里要加可选链,在浏览器环境中初始化时 store.paths 为 undefined 143 | resourceRootPath: store.paths?.downloadFolder 144 | }); 145 | -------------------------------------------------------------------------------- /packages/web/src/utils/user-scripts.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@arco-design/web-vue'; 2 | import { remote } from './remote'; 3 | import { store, StoreUserScript } from '../store'; 4 | import { electron } from './node'; 5 | 6 | /** 7 | * 添加并且解析本地脚本 8 | */ 9 | export function addScriptFromFile() { 10 | remote.dialog 11 | .call('showOpenDialog', { 12 | title: '选择脚本文件,后缀必须为 (.user.js)', 13 | buttonLabel: '添加脚本', 14 | filters: [{ extensions: ['user.js'], name: '用户脚本' }] 15 | }) 16 | .then(async ({ canceled, filePaths }) => { 17 | if (canceled === false && filePaths.length) { 18 | const text = await remote.fs.call('readFileSync', filePaths[0], { encoding: 'utf8' }); 19 | await addLocalScript(filePaths[0], text.toString()); 20 | } 21 | }); 22 | } 23 | 24 | async function addLocalScript(uri: string, text: string) { 25 | if (await remote.fs.call('existsSync', uri)) { 26 | const metadata = getMetadataFromScript(text); 27 | if (metadata === undefined) { 28 | Message.error('脚本格式不正确,请选择能够解析的用户脚本。'); 29 | } else { 30 | if (store.render.scripts.find((s) => s.url === uri)) { 31 | Message.warning('当前脚本已安装。'); 32 | } else { 33 | const id = Date.now(); 34 | store.render.scripts.push({ 35 | id, 36 | url: uri, 37 | enable: true, 38 | isLocalScript: true, 39 | isInternetLinkScript: false, 40 | info: { 41 | id, 42 | url: uri, 43 | code_url: uri, 44 | ratings: 0, 45 | total_installs: 0, 46 | daily_installs: 0, 47 | create_time: 0, 48 | update_time: 0, 49 | ...metadata 50 | } 51 | }); 52 | } 53 | } 54 | } else { 55 | Message.warning('文件不存在。'); 56 | } 57 | } 58 | 59 | export async function addScriptFromUrl(url: string) { 60 | if (store.render.scripts.find((s) => s.url === url)) { 61 | Message.success('当前脚本已安装。'); 62 | return false; 63 | } 64 | 65 | if (url.startsWith('http')) { 66 | const text = await remote.methods.call('get', url); 67 | const metadata = getMetadataFromScript(text); 68 | if (metadata === undefined) { 69 | Message.error('脚本格式不正确,请选择能够解析的用户脚本。'); 70 | } else { 71 | const id = Math.round(Math.random() * 10000000000000); 72 | store.render.scripts.push({ 73 | id: id, 74 | url: url, 75 | enable: true, 76 | isLocalScript: false, 77 | isInternetLinkScript: true, 78 | info: { 79 | id, 80 | url: url, 81 | code_url: url, 82 | ratings: 0, 83 | total_installs: 0, 84 | daily_installs: 0, 85 | create_time: 0, 86 | update_time: 0, 87 | ...metadata 88 | } 89 | }); 90 | } 91 | } else { 92 | Message.error('脚本链接无效,必须是以 http 开头的网络链接。'); 93 | return false; 94 | } 95 | 96 | return true; 97 | } 98 | 99 | function getMetadataFromScript(text: string) { 100 | const metadata = text.match(/\/\/\s+==UserScript==([\s\S]+)\/\/\s+==\/UserScript==/)?.[1] || ''; 101 | 102 | if (metadata === '') { 103 | return undefined; 104 | } else { 105 | const metadataList = (metadata.match(/\/\/\s+@(.+?)\s+(.*?)(?:\n|$)/g) || []).map((line) => { 106 | const words = line.match(/[\S]+/g) || []; 107 | return { 108 | key: (words[1] || '').replace('@', ''), 109 | value: words.slice(2).join(' ') 110 | }; 111 | }); 112 | 113 | // 解析函数 114 | const getMetadata = (key: string) => { 115 | return metadataList.filter((l) => l.key === key).map((l) => l.value); 116 | }; 117 | 118 | return { 119 | authors: getMetadata('author').map((a) => ({ name: a, url: '' })) || [], 120 | description: getMetadata('description')[0], 121 | license: getMetadata('license')[0], 122 | name: getMetadata('name')[0], 123 | version: getMetadata('version')[0] 124 | }; 125 | } 126 | } 127 | 128 | export function openScriptSource(script: StoreUserScript) { 129 | if (script.info) { 130 | if (script.info.url.startsWith('http')) { 131 | window.open(script.info.url, '_blank'); 132 | } else { 133 | electron.shell.openPath(script.info.url); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /packages/web/src/utils/xterm.ts: -------------------------------------------------------------------------------- 1 | import { ITerminalOptions, Terminal } from 'xterm'; 2 | import { FitAddon } from 'xterm-addon-fit'; 3 | import { remote } from './remote'; 4 | const fitAddon = new FitAddon(); 5 | 6 | /** 7 | * xterm 终端 8 | * 9 | * @see https://github.com/xtermjs/xterm.js 10 | */ 11 | export class XTerm extends Terminal { 12 | constructor(options?: ITerminalOptions) { 13 | super( 14 | Object.assign( 15 | { 16 | cursorBlink: true, 17 | convertEol: true, 18 | fontFamily: "Consolas, 'Courier New', monospace", 19 | fontSize: 12, 20 | theme: { background: '#32302F' }, 21 | rows: 40 22 | }, 23 | options 24 | ) 25 | ); 26 | 27 | /** 载入窗口尺寸自适应插件 */ 28 | this.loadAddon(fitAddon); 29 | this.onKey((key) => { 30 | const char = key.domEvent.key; 31 | /** 复制内容 */ 32 | if (key.domEvent.ctrlKey && char === 'c') { 33 | remote.webContents.call('copy'); 34 | } 35 | }); 36 | 37 | window.onresize = () => { 38 | this.fit(); 39 | }; 40 | } 41 | 42 | fit() { 43 | try { 44 | fitAddon.fit(); 45 | } catch {} 46 | // const dimensions = fitAddon.proposeDimensions(); 47 | // if (dimensions?.cols && dimensions?.rows) { 48 | // this.resize?.(dimensions.cols, dimensions.rows); 49 | // } 50 | } 51 | 52 | write(data: string) { 53 | super.write(data); 54 | /** 内容写入时,定时自适应界面 */ 55 | this.fit(); 56 | } 57 | 58 | writeln(data: string | Uint8Array, callback?: () => void): void { 59 | super.writeln(data, callback); 60 | /** 内容写入时,定时自适应界面 */ 61 | this.fit(); 62 | } 63 | 64 | override clear(): void { 65 | super.clear(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "lib": [ 13 | "esnext", 14 | "dom" 15 | ], 16 | "skipLibCheck": true, 17 | "outDir": "./lib", 18 | "typeRoots": [ 19 | "./src/env.d.ts" 20 | ] 21 | }, 22 | "exclude": [ 23 | "app/**/*" 24 | ], 25 | "include": [ 26 | "src/**/*.ts", 27 | "src/**/*.d.ts" 28 | ] 29 | } -------------------------------------------------------------------------------- /packages/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import { visualizer } from 'rollup-plugin-visualizer'; 4 | import commonjs from 'vite-plugin-commonjs'; 5 | 6 | import path from 'path'; 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | build: { 10 | outDir: '../app/public', 11 | rollupOptions: { 12 | output: { 13 | manualChunks(id) { 14 | if (id.includes('node_modules')) { 15 | return id.toString().split('node_modules/')[1].split('/')[0].toString(); 16 | } 17 | } 18 | } 19 | }, 20 | /** 是否压缩代码, 这里写 false,不然打包后类名会发生变化 */ 21 | minify: false 22 | }, 23 | server: { 24 | open: false 25 | }, 26 | base: '', 27 | resolve: { 28 | alias: { 29 | '@': path.resolve(__dirname, './src'), 30 | root: path.resolve(__dirname), 31 | app: path.resolve(__dirname, './app') 32 | } 33 | }, 34 | plugins: [commonjs({ filter: (id) => (id.includes('xlsx') ? undefined : false) }), vue(), visualizer()] 35 | }); 36 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/**' 3 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ocs-desktop 2 | 3 | 拥有 一键刷课,多账号刷课,浏览器多开/分身,浏览器管理,自动登录,等功能,支持 Windows, Linux, Mac 多个操作系统。 4 | 5 | ## 初始化项目依赖 6 | 7 | ```bash 8 | # 安装依赖 9 | npm i pnpm -g 10 | pnpm install 11 | 12 | # 编译 common 库,此操作只需执行一次,除非后续更改 common 代码,否则无需重复操作。 13 | cd packages/common 14 | npx tsc 15 | ``` 16 | 17 | ## 运行 18 | 19 | 启动项目,需要打开两个终端(命令窗口) 20 | 21 | **终端 1** - 运行 electron 渲染进程 22 | 23 | ```bash 24 | cd packages/web 25 | npm run dev 26 | ``` 27 | 28 | **终端 2** - 运行 electron 主进程 29 | 30 | ```bash 31 | cd packages/app 32 | npm run dev 33 | ``` 34 | 35 | ## 打包 36 | 37 | ```bash 38 | npm run build 39 | ``` 40 | 41 | ## 注意 42 | 43 | 新项目打包时需删除 node_modules/chromium/lib/chromium 中的文件,否则打包后会带有 chromium 文件导致体积非常大。 44 | 因为此项目主要依赖电脑本地的浏览器,所以无需内置 chromium,移除即可。 45 | -------------------------------------------------------------------------------- /scripts/build-app.js: -------------------------------------------------------------------------------- 1 | const { series, src, dest } = require('gulp'); 2 | const del = require('del'); 3 | const zip = require('gulp-zip'); 4 | const { execOut } = require('./utils'); 5 | const { readFileSync } = require('fs'); 6 | const chromeInstall = require('./chrome.install').default; 7 | const { version } = JSON.parse(readFileSync('../packages/app/package.json').toString()); 8 | 9 | function buildWeb() { 10 | return execOut('pnpm build', { cwd: '../packages/web' }); 11 | } 12 | 13 | function buildApp() { 14 | return execOut('pnpm dist', { cwd: '../packages/app' }); 15 | } 16 | 17 | function cleanOutput() { 18 | return del([`../packages/app/dist/app${version}.zip`], { force: true }); 19 | } 20 | 21 | function packResource() { 22 | return src('../packages/app/dist/win-unpacked/resources/app/**/*') 23 | .pipe(zip(`app${version}.zip`)) 24 | .pipe(dest('../packages/app/dist')); 25 | } 26 | 27 | exports.default = series(cleanOutput, buildWeb, chromeInstall, buildApp, packResource); 28 | -------------------------------------------------------------------------------- /scripts/chrome.install.js: -------------------------------------------------------------------------------- 1 | const { series, src, dest } = require('gulp'); 2 | const { execOut } = require('./utils'); 3 | const zip = require('gulp-zip'); 4 | 5 | // 137 版本后谷歌禁止使用命令行加载插件,目前只能内置Chrome for Test测试浏览器,并且进行严格版本控制,手动更新 6 | function downloadChrome() { 7 | return execOut('npx @puppeteer/browsers install chrome@137.0.7151.55 --path ../.chrome-temp'); 8 | } 9 | 10 | function pack() { 11 | return src('../.chrome-temp/chrome/**/*').pipe(zip(`chrome.zip`)).pipe(dest('../bin/chrome')); 12 | } 13 | 14 | exports.default = series(downloadChrome, pack); 15 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 自动发布npm包 4 | 5 | # 从控制台获取需要发布的版本 6 | read -p "请输入需要发布的版本(例如: 0.0.1): " version 7 | # 判断是否为空 8 | if [ -z "$version" ]; then 9 | echo "版本号不能为空!" 10 | exit 1 11 | fi 12 | # 确认是否发布版本 13 | read -p "确认发布版本 $version ? [y/n]: " isRelease 14 | # 判断是发布,还是取消发布 15 | if [ "$isRelease" = "y" ]; then 16 | # 发布 17 | echo "版本发布 $version" 18 | 19 | # 代码检查 20 | npm run lint && 21 | # 更新版本 22 | npm version "$version" --no-git-tag-version --allow-same-version && 23 | cd ./packages/app && 24 | npm version "$version" --no-git-tag-version --allow-same-version && 25 | cd ../../ && 26 | # 本地构建 27 | npm run build && 28 | # 更新日志 29 | npm run changelog && 30 | # 更新日志 31 | npm run changelog:current && 32 | # 保存 33 | git add ./packages/app/package.json package.json CHANGELOG.md CHANGELOG_CURRENT.md && 34 | git commit -m "version release $version" && 35 | git tag "$version" && 36 | # 提交 37 | git push origin main --tags 38 | echo "$version 发布成功" 39 | elif [ "$isRelease" = "n" ]; then 40 | echo "取消发布" 41 | else 42 | echo "输入有误" 43 | fi 44 | 45 | 46 | -------------------------------------------------------------------------------- /scripts/tsc.js: -------------------------------------------------------------------------------- 1 | const { series } = require('gulp'); 2 | const { execOut } = require('./utils'); 3 | 4 | exports.default = series( 5 | series( 6 | () => execOut('tsc', { cwd: '../packages/common' }), 7 | () => execOut('tsc', { cwd: '../packages/app' }), 8 | () => execOut('tsc', { cwd: '../packages/web' }) 9 | ) 10 | ); 11 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('child_process'); 2 | 3 | /** 将 exec 错误信息输出到 stdout */ 4 | function execOut(command, ...opts) { 5 | const cmd = exec(command, ...opts); 6 | cmd.stdout.pipe(process.stdout); 7 | cmd.stderr.pipe(process.stdout); 8 | return cmd; 9 | } 10 | 11 | exports.execOut = execOut; 12 | --------------------------------------------------------------------------------