├── .editorconfig ├── .electron-builder.config.js ├── .eslintrc.json ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── renovate.json └── workflows │ ├── ci.yml │ ├── lint.yml │ ├── release.yml │ ├── tests.yml │ └── typechecking.yml ├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── deployment.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jsLibraryMappings.xml ├── jsLinters │ └── eslint.xml ├── jsonSchemas.xml ├── modules.xml ├── prettier.xml ├── vcs.xml ├── vite-electron-builder.iml └── webResources.xml ├── .nano-staged.mjs ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .simple-git-hooks.json ├── .vscode ├── c_cpp_properties.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── assets ├── IP2LOCATION-LITE-DB11.BIN └── ua.txt ├── buildResources ├── .gitkeep ├── entitlements.mac.plist ├── icon.icns ├── icon.ico └── icon.png ├── contributing.md ├── cursor-directive.md ├── examples ├── ads-migration.js ├── api.json ├── demo │ ├── profiles.js │ └── window.js ├── index.js ├── package-lock.json ├── package.json └── puppeteer.js ├── knexfile.js ├── migrations ├── 20231017020548_init_table.js ├── 20231114045619_add_profile_id_to_window_table.js ├── 20240317074532_add_fingerprint_to_window.js ├── 20250206085919_create_extension_table.js ├── 20250207102708_update_extension_table.js ├── 20250208075039_window_extension_table.js └── 20250221075247_add_pid_to_window.js ├── package-lock.json ├── package.json ├── packages ├── main │ ├── src │ │ ├── constants │ │ │ └── index.ts │ │ ├── db │ │ │ ├── extension.ts │ │ │ ├── group.ts │ │ │ ├── index.ts │ │ │ ├── proxy.ts │ │ │ ├── tag.ts │ │ │ └── window.ts │ │ ├── fingerprint │ │ │ ├── device.ts │ │ │ ├── index.ts │ │ │ └── prepare.ts │ │ ├── index.ts │ │ ├── mainWindow.ts │ │ ├── native-addon │ │ │ ├── CMakeLists.txt │ │ │ ├── binding.gyp │ │ │ └── window-addon.cpp │ │ ├── proxy-server │ │ │ └── socks-server.ts │ │ ├── puppeteer │ │ │ ├── helpers.ts │ │ │ ├── index.ts │ │ │ └── tasks.ts │ │ ├── security-restrictions.ts │ │ ├── server │ │ │ ├── index.ts │ │ │ └── routes │ │ │ │ ├── ip.ts │ │ │ │ ├── profiles.ts │ │ │ │ ├── proxy.ts │ │ │ │ └── window.ts │ │ ├── services │ │ │ ├── common-service.ts │ │ │ ├── extension-service.ts │ │ │ ├── group-service.ts │ │ │ ├── index.ts │ │ │ ├── proxy-service.ts │ │ │ ├── sync-service.ts │ │ │ ├── tag-service.ts │ │ │ └── window-service.ts │ │ ├── sync │ │ │ └── index.ts │ │ ├── types │ │ │ ├── cookie.d.ts │ │ │ └── window-template.d.ts │ │ └── utils │ │ │ ├── chrome-icon.ts │ │ │ ├── extract.ts │ │ │ ├── get-db-path.ts │ │ │ ├── get-settings.ts │ │ │ ├── sleep.ts │ │ │ └── txt-to-json.ts │ ├── tests │ │ └── unit.spec.ts │ ├── tsconfig.json │ └── vite.config.js ├── package-lock.json ├── package.json ├── preload │ ├── src │ │ ├── bridges │ │ │ ├── common.ts │ │ │ ├── extension.ts │ │ │ ├── group.ts │ │ │ ├── proxy.ts │ │ │ ├── sync.ts │ │ │ ├── tag.ts │ │ │ └── window.ts │ │ ├── customize-control.ts │ │ ├── index.ts │ │ ├── node-crypto.ts │ │ └── versions.ts │ ├── tests │ │ └── unit.spec.ts │ ├── tsconfig.json │ └── vite.config.js ├── renderer │ ├── .eslintrc.json │ ├── assets │ │ ├── bg2.png │ │ ├── logo.png │ │ ├── logo2-min.png │ │ ├── logo2.png │ │ ├── logo5.png │ │ ├── logo6.png │ │ └── template.xlsx │ ├── index.html │ ├── src │ │ ├── App.tsx │ │ ├── components │ │ │ ├── addable-select │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ ├── header │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ └── navigation │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ ├── constants │ │ │ ├── colors.ts │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ └── status.ts │ │ ├── i18n.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── interface │ │ │ └── window.ts │ │ ├── pages │ │ │ ├── api │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ ├── extensions │ │ │ │ └── index.tsx │ │ │ ├── logs │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ ├── proxy │ │ │ │ ├── import │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── settings │ │ │ │ └── index.tsx │ │ │ ├── start │ │ │ │ └── index.tsx │ │ │ ├── sync │ │ │ │ └── index.tsx │ │ │ └── windows │ │ │ │ ├── components │ │ │ │ ├── edit-footer │ │ │ │ │ └── index.tsx │ │ │ │ ├── edit-form │ │ │ │ │ └── index.tsx │ │ │ │ ├── fingerprint-info │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.tsx │ │ │ │ └── import-form │ │ │ │ │ └── index.tsx │ │ │ │ ├── detail │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ ├── routes │ │ │ └── index.tsx │ │ ├── store.ts │ │ ├── styles │ │ │ └── antd.css │ │ └── utils │ │ │ └── str.ts │ ├── tsconfig.json │ ├── types │ │ └── shims-vue.d.ts │ └── vite.config.js └── shared │ ├── api │ └── api.ts │ ├── constants │ └── index.ts │ ├── types │ ├── common.d.ts │ ├── db.d.ts │ └── ip.d.ts │ └── utils │ ├── index.ts │ ├── logger.ts │ ├── proxy.ts │ └── random.ts ├── pic.png ├── postcss.config.js ├── scripts ├── build-native-addon.js ├── sign-native-modules.js ├── ua-generator.js ├── update-electron-vendors.mjs └── watch.mjs ├── tailwind.config.js ├── tests └── e2e.spec.ts ├── types └── env.d.ts ├── version ├── getVersion.mjs └── inject-app-version-plugin.mjs └── vitest.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # https://github.com/jokeyrhyme/standard-editorconfig 4 | 5 | # top-most EditorConfig file 6 | root = true 7 | 8 | # defaults 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_size = 2 15 | indent_style = space 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "es2021": true, 5 | "node": true, 6 | "browser": false 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | /** @see https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#recommended-configs */ 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": 12, 16 | "sourceType": "module" 17 | }, 18 | "plugins": ["@typescript-eslint"], 19 | "ignorePatterns": ["node_modules/**", "**/dist/**", "!.electron-builder.config.js"], 20 | "rules": { 21 | "@typescript-eslint/no-unused-vars": [ 22 | "error", 23 | { 24 | "argsIgnorePattern": "^_", 25 | "varsIgnorePattern": "^_" 26 | } 27 | ], 28 | "@typescript-eslint/no-var-requires": "off", 29 | "@typescript-eslint/consistent-type-imports": "error", 30 | /** 31 | * Having a semicolon helps the optimizer interpret your code correctly. 32 | * This avoids rare errors in optimized code. 33 | * @see https://twitter.com/alex_kozack/status/1364210394328408066 34 | */ 35 | "semi": ["error", "always"], 36 | /** 37 | * This will make the history of changes in the hit a little cleaner 38 | */ 39 | "comma-dangle": ["warn", "always-multiline"], 40 | /** 41 | * Just for beauty 42 | */ 43 | "quotes": [ 44 | "warn", 45 | "single", 46 | { 47 | "avoidEscape": true 48 | } 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .github/actions/**/*.js linguist-detectable=false 2 | scripts/*.js linguist-detectable=false 3 | *.config.js linguist-detectable=false 4 | * text=auto eol=lf 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | # custom: [""] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: cawa-93 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Questions & Discussions 4 | url: https://github.com/cawa-93/vite-electron-builder/discussions/categories/q-a 5 | about: Use GitHub discussions for message-board style questions and discussions. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: cawa-93 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":semanticCommits", 5 | ":semanticCommitTypeAll(deps)", 6 | ":semanticCommitScopeDisabled", 7 | ":automergeAll", 8 | ":automergeBranch", 9 | ":disableDependencyDashboard", 10 | ":pinVersions", 11 | ":onlyNpm", 12 | ":label(dependencies)" 13 | ], 14 | "packageRules": [ 15 | { 16 | "groupName": "Vite packages", 17 | "matchUpdateTypes": "major", 18 | "matchSourceUrlPrefixes": [ 19 | "https://github.com/vitejs/" 20 | ] 21 | } 22 | ], 23 | "gitNoVerify": [ 24 | "commit", 25 | "push" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow is the entry point for all CI processes. 2 | # It is from here that all other workflows are launched. 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - '*' 8 | branches: 9 | - main 10 | - 'renovate/**' 11 | paths-ignore: 12 | - '.github/**' 13 | - '!.github/workflows/ci.yml' 14 | - '!.github/workflows/release.yml' 15 | - '**.md' 16 | - .editorconfig 17 | - .gitignore 18 | - '.idea/**' 19 | - '.vscode/**' 20 | pull_request: 21 | paths-ignore: 22 | - '.github/**' 23 | - '!.github/workflows/ci.yml' 24 | - '!.github/workflows/release.yml' 25 | - '**.md' 26 | - .editorconfig 27 | - .gitignore 28 | - '.idea/**' 29 | - '.vscode/**' 30 | 31 | concurrency: 32 | group: ci-${{ github.ref }} 33 | cancel-in-progress: true 34 | 35 | jobs: 36 | draft_release: 37 | permissions: 38 | contents: write 39 | uses: ./.github/workflows/release.yml 40 | with: 41 | dry-run: ${{ github.event_name != 'push' || (github.ref_type != 'tag' && github.ref_name != 'main') }} 42 | secrets: 43 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 44 | BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} 45 | P12_PASSWORD: ${{ secrets.P12_PASSWORD }} 46 | KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} 47 | APPLE_IDENTITY: ${{ secrets.APPLE_IDENTITY }} 48 | VITE_APP_API: ${{ secrets.VITE_APP_API }} 49 | VITE_START_PAGE_URL: ${{ secrets.VITE_START_PAGE_URL }} 50 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | paths: 5 | - '**.js' 6 | - '**.mjs' 7 | - '**.cjs' 8 | - '**.jsx' 9 | - '**.ts' 10 | - '**.mts' 11 | - '**.cts' 12 | - '**.tsx' 13 | - '**.vue' 14 | pull_request: 15 | paths: 16 | - '**.js' 17 | - '**.mjs' 18 | - '**.cjs' 19 | - '**.jsx' 20 | - '**.ts' 21 | - '**.mts' 22 | - '**.cts' 23 | - '**.tsx' 24 | - '**.vue' 25 | 26 | concurrency: 27 | group: lint-${{ github.ref }} 28 | cancel-in-progress: true 29 | 30 | defaults: 31 | run: 32 | shell: 'bash' 33 | 34 | jobs: 35 | eslint: 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 40 | - uses: actions/setup-node@v4 41 | with: 42 | cache: 'npm' 43 | 44 | - run: npm ci 45 | 46 | - run: npm run lint --if-present 47 | 48 | # This job just check code style for in-template contributions. 49 | code-style: 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 54 | - uses: actions/setup-node@v4 55 | with: 56 | cache: 'npm' 57 | 58 | - run: npm i prettier 59 | - run: npx prettier --check "**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [ workflow_call ] 3 | 4 | concurrency: 5 | group: tests-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | defaults: 9 | run: 10 | shell: 'bash' 11 | 12 | jobs: 13 | tests: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ windows-latest ] 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4 21 | - uses: actions/setup-node@v3 22 | with: 23 | cache: 'npm' 24 | - run: npm ci 25 | - run: npm run test:main --if-present 26 | - run: npm run test:preload --if-present 27 | - run: npm run test:renderer --if-present 28 | 29 | # I ran into problems trying to run an electron window in ubuntu due to a missing graphics server. 30 | # That's why this special command for Ubuntu is here 31 | # - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test:e2e --if-present 32 | # if: matrix.os == 'ubuntu-latest' 33 | 34 | # - run: npm run test:e2e --if-present 35 | # if: matrix.os != 'ubuntu-latest' 36 | -------------------------------------------------------------------------------- /.github/workflows/typechecking.yml: -------------------------------------------------------------------------------- 1 | name: Typechecking 2 | on: [ workflow_call ] 3 | 4 | concurrency: 5 | group: typechecking-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | defaults: 9 | run: 10 | shell: 'bash' 11 | 12 | jobs: 13 | typescript: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4 18 | - uses: actions/setup-node@v3 19 | with: 20 | cache: 'npm' 21 | 22 | - run: npm ci 23 | 24 | - run: npm run typecheck --if-present 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | *.local 5 | .env* 6 | thumbs.db 7 | 8 | .eslintcache 9 | .browserslistrc 10 | .electron-vendors.cache.json 11 | 12 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 13 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 14 | 15 | # User-specific stuff 16 | .idea/**/workspace.xml 17 | .idea/**/tasks.xml 18 | .idea/**/usage.statistics.xml 19 | .idea/**/dictionaries 20 | .idea/**/shelf 21 | 22 | # Generated files 23 | .idea/**/contentModel.xml 24 | 25 | # Sensitive or high-churn files 26 | .idea/**/dataSources/ 27 | .idea/**/dataSources.ids 28 | .idea/**/dataSources.local.xml 29 | .idea/**/sqlDataSources.xml 30 | .idea/**/dynamic.xml 31 | .idea/**/uiDesigner.xml 32 | .idea/**/dbnavigator.xml 33 | 34 | # Gradle 35 | .idea/**/gradle.xml 36 | .idea/**/libraries 37 | 38 | # Gradle and Maven with auto-import 39 | # When using Gradle or Maven with auto-import, you should exclude module files, 40 | # since they will be recreated, and may cause churn. Uncomment if using 41 | # auto-import. 42 | .idea/artifacts 43 | .idea/compiler.xml 44 | .idea/jarRepositories.xml 45 | .idea/modules.xml 46 | .idea/*.iml 47 | .idea/modules 48 | *.iml 49 | *.ipr 50 | 51 | # Mongo Explorer plugin 52 | .idea/**/mongoSettings.xml 53 | 54 | # File-based project format 55 | *.iws 56 | 57 | # Editor-based Rest Client 58 | .idea/httpRequests 59 | /.idea/csv-plugin.xml 60 | packages/main/src/native-addon/build -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/deployment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsLinters/eslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /.idea/jsonSchemas.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/vite-electron-builder.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /.idea/webResources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.nano-staged.mjs: -------------------------------------------------------------------------------- 1 | import {resolve, sep} from 'path'; 2 | 3 | export default { 4 | '*.{js,mjs,cjs,ts,mts,cts,vue}': 'eslint --cache --fix', 5 | 6 | /** 7 | * Run typechecking if any type-sensitive files or project dependencies was changed 8 | * @param {string[]} filenames 9 | * @return {string[]} 10 | */ 11 | '{package-lock.json,packages/**/{*.ts,*.vue,tsconfig.json}}': ({filenames}) => { 12 | // if dependencies was changed run type checking for all packages 13 | if (filenames.some(f => f.endsWith('package-lock.json'))) { 14 | return ['npm run typecheck --if-present']; 15 | } 16 | 17 | // else run type checking for staged packages 18 | const fileNameToPackageName = filename => 19 | filename.replace(resolve(process.cwd(), 'packages') + sep, '').split(sep)[0]; 20 | return [...new Set(filenames.map(fileNameToPackageName))].map( 21 | p => `npm run typecheck:${p} --if-present`, 22 | ); 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | **/*.svg 4 | 5 | package.json 6 | package-lock.json 7 | .electron-vendors.cache.json 8 | 9 | .github 10 | .idea -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "singleQuote": true, 5 | "overrides": [ 6 | { 7 | "files": ["**/*.css", "**/*.scss", "**/*.html"], 8 | "options": { 9 | "singleQuote": false 10 | } 11 | } 12 | ], 13 | "trailingComma": "all", 14 | "bracketSpacing": false, 15 | "arrowParens": "avoid", 16 | "proseWrap": "never", 17 | "htmlWhitespaceSensitivity": "strict", 18 | "vueIndentScriptAndStyle": false, 19 | "endOfLine": "lf", 20 | "singleAttributePerLine": true 21 | } 22 | -------------------------------------------------------------------------------- /.simple-git-hooks.json: -------------------------------------------------------------------------------- 1 | { 2 | "pre-commit": "npx nano-staged" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/c_cpp_properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Win32", 5 | "includePath": ["${workspaceFolder}/**"], 6 | "defines": ["_DEBUG", "UNICODE", "_UNICODE"], 7 | "windowsSdkVersion": "10.0.22621.0", 8 | "compilerPath": "cl.exe", 9 | "cStandard": "c17", 10 | "cppStandard": "c++17", 11 | "intelliSenseMode": "windows-msvc-x64" 12 | } 13 | ], 14 | "version": 4 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug Main Process", 8 | "skipFiles": ["/**"], 9 | "program": "${workspaceFolder}\\scripts\\watch.mjs", 10 | "autoAttachChildProcesses": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "algorithm": "cpp", 4 | "atomic": "cpp", 5 | "bit": "cpp", 6 | "cctype": "cpp", 7 | "charconv": "cpp", 8 | "clocale": "cpp", 9 | "cmath": "cpp", 10 | "compare": "cpp", 11 | "concepts": "cpp", 12 | "cstddef": "cpp", 13 | "cstdint": "cpp", 14 | "cstdio": "cpp", 15 | "cstdlib": "cpp", 16 | "cstring": "cpp", 17 | "ctime": "cpp", 18 | "cwchar": "cpp", 19 | "exception": "cpp", 20 | "format": "cpp", 21 | "functional": "cpp", 22 | "initializer_list": "cpp", 23 | "iosfwd": "cpp", 24 | "iterator": "cpp", 25 | "limits": "cpp", 26 | "list": "cpp", 27 | "locale": "cpp", 28 | "memory": "cpp", 29 | "mutex": "cpp", 30 | "new": "cpp", 31 | "optional": "cpp", 32 | "ratio": "cpp", 33 | "stdexcept": "cpp", 34 | "stop_token": "cpp", 35 | "streambuf": "cpp", 36 | "string": "cpp", 37 | "system_error": "cpp", 38 | "thread": "cpp", 39 | "tuple": "cpp", 40 | "type_traits": "cpp", 41 | "typeinfo": "cpp", 42 | "unordered_map": "cpp", 43 | "utility": "cpp", 44 | "vector": "cpp", 45 | "xfacet": "cpp", 46 | "xhash": "cpp", 47 | "xiosbase": "cpp", 48 | "xlocale": "cpp", 49 | "xlocbuf": "cpp", 50 | "xlocinfo": "cpp", 51 | "xlocmes": "cpp", 52 | "xlocmon": "cpp", 53 | "xlocnum": "cpp", 54 | "xloctime": "cpp", 55 | "xmemory": "cpp", 56 | "xstring": "cpp", 57 | "xtr1common": "cpp", 58 | "xutility": "cpp", 59 | "array": "cpp", 60 | "chrono": "cpp", 61 | "coroutine": "cpp", 62 | "deque": "cpp", 63 | "forward_list": "cpp", 64 | "iomanip": "cpp", 65 | "ios": "cpp", 66 | "istream": "cpp", 67 | "map": "cpp", 68 | "ostream": "cpp", 69 | "queue": "cpp", 70 | "ranges": "cpp", 71 | "set": "cpp", 72 | "span": "cpp", 73 | "sstream": "cpp", 74 | "xtree": "cpp" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chrome Power 2 | 3 | ![Visualization](pic.png) 4 | 5 | --- 6 | 7 | **Warning: 此项目只有作者一人在维护,指纹启动方式目前已经失效,目前已在代码中注释掉了(有条件请自行修改编译)。目前只单纯作为 Chrome 多开管理工具使用,支持独立窗口,http/socks5 代理,API 管理等。** 8 | 9 | 首款开源~~指纹浏览器~~ Chrome 多开管理工具。基于 Puppeteer、Electron、React 开发。 10 | 11 | 此软件遵循 AGPL 协议,因此如果你想对其进行修改发布,请保持开源。 12 | 13 | Chromium 源码修改请参考 [chrome-power-chromium](https://github.com/zmzimpl/chrome-power-chromium) 14 | 15 | ## 免责声明 16 | 17 | 本代码仅用于技术交流、学习,请勿用于非法、商业用途。本代码只承诺不保存任何用户数据,不对用户数据负任何责任,请知悉。 18 | 19 | ## 开始 20 | 21 | 按照以下步骤开始使用此软件: 22 | 23 | - 下载安装包[点击此处下载](https://github.com/zmzimpl/chrome-power-app/releases) 24 | - 建议前往设置页面设置你的缓存目录。 25 | - 创建代理 26 | - 创建窗口 27 | - 创建空白窗口 28 | - 导入窗口 29 | - 从模板导入 30 | - 从 AdsPower 导入 31 | 32 | ## 功能 33 | 34 | - [x] 多窗口管理 35 | - [x] 代理设置 36 | - [x] 中英文支持 37 | - [x] Puppeteer/Playwright/Selenium 接入 38 | - [x] ~~支持 cookie 导入~~ 39 | - [x] Mac 安装支持 40 | - [x] 扩展程序管理 41 | - [ ] 同步操作 42 | - [ ] 自动化脚本 43 | 44 | ## 本地运行/打包 45 | 46 | 环境:Node v18.18.2, npm 9.8.1 47 | 48 | - 安装依赖 `npm i` 49 | - 运行调试 `npm run watch` 50 | - (非必要)打包部署 `npm run package`,注意打包时要把开发环境停掉,不然会导致 sqlite3 的包打包不了 51 | 52 | ## API 文档 53 | 54 | [Postman API](https://documenter.getpostman.com/view/25586363/2sA3BkdZ61#intro) 55 | 56 | ## FAQ 57 | 58 | ### 缓存目录如何设置 59 | 60 | 在设置页面,点击缓存目录,选择你的缓存目录,然后点击确定。注意:缓存目录不要设置在 C 盘以及安装目录,否则更新可能导致缓存目录丢失。 61 | 62 | ### Windows 10 安装之后闪退 63 | 64 | 如遇闪退,尝试在安装完成之后,右键启动程序 - 属性,在目标的末尾加入 --no-sandbox 或者 --in-process-gpu,再尝试启动 65 | 66 | ### 代理无法使用 67 | 68 | 目前代理只支持 socks5 和 http, 请检查代理格式是否正确,本地代理是否开启 TUN mode 和 Global mode。请在检查后提起 issue 或者联系作者。 69 | 70 | ### Mac 自动排列无法使用 71 | 72 | Mac 自动排列需要辅助功能权限,可以查看运行日志,如果提示缺少权限,请在设置 - 辅助功能中开启。 73 | 74 | ## 打赏 75 | 76 | 🙌你可以通过向下面的钱包打赏一杯咖啡来支持我 77 | 78 | Bitcoin Address: `bc1p0uex9rn8nd9uyguulp6r3d3t9kylrk42dg6sq22f3h5rktlk22ks6mlv6t` 79 | 80 | Ethereum Address: `0x83DF381FF65806B68AA1963636f4ca87990F2860` 81 | 82 | Solana Address: `HYKo3uVuCQzWkWUkGcGwiDAAsxPYyJZtjf28Xk143eb1` 83 | -------------------------------------------------------------------------------- /assets/IP2LOCATION-LITE-DB11.BIN: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/assets/IP2LOCATION-LITE-DB11.BIN -------------------------------------------------------------------------------- /buildResources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/buildResources/.gitkeep -------------------------------------------------------------------------------- /buildResources/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.apple.security.network.client 7 | 8 | com.apple.security.network.server 9 | 10 | 11 | 12 | com.apple.security.cs.allow-jit 13 | 14 | com.apple.security.cs.allow-unsigned-executable-memory 15 | 16 | com.apple.security.cs.disable-library-validation 17 | 18 | com.apple.security.cs.allow-dyld-environment-variables 19 | 20 | 21 | 22 | com.apple.security.files.user-selected.read-write 23 | 24 | com.apple.security.files.downloads.read-write 25 | 26 | 27 | 28 | com.apple.security.automation.apple-events 29 | 30 | com.apple.security.accessibility 31 | 32 | 33 | -------------------------------------------------------------------------------- /buildResources/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/buildResources/icon.icns -------------------------------------------------------------------------------- /buildResources/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/buildResources/icon.ico -------------------------------------------------------------------------------- /buildResources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/buildResources/icon.png -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First and foremost, thank you! We appreciate that you want to contribute to vite-electron-builder, your time is 4 | valuable, and your contributions mean a lot to us. 5 | 6 | ## Issues 7 | 8 | Do not create issues about bumping dependencies unless a bug has been identified, and you can demonstrate that it 9 | effects this library. 10 | 11 | **Help us to help you** 12 | 13 | Remember that we’re here to help, but not to make guesses about what you need help with: 14 | 15 | - Whatever bug or issue you're experiencing, assume that it will not be as obvious to the maintainers as it is to you. 16 | - Spell it out completely. Keep in mind that maintainers need to think about _all potential use cases_ of a library. 17 | It's important that you explain how you're using a library so that maintainers can make that connection and solve the 18 | issue. 19 | 20 | _It can't be understated how frustrating and draining it can be to maintainers to have to ask clarifying questions on 21 | the most basic things, before it's even possible to start debugging. Please try to make the best use of everyone's time 22 | involved, including yourself, by providing this information up front._ 23 | 24 | ## Repo Setup 25 | 26 | The package manager used to install and link dependencies must be npm v7 or later. 27 | 28 | 1. Clone repo 29 | 1. `npm run watch` start electron app in watch mode. 30 | 1. `npm run compile` build app but for local debugging only. 31 | 1. `npm run lint` lint your code. 32 | 1. `npm run typecheck` Run typescript check. 33 | 1. `npm run test` Run app test. 34 | 1. `npm run format` Reformat all codebase to project code style. 35 | -------------------------------------------------------------------------------- /cursor-directive.md: -------------------------------------------------------------------------------- 1 | ## Core Technologies 2 | 3 | - Electron (v27.0.0) 4 | - React (v18.2.0) 5 | - TypeScript (v5.1.3) 6 | - Node.js 7 | 8 | ## Frontend Stack 9 | 10 | 1. React Ecosystem 11 | - React Router DOM (v6.16.0) 12 | - Redux Toolkit 13 | - React Redux 14 | - Ant Design (v5.14.0) 15 | 16 | 2. Styling & UI 17 | - TailwindCSS 18 | - WindiCSS 19 | - Iconify 20 | 21 | 3. Internationalization 22 | - i18next 23 | - react-i18next 24 | - i18next-browser-languagedetector 25 | 26 | ## Backend & Electron Features 27 | 28 | 1. Database 29 | - SQLite3 30 | - Knex.js (Query Builder) 31 | 32 | 2. System Integration 33 | - Native Addons (node-addon-api) 34 | - Process Management (ps-list) 35 | 36 | 3. Network & Proxy 37 | - Express.js 38 | - Axios 39 | - Proxy Chain 40 | - SOCKS Proxy Support 41 | - HTTP/HTTPS Proxy Agents 42 | - Port Scanner 43 | 44 | 4. Browser Automation 45 | - Puppeteer 46 | - User Agents 47 | 48 | 5. Geolocation & IP 49 | - GeoIP-lite 50 | - IP2Location 51 | - geo-tz 52 | 53 | ## Build & Development 54 | 55 | 1. Build Tools 56 | - Vite 57 | - Electron Builder 58 | - Node-gyp 59 | 60 | 2. Testing 61 | - Vitest 62 | - Playwright 63 | - Happy DOM 64 | 65 | 3. Code Quality 66 | - ESLint 67 | - TypeScript Type Checking 68 | - Prettier 69 | 70 | ## Common Development Queries 71 | 72 | 1. Electron IPC Communication 73 | 2. Native Addon Development 74 | 3. Proxy Configuration 75 | 4. Browser Profile Management 76 | 5. Database Operations 77 | 6. Window Management 78 | 7. Process Control 79 | 8. Network Security 80 | 9. Automated Testing 81 | 10. Build Configuration 82 | 83 | ## Security Considerations 84 | 85 | 1. Proxy Chain Security 86 | 2. Native Addon Safety 87 | 3. IPC Communication Security 88 | 4. Database Access Control 89 | 5. Browser Profile Isolation 90 | 91 | ## Performance Optimization 92 | 93 | 1. Memory Management 94 | 2. Process Communication 95 | 3. Database Query Optimization 96 | 4. Resource Usage Monitoring 97 | 5. Build Size Optimization 98 | 99 | Think in English, answer in Chinese 100 | -------------------------------------------------------------------------------- /examples/ads-migration.js: -------------------------------------------------------------------------------- 1 | import {getAllWindows} from './demo/window.js'; 2 | import fs from 'fs-extra'; 3 | import path from 'path'; 4 | 5 | async function main() { 6 | const fromPath = 'E:\\.ADSPOWER_GLOBAL\\cache'; 7 | const toPath = 'E:\\ChromePowerCache\\chrome'; 8 | const adsDirSuffix = '_g1nkg14'; 9 | try { 10 | const windows = (await getAllWindows()).filter(f => f.group_id); 11 | console.log(windows[0]); 12 | 13 | for (const window of windows) { 14 | const fromDir = path.join(fromPath, window.profile_id + adsDirSuffix, 'Default'); 15 | const toDir = path.join(toPath, window.profile_id, 'Default'); 16 | 17 | // 确保目标目录存在 18 | await fs.ensureDir(toDir); 19 | 20 | // 要复制的文件夹列表 21 | const foldersToMove = [ 22 | 'Extension Rules', 23 | 'Extension Scripts', 24 | 'Extension State', 25 | 'Local Extension Settings', 26 | 'Local Storage', 27 | 'IndexedDB', 28 | ]; 29 | 30 | // 要复制的文件列表 31 | const filesToMove = [ 32 | 'Bookmarks', 33 | 'Bookmarks.bak', 34 | 'History', 35 | 'History-journal', 36 | ]; 37 | 38 | // 复制文件夹 39 | for (const folder of foldersToMove) { 40 | const source = path.join(fromDir, folder); 41 | const destination = path.join(toDir, folder); 42 | 43 | if (await fs.pathExists(source)) { 44 | console.log(`正在复制文件夹: ${folder} (${window.profile_id})`); 45 | if (await fs.pathExists(destination)) { 46 | await fs.remove(destination); 47 | } 48 | await fs.copy(source, destination); 49 | } else { 50 | console.log(`源文件夹不存在: ${folder} (${window.profile_id})`); 51 | } 52 | } 53 | 54 | // 复制文件 55 | for (const file of filesToMove) { 56 | const source = path.join(fromDir, file); 57 | const destination = path.join(toDir, file); 58 | 59 | if (await fs.pathExists(source)) { 60 | console.log(`正在复制文件: ${file} (${window.profile_id})`); 61 | await fs.copy(source, destination, { overwrite: true }); 62 | } else { 63 | console.log(`源文件不存在: ${file} (${window.profile_id})`); 64 | } 65 | } 66 | 67 | console.log(`已完成 ${window.profile_id} 的数据迁移`); 68 | } 69 | 70 | console.log('所有配置文件迁移完成'); 71 | } catch (error) { 72 | console.error('执行过程中出现错误:', error); 73 | } 74 | } 75 | 76 | main(); 77 | -------------------------------------------------------------------------------- /examples/demo/profiles.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | const BASE_URL = 'http://localhost:49156'; 4 | 5 | export async function openProfile(windowId) { 6 | const response = await fetch(`${BASE_URL}/profiles/open?windowId=${windowId}`); 7 | return await response.json(); 8 | } 9 | 10 | export async function closeProfile(windowId) { 11 | const response = await fetch(`${BASE_URL}/profiles/close?windowId=${windowId}`); 12 | return await response.json(); 13 | } 14 | -------------------------------------------------------------------------------- /examples/demo/window.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | const BASE_URL = 'http://localhost:49156'; 4 | 5 | export async function createWindow(name) { 6 | const response = await fetch(`${BASE_URL}/window/create`, { 7 | method: 'POST', 8 | headers: { 9 | 'Content-Type': 'application/json', 10 | }, 11 | body: JSON.stringify({ 12 | name, 13 | }), 14 | }); 15 | return await response.json(); 16 | } 17 | 18 | export async function batchCreateWindows(windows) { 19 | const results = []; 20 | for (const window of windows) { 21 | try { 22 | const result = await createWindow(window.name); 23 | results.push(result); 24 | } catch (error) { 25 | console.error(`创建窗口失败: ${window.name}`, error); 26 | } 27 | } 28 | return results; 29 | } 30 | 31 | export async function getAllWindows() { 32 | const response = await fetch(`${BASE_URL}/window/all`); 33 | return await response.json(); 34 | } 35 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | import {batchCreateWindows, getAllWindows} from './demo/window.js'; 4 | import {openProfile} from './demo/profiles.js'; 5 | 6 | async function main() { 7 | try { 8 | // 批量创建窗口 9 | const windowsToCreate = [{name: '窗口1'}, {name: '窗口2'}, {name: '窗口3'}]; 10 | 11 | console.log('开始创建窗口...'); 12 | const createdWindows = await batchCreateWindows(windowsToCreate); 13 | console.log('创建的窗口:', createdWindows); 14 | 15 | console.log('打开指定 id 的窗口'); 16 | const openResult = await openProfile(247); 17 | console.log('打开结果:', openResult); 18 | 19 | const windows = await getAllWindows(); 20 | 21 | const openedWindows = windows?.filter(f => f.status > 1); 22 | console.log('已打开的窗口:', openedWindows); 23 | 24 | const connectInfo = await fetch(`http://localhost:${openedWindows[0].port}/json/version`); 25 | 26 | console.log(await connectInfo.json()); 27 | } catch (error) { 28 | console.error('执行过程中出现错误:', error); 29 | } 30 | } 31 | 32 | main(); 33 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "license": "AGPL-version-3.0", 8 | "private": false, 9 | "engines": { 10 | "node": ">= 14.0.0", 11 | "npm": ">= 6.0.0" 12 | }, 13 | "homepage": "", 14 | "repository": { 15 | "type": "git", 16 | "url": "" 17 | }, 18 | "bugs": "", 19 | "keywords": [], 20 | "author": { 21 | "name": "", 22 | "email": "", 23 | "url": "" 24 | }, 25 | "contributors": [], 26 | "scripts": { 27 | "dev": "", 28 | "test": "" 29 | }, 30 | "dependencies": { 31 | "inquirer": "^9.2.12", 32 | "node-fetch": "^3.3.2", 33 | "puppeteer": "^24.2.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/puppeteer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | 4 | import puppeteer from 'puppeteer'; 5 | import {openProfile} from './demo/profiles.js'; 6 | export async function createBrowser(wsEndpoint) { 7 | const browser = await puppeteer.connect({ 8 | browserWSEndpoint: wsEndpoint, 9 | defaultViewport: null, 10 | }); 11 | return browser; 12 | } 13 | 14 | // 辅助函数:随机等待时间 15 | async function randomWait(min, max) { 16 | const waitTime = Math.floor(Math.random() * (max - min + 1) + min); 17 | await new Promise(resolve => setTimeout(resolve, waitTime)); 18 | } 19 | 20 | // 主要的自动化脚本函数 21 | export async function autoScript(browser) { 22 | try { 23 | 24 | } catch (error) { 25 | console.error('自动化脚本执行出错:', error); 26 | } 27 | } 28 | 29 | (async () => { 30 | const openResult = await openProfile(77); 31 | console.log(openResult); 32 | const browser = await createBrowser(openResult.browser.webSocketDebuggerUrl); 33 | await autoScript(browser); 34 | // await browser.close(); // Only if you want to close the external Chrome instance. 35 | })(); 36 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | client: 'sqlite3', 3 | migrations: { 4 | directory: './migrations', 5 | }, 6 | useNullAsDefault: true, 7 | }; 8 | -------------------------------------------------------------------------------- /migrations/20231017020548_init_table.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import("knex").Knex } knex 3 | * @returns { Promise } 4 | */ 5 | exports.up = function (knex) { 6 | return knex.schema 7 | .createTable('tag', table => { 8 | table.increments('id').primary(); 9 | table.string('name').notNullable(); 10 | table.string('color').nullable(); 11 | table.timestamp('created_at').defaultTo(knex.fn.now()); 12 | }) 13 | .createTable('group', table => { 14 | table.increments('id').primary().unique(); 15 | table.string('name').notNullable(); 16 | table.timestamp('created_at').defaultTo(knex.fn.now()); 17 | }) 18 | .createTable('proxy', table => { 19 | table.increments('id').primary().unique(); 20 | table.string('ip').nullable(); 21 | table.string('proxy').notNullable(); 22 | table.string('proxy_type').notNullable(); 23 | table.string('ip_checker').nullable(); 24 | table.string('ip_country').nullable(); 25 | table.json('remark').defaultTo(null); 26 | table.json('check_result').defaultTo(null); 27 | table.timestamp('created_at').defaultTo(knex.fn.now()); 28 | table.timestamp('checked_at').defaultTo(null).nullable(); 29 | }) 30 | .createTable('window', table => { 31 | table.increments('id').primary().unique(); 32 | table.integer('group_id').references('id').inTable('group').nullable(); 33 | table.integer('proxy_id').references('id').inTable('group').nullable(); 34 | table.json('tags').nullable(); 35 | table.string('name').nullable(); 36 | table.string('remark').nullable(); 37 | table.json('cookie').nullable(); 38 | table.string('ua').nullable(); 39 | table.integer('status').defaultTo(1); 40 | table.integer('port').defaultTo(null); 41 | table.timestamp('created_at').defaultTo(knex.fn.now()); 42 | table.timestamp('updated_at').defaultTo(null).nullable(); 43 | table.timestamp('opened_at').defaultTo(null).nullable(); 44 | }); 45 | }; 46 | 47 | /** 48 | * @param { import("knex").Knex } knex 49 | * @returns { Promise } 50 | */ 51 | exports.down = function (knex) { 52 | return knex.schema.dropTable('tag').dropTable('group').dropTable('proxy').dropTable('window'); 53 | }; 54 | -------------------------------------------------------------------------------- /migrations/20231114045619_add_profile_id_to_window_table.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import("knex").Knex } knex 3 | * @returns { Promise } 4 | */ 5 | exports.up = async function (knex) { 6 | // Step 1: Add the profile_id column, allowing NULL 7 | await knex.schema.table('window', table => { 8 | table.string('profile_id', 255); 9 | }); 10 | 11 | // Step 2: Update existing rows with a unique profile_id 12 | const windows = await knex.select('*').from('window'); 13 | for (const window of windows) { 14 | await knex('window').where({id: window.id}).update({profile_id: generateUniqueProfileId()}); 15 | } 16 | 17 | // Step 3: Alter the column to set it to NOT NULL 18 | await knex.schema.table('window', table => { 19 | table.string('profile_id', 255).notNullable().alter(); 20 | }); 21 | }; 22 | 23 | /** 24 | * @param { import("knex").Knex } knex 25 | * @returns { Promise } 26 | */ 27 | exports.down = function (knex) { 28 | return knex.schema.table('window', function (table) { 29 | table.dropColumn('profile_id'); 30 | }); 31 | }; 32 | 33 | function generateUniqueProfileId(length = 7) { 34 | let result = ''; 35 | const characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; 36 | const charactersLength = characters.length; 37 | 38 | for (let i = 0; i < length; i++) { 39 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 40 | } 41 | 42 | return result; 43 | } 44 | -------------------------------------------------------------------------------- /migrations/20240317074532_add_fingerprint_to_window.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import("knex").Knex } knex 3 | * @returns { Promise } 4 | */ 5 | exports.up = async function (knex) { 6 | // Step 1: Add the profile_id column, allowing NULL 7 | await knex.schema.table('window', table => { 8 | table.text('fingerprint').nullable(); 9 | }); 10 | }; 11 | 12 | /** 13 | * @param { import("knex").Knex } knex 14 | * @returns { Promise } 15 | */ 16 | exports.down = function (knex) { 17 | return knex.schema.table('window', function (table) { 18 | table.dropColumn('fingerprint'); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /migrations/20250206085919_create_extension_table.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import("knex").Knex } knex 3 | * @returns { Promise } 4 | */ 5 | exports.up = function (knex) { 6 | return knex.schema.createTable('extension', table => { 7 | table.increments('id').primary().unique(); 8 | table.string('name').notNullable(); 9 | table.string('version').notNullable(); 10 | table.string('path').notNullable(); 11 | table.json('windows').nullable(); 12 | table.string('icon').nullable(); 13 | table.text('description').nullable(); 14 | table.timestamp('created_at').defaultTo(knex.fn.now()); 15 | table.timestamp('updated_at').defaultTo(null).nullable(); 16 | }); 17 | }; 18 | 19 | /** 20 | * @param { import("knex").Knex } knex 21 | * @returns { Promise } 22 | */ 23 | exports.down = function (knex) { 24 | return knex.schema.dropTable('extension'); 25 | }; 26 | -------------------------------------------------------------------------------- /migrations/20250207102708_update_extension_table.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import("knex").Knex } knex 3 | * @returns { Promise } 4 | */ 5 | exports.up = async function (knex) { 6 | await knex.schema.alterTable('extension', table => { 7 | table.string('version').nullable().alter(); 8 | }); 9 | }; 10 | 11 | /** 12 | * @param { import("knex").Knex } knex 13 | * @returns { Promise } 14 | */ 15 | exports.down = function (knex) { 16 | return knex.schema.alterTable('extension', function (table) { 17 | table.string('version').notNullable().alter(); 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /migrations/20250208075039_window_extension_table.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import("knex").Knex } knex 3 | * @returns { Promise } 4 | */ 5 | exports.up = function (knex) { 6 | return knex.schema.createTable('window_extension', table => { 7 | table.increments('id').primary().unique(); 8 | table.integer('window_id').notNullable(); 9 | table.integer('extension_id').notNullable(); 10 | table.timestamp('created_at').defaultTo(knex.fn.now()); 11 | }); 12 | }; 13 | 14 | /** 15 | * @param { import("knex").Knex } knex 16 | * @returns { Promise } 17 | */ 18 | exports.down = function (knex) { 19 | return knex.schema.dropTable('window_extension'); 20 | }; 21 | -------------------------------------------------------------------------------- /migrations/20250221075247_add_pid_to_window.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import("knex").Knex } knex 3 | * @returns { Promise } 4 | */ 5 | exports.up = async function (knex) { 6 | // Step 1: Add the profile_id column, allowing NULL 7 | await knex.schema.table('window', table => { 8 | table.integer('pid').nullable(); 9 | }); 10 | }; 11 | 12 | /** 13 | * @param { import("knex").Knex } knex 14 | * @returns { Promise } 15 | */ 16 | exports.down = function (knex) { 17 | return knex.schema.table('window', function (table) { 18 | table.dropColumn('pid'); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/main/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | import {getDbPath} from '../utils/get-db-path'; 2 | import {app} from 'electron'; 3 | import {join} from 'path'; 4 | 5 | export const DB_CONFIG = { 6 | client: 'sqlite3', 7 | connection: { 8 | filename: getDbPath(), 9 | }, 10 | migrations: { 11 | directory: import.meta.env.MODE === 'development' ? 'migrations' : 'resources/app/migrations', 12 | }, 13 | useNullAsDefault: true, 14 | }; 15 | 16 | export const APP_LOGGER_LABEL = 'App'; 17 | export const SERVICE_LOGGER_LABEL = 'Service'; 18 | export const WINDOW_LOGGER_LABEL = 'Window'; 19 | export const PROXY_LOGGER_LABEL = 'Proxy'; 20 | export const API_LOGGER_LABEL = 'Api'; 21 | export const MAIN_LOGGER_LABEL = 'Main'; 22 | 23 | export const CONFIG_FILE_PATH = join(app.getPath('userData'), 'chrome-power-config.json'); 24 | export const LOGS_PATH = join(app.getPath('userData'), 'logs'); 25 | -------------------------------------------------------------------------------- /packages/main/src/db/extension.ts: -------------------------------------------------------------------------------- 1 | import {db} from '.'; 2 | import type {DB} from '../../../shared/types/db'; 3 | 4 | const getAllExtensions = async () => { 5 | return await db('extension').select('*'); 6 | }; 7 | 8 | const getExtensionById = async (id: number) => { 9 | return await db('extension').where({id}).first(); 10 | }; 11 | 12 | const createExtension = async (extension: DB.Extension) => { 13 | return await db('extension').insert(extension); 14 | }; 15 | 16 | const updateExtension = async (id: number, extension: Partial) => { 17 | const extensionData = await getExtensionById(id); 18 | if (!extensionData) { 19 | throw new Error('Extension not found'); 20 | } 21 | 22 | return await db('extension') 23 | .where({id}) 24 | .update({ 25 | ...extensionData, 26 | ...extension, 27 | }); 28 | }; 29 | 30 | const insertExtensionWindows = async (id: number, windows: number[]) => { 31 | for (const windowId of windows) { 32 | await db('window_extension').insert({extension_id: id, window_id: windowId}); 33 | } 34 | }; 35 | 36 | const getExtensionsByWindowId = async (windowId: number) => { 37 | const extensionIds = await db('window_extension') 38 | .where({window_id: windowId}) 39 | .select('extension_id'); 40 | return await db('extension').whereIn( 41 | 'id', 42 | extensionIds.map(e => e.extension_id), 43 | ); 44 | }; 45 | 46 | const deleteExtensionWindows = async (id: number, windowIds: number[]) => { 47 | return await db('window_extension') 48 | .where({extension_id: id}) 49 | .whereIn('window_id', windowIds) 50 | .delete(); 51 | }; 52 | 53 | const deleteWindowReleted = async (windowIds: number | number[]) => { 54 | return await db('window_extension') 55 | .whereIn('window_id', Array.isArray(windowIds) ? windowIds : [windowIds]) 56 | .delete(); 57 | }; 58 | 59 | const getExtensionWindows = async (id: number) => { 60 | return await db('window_extension').where({extension_id: id}); 61 | }; 62 | 63 | const deleteExtension = async (id: number) => { 64 | const relatedWindows = await getExtensionWindows(id); 65 | if (relatedWindows.length > 0) { 66 | return { 67 | success: false, 68 | message: 'Extension is still in use', 69 | }; 70 | } else { 71 | return await db('extension').where({id}).delete(); 72 | } 73 | }; 74 | 75 | export const ExtensionDB = { 76 | getAllExtensions, 77 | getExtensionById, 78 | createExtension, 79 | updateExtension, 80 | deleteExtension, 81 | deleteWindowReleted, 82 | insertExtensionWindows, 83 | deleteExtensionWindows, 84 | getExtensionWindows, 85 | getExtensionsByWindowId, 86 | }; 87 | -------------------------------------------------------------------------------- /packages/main/src/db/group.ts: -------------------------------------------------------------------------------- 1 | import {db} from '.'; 2 | import type {DB} from '../../../shared/types/db'; 3 | 4 | const all = async () => { 5 | return await db('group').select('*'); 6 | }; 7 | 8 | const getById = async (id: number) => { 9 | return await db('group').where({id}).first(); 10 | }; 11 | const getByName = async (name: string) => { 12 | return await db('group').where({name}).first(); 13 | }; 14 | 15 | const update = async (id: number, updatedData: DB.Group) => { 16 | return await db('group').where({id}).update(updatedData); 17 | }; 18 | 19 | const create = async (groupData: DB.Group) => { 20 | return await db('group').insert(groupData); 21 | }; 22 | 23 | const remove = async (id: number) => { 24 | return await db('group').where({id}).delete(); 25 | }; 26 | 27 | const deleteAll = async () => { 28 | return await db('group').delete(); 29 | }; 30 | 31 | export const GroupDB = { 32 | all, 33 | getById, 34 | getByName, 35 | update, 36 | create, 37 | remove, 38 | deleteAll, 39 | }; 40 | -------------------------------------------------------------------------------- /packages/main/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import knex from 'knex'; 2 | import {app} from 'electron'; 3 | import {mkdirSync, existsSync} from 'fs'; 4 | import {DB_CONFIG} from '../constants'; 5 | import {WindowDB} from './window'; 6 | import {resetWindowStatus} from '../fingerprint'; 7 | import {join} from 'path'; 8 | 9 | // import {ProxyDB} from './proxy'; 10 | // import {GroupDB} from './group'; 11 | // import {TagDB} from './tag'; 12 | 13 | const db = knex(DB_CONFIG); 14 | 15 | const initWindowStatus = async () => { 16 | const windows = await WindowDB.all(); 17 | for (let index = 0; index < windows.length; index++) { 18 | const window = windows[index]; 19 | if (window.status === 2) { 20 | await resetWindowStatus(window.id); 21 | } 22 | } 23 | }; 24 | 25 | const initializeDatabase = async () => { 26 | const userDataPath = app.getPath('userData'); 27 | 28 | // 确保目录存在 29 | if (!existsSync(userDataPath)) { 30 | mkdirSync(userDataPath, {recursive: true}); 31 | } 32 | 33 | try { 34 | // 初始化数据库连接 35 | await db.raw('SELECT 1'); 36 | 37 | // 运行迁移 38 | await db.migrate.latest({ 39 | directory: app.isPackaged ? join(process.resourcesPath, 'app/migrations') : './migrations', 40 | }); 41 | 42 | // 初始化窗口状态 43 | await initWindowStatus(); 44 | 45 | console.log('Database initialized successfully'); 46 | } catch (error) { 47 | console.error('Database initialization failed:', error); 48 | throw error; 49 | } 50 | }; 51 | 52 | export {db, initializeDatabase}; 53 | -------------------------------------------------------------------------------- /packages/main/src/db/proxy.ts: -------------------------------------------------------------------------------- 1 | import {db} from '.'; 2 | import type {DB, SafeAny} from '../../../shared/types/db'; 3 | 4 | const all = async () => { 5 | return await db('proxy') 6 | .leftJoin('window', function () { 7 | this.on('window.proxy_id', '=', 'proxy.id').andOn('window.status', '>', 0 as SafeAny); // 增加的筛选条件 8 | }) 9 | .select('proxy.*') 10 | .count('window.id as usageCount') 11 | .groupBy('proxy.id') 12 | .orderBy('proxy.created_at', 'desc'); 13 | }; 14 | 15 | const getById = async (id: number) => { 16 | return await db('proxy').where({id}).first(); 17 | }; 18 | 19 | const getByProxy = async (proxy_type?: string, proxy?: string) => { 20 | return await db('proxy').where({proxy_type, proxy}).first(); 21 | }; 22 | 23 | const update = async (id: number, updatedData: DB.Proxy) => { 24 | return await db('proxy').where({id}).update(updatedData); 25 | }; 26 | 27 | const create = async (proxyData: DB.Proxy) => { 28 | return await db('proxy').insert(proxyData); 29 | }; 30 | 31 | const importProxies = async (proxies: DB.Proxy[]) => { 32 | return await db('proxy').insert(proxies); 33 | }; 34 | 35 | const remove = async (id: number) => { 36 | return await db('proxy').where({id}).delete(); 37 | }; 38 | 39 | const deleteAll = async () => { 40 | return await db('proxy').delete(); 41 | }; 42 | 43 | const batchDelete = async (ids: number[]) => { 44 | // 首先,检查这些 IDs 是否被 window 表所引用 45 | const referencedIds = await db('window') 46 | .select('proxy_id') 47 | .where('status', '>', 0) 48 | .whereIn('proxy_id', ids) 49 | .then(rows => rows.map(row => row.proxy_id)); 50 | 51 | // 如果有被引用的 ID,可以选择抛出错误或者返回相关信息 52 | if (referencedIds.length > 0) { 53 | // 或者返回相关信息 54 | return {success: false, message: 'Some IDs are referenced in the window table.', referencedIds}; 55 | } else { 56 | try { 57 | await db('proxy').delete().whereIn('id', ids); 58 | return {success: true}; 59 | } catch (error) { 60 | return {success: false, message: 'Failed to delete.'}; 61 | } 62 | } 63 | }; 64 | 65 | export const ProxyDB = { 66 | all, 67 | getById, 68 | getByProxy, 69 | batchDelete, 70 | importProxies, 71 | update, 72 | create, 73 | remove, 74 | deleteAll, 75 | }; 76 | -------------------------------------------------------------------------------- /packages/main/src/db/tag.ts: -------------------------------------------------------------------------------- 1 | import {db} from '.'; 2 | import type {DB} from '../../../shared/types/db'; 3 | 4 | const all = async () => { 5 | return await db('tag').select('*'); 6 | }; 7 | 8 | const getById = async (id: number) => { 9 | return await db('tag').where({id}).first(); 10 | }; 11 | const getByName = async (name: string) => { 12 | return await db('tag').where({name}).first(); 13 | }; 14 | 15 | const update = async (id: number, updatedData: DB.Tag) => { 16 | return await db('tag').where({id}).update(updatedData); 17 | }; 18 | 19 | const create = async (tagData: DB.Tag) => { 20 | return await db('tag').insert(tagData); 21 | }; 22 | 23 | const remove = async (id: number) => { 24 | return await db('tag').where({id}).delete(); 25 | }; 26 | 27 | const deleteAll = async () => { 28 | return await db('tag').delete(); 29 | }; 30 | 31 | export const TagDB = { 32 | all, 33 | getById, 34 | getByName, 35 | update, 36 | create, 37 | remove, 38 | deleteAll, 39 | }; 40 | -------------------------------------------------------------------------------- /packages/main/src/fingerprint/device.ts: -------------------------------------------------------------------------------- 1 | import {execSync} from 'child_process'; 2 | import * as os from 'os'; 3 | import {createLogger} from '../../../shared/utils/logger'; 4 | import {APP_LOGGER_LABEL} from '../constants'; 5 | import {parse} from 'path'; 6 | import {existsSync} from 'fs'; 7 | 8 | const logger = createLogger(APP_LOGGER_LABEL); 9 | 10 | export function getOperatingSystem() { 11 | const platform = os.platform(); 12 | 13 | if (platform === 'win32') { 14 | return 'Windows'; 15 | } else if (platform === 'darwin') { 16 | return 'Mac'; 17 | } else if (platform === 'linux') { 18 | return 'Linux'; 19 | } else { 20 | return 'Unknown OS'; 21 | } 22 | } 23 | 24 | export function getChromePath() { 25 | const operatingSystem = getOperatingSystem(); 26 | let chromePath; 27 | switch (operatingSystem) { 28 | case 'Windows': 29 | { 30 | const stdoutBuffer = execSync( 31 | 'reg query "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe" /ve', 32 | ); 33 | const stdout = stdoutBuffer.toString(); 34 | const match = stdout.match(/(.:\\.*chrome.exe)/); 35 | if (match) { 36 | chromePath = match[1]; 37 | } else { 38 | logger.error('Chrome not found'); 39 | } 40 | } 41 | break; 42 | case 'Mac': 43 | { 44 | try { 45 | // 首先检查默认安装路径 46 | const defaultPath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; 47 | if (existsSync(defaultPath)) { 48 | chromePath = defaultPath; 49 | } else { 50 | // 使用 mdfind 搜索 51 | const stdoutBuffer = execSync( 52 | 'mdfind "kMDItemCFBundleIdentifier == \'com.google.Chrome\'"', 53 | ); 54 | const stdout = stdoutBuffer.toString(); 55 | const paths = stdout 56 | .split('\n') 57 | .filter(path => path.endsWith('/Google Chrome.app')) 58 | .map(path => `${path}/Contents/MacOS/Google Chrome`); 59 | 60 | if (paths.length > 0 && existsSync(paths[0])) { 61 | chromePath = paths[0]; 62 | } else { 63 | // 尝试其他可能的路径 64 | const alternativePaths = [ 65 | '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 66 | `${process.env.HOME}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`, 67 | '/usr/bin/google-chrome', 68 | ]; 69 | 70 | for (const path of alternativePaths) { 71 | if (existsSync(path)) { 72 | chromePath = path; 73 | break; 74 | } 75 | } 76 | } 77 | } 78 | 79 | if (!chromePath) { 80 | logger.error('Chrome executable not found in any standard location'); 81 | } else { 82 | // 确保文件有执行权限 83 | execSync(`chmod +x "${chromePath}"`); 84 | } 85 | } catch (error) { 86 | logger.error(`Error finding Chrome: ${error}`); 87 | } 88 | } 89 | break; 90 | default: 91 | break; 92 | } 93 | return chromePath; 94 | } 95 | 96 | export function getRootDir() { 97 | const installationPath = process.resourcesPath; 98 | const parsedPath = parse(installationPath); 99 | // 获取根目录 100 | const rootDirectory = parsedPath.root; 101 | return rootDirectory; 102 | } 103 | -------------------------------------------------------------------------------- /packages/main/src/index.ts: -------------------------------------------------------------------------------- 1 | import {BrowserWindow, app, globalShortcut} from 'electron'; 2 | import './security-restrictions'; 3 | import {restoreOrCreateWindow} from '/@/mainWindow'; 4 | import {platform} from 'node:process'; 5 | import {db, initializeDatabase} from './db'; 6 | import {initServices} from './services'; 7 | import {createLogger} from '../../shared/utils/logger'; 8 | import {MAIN_LOGGER_LABEL} from './constants'; 9 | import './server/index'; 10 | 11 | const logger = createLogger(MAIN_LOGGER_LABEL); 12 | 13 | /** 14 | * Prevent electron from running multiple instances. 15 | */ 16 | const isSingleInstance = app.requestSingleInstanceLock(); 17 | if (!isSingleInstance) { 18 | app.quit(); 19 | process.exit(0); 20 | } 21 | app.on('second-instance', restoreOrCreateWindow); 22 | 23 | /** 24 | * Disable Hardware Acceleration to save more system resources. 25 | */ 26 | app.disableHardwareAcceleration(); 27 | 28 | /** 29 | * Shout down background process if all windows was closed 30 | */ 31 | app.on('window-all-closed', () => { 32 | if (platform !== 'darwin') { 33 | app.quit(); 34 | } 35 | }); 36 | 37 | /** 38 | * @see https://www.electronjs.org/docs/latest/api/app#event-activate-macos Event: 'activate'. 39 | */ 40 | app.on('activate', restoreOrCreateWindow); 41 | 42 | /** 43 | * Create the application window when the background process is ready. 44 | */ 45 | app 46 | .whenReady() 47 | .then(async () => { 48 | globalShortcut.register('CommandOrControl+Shift+I', () => { 49 | const win = BrowserWindow.getFocusedWindow(); 50 | if (win) { 51 | win.webContents.toggleDevTools(); 52 | } 53 | }); 54 | try { 55 | await initializeDatabase(); 56 | } catch (error) { 57 | const errorString = error && typeof error === 'string' ? error : JSON.stringify(error); 58 | logger.error(`Failed initialize database: ${errorString}`); 59 | } 60 | await initServices(); 61 | await restoreOrCreateWindow(); 62 | // if (!import.meta.env.DEV) { 63 | // const {result, error, exist} = await extractChromeBin(); 64 | // if (result) { 65 | // if (!exist) { 66 | // logger.info('Extracted Chrome-bin.zip'); 67 | // } 68 | // } else { 69 | // logger.error('Failed extract Chrome-bin.zip, try to manually extract it', error); 70 | // } 71 | // } 72 | }) 73 | .catch(e => logger.error('Failed create window:', e)); 74 | 75 | /** 76 | * Install Vue.js or any other extension in development mode only. 77 | * Note: You must install `electron-devtools-installer` manually 78 | */ 79 | // REACT_DEVELOPER_TOOLS doesn't work 80 | // if (import.meta.env.DEV) { 81 | // app 82 | // .whenReady() 83 | // .then(() => import('electron-devtools-installer')) 84 | // .then(module => { 85 | // const {default: installExtension, REACT_DEVELOPER_TOOLS} = 86 | // // @ts-expect-error Hotfix for https://github.com/cawa-93/vite-electron-builder/issues/915 87 | // typeof module.default === 'function' ? module : (module.default as typeof module); 88 | 89 | // return installExtension(REACT_DEVELOPER_TOOLS, { 90 | // loadExtensionOptions: { 91 | // allowFileAccess: true, 92 | // }, 93 | // }); 94 | // }) 95 | // .catch(e => console.error('Failed install extension:', e)); 96 | // } 97 | 98 | /** 99 | * Check for app updates, install it in background and notify user that new version was installed. 100 | * No reason run this in non-production build. 101 | * @see https://www.electron.build/auto-update.html#quick-setup-guide 102 | * 103 | * Note: It may throw "ENOENT: no such file app-update.yml" 104 | * if you compile production app without publishing it to distribution server. 105 | * Like `npm run compile` does. It's ok 😅 106 | */ 107 | if (import.meta.env.PROD) { 108 | app 109 | .whenReady() 110 | .then(() => 111 | /** 112 | * Here we forced to use `require` since electron doesn't fully support dynamic import in asar archives 113 | * @see https://github.com/electron/electron/issues/38829 114 | * Potentially it may be fixed by this https://github.com/electron/electron/pull/37535 115 | */ 116 | require('electron-updater').autoUpdater.checkForUpdatesAndNotify(), 117 | ) 118 | .catch(e => console.error('Failed check and install updates:', e)); 119 | } 120 | 121 | app.on('before-quit', async () => { 122 | await db.destroy(); 123 | }); 124 | 125 | process.on('uncaughtException', (error) => { 126 | logger.error('Uncaught exception:', error); 127 | }); 128 | 129 | 130 | process.on('unhandledRejection', (reason) => { 131 | logger.error('Unhandled rejection:', reason); 132 | }); -------------------------------------------------------------------------------- /packages/main/src/native-addon/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | project(window_addon) 3 | 4 | if(MSVC) 5 | add_compile_options(/utf-8) 6 | endif() 7 | 8 | # 设置 C++ 标准 9 | set(CMAKE_CXX_STANDARD 17) 10 | set(CMAKE_CXX_STANDARD_REQUIRED ON) 11 | 12 | # 查找 Node.js 依赖 13 | execute_process(COMMAND node -p "require('node-addon-api').include" 14 | WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} 15 | OUTPUT_VARIABLE NODE_ADDON_API_DIR 16 | OUTPUT_STRIP_TRAILING_WHITESPACE 17 | ) 18 | 19 | execute_process( 20 | COMMAND node -e "console.log(process.versions.node)" 21 | OUTPUT_VARIABLE NODE_VERSION 22 | OUTPUT_STRIP_TRAILING_WHITESPACE 23 | ) 24 | 25 | execute_process( 26 | COMMAND node -p "process.execPath" 27 | OUTPUT_VARIABLE NODE_EXECUTABLE_PATH 28 | OUTPUT_STRIP_TRAILING_WHITESPACE 29 | ) 30 | 31 | get_filename_component(NODE_DIR ${NODE_EXECUTABLE_PATH} DIRECTORY) 32 | 33 | string(REPLACE "\"" "" NODE_ADDON_API_DIR ${NODE_ADDON_API_DIR}) 34 | 35 | # 添加头文件路径 36 | include_directories( 37 | ${NODE_ADDON_API_DIR} 38 | ${NODE_DIR}/../include/node 39 | ) 40 | 41 | # 添加源文件 42 | add_library(${PROJECT_NAME} SHARED 43 | window-addon.cpp 44 | ) 45 | 46 | # 设置目标属性 47 | set_target_properties(${PROJECT_NAME} PROPERTIES 48 | PREFIX "" 49 | SUFFIX ".node" 50 | ) 51 | 52 | # 根据平台添加不同的链接库 53 | if(WIN32) 54 | target_link_libraries(${PROJECT_NAME} PRIVATE) 55 | elseif(APPLE) 56 | target_link_libraries(${PROJECT_NAME} PRIVATE 57 | "-framework CoreFoundation" 58 | "-framework ApplicationServices" 59 | ) 60 | endif() 61 | 62 | # 定义 NAPI_VERSION 63 | target_compile_definitions(${PROJECT_NAME} PRIVATE 64 | NAPI_VERSION=8 65 | ) -------------------------------------------------------------------------------- /packages/main/src/native-addon/binding.gyp: -------------------------------------------------------------------------------- 1 | { 2 | "targets": [ 3 | { 4 | "target_name": "window-addon", 5 | "sources": [ "window-addon.cpp" ], 6 | "include_dirs": [ 7 | "> = new Map(); 10 | 11 | // const cookieToMap = (windowId: number, cookies: ICookie[]) => { 12 | // const map = new Map(); 13 | // cookies.forEach(cookie => { 14 | // console.log(cookie.domain); 15 | // let domain; 16 | // if (cookie.domain?.startsWith('.')) { 17 | // domain = cookie.domain.slice(1); 18 | // } 19 | // if (!map.get(domain!)) { 20 | // map.set(domain!, [cookie]); 21 | // } else { 22 | // const domainCookies = map.get(domain!); 23 | // domainCookies?.push(cookie); 24 | // map.set(domain!, domainCookies!); 25 | // } 26 | // }); 27 | // cookieMap.set(windowId, map); 28 | // }; 29 | 30 | const getCookie = (windowId: number, domain: CookieDomain) => { 31 | const map = cookieMap.get(windowId); 32 | if (map) { 33 | return map.get(domain); 34 | } 35 | return null; 36 | }; 37 | 38 | const parseCookie = (cookie: string) => { 39 | // const correctedCookie = cookie.replace(/(\w+)(?=:)/g, '"$1"'); 40 | try { 41 | const jsonArray = JSON.parse(cookie); 42 | return jsonArray; 43 | } catch (error) { 44 | console.error('解析错误:', error); 45 | bridgeMessageToUI({ 46 | type: 'error', 47 | text: 'Cookie JSON 解析错误', 48 | }); 49 | } 50 | }; 51 | 52 | export const setCookieToPage = async (windowId: number, page: Page) => { 53 | const url = page.url(); 54 | const urlObj = new URL(url); 55 | const domain = urlObj.hostname; 56 | const cookie = getCookie(windowId, domain); 57 | const pageCookies = await page.cookies(); 58 | console.log(domain, typeof pageCookies, pageCookies.length, cookie?.length); 59 | if (!pageCookies.length) { 60 | if (cookie?.length) { 61 | console.log('set cookie:', cookie); 62 | await page.setCookie(...cookie); 63 | } 64 | } 65 | }; 66 | 67 | // 限流函数,限制同时执行的任务数 68 | // function limitConcurrency(maxConcurrentTasks: number) { 69 | // let activeTasks = 0; 70 | // const taskQueue: (() => Promise)[] = []; 71 | 72 | // function next() { 73 | // if (activeTasks < maxConcurrentTasks && taskQueue.length > 0) { 74 | // activeTasks++; 75 | // const task = taskQueue.shift(); 76 | // task!().finally(() => { 77 | // activeTasks--; 78 | // next(); 79 | // }); 80 | // } 81 | // } 82 | 83 | // return (task: () => Promise) => { 84 | // taskQueue.push(task); 85 | // next(); 86 | // }; 87 | // } 88 | 89 | export const presetCookie = async (windowId: number, browser: Browser) => { 90 | const window = await WindowDB.getById(windowId); 91 | if (window?.cookie) { 92 | if (typeof window.cookie === 'string') { 93 | const correctedCookie = parseCookie(window.cookie); 94 | const page = await browser.newPage(); 95 | const client = await page.target().createCDPSession(); 96 | await client.send('Network.enable'); 97 | await client.send('Network.setCookies', { 98 | cookies: correctedCookie, 99 | }); 100 | await page.close(); 101 | } 102 | } 103 | return true; 104 | }; 105 | 106 | // export const pageRequestInterceptor = async (windowId: number, page: Page) => { 107 | // const url = page.url(); 108 | // const urlObj = new URL(url); 109 | // page.on('request', async request => { 110 | 111 | // request.continue(); 112 | // }); 113 | // }; 114 | 115 | export const modifyPageInfo = async (windowId: number, page: Page, ipInfo: IP) => { 116 | page.on('framenavigated', async _msg => { 117 | try { 118 | const title = await page.title(); 119 | if (!title.includes('By ChromePower')) { 120 | await page.evaluate(title => { 121 | document.title = title + ' By ChromePower'; 122 | }, title); 123 | } 124 | 125 | await page.setGeolocation({latitude: ipInfo.ll?.[0], longitude: ipInfo.ll?.[1]}); 126 | await page.emulateTimezone(ipInfo.timeZone); 127 | } catch (error) { 128 | console.error('| ModifyPageInfo | error:', error); 129 | } 130 | }); 131 | await page.evaluateOnNewDocument( 132 | 'navigator.mediaDevices.getUserMedia = navigator.webkitGetUserMedia = navigator.mozGetUserMedia = navigator.getUserMedia = webkitRTCPeerConnection = RTCPeerConnection = MediaStreamTrack = undefined;', 133 | ); 134 | }; 135 | -------------------------------------------------------------------------------- /packages/main/src/puppeteer/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/main/src/puppeteer/index.ts -------------------------------------------------------------------------------- /packages/main/src/puppeteer/tasks.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/main/src/puppeteer/tasks.ts -------------------------------------------------------------------------------- /packages/main/src/security-restrictions.ts: -------------------------------------------------------------------------------- 1 | import type {Session} from 'electron'; 2 | import {app, shell} from 'electron'; 3 | import {URL} from 'node:url'; 4 | 5 | /** 6 | * Union for all existing permissions in electron 7 | */ 8 | type Permission = Parameters< 9 | Exclude[0], null> 10 | >[1]; 11 | 12 | /** 13 | * A list of origins that you allow open INSIDE the application and permissions for them. 14 | * 15 | * In development mode you need allow open `VITE_DEV_SERVER_URL`. 16 | */ 17 | const ALLOWED_ORIGINS_AND_PERMISSIONS = new Map>( 18 | import.meta.env.DEV && import.meta.env.VITE_DEV_SERVER_URL 19 | ? [[new URL(import.meta.env.VITE_DEV_SERVER_URL).origin, new Set()]] 20 | : [], 21 | ); 22 | 23 | /** 24 | * A list of origins that you allow open IN BROWSER. 25 | * Navigation to the origins below is only possible if the link opens in a new window. 26 | * 27 | * @example 28 | * 32 | */ 33 | const ALLOWED_EXTERNAL_ORIGINS = new Set<`https://${string}`>(['https://github.com']); 34 | 35 | app.on('web-contents-created', (_, contents) => { 36 | /** 37 | * Block navigation to origins not on the allowlist. 38 | * 39 | * Navigation exploits are quite common. If an attacker can convince the app to navigate away from its current page, 40 | * they can possibly force the app to open arbitrary web resources/websites on the web. 41 | * 42 | * @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation 43 | */ 44 | contents.on('will-navigate', (event, url) => { 45 | const {origin} = new URL(url); 46 | if (ALLOWED_ORIGINS_AND_PERMISSIONS.has(origin)) { 47 | return; 48 | } 49 | 50 | // Prevent navigation 51 | event.preventDefault(); 52 | 53 | if (import.meta.env.DEV) { 54 | console.warn(`Blocked navigating to disallowed origin: ${origin}`); 55 | } 56 | }); 57 | 58 | /** 59 | * Block requests for disallowed permissions. 60 | * By default, Electron will automatically approve all permission requests. 61 | * 62 | * @see https://www.electronjs.org/docs/latest/tutorial/security#5-handle-session-permission-requests-from-remote-content 63 | */ 64 | contents.session.setPermissionRequestHandler((webContents, permission, callback) => { 65 | const {origin} = new URL(webContents.getURL()); 66 | 67 | const permissionGranted = !!ALLOWED_ORIGINS_AND_PERMISSIONS.get(origin)?.has(permission); 68 | callback(permissionGranted); 69 | 70 | if (!permissionGranted && import.meta.env.DEV) { 71 | console.warn(`${origin} requested permission for '${permission}', but was rejected.`); 72 | } 73 | }); 74 | 75 | /** 76 | * Hyperlinks leading to allowed sites are opened in the default browser. 77 | * 78 | * The creation of new `webContents` is a common attack vector. Attackers attempt to convince the app to create new windows, 79 | * frames, or other renderer processes with more privileges than they had before; or with pages opened that they couldn't open before. 80 | * You should deny any unexpected window creation. 81 | * 82 | * @see https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows 83 | * @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content 84 | */ 85 | contents.setWindowOpenHandler(({url}) => { 86 | const {origin} = new URL(url); 87 | 88 | if (ALLOWED_EXTERNAL_ORIGINS.has(origin as `https://${string}`)) { 89 | // Open url in default browser. 90 | shell.openExternal(url).catch(console.error); 91 | } else if (import.meta.env.DEV) { 92 | console.warn(`Blocked the opening of a disallowed origin: ${origin}`); 93 | } 94 | 95 | // Prevent creating a new window. 96 | return {action: 'deny'}; 97 | }); 98 | 99 | /** 100 | * Verify webview options before creation. 101 | * 102 | * Strip away preload scripts, disable Node.js integration, and ensure origins are on the allowlist. 103 | * 104 | * @see https://www.electronjs.org/docs/latest/tutorial/security#12-verify-webview-options-before-creation 105 | */ 106 | contents.on('will-attach-webview', (event, webPreferences, params) => { 107 | const {origin} = new URL(params.src); 108 | if (!ALLOWED_ORIGINS_AND_PERMISSIONS.has(origin)) { 109 | if (import.meta.env.DEV) { 110 | console.warn(`A webview tried to attach ${params.src}, but was blocked.`); 111 | } 112 | 113 | event.preventDefault(); 114 | return; 115 | } 116 | 117 | // Strip away preload scripts if unused or verify their location is legitimate. 118 | delete webPreferences.preload; 119 | // @ts-expect-error `preloadURL` exists. - @see https://www.electronjs.org/docs/latest/api/web-contents#event-will-attach-webview 120 | delete webPreferences.preloadURL; 121 | 122 | // Disable Node.js integration 123 | webPreferences.nodeIntegration = true; 124 | 125 | // Disable webSecurity 126 | webPreferences.webSecurity = false; 127 | 128 | // Enable contextIsolation 129 | webPreferences.contextIsolation = true; 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /packages/main/src/server/index.ts: -------------------------------------------------------------------------------- 1 | import type {Express} from 'express'; 2 | import {type Server} from 'http'; 3 | import express from 'express'; 4 | import cors from 'cors'; 5 | import IPRouter from './routes/ip'; 6 | import WindowRouter from './routes/window'; 7 | import ProfilesRouter from './routes/profiles'; 8 | import ProxyRouter from './routes/proxy'; 9 | 10 | const app: Express = express(); 11 | let port: number = 49156; // 初始端口 12 | 13 | app.use(cors()); 14 | app.use(express.json()); 15 | 16 | app.use('/ip', IPRouter); 17 | app.use('/window', WindowRouter); 18 | app.use('/profiles', ProfilesRouter); 19 | app.use('/proxy', ProxyRouter); 20 | 21 | app.get('/status', async (req, res) => { 22 | res.send({ 23 | status: 'ok', 24 | port, 25 | }); 26 | }); 27 | 28 | // 添加全局错误处理 29 | app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { 30 | console.error('Express error:', err); 31 | if (!res.headersSent) { 32 | res.status(500).json({error: 'Internal server error'}); 33 | } 34 | next(err); 35 | }); 36 | 37 | // 处理未捕获的 Promise 拒绝 38 | process.on('unhandledRejection', (reason, promise) => { 39 | console.error('Unhandled Rejection at:', promise, 'reason:', reason); 40 | }); 41 | 42 | const server: Server = app 43 | .listen(port, () => { 44 | console.log(`Server running on http://localhost:${port}`); 45 | module.exports.port = port; 46 | }) 47 | .on('error', (error: NodeJS.ErrnoException) => { 48 | if (error.code === 'EADDRINUSE') { 49 | console.log(`Port ${port} is already in use, trying another port...`); 50 | port++; 51 | server.close(); 52 | server.listen(port); 53 | } else if (error.code === 'EACCES') { 54 | console.error(`Port ${port} requires elevated privileges`); 55 | port++; 56 | server.close(); 57 | server.listen(port); 58 | } else { 59 | console.error(error); 60 | } 61 | }); 62 | 63 | export const getPort = () => port; 64 | 65 | export const getOrigin = () => `http://localhost:${port}`; 66 | 67 | // ... 其他的 Express 配置和路由 ... 68 | 69 | export function createServer() { 70 | // ... existing code ... 71 | // 移除任何使用 getNativeAddon 的代码 72 | // ... existing code ... 73 | } 74 | -------------------------------------------------------------------------------- /packages/main/src/server/routes/ip.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import {IP2Location} from 'ip2location-nodejs'; 3 | import geoip from 'geoip-lite'; 4 | import {find} from 'geo-tz'; 5 | import path from 'path'; 6 | import type {DB} from '../../../../shared/types/db'; 7 | import {WindowDB} from '/@/db/window'; 8 | import {ProxyDB} from '/@/db/proxy'; 9 | import {testProxy} from '../../fingerprint/prepare'; 10 | import {createLogger} from '../../../../shared/utils/logger'; 11 | import {PROXY_LOGGER_LABEL} from '/@/constants'; 12 | 13 | const router = express.Router(); 14 | 15 | const logger = createLogger(PROXY_LOGGER_LABEL); 16 | 17 | const getIPInfo = async (ip: string, gateway: 'ip2location' | 'geoip') => { 18 | try { 19 | if (ip.includes(':')) { 20 | return { 21 | ip, 22 | }; 23 | } 24 | if (gateway === 'ip2location') { 25 | const ip2location = new IP2Location(); 26 | const filePath = path.join( 27 | import.meta.env.MODE === 'development' ? 'assets' : `${process.resourcesPath}/app/assets`, 28 | 'IP2LOCATION-LITE-DB11.BIN', 29 | ); 30 | ip2location.open(filePath); 31 | const ipInfo = ip2location.getAll(ip); 32 | const {latitude, longitude, countryShort} = ipInfo; 33 | const timeZone = latitude && longitude ? find(Number(latitude), Number(longitude)) : []; 34 | return { 35 | country: countryShort, 36 | ip, 37 | ll: [latitude, longitude], 38 | timeZone: timeZone[0], 39 | }; 40 | } else if (gateway === 'geoip') { 41 | const ipInfo = geoip.lookup(ip); 42 | const {ll, country, timezone} = ipInfo; 43 | return { 44 | country, 45 | ip, 46 | ll, 47 | timeZone: timezone, 48 | }; 49 | } 50 | } catch (error) { 51 | logger.error('| Proxy | getIPInfo | error:', error); 52 | return {}; 53 | } 54 | }; 55 | 56 | router.get('/geoip', async (req, res) => { 57 | const ip = req.query?.ip as string; 58 | if (ip) { 59 | const ipInfo = await getIPInfo(ip, 'geoip'); 60 | res.send(ipInfo); 61 | } else { 62 | res.send({}); 63 | } 64 | }); 65 | 66 | router.get('/ip2location', async (req, res) => { 67 | const ip = req.query?.ip as string; 68 | if (ip) { 69 | const ipInfo = await getIPInfo(ip, 'ip2location'); 70 | res.send(ipInfo); 71 | } else { 72 | res.send({}); 73 | } 74 | }); 75 | 76 | router.get('/ping', async (req, res) => { 77 | const {windowId} = req.query; 78 | let windowData: DB.Window = {}; 79 | let pings: { 80 | connectivity: {name: string; elapsedTime: number; status: string; reason?: string}[]; 81 | } = {connectivity: []}; 82 | 83 | try { 84 | windowData = await WindowDB.getById(Number(windowId)); 85 | let proxyData: DB.Proxy = {}; 86 | if (windowData.proxy_id) { 87 | proxyData = await ProxyDB.getById(windowData.proxy_id); 88 | pings = await testProxy(proxyData); 89 | } else { 90 | pings = await testProxy(proxyData); 91 | } 92 | } catch (error) { 93 | console.error(error); 94 | } 95 | res.send({ 96 | pings: pings.connectivity, 97 | }); 98 | }); 99 | 100 | export default router; 101 | -------------------------------------------------------------------------------- /packages/main/src/server/routes/profiles.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import {WindowDB} from '/@/db/window'; 3 | import {closeFingerprintWindow, openFingerprintWindow} from '/@/fingerprint'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('', async (req, res) => { 8 | const windows = await WindowDB.all(); 9 | res.send(windows); 10 | }); 11 | 12 | router.get('/open', async (req, res) => { 13 | const windowId = req.query.windowId as unknown as number; 14 | const window = await WindowDB.getById(windowId); 15 | const result = await openFingerprintWindow(windowId); 16 | 17 | res.send({ 18 | window, 19 | browser: result, 20 | }); 21 | }); 22 | 23 | router.get('/close', async (req, res) => { 24 | const windowId = req.query.windowId as unknown as number; 25 | const window = await WindowDB.getById(windowId); 26 | await closeFingerprintWindow(windowId, true); 27 | res.send({ 28 | window, 29 | }); 30 | }); 31 | 32 | export default router; 33 | -------------------------------------------------------------------------------- /packages/main/src/server/routes/proxy.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import {ProxyDB} from '/@/db/proxy'; 3 | import type {DB} from '../../../../shared/types/db'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/info', async (req, res) => { 8 | const {proxyId} = req.query; 9 | if (!proxyId) { 10 | res.send({success: false, message: 'proxyId is required.'}); 11 | return; 12 | } 13 | 14 | const proxyData = await ProxyDB.getById(Number(proxyId)); 15 | res.send(proxyData); 16 | }); 17 | 18 | router.get('/all', async (_, res) => { 19 | const proxies = await ProxyDB.all(); 20 | res.json(proxies); 21 | }); 22 | 23 | router.post('/create', async (req, res) => { 24 | const proxy = req.body as DB.Proxy; 25 | const result = await ProxyDB.create(proxy); 26 | res.send({ 27 | success: result.length, 28 | id: result[0], 29 | }); 30 | }); 31 | 32 | router.put('/update', async (req, res) => { 33 | const {id, proxy} = req.body; 34 | const result = await ProxyDB.update(id, proxy); 35 | res.send({ 36 | success: result === 1, 37 | }); 38 | }); 39 | 40 | router.delete('/delete', async (req, res) => { 41 | const {proxyId} = req.query; 42 | if (!proxyId) { 43 | res.send({success: false, message: 'proxyId is required.'}); 44 | return; 45 | } 46 | const result = await ProxyDB.remove(Number(proxyId)); 47 | res.send({ 48 | success: result === 1, 49 | }); 50 | }); 51 | 52 | export default router; 53 | -------------------------------------------------------------------------------- /packages/main/src/server/routes/window.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import type {DB} from '../../../../shared/types/db'; 3 | import {WindowDB} from '/@/db/window'; 4 | import {ProxyDB} from '/@/db/proxy'; 5 | import {getProxyInfo} from '../../fingerprint/prepare'; 6 | 7 | const router = express.Router(); 8 | 9 | router.get('/info', async (req, res) => { 10 | const {windowId} = req.query; 11 | if (!windowId) { 12 | res.send({success: false, message: 'windowId is required.', windowData: {}, ipInfo: {}}); 13 | return; 14 | } 15 | let windowData: DB.Window = {}; 16 | let ipInfo = {}; 17 | 18 | try { 19 | windowData = await WindowDB.getById(Number(windowId)); 20 | let proxyData: DB.Proxy = {}; 21 | if (windowData.proxy_id) { 22 | proxyData = await ProxyDB.getById(windowData.proxy_id); 23 | } 24 | ipInfo = await getProxyInfo(proxyData); 25 | } catch (error) { 26 | console.error(error); 27 | } 28 | res.send({windowData, ipInfo}); 29 | }); 30 | 31 | router.delete('/delete', async (req, res) => { 32 | const {windowId} = req.query; 33 | if (!windowId) { 34 | res.send({success: false, message: 'windowId is required.'}); 35 | return; 36 | } 37 | const result = await WindowDB.remove(Number(windowId)); 38 | res.send({ 39 | success: result === 1, 40 | }); 41 | }); 42 | 43 | router.get('/all', async (_, res) => { 44 | const windows = await WindowDB.all(); 45 | res.send(windows); 46 | }); 47 | 48 | router.get('/opened', async (_, res) => { 49 | const windows = await WindowDB.getOpenedWindows(); 50 | res.send(windows); 51 | }); 52 | 53 | router.post('/create', async (req, res) => { 54 | if (!req.body) { 55 | res.send({success: false, message: 'window is required.'}); 56 | return; 57 | } 58 | const window = req.body as DB.Window; 59 | const result = await WindowDB.create(window); 60 | res.send(result); 61 | }); 62 | 63 | router.put('/update', async (req, res) => { 64 | const {id, window} = req.body; 65 | if (!id || !window) { 66 | res.send({success: false, message: 'id and window is required.'}); 67 | return; 68 | } 69 | const originalWindow = await WindowDB.getById(id); 70 | const result = await WindowDB.update(id, {...originalWindow, ...window}); 71 | res.send(result); 72 | }); 73 | 74 | export default router; 75 | -------------------------------------------------------------------------------- /packages/main/src/services/common-service.ts: -------------------------------------------------------------------------------- 1 | import {app, BrowserWindow, ipcMain, dialog, shell} from 'electron'; 2 | import {createLogger} from '../../../shared/utils/logger'; 3 | import {CONFIG_FILE_PATH, LOGS_PATH, SERVICE_LOGGER_LABEL} from '../constants'; 4 | import {join} from 'path'; 5 | import {copyFileSync, writeFileSync, readFileSync, readdir, existsSync, mkdirSync} from 'fs'; 6 | import type {SettingOptions} from '../../../shared/types/common'; 7 | import {getSettings} from '../utils/get-settings'; 8 | import {getOrigin} from '../server'; 9 | import axios from 'axios'; 10 | import {writeFile} from 'fs/promises'; 11 | 12 | 13 | const logger = createLogger(SERVICE_LOGGER_LABEL); 14 | 15 | export const initCommonService = () => { 16 | ipcMain.handle('common-download', async (_, filePath: string) => { 17 | const win = BrowserWindow.getAllWindows()[0]; 18 | const defaultPath = join(app.getPath('downloads'), 'template.xlsx'); 19 | 20 | const {filePath: savePath} = await dialog.showSaveDialog(win, { 21 | title: 'Save Template', 22 | defaultPath: defaultPath, 23 | buttonLabel: 'Save', 24 | }); 25 | 26 | if (savePath) { 27 | copyFileSync(join(__dirname, '../..', filePath), savePath); 28 | 29 | // 打开文件管理器并选择该文件 30 | shell.showItemInFolder(savePath); 31 | 32 | return savePath; 33 | } else { 34 | return null; 35 | } 36 | }); 37 | 38 | // 添加 IPC 处理程序 39 | ipcMain.handle('common-save-dialog', async (_, options) => { 40 | const win = BrowserWindow.getFocusedWindow(); 41 | if (!win) return {canceled: true}; 42 | return dialog.showSaveDialog(win, options); 43 | }); 44 | 45 | ipcMain.handle('common-save-file', async (_, {filePath, buffer}) => { 46 | await writeFile(filePath, buffer); 47 | }); 48 | 49 | ipcMain.handle('common-fetch-settings', async () => { 50 | const settings = getSettings(); 51 | 52 | return settings; 53 | }); 54 | 55 | ipcMain.handle( 56 | 'common-fetch-logs', 57 | async (_, module: 'Main' | 'Windows' | 'Proxy' | 'Services' | 'Api' = 'Main') => { 58 | // if (import.meta.env.DEV) { 59 | // return []; 60 | // } 61 | const logDir = join(LOGS_PATH, module); 62 | if (!existsSync(logDir)) { 63 | mkdirSync(logDir, {recursive: true}); 64 | } 65 | // read directory and get all folders 66 | const logFiles = await new Promise((resolve, reject) => { 67 | readdir(logDir, (err, files) => { 68 | if (err) { 69 | reject(err); 70 | } else { 71 | resolve(files); 72 | } 73 | }); 74 | }); 75 | // read latest 10 files content 76 | return logFiles.slice(-10).map(file => { 77 | const logFile = join(logDir, file); 78 | const content = readFileSync(logFile, 'utf8'); 79 | const formatContent = content 80 | .split('\n') 81 | .map(line => { 82 | const regex = /-\s*(info|warn|error):/; 83 | let logLevel = 'info'; 84 | const match = line.match(regex); 85 | if (match) { 86 | logLevel = match[1]; 87 | } 88 | return { 89 | message: line, 90 | level: logLevel, 91 | }; 92 | }) 93 | .filter(line => line.message); 94 | return { 95 | name: file, 96 | content: formatContent, 97 | }; 98 | }); 99 | }, 100 | ); 101 | 102 | ipcMain.handle('common-save-settings', async (_, values: SettingOptions) => { 103 | if (values.localChromePath === '/Applications/Google Chrome.app') { 104 | values.localChromePath = values.localChromePath + '/Contents/MacOS/Google Chrome'; 105 | } 106 | const configFilePath = CONFIG_FILE_PATH; 107 | 108 | try { 109 | writeFileSync(configFilePath, JSON.stringify(values), 'utf8'); 110 | } catch (error) { 111 | logger.error('Error writing to the settings file:', error); 112 | } 113 | 114 | return {}; 115 | }); 116 | 117 | ipcMain.handle( 118 | 'common-choose-path', 119 | async (_, type: 'openFile' | 'openDirectory' = 'openDirectory') => { 120 | // const win = BrowserWindow.getAllWindows()[0]; 121 | 122 | const path = await dialog.showOpenDialog({properties: [type]}); 123 | 124 | return path.filePaths[0]; 125 | }, 126 | ); 127 | 128 | ipcMain.handle('common-api', async () => { 129 | const apiUrl = getOrigin(); 130 | const res = await axios.get(`${apiUrl}/status`); 131 | return { 132 | url: apiUrl, 133 | ...(res?.data || {}), 134 | }; 135 | }); 136 | }; 137 | -------------------------------------------------------------------------------- /packages/main/src/services/group-service.ts: -------------------------------------------------------------------------------- 1 | import {ipcMain} from 'electron'; 2 | import type {DB} from '../../../shared/types/db'; 3 | import {GroupDB} from '../db/group'; 4 | import {WindowDB} from '../db/window'; 5 | export const initGroupService = () => { 6 | ipcMain.handle('group-create', async (_, group: DB.Group) => { 7 | return await GroupDB.create(group); 8 | }); 9 | 10 | ipcMain.handle('group-update', async (_, group: DB.Group) => { 11 | return await GroupDB.update(group.id!, group); 12 | }); 13 | 14 | ipcMain.handle('group-delete', async (_, id: number) => { 15 | // group_id = id, status > 0 16 | const windows = await WindowDB.find({group_id: id}); 17 | if (windows.filter(window => window.status > 0).length > 0) { 18 | return { 19 | success: false, 20 | message: 'Group is used by some windows', 21 | }; 22 | } 23 | const res = await GroupDB.remove(id); 24 | return { 25 | success: true, 26 | message: 'Group deleted successfully', 27 | data: res, 28 | }; 29 | }); 30 | 31 | ipcMain.handle('group-getAll', async () => { 32 | return await GroupDB.all(); 33 | }); 34 | ipcMain.handle('group-getById', async (_, id: number) => { 35 | return await GroupDB.getById(id); 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/main/src/services/index.ts: -------------------------------------------------------------------------------- 1 | import {initCommonService} from './common-service'; 2 | import {initGroupService} from './group-service'; 3 | import {initProxyService} from './proxy-service'; 4 | import {initSyncService} from './sync-service'; 5 | import {initTagService} from './tag-service'; 6 | import {initWindowService} from './window-service'; 7 | import {initExtensionService} from './extension-service'; 8 | 9 | export async function initServices() { 10 | initCommonService(); 11 | initWindowService(); 12 | initGroupService(); 13 | initProxyService(); 14 | initTagService(); 15 | initSyncService(); 16 | initExtensionService(); 17 | } 18 | -------------------------------------------------------------------------------- /packages/main/src/services/proxy-service.ts: -------------------------------------------------------------------------------- 1 | import {ipcMain} from 'electron'; 2 | import type {DB} from '../../../shared/types/db'; 3 | import {ProxyDB} from '../db/proxy'; 4 | import {testProxy} from '../fingerprint/prepare'; 5 | 6 | export const initProxyService = () => { 7 | ipcMain.handle('proxy-create', async (_, proxy: DB.Proxy) => { 8 | return await ProxyDB.create(proxy); 9 | }); 10 | 11 | ipcMain.handle('proxy-import', async (_, proxies: DB.Proxy[]) => { 12 | return await ProxyDB.importProxies(proxies); 13 | }); 14 | 15 | ipcMain.handle('proxy-update', async (_, id: number, proxy: DB.Proxy) => { 16 | return await ProxyDB.update(id, proxy); 17 | }); 18 | 19 | ipcMain.handle('proxy-delete', async (_, proxy: DB.Proxy) => { 20 | return await ProxyDB.remove(proxy.id!); 21 | }); 22 | 23 | ipcMain.handle('proxy-getAll', async () => { 24 | return await ProxyDB.all(); 25 | }); 26 | ipcMain.handle('proxy-batchDelete', async (_, ids: number[]) => { 27 | return await ProxyDB.batchDelete(ids); 28 | }); 29 | 30 | ipcMain.handle('proxy-getById', async (_, id: number) => { 31 | return await ProxyDB.getById(id); 32 | }); 33 | 34 | ipcMain.handle('proxy-test', async (_, testParams: number | DB.Proxy) => { 35 | if (typeof testParams === 'number') { 36 | const proxy = await ProxyDB.getById(testParams); 37 | return await testProxy(proxy); 38 | } else { 39 | return await testProxy(testParams); 40 | } 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/main/src/services/sync-service.ts: -------------------------------------------------------------------------------- 1 | import {app, ipcMain, systemPreferences, shell} from 'electron'; 2 | import path from 'path'; 3 | import type {SafeAny} from '../../../shared/types/db'; 4 | import { createLogger } from '../../../shared/utils/logger'; 5 | import { MAIN_LOGGER_LABEL } from '../constants'; 6 | import { dialog } from 'electron'; 7 | const logger = createLogger(MAIN_LOGGER_LABEL); 8 | let addon: unknown; 9 | if (!app.isPackaged) { 10 | // 开发环境:直接从构建目录加载 11 | addon = require(path.join(__dirname, '../src/native-addon/build/Release/', 'window-addon.node')); 12 | } else { 13 | // 生产环境:根据平台和架构选择正确路径 14 | // const addonDir = `${process.platform}-${process.arch}`; 15 | 16 | const addonPath = path.join( 17 | process.resourcesPath, 18 | 'app.asar.unpacked/node_modules/window-addon/', 19 | 'window-addon.node', 20 | ); 21 | 22 | try { 23 | addon = require(addonPath); 24 | } catch (error) { 25 | logger.error('Failed to load addon:', error); 26 | logger.error('Attempted path:', addonPath); 27 | logger.error('Platform and arch:', process.platform, process.arch); 28 | } 29 | } 30 | 31 | export const initSyncService = () => { 32 | if (!addon) { 33 | logger.error('Window addon not loaded properly', process.resourcesPath); 34 | return; 35 | } 36 | 37 | // 检查辅助功能权限(仅macOS) 38 | if (process.platform === 'darwin') { 39 | const hasPermission = systemPreferences.isTrustedAccessibilityClient(false); 40 | logger.info(`Accessibility permission: ${hasPermission ? 'granted' : 'denied'}`); 41 | 42 | if (!hasPermission) { 43 | // 在应用启动时提示用户授予权限 44 | logger.warn('应用需要辅助功能权限来排列窗口'); 45 | dialog.showMessageBox({ 46 | type: 'warning', 47 | title: '需要辅助功能权限', 48 | message: '请在系统偏好设置中为应用授予辅助功能权限,以启用窗口排列功能。', 49 | buttons: ['前往设置', '稍后再说'], 50 | defaultId: 0, 51 | }).then(({ response }) => { 52 | if (response === 0) { 53 | // 打开辅助功能设置 54 | shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'); 55 | } 56 | }); 57 | } 58 | } 59 | 60 | const windowManager = new (addon as SafeAny).WindowManager(); 61 | 62 | logger.info('WindowManager initialized'); 63 | 64 | ipcMain.handle('window-arrange', async (_, args) => { 65 | const {mainPid, childPids, columns, size, spacing} = args; 66 | logger.info('Arranging windows', {mainPid, childPids, columns, size, spacing}); 67 | try { 68 | if (!windowManager) { 69 | logger.error('WindowManager not initialized'); 70 | throw new Error('WindowManager not initialized'); 71 | } 72 | logger.info('arrangeWindows', windowManager.arrangeWindows.toString()); 73 | try { 74 | windowManager.arrangeWindows(mainPid, childPids, columns, size, spacing); 75 | } catch (e) { 76 | logger.error('Native function execution error:', e); 77 | throw e; 78 | } 79 | 80 | return {success: true}; 81 | } catch (error) { 82 | logger.error('Window arrangement failed:', error); 83 | return { 84 | success: false, 85 | error: error instanceof Error ? error.message : 'Unknown error', 86 | }; 87 | } 88 | }); 89 | }; 90 | -------------------------------------------------------------------------------- /packages/main/src/services/tag-service.ts: -------------------------------------------------------------------------------- 1 | import {ipcMain} from 'electron'; 2 | import type {DB} from '../../../shared/types/db'; 3 | import {TagDB} from '../db/tag'; 4 | import {WindowDB} from '../db/window'; 5 | 6 | export const initTagService = () => { 7 | ipcMain.handle('tag-create', async (_, tag: DB.Tag) => { 8 | return await TagDB.create(tag); 9 | }); 10 | 11 | ipcMain.handle('tag-update', async (_, tag: DB.Tag) => { 12 | return await TagDB.update(tag.id!, tag); 13 | }); 14 | 15 | ipcMain.handle('tag-delete', async (_, id: number) => { 16 | const windows = await WindowDB.all(); 17 | const windowsWithTag = windows.filter(window => window.tags?.includes(id)); 18 | if (windowsWithTag.length > 0) { 19 | return { 20 | success: false, 21 | message: 'Tag is used by some windows', 22 | }; 23 | } 24 | const res = await TagDB.remove(id); 25 | return { 26 | success: true, 27 | message: 'Tag deleted successfully', 28 | data: res, 29 | }; 30 | }); 31 | 32 | ipcMain.handle('tag-getAll', async () => { 33 | return await TagDB.all(); 34 | }); 35 | ipcMain.handle('tag-getById', async (_, id: number) => { 36 | return await TagDB.getById(id); 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/main/src/sync/index.ts: -------------------------------------------------------------------------------- 1 | // import {ipcRenderer} from 'electron'; 2 | import type {SafeAny} from '../../../shared/types/db'; 3 | 4 | let windowAddon: unknown; 5 | if (process.env.MODE === 'development') { 6 | // const isMac = process.platform === 'darwin'; 7 | // windowAddon = require(path.join( 8 | // __dirname, 9 | // isMac 10 | // ? '../src/native-addon/build/Release/window-addon.node' 11 | // : '../src/native-addon/build/Release/window-addon.node', 12 | // )); 13 | } else { 14 | // windowAddon = require(path.join( 15 | // process.resourcesPath, 16 | // 'app.asar.unpacked', 17 | // 'node_modules', 18 | // 'window-addon', 19 | // 'window-addon.node', 20 | // )); 21 | } 22 | export const arrangeWindows = async () => { 23 | try { 24 | const arrangeResult = (windowAddon as unknown as SafeAny)!.arrangeWindows(); 25 | console.log('arrangeResult', arrangeResult); 26 | } catch (error) { 27 | console.error(error); 28 | } 29 | }; 30 | 31 | // export const startGroupControl = async (masterProcessId?: number, slaveProcessIds?: number[]) => { 32 | 33 | // }; 34 | 35 | // 创建一个函数,用于接收来自原生插件的消息 36 | // function controlActionCallback(action: SafeAny) { 37 | // console.log('controlActionCallback', action); 38 | // // 处理 action,比如发送到渲染进程 39 | // ipcRenderer.send('control-action', action); 40 | // } 41 | 42 | // 将函数传递给原生插件 43 | // (windowAddon as unknown as SafeAny)!.setControlActionCallback(controlActionCallback); 44 | -------------------------------------------------------------------------------- /packages/main/src/types/cookie.d.ts: -------------------------------------------------------------------------------- 1 | import type {Protocol} from 'puppeteer'; 2 | 3 | export type ICookie = Protocol.Network.CookieParam; 4 | -------------------------------------------------------------------------------- /packages/main/src/types/window-template.d.ts: -------------------------------------------------------------------------------- 1 | // name: row.name, 2 | // group: row.group, 3 | // proxy_id: row.proxyid, 4 | // ua: row.ua, 5 | // remark: row.remark, 6 | // cookie: row.cookie, 7 | // }; 8 | // const proxy = { 9 | // proxy_type: row.proxytype, 10 | // proxy: row.proxy, 11 | // ip: row.ip, 12 | // ip_checker: row.ipchecker, 13 | // }; 14 | // const group = { 15 | // name: row.group, 16 | export interface IWindowTemplate { 17 | name?: string; 18 | group?: string; 19 | proxy_id?: string; 20 | ua?: string; 21 | remark?: string; 22 | cookie?: string; 23 | [key: string]: unknown; 24 | } 25 | -------------------------------------------------------------------------------- /packages/main/src/utils/chrome-icon.ts: -------------------------------------------------------------------------------- 1 | import {join} from 'path'; 2 | import pngToIco from 'png-to-ico'; 3 | import {existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync} from 'fs'; 4 | import {app} from 'electron'; 5 | import {execSync} from 'child_process'; 6 | import {createLogger} from '../../../shared/utils/logger'; 7 | import {MAIN_LOGGER_LABEL} from '../constants'; 8 | import sharp from 'sharp'; 9 | 10 | const logger = createLogger(MAIN_LOGGER_LABEL); 11 | 12 | export async function generateChromeIcon(profileDir: string, tag: string | number): Promise { 13 | const winChromeIcoPath = join(profileDir, 'Default', 'Google Profile.ico'); 14 | const macChromeIcoPath = join(profileDir, 'Default', 'Google Profile.icns'); 15 | const isMac = process.platform === 'darwin'; 16 | const icoPath = isMac ? macChromeIcoPath : winChromeIcoPath; 17 | 18 | try { 19 | // 确保目标目录存在 20 | const targetDir = join(profileDir, 'Default'); 21 | if (!existsSync(targetDir)) { 22 | mkdirSync(targetDir, {recursive: true}); 23 | } 24 | 25 | // 临时文件路径 26 | const tempPngPath = join(targetDir, 'temp_icon.png'); 27 | const outputPngPath = join(targetDir, 'modified_icon.png'); 28 | 29 | // 直接寻找PNG格式图标 30 | const pngIconPaths = [ 31 | join(app.isPackaged ? process.resourcesPath : process.cwd(), 'buildResources', 'icon.png'), 32 | join(app.isPackaged ? process.resourcesPath : process.cwd(), 'assets', 'icon.png'), 33 | join(app.isPackaged ? process.resourcesPath : process.cwd(), 'resources', 'icon.png'), 34 | ]; 35 | 36 | let sourceIconPath = ''; 37 | for (const path of pngIconPaths) { 38 | if (existsSync(path)) { 39 | sourceIconPath = path; 40 | break; 41 | } 42 | } 43 | 44 | if (!sourceIconPath) { 45 | logger.error('未找到PNG格式图标,请确保在buildResources或assets目录中有icon.png文件'); 46 | return ''; 47 | } 48 | 49 | // 直接复制PNG图标到临时文件 50 | const pngBuffer = readFileSync(sourceIconPath); 51 | writeFileSync(tempPngPath, pngBuffer); 52 | 53 | // 获取图像信息 54 | const metadata = await sharp(tempPngPath).metadata(); 55 | const width = metadata.width || 128; 56 | const height = metadata.height || 128; 57 | 58 | // 创建底部标签区域(蓝色背景,占图像底部20%高度) 59 | const tagHeight = Math.floor(height * 0.25); 60 | const tagY = height - tagHeight; 61 | 62 | // 创建SVG叠加层 63 | const svgBuffer = Buffer.from(` 64 | 65 | 66 | ${tag.toString()} 76 | 77 | `); 78 | 79 | // 添加SVG叠加层到图像上 80 | await sharp(tempPngPath) 81 | .composite([{ input: svgBuffer }]) 82 | .toFile(outputPngPath); 83 | 84 | // 第3步: 将PNG转换回平台特定格式 85 | if (isMac) { 86 | // macOS: 使用sips将png转换为icns 87 | execSync(`sips -s format icns "${outputPngPath}" --out "${icoPath}"`); 88 | } else { 89 | // Windows: 使用png-to-ico将png转换为ico 90 | try { 91 | const pngBuffer = readFileSync(outputPngPath); 92 | const icoBuffer = await pngToIco([pngBuffer]); 93 | writeFileSync(icoPath, icoBuffer); 94 | } catch (err) { 95 | logger.error(`无法将PNG转换为ICO: ${err}`); 96 | return ''; 97 | } 98 | } 99 | 100 | // 清理临时文件 101 | try { 102 | if (existsSync(tempPngPath)) { 103 | unlinkSync(tempPngPath); 104 | } 105 | if (existsSync(outputPngPath)) { 106 | unlinkSync(outputPngPath); 107 | } 108 | } catch (err) { 109 | logger.warn(`清理临时文件失败: ${err}`); 110 | } 111 | 112 | logger.info(`成功为${isMac ? 'macOS' : 'Windows'}创建带标签的图标: ${icoPath}`); 113 | return icoPath; 114 | } catch (error) { 115 | logger.error(`生成Chrome图标失败: ${error}`); 116 | return ''; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /packages/main/src/utils/extract.ts: -------------------------------------------------------------------------------- 1 | const extract = require('extract-zip'); 2 | import {join} from 'path'; 3 | import {existsSync, mkdirSync, renameSync, rmdirSync} from 'fs'; 4 | 5 | export async function extractChromeBin() { 6 | const resourcesPath = process.resourcesPath; 7 | const chromeZipPath = join(resourcesPath, 'Chrome-bin.zip'); 8 | const tempExtractPath = join(resourcesPath, 'temp-chrome-bin'); 9 | 10 | // 检查临时解压目录是否存在 11 | if (!existsSync(tempExtractPath)) { 12 | mkdirSync(tempExtractPath); 13 | } 14 | 15 | const chromeBinPath = join(resourcesPath, 'Chrome-bin'); 16 | 17 | // 检查 Chrome-bin 目录是否存在 18 | if (!existsSync(chromeBinPath)) { 19 | try { 20 | await extract(chromeZipPath, {dir: tempExtractPath}); 21 | console.log('Chrome-bin extraction complete'); 22 | 23 | // 检查并调整目录结构 24 | const extractedDirPath = join(tempExtractPath, 'Chrome-bin'); 25 | if (existsSync(extractedDirPath)) { 26 | renameSync(extractedDirPath, chromeBinPath); 27 | rmdirSync(tempExtractPath, {recursive: true}); 28 | return {result: true, exist: false}; 29 | } else { 30 | return {result: false, error: 'Expected Chrome-bin directory not found inside ZIP'}; 31 | } 32 | } catch (err) { 33 | console.error('Error extracting Chrome-bin.zip:', err); 34 | return {result: false, error: err}; 35 | } 36 | } else { 37 | console.log('Chrome-bin already exists'); 38 | return {result: true, exist: true}; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/main/src/utils/get-db-path.ts: -------------------------------------------------------------------------------- 1 | import {app} from 'electron'; 2 | import {join} from 'path'; 3 | 4 | export function getDbPath() { 5 | let dbPath; 6 | 7 | try { 8 | if (app.isPackaged) { 9 | dbPath = join(app.getPath('userData'), 'db.sqlite3'); 10 | } else { 11 | dbPath = join(app.getPath('userData'), 'dev-db.sqlite3'); // 您原先的数据库位置 12 | } 13 | } catch { 14 | // 默认的开发数据库位置,或其他你选择的位置 15 | dbPath = join(__dirname, 'dev-db.sqlite3'); 16 | } 17 | 18 | return dbPath; 19 | } 20 | -------------------------------------------------------------------------------- /packages/main/src/utils/get-settings.ts: -------------------------------------------------------------------------------- 1 | import {existsSync, readFileSync, writeFileSync, mkdirSync} from 'fs'; 2 | import {join} from 'path'; 3 | import type {SettingOptions} from '../../../shared/types/common'; 4 | import {getChromePath} from '../fingerprint/device'; 5 | import {app} from 'electron'; 6 | import {CONFIG_FILE_PATH} from '../constants'; 7 | export const getSettings = (): SettingOptions => { 8 | const configFilePath = CONFIG_FILE_PATH; 9 | const isMac = process.platform === 'darwin'; 10 | const defaultCachePath = isMac 11 | ? `${app.getPath('documents')}/ChromePowerCache` 12 | : join(app.getPath('appData'), 'ChromePowerCache'); 13 | let settings = { 14 | profileCachePath: defaultCachePath, 15 | useLocalChrome: true, 16 | localChromePath: '', 17 | chromiumBinPath: '', 18 | automationConnect: false, 19 | }; 20 | 21 | try { 22 | if (existsSync(configFilePath)) { 23 | const fileContent = readFileSync(configFilePath, 'utf8'); 24 | settings = JSON.parse(fileContent); 25 | } else { 26 | if (!existsSync(defaultCachePath)) { 27 | mkdirSync(defaultCachePath, {recursive: true, mode: 0o755}); 28 | } 29 | writeFileSync(configFilePath, JSON.stringify(settings), 'utf8'); 30 | } 31 | 32 | if (!existsSync(settings.profileCachePath)) { 33 | mkdirSync(settings.profileCachePath, {recursive: true, mode: 0o755}); 34 | } 35 | } catch (error) { 36 | console.error('Error handling the settings file:', error); 37 | } 38 | 39 | if (!settings.localChromePath) { 40 | settings.localChromePath = getChromePath() as string; 41 | } 42 | settings.useLocalChrome = true; 43 | if (!settings.chromiumBinPath || settings.chromiumBinPath === 'Chrome-bin\\chrome.exe') { 44 | if (import.meta.env.DEV) { 45 | settings.chromiumBinPath = 'Chrome-bin\\chrome.exe'; 46 | } else { 47 | settings.chromiumBinPath = join(process.resourcesPath, 'Chrome-bin', 'chrome.exe'); 48 | } 49 | } 50 | return settings; 51 | }; 52 | -------------------------------------------------------------------------------- /packages/main/src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = async (seconds: number) => 2 | new Promise(resolve => 3 | setTimeout(() => { 4 | resolve(); 5 | }, seconds * 1000), 6 | ); 7 | -------------------------------------------------------------------------------- /packages/main/src/utils/txt-to-json.ts: -------------------------------------------------------------------------------- 1 | import type {IWindowTemplate} from '../types/window-template'; 2 | 3 | export function txtToJSON(txt: string): IWindowTemplate[] { 4 | const entries = txt.split('\n\n'); // 根据两个换行符来分割每一块数据 5 | const jsonOutput = []; 6 | for (const entry of entries) { 7 | const lines = entry.split('\n'); 8 | const jsonObject: IWindowTemplate = { 9 | name: '', 10 | group: '', 11 | proxyid: '', 12 | ua: '', 13 | remark: '', 14 | cookie: '', 15 | proxytype: '', 16 | proxy: '', 17 | ip: '', 18 | ipchecker: '', 19 | }; 20 | 21 | if (lines !== null && lines !== undefined) { 22 | for (const line of lines) { 23 | const parts = line.split('=', 2); 24 | const key = parts[0].trim(); 25 | const value = line.substring(key.length + 1); 26 | jsonObject[key] = value; 27 | } 28 | } 29 | 30 | jsonOutput.push(jsonObject); 31 | } 32 | 33 | return jsonOutput; 34 | } 35 | -------------------------------------------------------------------------------- /packages/main/tests/unit.spec.ts: -------------------------------------------------------------------------------- 1 | import type {MockedClass, MockedObject} from 'vitest'; 2 | import {beforeEach, expect, test, vi} from 'vitest'; 3 | import {restoreOrCreateWindow} from '../src/mainWindow'; 4 | 5 | import {BrowserWindow} from 'electron'; 6 | 7 | /** 8 | * Mock real electron BrowserWindow API 9 | */ 10 | vi.mock('electron', () => { 11 | // Use "as unknown as" because vi.fn() does not have static methods 12 | const bw = vi.fn() as unknown as MockedClass; 13 | bw.getAllWindows = vi.fn(() => bw.mock.instances); 14 | bw.prototype.loadURL = vi.fn((_: string, __?: Electron.LoadURLOptions) => Promise.resolve()); 15 | bw.prototype.loadFile = vi.fn((_: string, __?: Electron.LoadFileOptions) => Promise.resolve()); 16 | // Use "any" because the on function is overloaded 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | bw.prototype.on = vi.fn(); 19 | bw.prototype.destroy = vi.fn(); 20 | bw.prototype.isDestroyed = vi.fn(); 21 | bw.prototype.isMinimized = vi.fn(); 22 | bw.prototype.focus = vi.fn(); 23 | bw.prototype.restore = vi.fn(); 24 | 25 | const app: Pick = { 26 | getAppPath(): string { 27 | return ''; 28 | }, 29 | }; 30 | 31 | // 模拟 ipcMain 32 | const ipcMain = { 33 | handle: vi.fn(), 34 | // 根据需要模拟其他 ipcMain 方法 35 | }; 36 | 37 | return {BrowserWindow: bw, app, ipcMain}; 38 | }); 39 | 40 | beforeEach(() => { 41 | vi.clearAllMocks(); 42 | }); 43 | 44 | test('Should create a new window', async () => { 45 | const {mock} = vi.mocked(BrowserWindow); 46 | expect(mock.instances).toHaveLength(0); 47 | 48 | await restoreOrCreateWindow(); 49 | expect(mock.instances).toHaveLength(1); 50 | const instance = mock.instances[0] as MockedObject; 51 | const loadURLCalls = instance.loadURL.mock.calls.length; 52 | const loadFileCalls = instance.loadFile.mock.calls.length; 53 | expect(loadURLCalls + loadFileCalls).toBe(1); 54 | if (loadURLCalls === 1) { 55 | expect(instance.loadURL).toHaveBeenCalledWith(expect.stringMatching(/index\.html$/)); 56 | } else { 57 | expect(instance.loadFile).toHaveBeenCalledWith(expect.stringMatching(/index\.html$/)); 58 | } 59 | }); 60 | 61 | test('Should restore an existing window', async () => { 62 | const {mock} = vi.mocked(BrowserWindow); 63 | 64 | // Create a window and minimize it. 65 | await restoreOrCreateWindow(); 66 | expect(mock.instances).toHaveLength(1); 67 | const appWindow = vi.mocked(mock.instances[0]); 68 | appWindow.isMinimized.mockReturnValueOnce(true); 69 | 70 | await restoreOrCreateWindow(); 71 | expect(mock.instances).toHaveLength(1); 72 | expect(appWindow.restore).toHaveBeenCalledOnce(); 73 | }); 74 | 75 | test('Should create a new window if the previous one was destroyed', async () => { 76 | const {mock} = vi.mocked(BrowserWindow); 77 | 78 | // Create a window and destroy it. 79 | await restoreOrCreateWindow(); 80 | expect(mock.instances).toHaveLength(1); 81 | const appWindow = vi.mocked(mock.instances[0]); 82 | appWindow.isDestroyed.mockReturnValueOnce(true); 83 | 84 | await restoreOrCreateWindow(); 85 | expect(mock.instances).toHaveLength(2); 86 | }); 87 | -------------------------------------------------------------------------------- /packages/main/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "sourceMap": false, 6 | "moduleResolution": "Node", 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "allowSyntheticDefaultImports": true, 10 | "isolatedModules": true, 11 | "types": ["node"], 12 | "baseUrl": ".", 13 | "paths": { 14 | "/@/*": ["./src/*"] 15 | } 16 | }, 17 | "include": ["src/**/*.ts", "../../types/**/*.d.ts", "../shared/types/db.d.ts"], 18 | "exclude": ["**/*.spec.ts", "**/*.test.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/main/vite.config.js: -------------------------------------------------------------------------------- 1 | import {node} from '../../.electron-vendors.cache.json'; 2 | import {join} from 'node:path'; 3 | import {injectAppVersion} from '../../version/inject-app-version-plugin.mjs'; 4 | 5 | const PACKAGE_ROOT = __dirname; 6 | const PROJECT_ROOT = join(PACKAGE_ROOT, '../..'); 7 | 8 | /** 9 | * @type {import('vite').UserConfig} 10 | * @see https://vitejs.dev/config/ 11 | */ 12 | const config = { 13 | mode: process.env.MODE, 14 | root: PACKAGE_ROOT, 15 | envDir: PROJECT_ROOT, 16 | resolve: { 17 | alias: { 18 | '/@/': join(PACKAGE_ROOT, 'src') + '/', 19 | }, 20 | }, 21 | build: { 22 | ssr: true, 23 | sourcemap: 'inline', 24 | target: `node${node}`, 25 | outDir: 'dist', 26 | assetsDir: '.', 27 | minify: process.env.MODE !== 'development', 28 | lib: { 29 | entry: 'src/index.ts', 30 | formats: ['cjs'], 31 | }, 32 | rollupOptions: { 33 | output: { 34 | entryFileNames: '[name].cjs', 35 | }, 36 | // external: ['jimp', 'png-to-ico'], 37 | }, 38 | emptyOutDir: true, 39 | reportCompressedSize: false, 40 | }, 41 | plugins: [injectAppVersion()], 42 | }; 43 | 44 | export default config; 45 | -------------------------------------------------------------------------------- /packages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "puppeteer-extra-plugin": "^3.2.3" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/preload/src/bridges/common.ts: -------------------------------------------------------------------------------- 1 | import type {IpcRendererEvent} from 'electron'; 2 | import {ipcRenderer} from 'electron'; 3 | import type {BridgeMessage, SettingOptions} from '../../../shared/types/common'; 4 | 5 | export const CommonBridge = { 6 | async download(path: string) { 7 | const result = await ipcRenderer.invoke('common-download', path); 8 | return result; 9 | }, 10 | async choosePath(type: 'openFile' | 'openDirectory') { 11 | const result = await ipcRenderer.invoke('common-choose-path', type); 12 | return result; 13 | }, 14 | async share(key: string, value?: unknown) { 15 | const result = await ipcRenderer.invoke('data-share', key, value); 16 | return result; 17 | }, 18 | async saveDialog(options: Electron.SaveDialogOptions) { 19 | const result = await ipcRenderer.invoke('common-save-dialog', options); 20 | return result; 21 | }, 22 | async saveFile(filePath: string, buffer: Uint8Array | ArrayBuffer) { 23 | const result = await ipcRenderer.invoke('common-save-file', {filePath, buffer}); 24 | return result; 25 | }, 26 | async getSettings() { 27 | const result = await ipcRenderer.invoke('common-fetch-settings'); 28 | return result; 29 | }, 30 | async saveSettings(settings: SettingOptions) { 31 | const result = await ipcRenderer.invoke('common-save-settings', settings); 32 | return result; 33 | }, 34 | async getLogs(logModule: 'Main' | 'Windows' | 'Proxy' | 'Services' | 'Api') { 35 | const result = await ipcRenderer.invoke('common-fetch-logs', logModule); 36 | return result; 37 | }, 38 | async getApi() { 39 | const result = await ipcRenderer.invoke('common-api'); 40 | return result; 41 | }, 42 | 43 | onMessaged: (callback: (event: IpcRendererEvent, msg: BridgeMessage) => void) => 44 | ipcRenderer.on('bridge-msg', callback), 45 | 46 | offMessaged: (callback: (event: IpcRendererEvent, msg: BridgeMessage) => void) => 47 | ipcRenderer.off('bridge-msg', callback), 48 | }; 49 | -------------------------------------------------------------------------------- /packages/preload/src/bridges/extension.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from 'electron'; 2 | import type {DB} from '../../../shared/types/db'; 3 | 4 | export const ExtensionBridge = { 5 | import: (filePath: string) => ipcRenderer.invoke('extension-import', filePath), 6 | getAll: () => ipcRenderer.invoke('extension-get-all'), 7 | applyToWindows: (extensionId: number, windowIds: number[]) => 8 | ipcRenderer.invoke('extension-apply-to-windows', extensionId, windowIds), 9 | deleteExtensionWindows: (extensionId: number, windowIds: number[]) => 10 | ipcRenderer.invoke('delete-extension-windows', extensionId, windowIds), 11 | getExtensionWindows: (extensionId: number) => 12 | ipcRenderer.invoke('extension-get-windows', extensionId), 13 | createExtension: (extension: DB.Extension) => ipcRenderer.invoke('extension-create', extension), 14 | uploadPackage: (filePath: string, extensionId?: number) => 15 | ipcRenderer.invoke('extension-upload-package', filePath, extensionId), 16 | updateExtension: (extensionId: number, extension: Partial) => 17 | ipcRenderer.invoke('extension-update', extensionId, extension), 18 | deleteExtension: (extensionId: number) => ipcRenderer.invoke('extension-delete', extensionId), 19 | syncWindowExtensions: (extensionId: number, windowIds: number[]) => 20 | ipcRenderer.invoke('extension-sync-windows', extensionId, windowIds), 21 | }; 22 | -------------------------------------------------------------------------------- /packages/preload/src/bridges/group.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from 'electron'; 2 | import type {DB} from '../../../shared/types/db'; 3 | 4 | export const GroupBridge = { 5 | async getAll() { 6 | const result = await ipcRenderer.invoke('group-getAll'); 7 | return result; 8 | }, 9 | async create(group: DB.Group) { 10 | const result = await ipcRenderer.invoke('group-create', group); 11 | return result; 12 | }, 13 | 14 | async update(group: DB.Group) { 15 | const result = await ipcRenderer.invoke('group-update', group); 16 | return result; 17 | }, 18 | async delete(id: number) { 19 | const result = await ipcRenderer.invoke('group-delete', id); 20 | return result; 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/preload/src/bridges/proxy.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from 'electron'; 2 | import type {DB} from '../../../shared/types/db'; 3 | 4 | export const ProxyBridge = { 5 | async getAll() { 6 | const result = await ipcRenderer.invoke('proxy-getAll'); 7 | return result; 8 | }, 9 | 10 | async import(proxies: DB.Proxy[]) { 11 | const result = await ipcRenderer.invoke('proxy-import', proxies); 12 | return result; 13 | }, 14 | 15 | async update(id: number, proxy: DB.Proxy) { 16 | const result = await ipcRenderer.invoke('proxy-update', id, proxy); 17 | return result; 18 | }, 19 | 20 | async batchDelete(ids: number[]) { 21 | const result = await ipcRenderer.invoke('proxy-batchDelete', ids); 22 | return result; 23 | }, 24 | 25 | async checkProxy(params: number | DB.Proxy) { 26 | const result = await ipcRenderer.invoke('proxy-test', params); 27 | return result; 28 | }, 29 | // async checkTmpProxy(proxy: DB.Proxy) { 30 | // const result = await ipcRenderer.invoke('proxy-test', proxy); 31 | // return result; 32 | // }, 33 | }; 34 | -------------------------------------------------------------------------------- /packages/preload/src/bridges/sync.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from 'electron'; 2 | 3 | export const SyncBridge = { 4 | arrangeWindows: (args: { 5 | mainPid: number; 6 | childPids: number[]; 7 | columns: number; 8 | size: {width: number; height: number}; 9 | }) => { 10 | return ipcRenderer.invoke('window-arrange', args); 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/preload/src/bridges/tag.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from 'electron'; 2 | import type {DB} from '../../../shared/types/db'; 3 | 4 | export const TagBridge = { 5 | async getAll() { 6 | const result = await ipcRenderer.invoke('tag-getAll'); 7 | return result; 8 | }, 9 | async create(tag: DB.Tag) { 10 | const result = await ipcRenderer.invoke('tag-create', tag); 11 | return result; 12 | }, 13 | 14 | async update(tag: DB.Tag) { 15 | const result = await ipcRenderer.invoke('tag-update', tag); 16 | return result; 17 | }, 18 | async delete(id: number) { 19 | const result = await ipcRenderer.invoke('tag-delete', id); 20 | return result; 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/preload/src/bridges/window.ts: -------------------------------------------------------------------------------- 1 | import type {IpcRendererEvent} from 'electron'; 2 | import {ipcRenderer} from 'electron'; 3 | import type {DB, SafeAny} from '../../../shared/types/db'; 4 | 5 | export const WindowBridge = { 6 | async import(file: string) { 7 | const result = await ipcRenderer.invoke('window-import', file); 8 | return result; 9 | }, 10 | 11 | async create(window: DB.Window, fingerprints: SafeAny) { 12 | const result = await ipcRenderer.invoke('window-create', window, fingerprints); 13 | return result; 14 | }, 15 | 16 | async update(id: number, window: DB.Window) { 17 | const result = await ipcRenderer.invoke('window-update', id, window); 18 | return result; 19 | }, 20 | async delete(id: number) { 21 | const result = await ipcRenderer.invoke('window-delete', id); 22 | return result; 23 | }, 24 | async batchClear(ids: number[]) { 25 | const result = await ipcRenderer.invoke('window-batchClear', ids); 26 | return result; 27 | }, 28 | async batchDelete(ids: number[]) { 29 | const result = await ipcRenderer.invoke('window-batchDelete', ids); 30 | return result; 31 | }, 32 | async getAll() { 33 | const result = await ipcRenderer.invoke('window-getAll'); 34 | return result; 35 | }, 36 | async getOpenedWindows() { 37 | const result = await ipcRenderer.invoke('window-getOpened'); 38 | return result; 39 | }, 40 | async getFingerprint(windowId?: number) { 41 | const result = await ipcRenderer.invoke('window-fingerprint', windowId); 42 | return result; 43 | }, 44 | async getById(id: number) { 45 | const result = await ipcRenderer.invoke('window-getById', id); 46 | return result; 47 | }, 48 | 49 | async open(id: number) { 50 | const result = await ipcRenderer.invoke('window-open', id); 51 | return result; 52 | }, 53 | 54 | async close(id: number) { 55 | const result = await ipcRenderer.invoke('window-close', id, true); 56 | return result; 57 | }, 58 | 59 | async toogleSetCookie(id: number) { 60 | const result = await ipcRenderer.invoke('window-set-cookie', id); 61 | return result; 62 | }, 63 | 64 | onWindowClosed: (callback: (event: IpcRendererEvent, id: number) => void) => 65 | ipcRenderer.on('window-closed', callback), 66 | 67 | onWindowOpened: (callback: (event: IpcRendererEvent, id: number) => void) => 68 | ipcRenderer.on('window-opened', callback), 69 | 70 | offWindowClosed: (callback: (event: IpcRendererEvent, id: number) => void) => 71 | ipcRenderer.off('window-closed', callback), 72 | 73 | offWindowOpened: (callback: (event: IpcRendererEvent, id: number) => void) => 74 | ipcRenderer.off('window-opened', callback), 75 | }; 76 | -------------------------------------------------------------------------------- /packages/preload/src/customize-control.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from 'electron'; 2 | 3 | export const customizeToolbarControl = { 4 | close() { 5 | ipcRenderer.invoke('close'); 6 | }, 7 | minimize() { 8 | ipcRenderer.invoke('minimize'); 9 | }, 10 | maximize() { 11 | ipcRenderer.invoke('maximize'); 12 | }, 13 | async isMaximized() { 14 | const maximized = await ipcRenderer.invoke('isMaximized'); 15 | return maximized; 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/preload/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module preload 3 | */ 4 | 5 | export {sha256sum} from './node-crypto'; 6 | export {versions} from './versions'; 7 | export {customizeToolbarControl} from './customize-control'; 8 | export {WindowBridge} from './bridges/window'; 9 | export {GroupBridge} from './bridges/group'; 10 | export {ProxyBridge} from './bridges/proxy'; 11 | export {TagBridge} from './bridges/tag'; 12 | export {CommonBridge} from './bridges/common'; 13 | export {SyncBridge} from './bridges/sync'; 14 | export {ExtensionBridge} from './bridges/extension'; 15 | -------------------------------------------------------------------------------- /packages/preload/src/node-crypto.ts: -------------------------------------------------------------------------------- 1 | import {type BinaryLike, createHash} from 'node:crypto'; 2 | 3 | export function sha256sum(data: BinaryLike) { 4 | return createHash('sha256').update(data).digest('hex'); 5 | } 6 | -------------------------------------------------------------------------------- /packages/preload/src/versions.ts: -------------------------------------------------------------------------------- 1 | export {versions} from 'node:process'; 2 | -------------------------------------------------------------------------------- /packages/preload/tests/unit.spec.ts: -------------------------------------------------------------------------------- 1 | import {createHash} from 'crypto'; 2 | import {expect, test} from 'vitest'; 3 | import {sha256sum, versions} from '../src'; 4 | 5 | test('versions', async () => { 6 | expect(versions).toBe(process.versions); 7 | }); 8 | 9 | test('nodeCrypto', async () => { 10 | // Test hashing a random string. 11 | const testString = Math.random().toString(36).slice(2, 7); 12 | const expectedHash = createHash('sha256').update(testString).digest('hex'); 13 | 14 | expect(sha256sum(testString)).toBe(expectedHash); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/preload/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "sourceMap": false, 6 | "moduleResolution": "Node", 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "isolatedModules": true, 10 | "types": ["node"], 11 | "baseUrl": "." 12 | }, 13 | "include": ["src/**/*.ts", "../../types/**/*.d.ts"], 14 | "exclude": ["**/*.spec.ts", "**/*.test.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /packages/preload/vite.config.js: -------------------------------------------------------------------------------- 1 | import {chrome} from '../../.electron-vendors.cache.json'; 2 | import {preload} from 'unplugin-auto-expose'; 3 | import {join} from 'node:path'; 4 | import {injectAppVersion} from '../../version/inject-app-version-plugin.mjs'; 5 | 6 | const PACKAGE_ROOT = __dirname; 7 | const PROJECT_ROOT = join(PACKAGE_ROOT, '../..'); 8 | 9 | /** 10 | * @type {import('vite').UserConfig} 11 | * @see https://vitejs.dev/config/ 12 | */ 13 | const config = { 14 | mode: process.env.MODE, 15 | root: PACKAGE_ROOT, 16 | envDir: PROJECT_ROOT, 17 | build: { 18 | ssr: true, 19 | sourcemap: 'inline', 20 | target: `chrome${chrome}`, 21 | outDir: 'dist', 22 | assetsDir: '.', 23 | minify: process.env.MODE !== 'development', 24 | lib: { 25 | entry: 'src/index.ts', 26 | formats: ['cjs'], 27 | }, 28 | rollupOptions: { 29 | output: { 30 | entryFileNames: '[name].cjs', 31 | }, 32 | }, 33 | emptyOutDir: true, 34 | reportCompressedSize: false, 35 | }, 36 | plugins: [preload.vite(), injectAppVersion()], 37 | }; 38 | 39 | export default config; 40 | -------------------------------------------------------------------------------- /packages/renderer/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": false 5 | }, 6 | "extends": [], 7 | "parserOptions": { 8 | "parser": "@typescript-eslint/parser", 9 | "ecmaVersion": 12, 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | /** These rules are disabled because they are incompatible with prettier */ 14 | "vue/html-self-closing": "off", 15 | "vue/singleline-html-element-content-newline": "off" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/renderer/assets/bg2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/renderer/assets/bg2.png -------------------------------------------------------------------------------- /packages/renderer/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/renderer/assets/logo.png -------------------------------------------------------------------------------- /packages/renderer/assets/logo2-min.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/renderer/assets/logo2-min.png -------------------------------------------------------------------------------- /packages/renderer/assets/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/renderer/assets/logo2.png -------------------------------------------------------------------------------- /packages/renderer/assets/logo5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/renderer/assets/logo5.png -------------------------------------------------------------------------------- /packages/renderer/assets/logo6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/renderer/assets/logo6.png -------------------------------------------------------------------------------- /packages/renderer/assets/template.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/renderer/assets/template.xlsx -------------------------------------------------------------------------------- /packages/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 13 | 17 | Chrome Power 18 | 19 | 20 |
24 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/renderer/src/App.tsx: -------------------------------------------------------------------------------- 1 | import {Route, Routes, useLocation} from 'react-router-dom'; 2 | import Navigation from './components/navigation'; 3 | 4 | import dayjs from 'dayjs'; 5 | 6 | import './index.css'; 7 | import './styles/antd.css'; 8 | import {Layout, Typography, message} from 'antd'; 9 | import {useRoutes, useRoutesMap} from './routes'; 10 | import Header from './components/header'; 11 | import {useEffect, useState} from 'react'; 12 | import {CommonBridge} from '#preload'; 13 | import {MESSAGE_CONFIG} from './constants'; 14 | import type {BridgeMessage} from '../../shared/types/common'; 15 | 16 | const {Title} = Typography; 17 | 18 | const {Content, Sider} = Layout; 19 | 20 | dayjs.locale('zh-cn'); 21 | 22 | const App = () => { 23 | const routes = useRoutes(); 24 | const routesMap = useRoutesMap(); 25 | const [isVisible, setIsVisible] = useState(false); 26 | const [messageApi, contextHolder] = message.useMessage(MESSAGE_CONFIG); 27 | 28 | useEffect(() => { 29 | setTimeout(() => setIsVisible(true), 100); // 延迟显示组件 30 | }, []); 31 | 32 | const location = useLocation(); 33 | 34 | useEffect(() => { 35 | const handleMessaged = (_: Electron.IpcRendererEvent, msg: BridgeMessage) => { 36 | messageApi.open({ 37 | type: msg.type, 38 | content: msg.text, 39 | }); 40 | }; 41 | 42 | CommonBridge?.offMessaged(handleMessaged); 43 | 44 | CommonBridge?.onMessaged(handleMessaged); 45 | 46 | return () => { 47 | CommonBridge?.offMessaged(handleMessaged); 48 | }; 49 | }, []); 50 | 51 | return ( 52 | 53 | {contextHolder} 54 | {/* */} 58 | {location.pathname !== '/start' &&
} 59 | 60 | {location.pathname !== '/start' && ( 61 | 65 | 66 | 67 | )} 68 | 69 | 70 | 74 | {routesMap[location.pathname]?.name} 75 | 76 | 77 | {routes.map(route => { 78 | return ( 79 | 84 | ); 85 | })} 86 | 87 | 88 | 89 |
90 | ); 91 | }; 92 | export default App; 93 | -------------------------------------------------------------------------------- /packages/renderer/src/components/addable-select/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/renderer/src/components/addable-select/index.css -------------------------------------------------------------------------------- /packages/renderer/src/components/addable-select/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState, useRef} from 'react'; 2 | import {PlusOutlined} from '@ant-design/icons'; 3 | import {Divider, Input, Select, Space, Button, Tag, Row, Col, Typography} from 'antd'; 4 | import type {InputRef} from 'antd'; 5 | import type {CustomTagProps} from 'rc-select/lib/BaseSelect'; 6 | import './index.css'; 7 | import type {DB} from '../../../../shared/types/db'; 8 | import {DeleteOutlined} from '@ant-design/icons'; 9 | interface AddableSelectOptions { 10 | options: DB.Group[] | DB.Tag[]; 11 | value?: number | string[] | number[] | undefined | string; 12 | mode?: 'tags' | 'multiple' | undefined; 13 | onChange?: (value: number | string[] | number[] | string, options: DB.Group | DB.Group[] | undefined) => void; 14 | onClear?: () => void; 15 | onAddItem: (name: string) => Promise; 16 | addBtnLabel?: string; 17 | placeholder?: string; 18 | onRemoveItem?: (value: number | string | undefined) => void; 19 | } 20 | 21 | const AddableSelect: React.FC = ({ 22 | options, 23 | value, 24 | mode, 25 | onChange, 26 | onClear, 27 | onAddItem, 28 | addBtnLabel, 29 | placeholder, 30 | onRemoveItem, 31 | }) => { 32 | const [name, setName] = useState(''); 33 | const inputRef = useRef(null); 34 | const fieldNames = {label: 'name', value: 'id'}; 35 | const {Text} = Typography; 36 | const onNameChange = (event: React.ChangeEvent) => { 37 | setName(event.target.value); 38 | }; 39 | 40 | const addItem = async (e: React.MouseEvent) => { 41 | e.preventDefault(); 42 | if (name.trim()) { 43 | await triggerAddItem(name); 44 | } 45 | }; 46 | 47 | const triggerAddItem = async (name: string) => { 48 | const addResult = await onAddItem(name); 49 | if (addResult) { 50 | setName(''); 51 | setTimeout(() => { 52 | inputRef.current?.focus(); 53 | }, 0); 54 | } 55 | }; 56 | 57 | const tagRender = (props: CustomTagProps) => { 58 | const {label, value, closable, onClose} = props; 59 | const onPreventMouseDown = (event: React.MouseEvent) => { 60 | event.preventDefault(); 61 | event.stopPropagation(); 62 | }; 63 | const tag = (options as DB.Tag[]).find(item => item.id === value); 64 | const color = tag?.color; 65 | return ( 66 | 74 | {label} 75 | 76 | ); 77 | }; 78 | 79 | const filterOption = (input: string, option?: DB.Group | DB.Tag) => { 80 | return (option?.name ?? '').toLowerCase().includes(input.toLowerCase()); 81 | }; 82 | 83 | return ( 84 | { 108 | e.stopPropagation(); 109 | if (e.key === 'Enter') { 110 | await triggerAddItem(name); 111 | } 112 | }} 113 | /> 114 | 121 | 122 | 123 | )} 124 | options={options} 125 | optionRender={option => { 126 | return ( 127 | 128 | 129 | {option.label} 130 | 131 | 135 | { 137 | e.stopPropagation(); 138 | onRemoveItem?.(option.value); 139 | }} 140 | className="cursor-pointer p-1 border rounded-lg bg-red-200 text-red-500" 141 | > 142 | 143 | 144 | 145 | 146 | ); 147 | }} 148 | /> 149 | ); 150 | }; 151 | 152 | export default AddableSelect; 153 | -------------------------------------------------------------------------------- /packages/renderer/src/components/header/index.css: -------------------------------------------------------------------------------- 1 | .header { 2 | border-bottom: 1px solid rgba(5, 5, 5, 0.06); 3 | display: flex; 4 | align-items: center; 5 | -webkit-transition: all 0.3s; 6 | transition: all 0.3s; 7 | padding-left: 24px; 8 | padding-right: 12px; 9 | } 10 | 11 | .header-left { 12 | display: flex; 13 | } 14 | 15 | .logo { 16 | width: 48px; 17 | height: 48px; 18 | transform: scale(1.4) translateY(2px); 19 | } 20 | .logo-img { 21 | border-radius: 8px; 22 | } 23 | 24 | .title { 25 | line-height: 48px !important; 26 | margin-bottom: 0px !important; 27 | margin-left: 4px; 28 | margin-top: 2px; 29 | } 30 | 31 | .draggable-bar { 32 | flex: 1; 33 | height: 48px; 34 | } 35 | 36 | .header-right { 37 | display: flex; 38 | margin-inline-start: auto; 39 | } 40 | 41 | .avatar { 42 | /* background-color: #69b1ff; */ 43 | margin-bottom: 2px; 44 | cursor: pointer; 45 | } 46 | 47 | .control-btn { 48 | margin-left: 24px; 49 | } 50 | -------------------------------------------------------------------------------- /packages/renderer/src/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | import type {MenuProps} from 'antd'; 2 | import {Avatar, Button, Dropdown, Layout, Space} from 'antd'; 3 | import './index.css'; 4 | import Title from 'antd/es/typography/Title'; 5 | import {CloseOutlined, MinusOutlined, BorderOutlined, BlockOutlined} from '@ant-design/icons'; 6 | import {useState} from 'react'; 7 | import {customizeToolbarControl} from '#preload'; 8 | import type {MenuInfo} from 'rc-menu/lib/interface'; 9 | import {theme} from 'antd'; 10 | const {useToken} = theme; 11 | import logo from '../../../assets/logo.png'; 12 | import {Icon} from '@iconify/react'; 13 | import {useNavigate} from 'react-router-dom'; 14 | import {useTranslation} from 'react-i18next'; 15 | 16 | const {Header: AntdHeader} = Layout; 17 | 18 | export default function Header() { 19 | const {t, i18n} = useTranslation(); 20 | const [isMaximized, setIsMaximized] = useState(false); 21 | const navigate = useNavigate(); 22 | const checkIfMaximized = async () => { 23 | try { 24 | const maximized = await customizeToolbarControl.isMaximized(); 25 | setIsMaximized(maximized); 26 | } catch (error) { 27 | console.error('Failed to check if window is maximized:', error); 28 | } 29 | }; 30 | 31 | const {token} = useToken(); 32 | 33 | const items: MenuProps['items'] = [ 34 | { 35 | label: t('header_settings'), 36 | key: 'settings', 37 | }, 38 | { 39 | label: t('header_language'), 40 | key: 'language', 41 | children: [ 42 | { 43 | label: 'English', 44 | key: 'en', 45 | onClick: () => { 46 | i18n.changeLanguage('en'); 47 | }, 48 | }, 49 | { 50 | label: '简体中文', 51 | key: 'zh-cn', 52 | onClick: () => { 53 | i18n.changeLanguage('zh'); 54 | }, 55 | }, 56 | ], 57 | }, 58 | // { 59 | // type: 'divider', 60 | // }, 61 | // { 62 | // label: t('header_sign_out'), 63 | // key: 'signout', 64 | // }, 65 | ]; 66 | 67 | const appControl = (action: 'close' | 'minimize' | 'maximize') => { 68 | customizeToolbarControl[action](); 69 | checkIfMaximized(); 70 | }; 71 | 72 | const dropdownAction = (info: MenuInfo) => { 73 | switch (info.key) { 74 | // case 'signout': 75 | // supabase.auth.signOut(); 76 | // break; 77 | case 'settings': 78 | navigate('/settings'); 79 | break; 80 | 81 | default: 82 | break; 83 | } 84 | }; 85 | 86 | return ( 87 | 88 |
89 |
90 | logo 95 |
96 | 100 | ChromePower 101 | 102 |
103 |
104 |
105 |
106 | dropdownAction(menuInfo)}} 108 | trigger={['click']} 109 | > 110 | } 115 | /> 116 | 117 |
118 | 119 |
120 | 121 |
135 |
136 |
137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /packages/renderer/src/components/navigation/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/packages/renderer/src/components/navigation/index.css -------------------------------------------------------------------------------- /packages/renderer/src/components/navigation/index.tsx: -------------------------------------------------------------------------------- 1 | import {Button, Menu, type MenuProps} from 'antd'; 2 | import type {MenuInfo} from 'rc-menu/lib/interface'; 3 | import {useRoutes} from '/@/routes'; 4 | import {useLocation, useNavigate} from 'react-router-dom'; 5 | import {PlusCircleOutlined} from '@ant-design/icons'; 6 | import {useEffect} from 'react'; 7 | import './index.css'; 8 | import React from 'react'; 9 | import {t} from 'i18next'; 10 | 11 | export default function Navigation() { 12 | const routes = useRoutes(); 13 | const navigate = useNavigate(); 14 | const location = useLocation(); 15 | const [menuItems, setMenuItems] = React.useState([]); 16 | 17 | useEffect(() => { 18 | const menuItemsTemp: MenuProps['items'] = routes 19 | .filter(r => !r.invisible) 20 | .map(route => { 21 | return { 22 | key: route.path, 23 | icon: route.icon, 24 | label: route.name, 25 | }; 26 | }); 27 | menuItemsTemp.splice(4, 0, {type: 'divider'}); 28 | setMenuItems(menuItemsTemp); 29 | }, [routes]); 30 | 31 | const onItemClicked = (info: MenuInfo) => { 32 | navigate(info.key); 33 | }; 34 | 35 | return ( 36 | <> 37 |
38 | 49 |
50 | 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /packages/renderer/src/constants/colors.ts: -------------------------------------------------------------------------------- 1 | export const TAG_COLORS = [ 2 | 'blue', 3 | 'purple', 4 | 'cyan', 5 | 'green', 6 | 'magenta', 7 | 'pink', 8 | 'red', 9 | 'orange', 10 | 'yellow', 11 | 'volcano', 12 | 'geekblue', 13 | 'lime', 14 | 'gold', 15 | ]; 16 | -------------------------------------------------------------------------------- /packages/renderer/src/constants/config.ts: -------------------------------------------------------------------------------- 1 | export const MESSAGE_CONFIG = { 2 | duration: 1, 3 | top: 120, 4 | getContainer: () => document.body, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/renderer/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export {TAG_COLORS} from './colors'; 2 | export {WINDOW_STATUS} from './status'; 3 | export {MESSAGE_CONFIG} from './config'; 4 | -------------------------------------------------------------------------------- /packages/renderer/src/constants/status.ts: -------------------------------------------------------------------------------- 1 | export const WINDOW_STATUS = { 2 | DEPRECATED: 0, 3 | NORMAL: 1, 4 | RUNNING: 2, 5 | PREPARING: 3, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/renderer/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html { 6 | /* padding: 8px; */ 7 | background-color: transparent; 8 | border-radius: 12px; 9 | user-select: none; 10 | -webkit-user-select: none; 11 | } 12 | 13 | body { 14 | background: transparent; 15 | border-radius: 12px; 16 | } 17 | 18 | .fade-in { 19 | opacity: 0; 20 | transition: opacity 0.2s ease-in-out; 21 | } 22 | 23 | .fade-in.visible { 24 | opacity: 1; 25 | } 26 | 27 | #app { 28 | background-image: url("../assets/bg2.png"); 29 | /* -webkit-backdrop-filter: blur(50px); 30 | backdrop-filter: blur(50px); 31 | box-shadow: 0 2px 10px 2px rgba(0, 0, 0, 0.1); */ 32 | border-radius: 12px; 33 | } 34 | 35 | .content { 36 | position: relative; 37 | padding: 15px 24px; 38 | box-shadow: inset 10px 0 10px -10px rgba(0, 0, 0, 0.05); 39 | 40 | /* min-width: 1200px; */ 41 | /* overflow-y: auto; */ 42 | /* min-height: 700px; */ 43 | } 44 | 45 | .content-card { 46 | height: calc(100% - 132px); 47 | } 48 | 49 | .content-card .ant-card-body { 50 | padding: 0; 51 | height: 100%; 52 | } 53 | 54 | .content-toolbar { 55 | display: flex; 56 | height: 44px; 57 | align-items: center; 58 | } 59 | 60 | .content-toolbar * { 61 | border: none !important; 62 | border-radius: 4px !important; 63 | } 64 | 65 | .content-toolbar-search { 66 | max-width: 240px; 67 | } 68 | 69 | .content-toolbar-btns { 70 | margin-right: 12px; 71 | display: flex; 72 | margin-inline-start: auto; 73 | } 74 | 75 | .content-table { 76 | border-radius: 24px; 77 | height: 100%; 78 | } 79 | 80 | .content-table .ant-spin-nested-loading { 81 | height: 100%; 82 | } 83 | .content-table .ant-spin-nested-loading .ant-spin-container { 84 | height: 100%; 85 | } 86 | 87 | .content-table .ant-table { 88 | height: 100%; 89 | } 90 | .content-table .ant-table-container { 91 | height: 100%; 92 | } 93 | 94 | .content-table .ant-table-body { 95 | height: calc(100% - 55px); 96 | } 97 | 98 | .sider .ant-layout-sider-children { 99 | position: relative; 100 | } 101 | 102 | .content-footer { 103 | position: absolute; 104 | bottom: 0px; 105 | height: 40px; 106 | background: white; 107 | width: 100%; 108 | left: 2px; 109 | padding: 4px 24px; 110 | } 111 | 112 | .pagination-wrapper { 113 | position: absolute; 114 | right: 8px; 115 | z-index: 1; 116 | } 117 | 118 | .h-full { 119 | height: 100% !important; 120 | } 121 | 122 | /* 在CSS中 */ 123 | .draggable { 124 | -webkit-app-region: drag; 125 | } 126 | 127 | ::-webkit-scrollbar { 128 | width: 16px; 129 | } 130 | 131 | ::-webkit-scrollbar-track { 132 | background-color: transparent; 133 | } 134 | 135 | ::-webkit-scrollbar-thumb { 136 | background-color: #d6dee1; 137 | border-radius: 20px; 138 | border: 4px solid transparent; 139 | background-clip: content-box; 140 | } 141 | 142 | ::-webkit-scrollbar-thumb:hover { 143 | background-color: #a8bbbf; 144 | } 145 | 146 | .fullscreen-spin-wrapper { 147 | position: fixed; 148 | width: 100vw; 149 | height: 100vh; 150 | background-color: rgba(0, 0, 0, 0.25); 151 | z-index: 1000; 152 | inset: 0; 153 | display: flex; 154 | align-items: center; 155 | flex-direction: column; 156 | justify-content: center; 157 | pointer-events: none; 158 | opacity: 0; 159 | visibility: hidden; 160 | transition: all 0.2s; 161 | } 162 | 163 | .fullscreen-spin-wrapper.visible { 164 | opacity: 1; 165 | visibility: visible; 166 | pointer-events: auto; 167 | } 168 | -------------------------------------------------------------------------------- /packages/renderer/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './index.css'; 3 | import 'virtual:windi.css'; 4 | import {createRoot} from 'react-dom/client'; 5 | import App from './App'; 6 | import type {ThemeConfig} from 'antd'; 7 | import {ConfigProvider, message} from 'antd'; 8 | import {HashRouter as Router} from 'react-router-dom'; 9 | import 'dayjs/locale/zh-cn'; 10 | // import enUS from 'antd/locale/en_US'; 11 | import zhCN from 'antd/locale/zh_CN'; 12 | import './i18n'; 13 | 14 | const rootContainer = document.getElementById('app'); 15 | 16 | message.config({ 17 | top: 1000, 18 | duration: 2, 19 | }); 20 | 21 | const customTheme: ThemeConfig = { 22 | // token: { 23 | // colorPrimary: '#4096ff', 24 | // }, 25 | token: { 26 | motion: false, 27 | }, 28 | components: { 29 | Layout: { 30 | bodyBg: 'rgba(240, 242, 245, 0.25)', 31 | headerBg: 'transparent', 32 | siderBg: 'transparent', 33 | lightSiderBg: 'transparent', 34 | headerHeight: 48, 35 | }, 36 | Menu: { 37 | itemBg: 'transparent', 38 | }, 39 | }, 40 | }; 41 | 42 | const root = createRoot(rootContainer!); 43 | root.render( 44 | 45 | 49 | 50 | 51 | 52 | 53 | , 54 | ); 55 | -------------------------------------------------------------------------------- /packages/renderer/src/interface/window.ts: -------------------------------------------------------------------------------- 1 | // export interface IWindowDetail { 2 | // name?: string; 3 | // ua?: string; 4 | // cookie?: string; 5 | // remark?: string; 6 | // group_id?: number; 7 | // tags?: string[]; 8 | // // [key: string]: string | number; 9 | // } 10 | -------------------------------------------------------------------------------- /packages/renderer/src/pages/api/index.css: -------------------------------------------------------------------------------- 1 | .api-container-card { 2 | height: calc(100% - 64px); 3 | overflow: auto; 4 | } 5 | 6 | .api-container-card .ant-card-body { 7 | padding: 0; 8 | height: 100%; 9 | } 10 | -------------------------------------------------------------------------------- /packages/renderer/src/pages/api/index.tsx: -------------------------------------------------------------------------------- 1 | import {Card} from 'antd'; 2 | import React from 'react'; 3 | import './index.css'; 4 | import {CommonBridge} from '#preload'; 5 | import {useEffect, useState} from 'react'; 6 | import {Divider, Typography, Form} from 'antd'; 7 | import {useTranslation} from 'react-i18next'; 8 | const {Title, Paragraph, Text, Link} = Typography; 9 | 10 | const Api = () => { 11 | const [apiInfo, setApiInfo] = useState({ 12 | url: '', 13 | status: 'ok', 14 | port: undefined, 15 | }); 16 | const {t} = useTranslation(); 17 | const fetchApi = async () => { 18 | const apiInfo = await CommonBridge.getApi(); 19 | setApiInfo(apiInfo); 20 | }; 21 | 22 | useEffect(() => { 23 | fetchApi(); 24 | }, []); 25 | return ( 26 | <> 27 | 31 | 32 | {t('api_title')} 33 | <Link 34 | className="ml-2" 35 | href="https://documenter.getpostman.com/view/25586363/2sA3BkdZ61#intro" 36 | target="_blank" 37 | > 38 | {t('api_link')} 39 | </Link> 40 | 41 | 42 | {t('api_description')} 43 | 44 | 45 |
52 | 53 | {apiInfo.status} 54 | 55 | 56 | 60 | {apiInfo.url} 61 | 62 | 63 |
64 | 65 | 66 | {t('api_supported')} 67 | {t('api_control')} 68 | {t('api_getProfiles')} 69 | 73 | GET /profiles 74 | 75 | {t('api_openProfile')} 76 | 80 | GET /profiles/open?windowId=xxx 81 | 82 | {t('api_closeProfile')} 83 | 87 | GET /profiles/close?windowId=xxx 88 | 89 | {t('api_windows')} 90 | 96 | 100 | {t('api_windowsDoc')} 101 | 102 | 103 | {t('api.proxy')} 104 | 110 | 114 | {t('api_proxyDoc')} 115 | 116 | 117 |
118 | 119 | ); 120 | }; 121 | export default Api; 122 | -------------------------------------------------------------------------------- /packages/renderer/src/pages/logs/index.css: -------------------------------------------------------------------------------- 1 | .log-container { 2 | height: calc(100% - 64px); 3 | overflow: auto; 4 | } 5 | 6 | .log-aside { 7 | background-color: #052f4a; 8 | } -------------------------------------------------------------------------------- /packages/renderer/src/pages/logs/index.tsx: -------------------------------------------------------------------------------- 1 | import {Card, Tabs} from 'antd'; 2 | import {CommonBridge} from '#preload'; 3 | import {useEffect} from 'react'; 4 | import React from 'react'; 5 | import './index.css'; 6 | 7 | interface logsDataOptions { 8 | name: string; 9 | content: Array<{ 10 | level: string; 11 | message: string; 12 | }>; 13 | } 14 | 15 | const Logs = () => { 16 | const items = [ 17 | { 18 | key: 'Main', 19 | label: 'Main', 20 | }, 21 | { 22 | key: 'Window', 23 | label: 'Windows', 24 | }, 25 | { 26 | key: 'Proxy', 27 | label: 'Proxy', 28 | }, 29 | // { 30 | // key: 'Api', 31 | // label: 'Api', 32 | // }, 33 | ]; 34 | const [logsData, setLogsData] = React.useState([]); 35 | 36 | const fetchLogs = async (logModule: 'Main' | 'Windows' | 'Proxy' | 'Api') => { 37 | const logs = await CommonBridge.getLogs(logModule); 38 | setLogsData(logs.reverse()); 39 | }; 40 | 41 | useEffect(() => { 42 | fetchLogs('Main'); 43 | }, []); 44 | // type FieldType = SettingOptions; 45 | 46 | return ( 47 | <> 48 | 52 | fetchLogs(key as 'Main' | 'Windows' | 'Proxy' | 'Api')} 54 | size="small" 55 | items={items} 56 | /> 57 | 101 | 102 | 103 | ); 104 | }; 105 | export default Logs; 106 | -------------------------------------------------------------------------------- /packages/renderer/src/pages/proxy/import/index.css: -------------------------------------------------------------------------------- 1 | .proxy-detail-card { 2 | height: calc(100% - 86px); 3 | /* padding: 24px; */ 4 | } 5 | -------------------------------------------------------------------------------- /packages/renderer/src/pages/windows/components/edit-footer/index.tsx: -------------------------------------------------------------------------------- 1 | import {Button, Space, message} from 'antd'; 2 | import type {OperationResult} from '../../../../../../shared/types/common'; 3 | import {WindowBridge} from '#preload'; 4 | import type {DB, SafeAny} from '../../../../../../shared/types/db'; 5 | import {MESSAGE_CONFIG} from '/@/constants'; 6 | import {useState} from 'react'; 7 | import {useNavigate} from 'react-router-dom'; 8 | import {useTranslation} from 'react-i18next'; 9 | 10 | const WindowDetailFooter = ({ 11 | currentTab, 12 | formValue, 13 | fingerprints, 14 | loading, 15 | }: { 16 | loading: boolean; 17 | fingerprints: SafeAny; 18 | currentTab: string; 19 | formValue: DB.Window; 20 | }) => { 21 | const navigate = useNavigate(); 22 | const [messageApi, contextHolder] = message.useMessage(MESSAGE_CONFIG); 23 | const [saving, setSaving] = useState(false); 24 | const {t} = useTranslation(); 25 | 26 | const back = () => { 27 | history.back(); 28 | }; 29 | 30 | const handleOk = () => { 31 | console.log('handleOk', formValue); 32 | saveWindow(formValue); 33 | }; 34 | 35 | const savePreparation = (formValue: DB.Window) => { 36 | if (formValue.tags && formValue.tags instanceof Array) { 37 | formValue.tags = formValue.tags.join(','); 38 | } 39 | }; 40 | 41 | const showMessage = (result: OperationResult) => { 42 | messageApi[result.success ? 'success' : 'error']( 43 | result.success ? `Saved successfully` : result.message, 44 | ).then(() => { 45 | setSaving(false); 46 | if (result.success) { 47 | navigate('/'); 48 | } 49 | }); 50 | }; 51 | 52 | const saveWindow = async (formValue: DB.Window) => { 53 | setSaving(true); 54 | savePreparation(formValue); 55 | let result: OperationResult; 56 | if (formValue.id) { 57 | result = await WindowBridge?.update(formValue.id, { 58 | ...formValue, 59 | proxy_id: formValue.proxy_id || null, 60 | }); 61 | showMessage(result); 62 | } else { 63 | if (currentTab === 'windowForm') { 64 | result = await WindowBridge?.create(formValue, fingerprints); 65 | showMessage(result); 66 | } 67 | } 68 | }; 69 | 70 | return ( 71 | <> 72 | {contextHolder} 73 |
74 | 78 | {currentTab !== 'import' && ( 79 | 88 | )} 89 | 96 | 97 |
98 | 99 | ); 100 | }; 101 | 102 | export default WindowDetailFooter; 103 | -------------------------------------------------------------------------------- /packages/renderer/src/pages/windows/components/fingerprint-info/index.css: -------------------------------------------------------------------------------- 1 | .fingerprint-wrapper { 2 | height: 100%; 3 | min-width: 300px; 4 | max-width: 400px; 5 | padding: 12px 18px; 6 | border-radius: 8px; 7 | flex: 1 1 auto; 8 | position: sticky; 9 | top: 0; 10 | background-color: rgba(64, 150, 255, 0.1); 11 | } 12 | 13 | .fingerprint-value { 14 | max-width: 150px; 15 | margin-inline-start: auto; 16 | word-break: break-word; 17 | text-align: end; 18 | } 19 | -------------------------------------------------------------------------------- /packages/renderer/src/pages/windows/components/fingerprint-info/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | import type {SafeAny} from '../../../../../../shared/types/db'; 3 | import {useEffect, useState} from 'react'; 4 | 5 | interface FingerprintProps { 6 | ua: string; 7 | timezone: string; 8 | location: string; 9 | language: string; 10 | screen: string; 11 | fonts: string; 12 | canvas: string; 13 | webRTC: string; 14 | webGLImage: string; 15 | audio: string; 16 | } 17 | 18 | const FingerprintInfo = ({fingerprints}: {fingerprints: SafeAny}) => { 19 | const [fingerprintDisplay, setFingerprintDisplay] = useState({ 20 | ua: '', 21 | timezone: 'Based on IP address', 22 | location: 'Based on IP address', 23 | language: 'Based on IP address', 24 | screen: 'Default', 25 | fonts: 'Default', 26 | canvas: 'Noise', 27 | webRTC: 'Disabled', 28 | webGLImage: 'Noise', 29 | audio: 'Noise', 30 | }); 31 | const fingerprinstLog: {title: string; field: keyof FingerprintProps}[] = [ 32 | { 33 | title: 'User-Argent', 34 | field: 'ua', 35 | }, 36 | { 37 | title: 'Timezone', 38 | field: 'timezone', 39 | }, 40 | { 41 | title: 'WebRTC', 42 | field: 'webRTC', 43 | }, 44 | { 45 | title: 'Location', 46 | field: 'location', 47 | }, 48 | { 49 | title: 'Language', 50 | field: 'language', 51 | }, 52 | { 53 | title: 'Screen Resolution', 54 | field: 'screen', 55 | }, 56 | { 57 | title: 'Fonts', 58 | field: 'fonts', 59 | }, 60 | { 61 | title: 'Canvas', 62 | field: 'canvas', 63 | }, 64 | { 65 | title: 'WebGL Image', 66 | field: 'webGLImage', 67 | }, 68 | { 69 | title: 'Audio Context', 70 | field: 'audio', 71 | }, 72 | ]; 73 | useEffect(() => { 74 | if (fingerprints) { 75 | setFingerprintDisplay({ 76 | ...fingerprintDisplay, 77 | ua: fingerprints?.ua || window.navigator.userAgent, 78 | }); 79 | } 80 | }, [fingerprints]); 81 | return ( 82 |
83 | {fingerprinstLog.map((item, index) => { 84 | return ( 85 |
0 && 'mt-2'}`} 87 | key={item.field} 88 | > 89 |
{item.title}
90 |
91 | {typeof fingerprintDisplay[item.field] !== 'object' 92 | ? fingerprintDisplay[item.field] 93 | : ''} 94 |
95 |
96 | ); 97 | })} 98 |
99 | ); 100 | }; 101 | 102 | export default FingerprintInfo; 103 | -------------------------------------------------------------------------------- /packages/renderer/src/pages/windows/components/import-form/index.tsx: -------------------------------------------------------------------------------- 1 | import type {UploadProps} from 'antd'; 2 | import {Button, Form, Space, Spin, Upload, message} from 'antd'; 3 | import {UploadOutlined} from '@ant-design/icons'; 4 | import {CommonBridge, WindowBridge} from '#preload'; 5 | import {useNavigate} from 'react-router-dom'; 6 | import {MESSAGE_CONFIG} from '/@/constants'; 7 | import {useState} from 'react'; 8 | import type {OperationResult} from '../../../../../../shared/types/common'; 9 | import {useTranslation} from 'react-i18next'; 10 | 11 | const WindowImportForm = () => { 12 | const key = 'updatable'; 13 | 14 | const [messageApi, contextHolder] = message.useMessage(MESSAGE_CONFIG); 15 | const [loading, setLoading] = useState(false); 16 | 17 | const navigate = useNavigate(); 18 | const {t} = useTranslation(); 19 | 20 | const props: UploadProps = { 21 | name: 'import', 22 | customRequest: async ({file}) => { 23 | try { 24 | setLoading(true); 25 | messageApi.open({type: 'loading', content: 'Importing...', key: key}); 26 | const result: OperationResult = await WindowBridge?.import((file as unknown as File).path); 27 | console.log(result); 28 | messageApi 29 | .open({ 30 | type: (result.data as number[]).length > 0 ? 'success' : 'error', 31 | content: `${ 32 | result.message + 33 | (result.data.length > 0 34 | ? `, will be automatically jumped after ${MESSAGE_CONFIG.duration}s` 35 | : '') 36 | }`, 37 | key: key, 38 | }) 39 | .then(() => { 40 | setLoading(false); 41 | if (result.data.length > 0) { 42 | navigate('/'); 43 | } 44 | }); 45 | } catch (error) { 46 | console.error(error); 47 | } 48 | }, 49 | showUploadList: false, 50 | }; 51 | 52 | const downLoadTempalte = async () => { 53 | try { 54 | const filePath = 'renderer/assets/template.xlsx'; 55 | const rs = await CommonBridge.download(filePath); 56 | if (rs) { 57 | messageApi.success('Template downloaded successfully'); 58 | } 59 | } catch (error) { 60 | console.error(error); 61 | messageApi.error('Failed to download template'); 62 | } 63 | }; 64 | 65 | return ( 66 | <> 67 | {contextHolder} 68 | 69 |
74 | 75 | 76 | 77 | 78 | 79 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 |
93 |
94 | 95 | ); 96 | }; 97 | 98 | export default WindowImportForm; 99 | -------------------------------------------------------------------------------- /packages/renderer/src/pages/windows/detail/index.css: -------------------------------------------------------------------------------- 1 | .window-detail-card { 2 | height: calc(100% - 86px); 3 | } 4 | 5 | .window-detail-card .ant-card-body { 6 | padding: 12px 0px; 7 | height: 100%; 8 | } 9 | 10 | .window-detail-card .ant-tabs { 11 | height: 100%; 12 | } 13 | 14 | .window-detail-card .ant-tabs-top > .ant-tabs-nav { 15 | margin-bottom: 16px; 16 | padding: 0 24px; 17 | } 18 | 19 | .window-detail-card .ant-tabs-content-holder { 20 | height: calc(100% - 68px); 21 | overflow: auto; 22 | padding: 0 24px; 23 | } 24 | 25 | .window-detail-card form { 26 | flex: 0 0 450px; 27 | margin-right: 48px; 28 | } 29 | 30 | -------------------------------------------------------------------------------- /packages/renderer/src/pages/windows/detail/index.tsx: -------------------------------------------------------------------------------- 1 | import type {TabsProps} from 'antd'; 2 | import {Card, Tabs} from 'antd'; 3 | import './index.css'; 4 | import WindowEditForm from '../components/edit-form'; 5 | import WindowImportForm from '../components/import-form'; 6 | import {useCallback, useEffect, useState} from 'react'; 7 | import type {DB, SafeAny} from '../../../../../shared/types/db'; 8 | import {useSearchParams} from 'react-router-dom'; 9 | import {WindowBridge} from '#preload'; 10 | import FingerprintInfo from '../components/fingerprint-info'; 11 | import WindowDetailFooter from '../components/edit-footer'; 12 | import {useTranslation} from 'react-i18next'; 13 | 14 | const WindowDetailTabs = ({ 15 | formValue, 16 | onChange, 17 | formValueChangeCallback, 18 | }: { 19 | formValue: DB.Window; 20 | fingerprints?: SafeAny; 21 | onChange: (key: string) => void; 22 | formValueChangeCallback: (changed: DB.Window, data: DB.Window) => void; 23 | }) => { 24 | const {t} = useTranslation(); 25 | const DEFAULT_ACTIVE_KEY = '0'; 26 | const items: TabsProps['items'] = [ 27 | { 28 | key: 'windowForm', 29 | label: t('window_detail_create'), 30 | forceRender: true, 31 | children: ( 32 |
33 | {WindowEditForm({ 34 | loading: false, 35 | formValue: formValue, 36 | formChangeCallback: formValueChangeCallback, 37 | })} 38 | {/* {FingerprintInfo({fingerprints})} */} 39 |
40 | ), 41 | }, 42 | { 43 | key: 'import', 44 | label: t('window_detail_import'), 45 | children: WindowImportForm(), 46 | }, 47 | ]; 48 | 49 | return ( 50 | 56 | ); 57 | }; 58 | 59 | const WindowDetail = () => { 60 | // const [formValue, setFormValue] = useState({}); 61 | const [formValue, setFormValue] = useState(new Object()); 62 | const [currentTab, setCurrentTab] = useState('windowForm'); 63 | const [searchParams] = useSearchParams(); 64 | const [fingerprints, setFingerprints] = useState(new Object()); 65 | const [loading, setLoading] = useState(false); 66 | 67 | useEffect(() => { 68 | initFormValue(); 69 | }, [searchParams]); 70 | 71 | const fetchFingerprints = async (windowId?: number) => { 72 | try { 73 | // eslint-disable-next-line no-unsafe-optional-chaining 74 | const data = await WindowBridge?.getFingerprint(windowId); 75 | setFingerprints(data); 76 | } catch (error) { 77 | setFingerprints(new Object()); 78 | console.log(error); 79 | } 80 | }; 81 | 82 | const initFormValue = async () => { 83 | const id = searchParams.get('id'); 84 | setLoading(true); 85 | if (id) { 86 | const window = await WindowBridge?.getById(Number(id)); 87 | if (window.tags) { 88 | if (typeof window.tags === 'string') { 89 | window.tags = window.tags.split(',').map((item: string) => Number(item)); 90 | } else if (typeof window.tags === 'number') { 91 | window.tags = [window.tags]; 92 | } 93 | } else { 94 | window.tags = []; 95 | } 96 | setFormValue(window || new Object()); 97 | fetchFingerprints(Number(id)); 98 | } else { 99 | setFormValue(new Object()); 100 | fetchFingerprints(); 101 | } 102 | setLoading(false); 103 | }; 104 | 105 | const onTabChange = useCallback((tab: string) => { 106 | setCurrentTab(tab); 107 | }, []); 108 | 109 | const formValueChangeCallback = (changed: DB.Window, _: DB.Window) => { 110 | const newFormValue = { 111 | ...formValue, 112 | ...changed, 113 | }; 114 | setFormValue(newFormValue); 115 | // setFormValue(data); 116 | }; 117 | 118 | return ( 119 | <> 120 | 121 | {searchParams.get('id') ? ( 122 |
123 | 128 | 129 |
130 | ) : ( 131 | 137 | )} 138 |
139 | 145 | 146 | ); 147 | }; 148 | 149 | export default WindowDetail; 150 | -------------------------------------------------------------------------------- /packages/renderer/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import Windows from '../pages/windows'; 2 | // import About from '../pages/about'; 3 | import Settings from '../pages/settings'; 4 | import Proxy from '../pages/proxy'; 5 | import WindowDetail from '../pages/windows/detail'; 6 | import ProxyImport from '../pages/proxy/import'; 7 | import {Icon} from '@iconify/react'; 8 | import Sync from '../pages/sync'; 9 | import {useMemo, type ReactElement} from 'react'; 10 | import {useTranslation} from 'react-i18next'; 11 | import Logs from '../pages/logs'; 12 | import Start from '../pages/start'; 13 | import Api from '../pages/api'; 14 | import Extensions from '../pages/extensions'; 15 | interface RouteOption { 16 | path: string; 17 | name?: string; 18 | icon?: ReactElement; 19 | component: () => JSX.Element; 20 | invisible?: boolean; 21 | } 22 | 23 | export const useRoutes = () => { 24 | const {t, i18n} = useTranslation(); 25 | 26 | return useMemo(() => { 27 | return [ 28 | { 29 | path: '/', 30 | name: t('menu_windows'), 31 | icon: , 32 | component: Windows, 33 | }, 34 | { 35 | path: '/window/create', 36 | name: t('new_window'), 37 | component: WindowDetail, 38 | invisible: true, 39 | }, 40 | { 41 | path: '/window/edit', 42 | name: t('edit_window'), 43 | component: WindowDetail, 44 | invisible: true, 45 | }, 46 | { 47 | path: '/proxy', 48 | name: t('menu_proxy'), 49 | icon: , 50 | component: Proxy, 51 | }, 52 | { 53 | path: '/proxy/import', 54 | name: t('new_proxy'), 55 | component: ProxyImport, 56 | invisible: true, 57 | }, 58 | { 59 | path: '/extensions', 60 | name: t('menu_extensions'), 61 | icon: , 62 | component: Extensions, 63 | }, 64 | { 65 | path: '/sync', 66 | name: t('menu_sync'), 67 | icon: , 68 | component: Sync, 69 | }, 70 | { 71 | path: '/logs', 72 | name: t('menu_logs'), 73 | icon: , 74 | component: Logs, 75 | }, 76 | { 77 | path: '/settings', 78 | name: t('menu_settings'), 79 | icon: , 80 | component: Settings, 81 | }, 82 | { 83 | path: '/api', 84 | name: t('menu_api'), 85 | icon: , 86 | component: Api, 87 | }, 88 | { 89 | path: '/start', 90 | component: () => , 91 | invisible: true, 92 | }, 93 | ]; 94 | }, [i18n.language]); 95 | }; 96 | 97 | export const useRoutesMap = () => { 98 | const routes = useRoutes(); 99 | 100 | const routesMap: Record = {}; 101 | 102 | routes.forEach(route => { 103 | routesMap[route.path] = route; 104 | }); 105 | 106 | return routesMap; 107 | }; 108 | -------------------------------------------------------------------------------- /packages/renderer/src/store.ts: -------------------------------------------------------------------------------- 1 | // store.ts 2 | import {configureStore} from '@reduxjs/toolkit'; 3 | 4 | export const store = configureStore({ 5 | reducer: { 6 | // user: userReducer, 7 | }, 8 | }); 9 | 10 | export type RootState = ReturnType; 11 | export type AppDispatch = typeof store.dispatch; 12 | -------------------------------------------------------------------------------- /packages/renderer/src/styles/antd.css: -------------------------------------------------------------------------------- 1 | .ant-table-wrapper .ant-table-thead > tr > th { 2 | background: rgba(236, 242, 250); 3 | } 4 | 5 | .ant-table-wrapper .ant-table-tbody > tr:nth-child(2n) { 6 | background: rgba(251, 252, 252); 7 | } 8 | 9 | /* .ant-table-wrapper .ant-table-tbody > tr > td { 10 | white-space: nowrap; 11 | overflow: hidden; 12 | text-overflow: ellipsis; 13 | } */ 14 | 15 | .ant-table-wrapper .ant-table-cell-scrollbar:not([rowspan]) { 16 | box-shadow: 0 1px 0 1px rgba(236, 242, 250); 17 | } 18 | 19 | .ant-table-wrapper .ant-table-thead > tr > td { 20 | background: rgba(236, 242, 250); 21 | } 22 | 23 | .ant-table-wrapper .ant-table-pagination.ant-pagination { 24 | margin: 13px 0; 25 | } 26 | 27 | .ant-menu-light .ant-menu-item-selected { 28 | background-color: rgba(64, 150, 255, 0.1); 29 | box-shadow: 0 20px 27px rgba(64, 150, 255, 0.05); 30 | } 31 | 32 | .ant-input-affix-wrapper, 33 | .ant-select-selector, 34 | .ant-btn:not(.ant-btn-link), 35 | .ant-form-item-control-input-content .ant-input { 36 | box-shadow: 0 2px 2px rgba(0, 0, 0, 0.05); 37 | } 38 | 39 | .ant-input-affix-wrapper >input.ant-input { 40 | box-shadow: none; 41 | } 42 | 43 | .ant-table-wrapper .ant-table-cell-fix-left, 44 | .ant-table-wrapper .ant-table-cell-fix-right { 45 | z-index: 1; 46 | } 47 | -------------------------------------------------------------------------------- /packages/renderer/src/utils/str.ts: -------------------------------------------------------------------------------- 1 | export const containsKeyword = (str: string | number | undefined, keyword: string): boolean => { 2 | if (typeof str === 'undefined') return false; 3 | return str?.toString()?.toLowerCase()?.includes(keyword.toLowerCase()); 4 | }; 5 | -------------------------------------------------------------------------------- /packages/renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "sourceMap": false, 6 | "moduleResolution": "Node", 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "isolatedModules": true, 10 | "jsx": "react-jsx", 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "types": ["node"], 14 | "baseUrl": ".", 15 | "paths": { 16 | "#preload": ["../preload/src/index"], 17 | "/@/*": ["./src/*"] 18 | }, 19 | "lib": ["ESNext", "dom", "dom.iterable"] 20 | }, 21 | "include": [ 22 | "src/**/*.vue", 23 | "src/**/*.ts", 24 | "src/**/*.tsx", 25 | "types/**/*.d.ts", 26 | "../../types/**/*.d.ts", 27 | "../shared/interfaces/supabaseClient.ts" 28 | ], 29 | "exclude": ["**/*.spec.ts", "**/*.test.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/renderer/types/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type {DefineComponent} from 'vue'; 3 | // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any 4 | const component: DefineComponent<{}, {}, any>; 5 | export default component; 6 | } 7 | -------------------------------------------------------------------------------- /packages/renderer/vite.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | import {chrome} from '../../.electron-vendors.cache.json'; 4 | import react from '@vitejs/plugin-react'; 5 | import {renderer} from 'unplugin-auto-expose'; 6 | import {join} from 'node:path'; 7 | import {injectAppVersion} from '../../version/inject-app-version-plugin.mjs'; 8 | import WindiCSS from 'vite-plugin-windicss'; 9 | 10 | const PACKAGE_ROOT = __dirname; 11 | const PROJECT_ROOT = join(PACKAGE_ROOT, '../..'); 12 | 13 | /** 14 | * @type {import('vite').UserConfig} 15 | * @see https://vitejs.dev/config/ 16 | */ 17 | const config = { 18 | mode: process.env.MODE, 19 | root: PACKAGE_ROOT, 20 | envDir: PROJECT_ROOT, 21 | resolve: { 22 | alias: { 23 | '/@/': join(PACKAGE_ROOT, 'src') + '/', 24 | }, 25 | }, 26 | base: '', 27 | server: { 28 | fs: { 29 | strict: true, 30 | }, 31 | }, 32 | build: { 33 | sourcemap: true, 34 | target: `chrome${chrome}`, 35 | outDir: 'dist', 36 | assetsDir: '.', 37 | rollupOptions: { 38 | input: join(PACKAGE_ROOT, 'index.html'), 39 | }, 40 | emptyOutDir: true, 41 | reportCompressedSize: false, 42 | }, 43 | test: { 44 | environment: 'happy-dom', 45 | }, 46 | plugins: [ 47 | react(), 48 | renderer.vite({ 49 | preloadEntry: join(PACKAGE_ROOT, '../preload/src/index.ts'), 50 | }), 51 | injectAppVersion(), 52 | WindiCSS(), 53 | ], 54 | }; 55 | 56 | export default config; 57 | -------------------------------------------------------------------------------- /packages/shared/api/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const api = axios.create(); 4 | 5 | api.interceptors.response.use( 6 | response => { 7 | return response; 8 | }, 9 | error => { 10 | return Promise.reject(error); 11 | }, 12 | ); 13 | 14 | export default api; 15 | -------------------------------------------------------------------------------- /packages/shared/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const DB_ACTION_STATUS = {}; 2 | 3 | export const PIN_URL = [ 4 | { 5 | name: 'Google', 6 | n: 'GG', 7 | url: 'https://www.google.com/', 8 | }, 9 | { 10 | name: 'Discord', 11 | n: 'DC', 12 | url: 'https://www.discord.com/', 13 | }, 14 | { 15 | name: 'Twitter', 16 | n: 'X', 17 | url: 'https://x.com/', 18 | }, 19 | ]; 20 | -------------------------------------------------------------------------------- /packages/shared/types/common.d.ts: -------------------------------------------------------------------------------- 1 | export interface OperationResult { 2 | success: boolean; 3 | message: string; 4 | data?: SafeAny; 5 | } 6 | 7 | export interface SettingOptions { 8 | profileCachePath: string; 9 | useLocalChrome: boolean; 10 | localChromePath: string; 11 | chromiumBinPath: string; 12 | automationConnect: boolean; 13 | } 14 | 15 | export type NoticeType = 'info' | 'success' | 'error' | 'warning' | 'loading'; 16 | 17 | export interface BridgeMessage { 18 | type: NoticeType; 19 | text: string; 20 | } 21 | -------------------------------------------------------------------------------- /packages/shared/types/db.d.ts: -------------------------------------------------------------------------------- 1 | // types/models.d.ts 2 | 3 | export namespace DB { 4 | export interface Window { 5 | id?: number; 6 | profile_id?: string; 7 | name?: string; 8 | group_id?: number | null; 9 | group_name?: string; 10 | tags?: number[] | string[] | null | string; 11 | remark?: string; 12 | opened_at?: string; 13 | created_at?: string; 14 | updated_at?: string; 15 | ua?: string; 16 | fingerprint?: string; 17 | cookie?: string; 18 | /** 0: removed; 1: closed; 2: running; 3: Preparing */ 19 | status?: number; 20 | 21 | ip?: string; 22 | port?: number | null; 23 | pid?: number | null; 24 | local_proxy_port?: number; 25 | 26 | proxy_id?: number | null; 27 | proxy?: string; 28 | proxy_type?: string; 29 | ip_country?: string; 30 | ip_checker?: string; 31 | tags_name?: string[]; 32 | } 33 | 34 | export interface Proxy { 35 | id?: number; 36 | ip?: string; 37 | proxy?: string; 38 | host?: string; 39 | proxy_type?: string; 40 | ip_checker?: 'ip2location' | 'geoip'; 41 | ip_country?: string; 42 | check_result?: string; 43 | checking?: boolean; 44 | remark?: string; 45 | usageCount?: number; 46 | // ... other properties 47 | } 48 | 49 | export interface Group { 50 | id?: number; 51 | name?: string; 52 | } 53 | 54 | export interface Tag { 55 | id?: number; 56 | name?: string; 57 | color?: string; 58 | } 59 | 60 | export interface Extension { 61 | id?: number; 62 | name: string; 63 | version: string; 64 | path: string; 65 | windows?: number[] | string; 66 | icon?: string; 67 | description?: string; 68 | created_at?: string; 69 | updated_at?: string; 70 | } 71 | 72 | export interface WindowExtension { 73 | id?: number; 74 | extension_id?: number; 75 | window_id?: number; 76 | } 77 | } 78 | 79 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 80 | export type SafeAny = any; 81 | -------------------------------------------------------------------------------- /packages/shared/types/ip.d.ts: -------------------------------------------------------------------------------- 1 | export interface IP { 2 | ip: string; 3 | country: string; 4 | ll: number[]; 5 | timeZone: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | export {getRequestProxy} from './proxy'; 2 | export {createLogger} from './logger'; 3 | export {randomUniqueProfileId, randomASCII, randomFloat, randomInt} from './random'; 4 | -------------------------------------------------------------------------------- /packages/shared/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import {existsSync, mkdirSync} from 'fs'; 2 | import * as winston from 'winston'; 3 | import {join} from 'path'; 4 | import {app} from 'electron'; 5 | 6 | // const colorizer = winston.format.colorize(); 7 | 8 | export function createLogger(label: string) { 9 | // const isDevelopment = import.meta.env.MODE === 'development'; 10 | 11 | if (!winston.loggers.has(label)) { 12 | // if (isDevelopment) { 13 | // // 开发环境: 所有日志都输出到控制台 14 | // transport = new winston.transports.Console({level: 'info'}); 15 | // } else { 16 | const logsPath = join(app.getPath('userData'), 'logs'); 17 | if (!existsSync(logsPath)) { 18 | mkdirSync(logsPath, {recursive: true}); 19 | } 20 | if (!existsSync(join(logsPath, label))) { 21 | mkdirSync(join(logsPath, label)); 22 | } 23 | const date = new Date(); 24 | 25 | const year = date.getFullYear(); 26 | const month = date.getMonth() + 1; 27 | const day = date.getDate(); 28 | const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${day 29 | .toString() 30 | .padStart(2, '0')}`; 31 | // 定义日志文件的位置,每天记录一个日志文件 32 | const logFile = join(logsPath, label, `${formattedDate}.log`); 33 | // 生产环境: 所有日志都输出到文件 34 | const transport = new winston.transports.File({level: 'info', filename: logFile}); 35 | // } 36 | 37 | winston.loggers.add(label, { 38 | transports: [transport], 39 | format: winston.format.combine( 40 | winston.format.label({label}), 41 | winston.format.timestamp({format: 'YYYY-MM-DD HH:mm:ss'}), 42 | winston.format.printf(info => { 43 | const {timestamp, level, message, [Symbol.for('splat')]: splat} = info; 44 | const metaString = 45 | splat && Array.isArray(splat) && splat.length 46 | ? splat.map(item => JSON.stringify(item)).join(' ') 47 | : ''; 48 | const formattedMessage = `${message} ${metaString}`.trim(); 49 | return `${label} | ${timestamp} - ${level}: ${formattedMessage}`; 50 | }), 51 | ), 52 | }); 53 | } 54 | return winston.loggers.get(label); 55 | } 56 | -------------------------------------------------------------------------------- /packages/shared/utils/proxy.ts: -------------------------------------------------------------------------------- 1 | import type {AxiosProxyConfig} from 'axios'; 2 | 3 | export const getRequestProxy = ( 4 | proxy: string, 5 | proxy_type: string, 6 | ): AxiosProxyConfig | undefined => { 7 | if (!proxy) return; 8 | const [host, port, username, password] = proxy.split(':'); 9 | return { 10 | protocol: proxy_type.toLocaleLowerCase(), 11 | host, 12 | port: +port, 13 | auth: username 14 | ? { 15 | username, 16 | password, 17 | } 18 | : undefined, 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/shared/utils/random.ts: -------------------------------------------------------------------------------- 1 | export function randomUniqueProfileId(length = 7) { 2 | let result = ''; 3 | const characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; 4 | const charactersLength = characters.length; 5 | 6 | for (let i = 0; i < length; i++) { 7 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 8 | } 9 | 10 | return result; 11 | } 12 | 13 | export function randomASCII() { 14 | const min = 32; 15 | const max = 126; 16 | 17 | const randomNum = Math.floor(Math.random() * (max - min + 1)) + min; 18 | 19 | return String.fromCharCode(randomNum); 20 | } 21 | 22 | export function randomFloat() { 23 | return Math.random() / 2; 24 | } 25 | 26 | export function randomInt() { 27 | return Math.floor(Math.random() * 99); 28 | } 29 | -------------------------------------------------------------------------------- /pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmzimpl/chrome-power-app/7f73b96a3f7b71a2eb264359e0bef463c8ed5a3a/pic.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | plugins: { 5 | tailwindcss: { 6 | config: path.join(__dirname, 'tailwind.config.js'), 7 | }, 8 | autoprefixer: {}, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /scripts/build-native-addon.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * 根据平台和架构构建原生模块并组织输出文件 5 | * 这个脚本将: 6 | * 1. 确定当前操作系统和架构 7 | * 2. 执行适当的构建命令 8 | * 3. 创建特定于平台/架构的目录 9 | * 4. 将构建好的模块移动到对应目录 10 | */ 11 | 12 | const { execSync } = require('child_process'); 13 | const path = require('path'); 14 | const dotenv = require('dotenv'); 15 | 16 | // 加载环境变量 17 | dotenv.config(); 18 | 19 | // 获取平台和架构信息 20 | const platform = process.env.ELECTRON_PLATFORM || process.platform; 21 | const arch = process.env.ELECTRON_ARCH || process.arch; 22 | 23 | console.log(`构建原生模块 (平台: ${platform}, 架构: ${arch})`); 24 | 25 | // 原生模块目录 26 | const nativeAddonDir = path.join(__dirname, '../packages/main/src/native-addon'); 27 | const buildDir = path.join(nativeAddonDir, 'build'); 28 | const releaseDir = path.join(buildDir, 'Release'); 29 | 30 | // 创建特定于平台和架构的目标目录路径 31 | const targetDir = path.join(releaseDir, `${platform}-${arch}`); 32 | const sourcePath = path.join(releaseDir, 'window-addon.node'); 33 | 34 | try { 35 | // 根据不同平台和架构执行不同的构建命令 36 | console.log(`开始为 ${platform}-${arch} 构建原生模块...`); 37 | 38 | if (platform === 'win32') { 39 | console.log('在 Windows 平台构建原生模块...'); 40 | execSync('npm run build:native-addon', { stdio: 'inherit' }); 41 | } else if (platform === 'darwin') { 42 | if (arch === 'arm64') { 43 | console.log('在 macOS (arm64) 构建原生模块...'); 44 | execSync('npm run build:native-addon:mac-arm64', { stdio: 'inherit' }); 45 | } else if (arch === 'x64') { 46 | console.log('在 macOS (x64) 构建原生模块...'); 47 | execSync('npm run build:native-addon:mac-x64', { stdio: 'inherit' }); 48 | } else { 49 | console.log(`在 macOS (${arch}) 构建原生模块...`); 50 | execSync('npm run build:native-addon', { stdio: 'inherit' }); 51 | } 52 | } else { 53 | // 其他平台的处理 54 | console.log(`在 ${platform} 平台构建原生模块...`); 55 | execSync('npm run build:native-addon', { stdio: 'inherit' }); 56 | } 57 | 58 | console.log('构建命令执行完成,检查输出文件...'); 59 | 60 | // 使用命令行列出目录内容 61 | if (platform === 'win32') { 62 | execSync(`dir "${buildDir}"`, { stdio: 'inherit' }); 63 | execSync(`dir "${releaseDir}"`, { stdio: 'inherit' }); 64 | } else { 65 | execSync(`ls -la "${buildDir}"`, { stdio: 'inherit' }); 66 | execSync(`ls -la "${releaseDir}"`, { stdio: 'inherit' }); 67 | } 68 | 69 | // 使用命令行创建目录和复制文件 70 | console.log('创建目标目录并复制文件...'); 71 | if (platform === 'win32') { 72 | execSync(`mkdir "${targetDir}" 2>nul || echo "Directory already exists"`, { stdio: 'inherit' }); 73 | execSync(`copy "${sourcePath}" "${targetDir}\\window-addon.node"`, { stdio: 'inherit' }); 74 | } else { 75 | execSync(`mkdir -p "${targetDir}"`, { stdio: 'inherit' }); 76 | execSync(`cp "${sourcePath}" "${targetDir}/window-addon.node"`, { stdio: 'inherit' }); 77 | } 78 | 79 | // 验证文件已复制 80 | console.log('验证文件已复制...'); 81 | if (platform === 'win32') { 82 | execSync(`dir "${targetDir}"`, { stdio: 'inherit' }); 83 | } else { 84 | execSync(`ls -la "${targetDir}"`, { stdio: 'inherit' }); 85 | } 86 | 87 | console.log('原生模块构建和组织完成!'); 88 | } catch (error) { 89 | console.error('构建过程中发生错误:', error); 90 | process.exit(1); 91 | } -------------------------------------------------------------------------------- /scripts/sign-native-modules.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | 5 | require('dotenv').config(); 6 | 7 | // 获取应用路径 - 同时检查 mac 和 mac-arm64 目录 8 | let appPath; 9 | const macPath = path.join(__dirname, '../dist/mac/Chrome Power.app'); 10 | const macArm64Path = path.join(__dirname, '../dist/mac-arm64/Chrome Power.app'); 11 | 12 | if (fs.existsSync(macArm64Path)) { 13 | appPath = macArm64Path; 14 | console.log('Using arm64 app path:', appPath); 15 | } else if (fs.existsSync(macPath)) { 16 | appPath = macPath; 17 | console.log('Using regular mac app path:', appPath); 18 | } else { 19 | console.error('应用路径不存在,请先构建应用'); 20 | process.exit(1); 21 | } 22 | 23 | const identity = process.env.APPLE_IDENTITY; 24 | const entitlements = path.join(__dirname, '../buildResources/entitlements.mac.plist'); 25 | 26 | console.log('Signing native modules...'); 27 | 28 | // 找到所有 .node 文件并单独签名 29 | function signNativeModules(directory) { 30 | try { 31 | const files = fs.readdirSync(directory, { withFileTypes: true }); 32 | for (const file of files) { 33 | const fullPath = path.join(directory, file.name); 34 | if (file.isDirectory()) { 35 | signNativeModules(fullPath); 36 | } else if (file.name.endsWith('.node')) { 37 | console.log(`Signing ${fullPath}`); 38 | try { 39 | execSync(`codesign --force --sign "${identity}" --timestamp --options runtime --entitlements "${entitlements}" --verbose "${fullPath}"`, { stdio: 'inherit' }); 40 | } catch (err) { 41 | console.error(`Failed to sign ${fullPath}:`, err); 42 | } 43 | } 44 | } 45 | } catch (err) { 46 | console.error(`Error processing directory ${directory}:`, err); 47 | } 48 | } 49 | 50 | // 签名所有原生模块 51 | const unpackedPath = path.join(appPath, 'Contents/Resources/app.asar.unpacked'); 52 | if (fs.existsSync(unpackedPath)) { 53 | signNativeModules(unpackedPath); 54 | } else { 55 | console.error('app.asar.unpacked 目录不存在:', unpackedPath); 56 | } 57 | 58 | // 最后重新签名整个应用 59 | console.log('Re-signing entire application...'); 60 | execSync(`codesign --force --sign "${identity}" --timestamp --options runtime --entitlements "${entitlements}" --verbose "${appPath}"`, { stdio: 'inherit' }); 61 | 62 | // 设置执行权限 63 | console.log('设置执行权限...'); 64 | execSync(`chmod -R +x "${appPath}"`, { stdio: 'inherit' }); 65 | console.log(`特别设置主程序权限: ${appPath}/Contents/MacOS/Chrome Power`); 66 | execSync(`chmod +x "${appPath}/Contents/MacOS/Chrome Power"`, { stdio: 'inherit' }); 67 | 68 | // 移除隔离属性 69 | console.log('移除隔离属性...'); 70 | execSync(`xattr -dr com.apple.quarantine "${appPath}" || true`, { stdio: 'inherit' }); 71 | 72 | console.log('验证签名...'); 73 | execSync(`codesign --verify --deep --strict --verbose=2 "${appPath}"`, { stdio: 'inherit' }); 74 | 75 | // 签名所有二进制文件和框架 76 | console.log('Signing all binaries and frameworks...'); 77 | const exePath = path.join(appPath, 'Contents/MacOS/Chrome Power'); 78 | const helperPath = path.join(appPath, 'Contents/Frameworks/Chrome Power Helper.app'); 79 | const helperEXEPath = path.join(appPath, 'Contents/Frameworks/Chrome Power Helper.app/Contents/MacOS/Chrome Power Helper'); 80 | 81 | // 签名 Electron Helper 82 | if (fs.existsSync(helperPath)) { 83 | console.log(`Signing Electron Helper: ${helperPath}`); 84 | execSync(`codesign --force --sign "${identity}" --timestamp --options runtime --entitlements "${entitlements}" --verbose "${helperPath}"`, { stdio: 'inherit' }); 85 | 86 | // 确保 Helper 有执行权限 87 | if (fs.existsSync(helperEXEPath)) { 88 | console.log(`Setting permissions for Helper: ${helperEXEPath}`); 89 | execSync(`chmod +x "${helperEXEPath}"`, { stdio: 'inherit' }); 90 | } 91 | } 92 | 93 | // 确保主程序有执行权限 94 | console.log(`Setting permissions for main executable: ${exePath}`); 95 | execSync(`chmod +x "${exePath}"`, { stdio: 'inherit' }); 96 | 97 | // 签名其他框架 98 | const frameworksPath = path.join(appPath, 'Contents/Frameworks'); 99 | if (fs.existsSync(frameworksPath)) { 100 | const frameworks = fs.readdirSync(frameworksPath); 101 | for (const framework of frameworks) { 102 | if (framework.endsWith('.framework') || framework.includes('.dylib')) { 103 | const frameworkPath = path.join(frameworksPath, framework); 104 | console.log(`Signing framework: ${frameworkPath}`); 105 | try { 106 | execSync(`codesign --force --sign "${identity}" --timestamp --options runtime --entitlements "${entitlements}" --verbose "${frameworkPath}"`, { stdio: 'inherit' }); 107 | } catch (err) { 108 | console.error(`Failed to sign ${frameworkPath}:`, err); 109 | } 110 | } 111 | } 112 | } 113 | 114 | console.log('Done!'); 115 | -------------------------------------------------------------------------------- /scripts/ua-generator.js: -------------------------------------------------------------------------------- 1 | // 导入需要的模块 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const UserAgent = require('user-agents'); 5 | 6 | function appendUAToFile() { 7 | const ua = new UserAgent({ 8 | deviceCategory: 'desktop', 9 | platform: 'Win32', 10 | }); 11 | 12 | const {userAgent: uaString} = ua.random().data; 13 | 14 | const filePath = path.join('assets', 'ua.txt'); 15 | 16 | fs.readFile(filePath, 'utf8', (err, data) => { 17 | if (err) { 18 | fs.writeFile(filePath, uaString + '\n', err => { 19 | if (err) throw err; 20 | console.log('User-Agent string has been written to ua.txt'); 21 | }); 22 | } else { 23 | if (data.split('\n').indexOf(uaString) === -1) { 24 | fs.appendFile(filePath, uaString + '\n', err => { 25 | if (err) throw err; 26 | console.log('User-Agent string has been appended to ua.txt'); 27 | setTimeout(appendUAToFile, 1000); 28 | }); 29 | } else { 30 | console.log('User-Agent string already exists in ua.txt'); 31 | setTimeout(appendUAToFile, 1000); 32 | } 33 | } 34 | }); 35 | } 36 | appendUAToFile(); 37 | -------------------------------------------------------------------------------- /scripts/update-electron-vendors.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This script should be run in electron context 3 | * @example 4 | * ELECTRON_RUN_AS_NODE=1 electron scripts/update-electron-vendors.mjs 5 | */ 6 | 7 | import {writeFileSync} from 'fs'; 8 | import path from 'path'; 9 | 10 | const electronRelease = process.versions; 11 | 12 | const node = electronRelease.node.split('.')[0]; 13 | const chrome = electronRelease.v8.split('.').splice(0, 2).join(''); 14 | 15 | const browserslistrcPath = path.resolve(process.cwd(), '.browserslistrc'); 16 | 17 | writeFileSync('./.electron-vendors.cache.json', JSON.stringify({chrome, node})); 18 | writeFileSync(browserslistrcPath, `Chrome ${chrome}`, 'utf8'); 19 | -------------------------------------------------------------------------------- /scripts/watch.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import {build, createServer} from 'vite'; 4 | import electronPath from 'electron'; 5 | import {spawn} from 'child_process'; 6 | 7 | /** @type 'production' | 'development'' */ 8 | const mode = (process.env.MODE = process.env.MODE || 'development'); 9 | 10 | /** @type {import('vite').LogLevel} */ 11 | const logLevel = 'warn'; 12 | 13 | /** 14 | * Setup watcher for `main` package 15 | * On file changed it totally re-launch electron app. 16 | * @param {import('vite').ViteDevServer} watchServer Renderer watch server instance. 17 | * Needs to set up `VITE_DEV_SERVER_URL` environment variable from {@link import('vite').ViteDevServer.resolvedUrls} 18 | */ 19 | function setupMainPackageWatcher({resolvedUrls}) { 20 | process.env.VITE_DEV_SERVER_URL = resolvedUrls.local[0]; 21 | 22 | /** @type {ChildProcess | null} */ 23 | let electronApp = null; 24 | 25 | return build({ 26 | mode, 27 | logLevel, 28 | configFile: 'packages/main/vite.config.js', 29 | build: { 30 | /** 31 | * Set to {} to enable rollup watcher 32 | * @see https://vitejs.dev/config/build-options.html#build-watch 33 | */ 34 | watch: {}, 35 | }, 36 | plugins: [ 37 | { 38 | name: 'reload-app-on-main-package-change', 39 | writeBundle() { 40 | /** Kill electron if process already exist */ 41 | if (electronApp !== null) { 42 | electronApp.removeListener('exit', process.exit); 43 | electronApp.kill('SIGINT'); 44 | electronApp = null; 45 | } 46 | 47 | /** Spawn new electron process */ 48 | electronApp = spawn(String(electronPath), ['--inspect', '.'], { 49 | stdio: 'inherit', 50 | }); 51 | 52 | /** Stops the watch script when the application has been quit */ 53 | electronApp.addListener('exit', process.exit); 54 | }, 55 | }, 56 | ], 57 | }); 58 | } 59 | 60 | /** 61 | * Setup watcher for `preload` package 62 | * On file changed it reload web page. 63 | * @param {import('vite').ViteDevServer} watchServer Renderer watch server instance. 64 | * Required to access the web socket of the page. By sending the `full-reload` command to the socket, it reloads the web page. 65 | */ 66 | function setupPreloadPackageWatcher({ws}) { 67 | return build({ 68 | mode, 69 | logLevel, 70 | configFile: 'packages/preload/vite.config.js', 71 | build: { 72 | /** 73 | * Set to {} to enable rollup watcher 74 | * @see https://vitejs.dev/config/build-options.html#build-watch 75 | */ 76 | watch: {}, 77 | }, 78 | plugins: [ 79 | { 80 | name: 'reload-page-on-preload-package-change', 81 | writeBundle() { 82 | ws.send({ 83 | type: 'full-reload', 84 | }); 85 | }, 86 | }, 87 | ], 88 | }); 89 | } 90 | 91 | /** 92 | * Dev server for Renderer package 93 | * This must be the first, 94 | * because the {@link setupMainPackageWatcher} and {@link setupPreloadPackageWatcher} 95 | * depend on the dev server properties 96 | */ 97 | const rendererWatchServer = await createServer({ 98 | mode, 99 | logLevel, 100 | configFile: 'packages/renderer/vite.config.js', 101 | }).then(s => s.listen()); 102 | 103 | await setupPreloadPackageWatcher(rendererWatchServer); 104 | await setupMainPackageWatcher(rendererWatchServer); 105 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './packages/renderer/index.html', 5 | './packages/renderer/dist/index.html', 6 | './packages/renderer/dist/*.js', 7 | './packages/renderer/src/**/*.{js,ts,jsx,tsx}', 8 | ], 9 | important: true, 10 | theme: { 11 | extend: {}, 12 | }, 13 | corePlugins: { 14 | preflight: false, 15 | }, 16 | plugins: [], 17 | }; 18 | -------------------------------------------------------------------------------- /tests/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import type {ElectronApplication, JSHandle} from 'playwright'; 2 | import {_electron as electron} from 'playwright'; 3 | import {afterAll, beforeAll, expect, test} from 'vitest'; 4 | import {createHash} from 'crypto'; 5 | import type {BrowserWindow} from 'electron'; 6 | 7 | let electronApp: ElectronApplication; 8 | 9 | beforeAll(async () => { 10 | electronApp = await electron.launch({args: ['.']}); 11 | }); 12 | 13 | afterAll(async () => { 14 | await electronApp.close(); 15 | }); 16 | 17 | test('Main window state', async () => { 18 | const page = await electronApp.firstWindow(); 19 | const window: JSHandle = await electronApp.browserWindow(page); 20 | const windowState = await window.evaluate( 21 | (mainWindow): Promise<{isVisible: boolean; isDevToolsOpened: boolean; isCrashed: boolean}> => { 22 | const getState = () => ({ 23 | isVisible: mainWindow.isVisible(), 24 | isDevToolsOpened: mainWindow.webContents.isDevToolsOpened(), 25 | isCrashed: mainWindow.webContents.isCrashed(), 26 | }); 27 | 28 | return new Promise(resolve => { 29 | /** 30 | * The main window is created hidden, and is shown only when it is ready. 31 | * See {@link ../packages/main/src/mainWindow.ts} function 32 | */ 33 | if (mainWindow.isVisible()) { 34 | resolve(getState()); 35 | } else mainWindow.once('ready-to-show', () => resolve(getState())); 36 | }); 37 | }, 38 | ); 39 | 40 | expect(windowState.isCrashed, 'The app has crashed').toBeFalsy(); 41 | expect(windowState.isVisible, 'The main window was not visible').toBeTruthy(); 42 | expect(windowState.isDevToolsOpened, 'The DevTools panel was open').toBeFalsy(); 43 | }); 44 | 45 | test('Main window web content', async () => { 46 | const page = await electronApp.firstWindow(); 47 | const element = await page.$('#app', {strict: true}); 48 | expect(element, 'Was unable to find the root element').toBeDefined(); 49 | expect((await element.innerHTML()).trim(), 'Window content was empty').not.equal(''); 50 | }); 51 | 52 | test('Preload versions', async () => { 53 | const page = await electronApp.firstWindow(); 54 | const versionsElement = page.locator('#process-versions'); 55 | expect(await versionsElement.count(), 'expect find one element #process-versions').toStrictEqual( 56 | 1, 57 | ); 58 | 59 | /** 60 | * In this test we check only text value and don't care about formatting. That's why here we remove any space symbols 61 | */ 62 | const renderedVersions = (await versionsElement.innerText()).replace(/\s/g, ''); 63 | const expectedVersions = await electronApp.evaluate(() => process.versions); 64 | 65 | if (expectedVersions !== null && expectedVersions !== undefined) { 66 | for (const expectedVersionsKey in expectedVersions) { 67 | expect(renderedVersions).include( 68 | `${expectedVersionsKey}:v${expectedVersions[expectedVersionsKey]}`, 69 | ); 70 | } 71 | } 72 | }); 73 | 74 | test('Preload nodeCrypto', async () => { 75 | const page = await electronApp.firstWindow(); 76 | 77 | // Test hashing a random string 78 | const testString = Math.random().toString(36).slice(2, 7); 79 | 80 | const rawInput = page.locator('input#reactive-hash-raw-value'); 81 | expect( 82 | await rawInput.count(), 83 | 'expect find one element input#reactive-hash-raw-value', 84 | ).toStrictEqual(1); 85 | 86 | const hashedInput = page.locator('input#reactive-hash-hashed-value'); 87 | expect( 88 | await hashedInput.count(), 89 | 'expect find one element input#reactive-hash-hashed-value', 90 | ).toStrictEqual(1); 91 | 92 | await rawInput.fill(testString); 93 | const renderedHash = await hashedInput.inputValue(); 94 | const expectedHash = createHash('sha256').update(testString).digest('hex'); 95 | expect(renderedHash).toEqual(expectedHash); 96 | }); 97 | -------------------------------------------------------------------------------- /types/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Describes all existing environment variables and their types. 5 | * Required for Code completion/intellisense and type checking. 6 | * 7 | * Note: To prevent accidentally leaking env variables to the client, only variables prefixed with `VITE_` are exposed to your Vite-processed code. 8 | * 9 | * @see https://github.com/vitejs/vite/blob/0a699856b248116632c1ac18515c0a5c7cf3d1db/packages/vite/types/importMeta.d.ts#L7-L14 Base Interface. 10 | * @see https://vitejs.dev/guide/env-and-mode.html#env-files Vite Env Variables Doc. 11 | */ 12 | interface ImportMetaEnv { 13 | /** 14 | * URL where `renderer` web page is running. 15 | * This variable is initialized in scripts/watch.ts 16 | */ 17 | readonly VITE_DEV_SERVER_URL: undefined | string; 18 | 19 | /** Current app version */ 20 | readonly VITE_APP_VERSION: string; 21 | 22 | /** Current app API */ 23 | readonly VITE_APP_API: string; 24 | 25 | readonly VITE_START_PAGE_URL: string; 26 | } 27 | 28 | interface ImportMeta { 29 | readonly env: ImportMetaEnv; 30 | } 31 | 32 | declare module 'portscanner'; 33 | declare module 'geoip-lite'; 34 | declare module 'ip'; 35 | declare module 'async-icns'; 36 | // declare module 'png-to-ico'; 37 | // declare module 'jimp'; 38 | -------------------------------------------------------------------------------- /version/getVersion.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Entry function for get app version. 3 | * In current implementation, it returns `version` from `package.json`, but you can implement any logic here. 4 | * Runs several times for each vite configs and electron-builder config. 5 | * @return {string} 6 | */ 7 | export function getVersion() { 8 | return process.env.npm_package_version; 9 | } 10 | -------------------------------------------------------------------------------- /version/inject-app-version-plugin.mjs: -------------------------------------------------------------------------------- 1 | import {getVersion} from './getVersion.mjs'; 2 | 3 | /** 4 | * Somehow inject app version to vite build context 5 | * @return {import('vite').Plugin} 6 | */ 7 | export const injectAppVersion = () => ({ 8 | name: 'inject-version', 9 | config: () => { 10 | // TODO: Find better way to inject app version 11 | process.env.VITE_APP_VERSION = getVersion(); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for the global end-to-end testing, 3 | * placed in the project's root 'tests' folder. 4 | * @type {import('vite').UserConfig} 5 | * @see https://vitest.dev/config/ 6 | */ 7 | const config = { 8 | test: { 9 | /** 10 | * By default, vitest searches for the test files in all packages. 11 | * For e2e tests, have vitest search only in the project root 'tests' folder. 12 | */ 13 | include: ['./tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 14 | 15 | /** 16 | * The default timeout of 5000ms is sometimes not enough for playwright. 17 | */ 18 | testTimeout: 30_000, 19 | hookTimeout: 30_000, 20 | }, 21 | }; 22 | 23 | export default config; 24 | --------------------------------------------------------------------------------