From f648824bd2d7a1ea93ed6d836f3dfc9914b86770 Mon Sep 17 00:00:00 2001 From: SYM01 <33443792+SYM01@users.noreply.github.com> Date: Mon, 21 Oct 2024 00:48:39 +0800 Subject: [PATCH] Support Auto Switch profile, closing #10 (#15) * modified README.md * remove transifex from Github action. Use Github integration instead * update deps * replace jest with vitest * optimized UX * [I18n] Translate messages.json in zh_CN (#12) --- .tx/config | 12 - README.md | 4 +- package-lock.json | 103 +++- package.json | 7 +- public/_locales/en/messages.json | 65 ++- public/_locales/pt_BR/messages.json | 109 +++- public/_locales/zh_CN/messages.json | 63 ++- public/_locales/zh_TW/messages.json | 113 ++++- src/App.vue | 49 +- src/adapters/base.ts | 77 +++ src/adapters/chrome.ts | 88 ++++ src/adapters/index.ts | 24 + src/adapters/web.ts | 92 ++++ src/background.ts | 84 ++-- src/components/HelloWorld.vue | 10 - src/components/ProfileConfig.vue | 441 +++++++++++----- src/components/configs/AutoSwitchInput.vue | 183 +++++++ .../configs/AutoSwitchPacPreview.vue | 74 +++ src/components/configs/ProfileSelector.vue | 115 +++++ src/components/configs/ProxyServerInput.vue | 170 ++++--- src/components/configs/ScriptInput.vue | 87 ++-- src/components/controls/ThemeSwitcher.vue | 52 +- src/main.ts | 23 +- src/models/i18n.ts | 19 - src/models/indicator.ts | 34 -- src/models/preference.ts | 59 --- src/models/profile.ts | 96 ---- src/models/proxy/index.ts | 94 ---- src/models/proxy/proxyRules.ts | 262 ---------- src/models/store.ts | 40 -- src/pages/ConfigPage.vue | 81 +-- src/pages/PopupPage.vue | 167 +++--- src/router.ts | 42 +- src/services/indicator.ts | 28 ++ src/services/preference.ts | 62 +++ src/services/profile.ts | 129 +++++ src/services/proxy/index.ts | 127 +++++ src/services/proxy/profile2config.ts | 476 ++++++++++++++++++ src/services/proxy/scriptHelper.ts | 214 ++++++++ src/vite-env.d.ts | 1 - tests/models/proxy/proxyRules.test.ts | 47 -- tests/services/proxy/profile2config.test.ts | 169 +++++++ tsconfig.json | 2 +- 43 files changed, 3006 insertions(+), 1188 deletions(-) delete mode 100755 .tx/config create mode 100644 src/adapters/base.ts create mode 100644 src/adapters/chrome.ts create mode 100644 src/adapters/index.ts create mode 100644 src/adapters/web.ts delete mode 100644 src/components/HelloWorld.vue create mode 100644 src/components/configs/AutoSwitchInput.vue create mode 100644 src/components/configs/AutoSwitchPacPreview.vue create mode 100644 src/components/configs/ProfileSelector.vue delete mode 100644 src/models/i18n.ts delete mode 100644 src/models/indicator.ts delete mode 100644 src/models/preference.ts delete mode 100644 src/models/profile.ts delete mode 100644 src/models/proxy/index.ts delete mode 100644 src/models/proxy/proxyRules.ts delete mode 100644 src/models/store.ts create mode 100644 src/services/indicator.ts create mode 100644 src/services/preference.ts create mode 100644 src/services/profile.ts create mode 100644 src/services/proxy/index.ts create mode 100644 src/services/proxy/profile2config.ts create mode 100644 src/services/proxy/scriptHelper.ts delete mode 100644 tests/models/proxy/proxyRules.test.ts create mode 100644 tests/services/proxy/profile2config.test.ts diff --git a/.tx/config b/.tx/config deleted file mode 100755 index e611c2c..0000000 --- a/.tx/config +++ /dev/null @@ -1,12 +0,0 @@ -[main] -host = https://app.transifex.com - -[o:bytevet:p:proxyverse:r:messagesjson] -file_filter = public/_locales//messages.json -source_file = public/_locales/en/messages.json -type = CHROME -minimum_perc = 0 -resource_name = messages.json -replace_edited_strings = false -keep_translations = false - diff --git a/README.md b/README.md index cd814c9..c0154c7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It's still in the early development stage, and more features are still on the w - [x] Basic profile switch support - [x] Support proxy authentication -- [ ] Support auto switch rules +- [x] Support auto switch rules - [x] Support more languages - [ ] Support customized preference - [ ] Support Safari @@ -29,7 +29,7 @@ It's still in the early development stage, and more features are still on the w ## Development 1. Fork the repository and make changes. -2. Write unit tests. If applicable, write unit tests for your changes to ensure they don't break existing functionality. Our project uses [ts-jest](https://jestjs.io/docs/getting-started#via-ts-jest) for unit testing. +2. Write unit tests. If applicable, write unit tests for your changes to ensure they don't break existing functionality. Our project uses [vitest](https://vitest.dev/) for unit testing. 3. Make sure everything works perfectly before you make any pull request. diff --git a/package-lock.json b/package-lock.json index c588585..73befbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,19 @@ "version": "0.0.0", "dependencies": { "@highlightjs/vue-plugin": "^2.1.0", + "@vueuse/core": "^11.1.0", "escodegen": "^2.1.0", + "esprima": "^4.0.1", "ipaddr.js": "^2.2.0", "vue": "^3.4.35", "vue-router": "^4.3.2" }, "devDependencies": { - "@arco-design/web-vue": "^2.55.2", + "@arco-design/web-vue": "^2.56.2", "@arco-plugins/vite-vue": "^1.4.5", "@types/chrome": "^0.0.266", "@types/escodegen": "^0.0.10", + "@types/esprima": "^4.0.6", "@vitejs/plugin-vue": "^5.0.4", "@vitest/coverage-v8": "^2.1.3", "sass-embedded": "^1.79.5", @@ -1283,6 +1286,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esprima": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@types/esprima/-/esprima-4.0.6.tgz", + "integrity": "sha512-lIk+kSt9lGv5hxK6aZNjiUEGZqKmOTpmg0tKiJQI+Ow98fLillxsiZNik5+RcP7mXL929KiTH/D9jGtpDlMbVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1326,6 +1339,11 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" + }, "node_modules/@vitejs/plugin-vue": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.1.4.tgz", @@ -1668,6 +1686,89 @@ "integrity": "sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==", "license": "MIT" }, + "node_modules/@vueuse/core": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-11.1.0.tgz", + "integrity": "sha512-P6dk79QYA6sKQnghrUz/1tHi0n9mrb/iO1WTMk/ElLmTyNqgDeSZ3wcDf6fRBGzRJbeG1dxzEOvLENMjr+E3fg==", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "11.1.0", + "@vueuse/shared": "11.1.0", + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-11.1.0.tgz", + "integrity": "sha512-l9Q502TBTaPYGanl1G+hPgd3QX5s4CGnpXriVBR5fEZ/goI6fvDaVmIl3Td8oKFurOxTmbXvBPSsgrd6eu6HYg==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-11.1.0.tgz", + "integrity": "sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==", + "dependencies": { + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", diff --git a/package.json b/package.json index 83d1a9c..c6af4e3 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,6 @@ "dev": "vite", "test": "vitest", "coverage": "vitest run --coverage", - "i18n:sync": "tx pull -a", - "build:test": "vue-tsc && vite build", "build": "vue-tsc && vite build", "dist": "rm -f dist.zip && cd ./dist/ && zip -r ../dist.zip ./", "preview": "vite preview" @@ -18,16 +16,19 @@ }, "dependencies": { "@highlightjs/vue-plugin": "^2.1.0", + "@vueuse/core": "^11.1.0", "escodegen": "^2.1.0", + "esprima": "^4.0.1", "ipaddr.js": "^2.2.0", "vue": "^3.4.35", "vue-router": "^4.3.2" }, "devDependencies": { - "@arco-design/web-vue": "^2.55.2", + "@arco-design/web-vue": "^2.56.2", "@arco-plugins/vite-vue": "^1.4.5", "@types/chrome": "^0.0.266", "@types/escodegen": "^0.0.10", + "@types/esprima": "^4.0.6", "@vitejs/plugin-vue": "^5.0.4", "@vitest/coverage-v8": "^2.1.3", "sass-embedded": "^1.79.5", diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 2908bfd..54c5d91 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -10,9 +10,13 @@ "nav_config": { "message": "Config" }, - "nav_custome_profiles": { + "nav_custom_profiles": { "message": "Custom Profiles" }, + "nav_system_profiles": { + "message": "Presets" + }, + "theme_light_mode": { @@ -49,6 +53,9 @@ "config_proxy_type_pac": { "message": "PAC Script" }, + "config_proxy_type_auto": { + "message": "Auto Switch" + }, "config_proxy_type_default": { "message": "Same as Default" }, @@ -70,6 +77,9 @@ "config_section_proxy_auth_tips": { "message": "Set username and password if your proxy requires authentication" }, + "config_section_proxy_auth_unsupported": { + "message": "The current proxy type does not support authentication" + }, "config_section_proxy_auth_title": { "message": "Proxy Authentication" }, @@ -90,6 +100,49 @@ "message": "Learn more about bypass list" }, + "config_section_auto_switch_rules": { + "message": "Auto Switch Rules" + }, + "config_section_auto_switch_type": { + "message": "Condition Type" + }, + "config_section_auto_switch_type_domain": { + "message": "Domain" + }, + "config_section_auto_switch_type_url": { + "message": "URL" + }, + "config_section_auto_switch_type_cidr": { + "message": "IP/CIDR" + }, + "config_section_auto_switch_type_disabled": { + "message": "Temporarily Skip" + }, + "config_section_auto_switch_condition": { + "message": "Condition" + }, + "config_section_auto_switch_profile": { + "message": "Route to" + }, + "config_section_auto_switch_actions": { + "message": "Actions" + }, + "config_section_auto_switch_add_rule": { + "message": "Add Rule" + }, + "config_section_auto_switch_delete_rule": { + "message": "Delete Current Rule" + }, + "config_section_auto_switch_duplicate_rule": { + "message": "Duplicate Current Rule" + }, + "config_section_auto_switch_default_profile": { + "message": "Default Profile" + }, + "config_section_auto_switch_pac_preview": { + "message": "Preview PAC Script" + }, + "config_action_edit": { "message": "Edit" }, @@ -112,11 +165,17 @@ "config_feedback_saved": { "message": "The profile had been saved" }, + "config_feedback_copied": { + "message": "Copied" + }, "config_feedback_deleted": { "message": "The profile had been deleted" }, - "config_feedback_error_occured": { - "message": "Error occured: $1" + "config_feedback_unknown_profile": { + "message": "Unknown profile" + }, + "config_feedback_error_occurred": { + "message": "Error occurred: $1" }, diff --git a/public/_locales/pt_BR/messages.json b/public/_locales/pt_BR/messages.json index de81192..750109e 100644 --- a/public/_locales/pt_BR/messages.json +++ b/public/_locales/pt_BR/messages.json @@ -1,6 +1,6 @@ { "app_desc": { - "message": "Uma ferramenta para ajudar você a gerenciar e alternar seus perfis de proxy" + "message": "Uma ferramenta para ajudá-lo a gerenciar e alternar seus perfis de proxy" }, @@ -8,40 +8,44 @@ "message": "Preferência" }, "nav_config": { - "message": "Config" + "message": "Configuração" }, - "nav_custome_profiles": { - "message": "Perfis Personalizados" + "nav_custom_profiles": { + "message": "Perfis personalizados" }, + "nav_system_profiles": { + "message": "Predefinições" + }, + "theme_light_mode": { - "message": "Modo claro" + "message": "Modo de luz" }, "theme_dark_mode": { "message": "Modo escuro" }, "theme_auto_mode": { - "message": "Siga o sistema" + "message": "Seguir o sistema" }, "mode_auto_switch": { - "message": "Auto Switch" + "message": "Comutação automática" }, "mode_direct": { "message": "Direto" }, "mode_system": { - "message": "Usar Proxy do Sistema" + "message": "Usar proxy do sistema" }, "mode_profile_create": { - "message": "Criar Novo Perfil" + "message": "Criar novo perfil" }, "config_proxy_type": { - "message": "Tipo de Proxy" + "message": "Tipo de proxy" }, "config_proxy_type_proxy": { "message": "Proxy" @@ -49,14 +53,17 @@ "config_proxy_type_pac": { "message": "Script PAC" }, + "config_proxy_type_auto": { + "message": "Comutação automática" + }, "config_proxy_type_default": { - "message": "Mesmo como o Padrão" + "message": "Igual ao padrão" }, "config_section_proxy_server": { - "message": "Servidor Proxy" + "message": "Servidor proxy" }, "config_section_proxy_server_default": { - "message": "Servidor Padrão" + "message": "Servidor padrão" }, "config_section_proxy_server_http": { "message": "HTTP" @@ -68,10 +75,13 @@ "message": "FTP" }, "config_section_proxy_auth_tips": { - "message": "Defina nome de usuário e senha se o seu proxy exigir autenticação" + "message": "Defina o nome de usuário e a senha se o proxy exigir autenticação" + }, + "config_section_proxy_auth_unsupported": { + "message": "O tipo de proxy atual não é compatível com a autenticação" }, "config_section_proxy_auth_title": { - "message": "Autenticação de Proxy" + "message": "Autenticação de proxy" }, "config_section_proxy_auth_username": { "message": "Nome de usuário" @@ -81,47 +91,96 @@ }, "config_section_bypass_list": { - "message": "Lista de Bypass" + "message": "Lista de desvios" }, "config_section_advance": { - "message": "Configuração Avançada" + "message": "Configuração avançada" }, "config_reference_bypass_list": { - "message": "Saiba mais sobre a lista de bypass." + "message": "Saiba mais sobre a lista de bypass" + }, + + "config_section_auto_switch_rules": { + "message": "Regras de comutação automática" + }, + "config_section_auto_switch_type": { + "message": "Tipo de condição" + }, + "config_section_auto_switch_type_domain": { + "message": "Domínio" + }, + "config_section_auto_switch_type_url": { + "message": "URL" + }, + "config_section_auto_switch_type_cidr": { + "message": "IP/CIDR" + }, + "config_section_auto_switch_type_disabled": { + "message": "Ignorar temporariamente" + }, + "config_section_auto_switch_condition": { + "message": "Condição" + }, + "config_section_auto_switch_profile": { + "message": "Rota para" + }, + "config_section_auto_switch_actions": { + "message": "Ações" + }, + "config_section_auto_switch_add_rule": { + "message": "Adicionar regra" + }, + "config_section_auto_switch_delete_rule": { + "message": "Excluir regra atual" + }, + "config_section_auto_switch_duplicate_rule": { + "message": "Duplicar regra atual" + }, + "config_section_auto_switch_default_profile": { + "message": "Perfil padrão" + }, + "config_section_auto_switch_pac_preview": { + "message": "Visualizar o PAC Script" }, "config_action_edit": { "message": "Editar" }, "config_action_delete": { - "message": "Excluir Perfil" + "message": "Excluir perfil" }, "config_action_delete_double_confirm": { - "message": "Você tem certeza de que deseja excluir o perfil atual" + "message": "Tem certeza de que deseja excluir o perfil atual?" }, "config_action_save": { "message": "Salvar" }, "config_action_cancel": { - "message": "Descartar alteração" + "message": "Mudança de descarte" }, "config_action_clear": { - "message": "Claro" + "message": "Limpo" }, "config_feedback_saved": { "message": "O perfil foi salvo" }, + "config_feedback_copied": { + "message": "Copiado" + }, "config_feedback_deleted": { - "message": "O perfil foi deletado" + "message": "O perfil foi excluído" + }, + "config_feedback_unknown_profile": { + "message": "Perfil desconhecido" }, - "config_feedback_error_occured": { + "config_feedback_error_occurred": { "message": "Ocorreu um erro: $1" }, "form_is_required": { - "message": "$1 é necessário." + "message": "$1 é necessário" }, "_": { diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index 6f27810..57f339e 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -10,9 +10,13 @@ "nav_config": { "message": "配置" }, - "nav_custome_profiles": { + "nav_custom_profiles": { "message": "自定义配置文件" }, + "nav_system_profiles": { + "message": "预设" + }, + "theme_light_mode": { @@ -49,6 +53,9 @@ "config_proxy_type_pac": { "message": "PAC 脚本" }, + "config_proxy_type_auto": { + "message": "自动切换" + }, "config_proxy_type_default": { "message": "默认" }, @@ -70,6 +77,9 @@ "config_section_proxy_auth_tips": { "message": "如果您的代理需要身份验证,请设置用户名和密码" }, + "config_section_proxy_auth_unsupported": { + "message": "当前代理类型暂不支持鉴权" + }, "config_section_proxy_auth_title": { "message": "代理认证信息" }, @@ -90,6 +100,49 @@ "message": "详细了解 Bypass 列表" }, + "config_section_auto_switch_rules": { + "message": "自动分流规则" + }, + "config_section_auto_switch_type": { + "message": "匹配类型" + }, + "config_section_auto_switch_type_domain": { + "message": "域名" + }, + "config_section_auto_switch_type_url": { + "message": "URL" + }, + "config_section_auto_switch_type_cidr": { + "message": "IP/网段" + }, + "config_section_auto_switch_type_disabled": { + "message": "临时禁用" + }, + "config_section_auto_switch_condition": { + "message": "匹配规则" + }, + "config_section_auto_switch_profile": { + "message": "分流至" + }, + "config_section_auto_switch_actions": { + "message": "动作" + }, + "config_section_auto_switch_add_rule": { + "message": "添加规则" + }, + "config_section_auto_switch_delete_rule": { + "message": "删除当前规则" + }, + "config_section_auto_switch_duplicate_rule": { + "message": "复制当前规则" + }, + "config_section_auto_switch_default_profile": { + "message": "默认配置" + }, + "config_section_auto_switch_pac_preview": { + "message": "预览 PAC 脚本" + }, + "config_action_edit": { "message": "编辑" }, @@ -112,10 +165,16 @@ "config_feedback_saved": { "message": "配置已保存" }, + "config_feedback_copied": { + "message": "已复制" + }, "config_feedback_deleted": { "message": "配置已删除" }, - "config_feedback_error_occured": { + "config_feedback_unknown_profile": { + "message": "未知配置" + }, + "config_feedback_error_occurred": { "message": "发生错误: $1" }, diff --git a/public/_locales/zh_TW/messages.json b/public/_locales/zh_TW/messages.json index d85a4df..6d11973 100644 --- a/public/_locales/zh_TW/messages.json +++ b/public/_locales/zh_TW/messages.json @@ -1,42 +1,46 @@ { "app_desc": { - "message": "一个帮助您管理和切换代理配置文件的工具" + "message": "一個協助您管理和切換代理設定檔的工具" }, "nav_preference": { - "message": "偏好设置" + "message": "偏好設定" }, "nav_config": { - "message": "配置" + "message": "設定" }, - "nav_custome_profiles": { - "message": "自定义配置文件" + "nav_custom_profiles": { + "message": "自訂檔案" }, + "nav_system_profiles": { + "message": "預設" + }, + "theme_light_mode": { - "message": "浅色模式" + "message": "日間模式" }, "theme_dark_mode": { - "message": "暗黑模式" + "message": "黑暗模式" }, "theme_auto_mode": { - "message": "跟随系统" + "message": "遵循系統" }, "mode_auto_switch": { - "message": "自动切换" + "message": "自動切換" }, "mode_direct": { - "message": "直接连接" + "message": "直通模式" }, "mode_system": { "message": "使用系統代理" }, "mode_profile_create": { - "message": "新建配置" + "message": "建立新的檔案" }, @@ -47,10 +51,13 @@ "message": "代理" }, "config_proxy_type_pac": { - "message": "PAC Script" + "message": "PAC 腳本" + }, + "config_proxy_type_auto": { + "message": "自動切換" }, "config_proxy_type_default": { - "message": "默认" + "message": "與預設值相同" }, "config_section_proxy_server": { "message": "代理伺服器" @@ -68,60 +75,112 @@ "message": "FTP" }, "config_section_proxy_auth_tips": { - "message": "設定使用者名稱和密碼,如果您的代理需要驗證" + "message": "如果您的代理需要驗證,請設定使用者名稱和密碼" + }, + "config_section_proxy_auth_unsupported": { + "message": "當前的代理類型不支援認證" }, "config_section_proxy_auth_title": { - "message": "代理认证信息" + "message": "代理認證" }, "config_section_proxy_auth_username": { - "message": "用户名" + "message": "使用者名稱" }, "config_section_proxy_auth_password": { "message": "密碼" }, "config_section_bypass_list": { - "message": "Bypass 列表" + "message": "旁路清單" }, "config_section_advance": { "message": "進階設定" }, "config_reference_bypass_list": { - "message": "详细了解 Bypass 列表" + "message": "進一步瞭解旁路清單" + }, + + "config_section_auto_switch_rules": { + "message": "自動切換規則" + }, + "config_section_auto_switch_type": { + "message": "條件類型" + }, + "config_section_auto_switch_type_domain": { + "message": "網域名稱" + }, + "config_section_auto_switch_type_url": { + "message": "URL" + }, + "config_section_auto_switch_type_cidr": { + "message": "IP/CIDR" + }, + "config_section_auto_switch_type_disabled": { + "message": "暫時略過" + }, + "config_section_auto_switch_condition": { + "message": "條件定義" + }, + "config_section_auto_switch_profile": { + "message": "要路由到的設定檔" + }, + "config_section_auto_switch_actions": { + "message": "動作" + }, + "config_section_auto_switch_add_rule": { + "message": "新增規則" + }, + "config_section_auto_switch_delete_rule": { + "message": "刪除當前規則" + }, + "config_section_auto_switch_duplicate_rule": { + "message": "重複當前規則" + }, + "config_section_auto_switch_default_profile": { + "message": "預設檔案" + }, + "config_section_auto_switch_pac_preview": { + "message": "預覽 PAC 腳本" }, "config_action_edit": { "message": "編輯" }, "config_action_delete": { - "message": "删除配置" + "message": "刪除檔案" }, "config_action_delete_double_confirm": { - "message": "您确定要删除当前的配置文件吗?" + "message": "您確定要刪除目前的設定檔嗎?" }, "config_action_save": { - "message": "保存" + "message": "儲存" }, "config_action_cancel": { - "message": "放棄變更" + "message": "捨棄變更" }, "config_action_clear": { "message": "清除" }, "config_feedback_saved": { - "message": "配置已經被保存了" + "message": "檔案已儲存" + }, + "config_feedback_copied": { + "message": "已複製" }, "config_feedback_deleted": { - "message": "個人資料已被刪除" + "message": "檔案已刪除" + }, + "config_feedback_unknown_profile": { + "message": "不明檔案" }, - "config_feedback_error_occured": { - "message": "發生錯誤:$1" + "config_feedback_error_occurred": { + "message": "發生錯誤: $1" }, "form_is_required": { - "message": "$1是必填项" + "message": "$1 是必需的" }, "_": { diff --git a/src/App.vue b/src/App.vue index 9f07382..1378118 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,42 +1,45 @@ \ No newline at end of file + diff --git a/src/adapters/base.ts b/src/adapters/base.ts new file mode 100644 index 0000000..8248223 --- /dev/null +++ b/src/adapters/base.ts @@ -0,0 +1,77 @@ +export type WebAuthenticationChallengeDetails = + chrome.webRequest.WebAuthenticationChallengeDetails; +export type BlockingResponse = chrome.webRequest.BlockingResponse; +export type WebResponseDetails = chrome.webRequest.WebResponseDetails; + +export type ProxyConfig = chrome.proxy.ProxyConfig; +export type ProxyErrorDetails = chrome.proxy.ErrorDetails; +export type ProxySettingResultDetails = { + /** + * One of + * • not_controllable: cannot be controlled by any extension + * • controlled_by_other_extensions: controlled by extensions with higher precedence + * • controllable_by_this_extension: can be controlled by this extension + * • controlled_by_this_extension: controlled by this extension + */ + levelOfControl: + | "not_controllable" + | "controlled_by_other_extensions" + | "controllable_by_this_extension" + | "controlled_by_this_extension"; + /** The value of the setting. */ + value: ProxyConfig; + /** + * Optional. + * Whether the effective value is specific to the incognito session. + * This property will only be present if the incognito property in the details parameter of get() was true. + */ + incognitoSpecific?: boolean | undefined; +}; + +export type SimpleProxyServer = chrome.proxy.ProxyServer; +export type PacScript = chrome.proxy.PacScript; +export type ProxyRules = chrome.proxy.ProxyRules; + +export abstract class BaseAdapter { + // local storage + abstract set(key: string, val: T): Promise; + abstract get(key: string): Promise; + async getWithDefault(key: string, defaultVal: T): Promise { + const ret = await this.get(key); + if (ret === null) { + return defaultVal; + } + + return ret; + } + + // proxy + abstract setProxy(cfg: ProxyConfig): Promise; + abstract clearProxy(): Promise; + abstract onProxyError(callback: (error: ProxyErrorDetails) => void): void; + abstract onProxyChanged( + callback: (setting: ProxySettingResultDetails) => void + ): void; + abstract getProxySettings(): Promise; + + // indicator + abstract setBadge(text: string, color: string): Promise; + + // webRequest + abstract onWebRequestAuthRequired( + callback: ( + details: WebAuthenticationChallengeDetails, + callback?: (response: BlockingResponse) => void + ) => void + ): void; + abstract onWebRequestCompleted( + callback: (details: WebResponseDetails) => void + ): void; + abstract onWebRequestErrorOccurred( + callback: (details: WebResponseDetails) => void + ): void; + + // i18n + abstract currentLocale(): string; + abstract getMessage(key: string, substitutions?: string | string[]): string; +} diff --git a/src/adapters/chrome.ts b/src/adapters/chrome.ts new file mode 100644 index 0000000..6a42f3a --- /dev/null +++ b/src/adapters/chrome.ts @@ -0,0 +1,88 @@ +/// + +import { + BaseAdapter, + BlockingResponse, + ProxyConfig, + ProxyErrorDetails, + ProxySettingResultDetails, + WebAuthenticationChallengeDetails, + WebResponseDetails, +} from "./base"; + +export class Chrome extends BaseAdapter { + async set(key: string, val: T): Promise { + return await chrome.storage.local.set({ + [key]: val, + }); + } + + async get(key: string): Promise { + const ret = await chrome.storage.local.get(key); + return ret[key]; + } + + async setProxy(cfg: ProxyConfig): Promise { + await chrome.proxy.settings.set({ + value: cfg, + scope: "regular", + }); + } + + async clearProxy(): Promise { + await chrome.proxy.settings.clear({ scope: "regular" }); + } + + async getProxySettings(): Promise { + return (await chrome.proxy.settings.get({})) as any; + } + + onProxyError(callback: (error: ProxyErrorDetails) => void): void { + chrome.proxy.onProxyError.addListener(callback); + } + onProxyChanged(callback: (setting: ProxySettingResultDetails) => void): void { + chrome.proxy.settings.onChange.addListener(callback); + } + + async setBadge(text: string, color: string): Promise { + await chrome.action.setBadgeText({ + text: text.trimStart().substring(0, 2), + }); + await chrome.action.setBadgeBackgroundColor({ + color: color, + }); + } + + onWebRequestAuthRequired( + callback: ( + details: WebAuthenticationChallengeDetails, + callback?: (response: BlockingResponse) => void + ) => void + ): void { + chrome.webRequest.onAuthRequired.addListener( + callback, + { urls: [""] }, + ["asyncBlocking"] + ); + } + + onWebRequestCompleted(callback: (details: WebResponseDetails) => void): void { + chrome.webRequest.onCompleted.addListener(callback, { + urls: [""], + }); + } + + onWebRequestErrorOccurred( + callback: (details: WebResponseDetails) => void + ): void { + chrome.webRequest.onErrorOccurred.addListener(callback, { + urls: [""], + }); + } + currentLocale(): string { + return chrome.i18n.getUILanguage(); + } + getMessage(key: string, substitutions?: string | string[]): string { + return chrome.i18n.getMessage(key, substitutions); + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..cae966f --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,24 @@ +import { BaseAdapter } from "./base"; +import { Chrome } from "./chrome"; +import { WebBrowser } from "./web"; + +function chooseAdapter(): BaseAdapter { + if (globalThis.chrome?.proxy) { + return new Chrome(); + } + + return new WebBrowser(); +} + +export const Host = chooseAdapter(); +export type { + ProxyConfig, + WebAuthenticationChallengeDetails, + BlockingResponse, + WebResponseDetails, + ProxyErrorDetails, + ProxySettingResultDetails, + SimpleProxyServer, + PacScript, + ProxyRules, +} from "./base"; diff --git a/src/adapters/web.ts b/src/adapters/web.ts new file mode 100644 index 0000000..fdd6d10 --- /dev/null +++ b/src/adapters/web.ts @@ -0,0 +1,92 @@ +// this adapter is for local testing purpose + +import { + BaseAdapter, + BlockingResponse, + ProxyConfig, + ProxyErrorDetails, + ProxySettingResultDetails, + WebAuthenticationChallengeDetails, + WebResponseDetails, +} from "./base"; + +import i18nData from "@/../public/_locales/en/messages.json?raw"; + +const _i18n: { + [key: string]: { + message: string; + }; +} = JSON.parse(i18nData); + +export class WebBrowser extends BaseAdapter { + async set(key: string, val: T): Promise { + localStorage.setItem(key, JSON.stringify(val)); + } + async get(key: string): Promise { + let s: any; + s = localStorage.getItem(key); + return s && JSON.parse(s); + } + + setProxy(_: ProxyConfig): Promise { + throw new Error("Method not implemented."); + } + clearProxy(): Promise { + throw new Error("Method not implemented."); + } + async getProxySettings(): Promise { + return { + levelOfControl: "controlled_by_this_extension", + value: { + mode: "system", + }, + }; + } + + onProxyError(_: (error: ProxyErrorDetails) => void): void { + throw new Error("Method not implemented."); + } + onProxyChanged(_: (setting: ProxySettingResultDetails) => void): void { + throw new Error("Method not implemented."); + } + + async setBadge(text: string, color: string): Promise { + return console.log(`Badge: ${text}, ${color}`); + } + onWebRequestAuthRequired( + _: ( + details: WebAuthenticationChallengeDetails, + callback?: (response: BlockingResponse) => void + ) => void + ): void { + throw new Error("Method not implemented."); + } + onWebRequestCompleted(_: (details: WebResponseDetails) => void): void { + throw new Error("Method not implemented."); + } + onWebRequestErrorOccurred(_: (details: WebResponseDetails) => void): void { + throw new Error("Method not implemented."); + } + currentLocale(): string { + return "en-US"; + } + getMessage(key: string, substitutions?: string | string[]): string { + let ret = key; + if (_i18n && _i18n[key]) { + ret = _i18n[key]["message"] || key; + } + + if (!substitutions) { + return ret; + } + + if (typeof substitutions === "string") { + substitutions = [substitutions]; + } + + for (let i = 0; i < substitutions.length; i++) { + ret = ret.replace(`$${i + 1}`, substitutions[i]); + } + return ret; + } +} diff --git a/src/background.ts b/src/background.ts index ce47f37..ca5a364 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,62 +1,76 @@ -import { setIndicator } from "./models/indicator"; -import { getAuthInfos, getCurrentProxySetting, onCurrentProxySettingChanged } from "./models/proxy"; - +import { + BlockingResponse, + Host, + WebAuthenticationChallengeDetails, + WebResponseDetails, +} from "./adapters"; +import { setIndicator } from "./services/indicator"; +import { + getAuthInfos, + getCurrentProxySetting, + onCurrentProxySettingChanged, +} from "./services/proxy"; // indicator async function initIndicator() { - await setIndicator(await getCurrentProxySetting()) + await setIndicator(await getCurrentProxySetting()); onCurrentProxySettingChanged(async (proxy) => { - await setIndicator(proxy) - }) + await setIndicator(proxy); + }); } -initIndicator().catch(console.error) - +initIndicator().catch(console.error); // proxy auth provider class ProxyAuthProvider { // requests[requestID] = request attempts. 0 means the 1st attempt - static requests: Record = {} + static requests: Record = {}; - static onCompleted(details: chrome.webRequest.WebResponseDetails) { + static onCompleted(details: WebResponseDetails) { if (ProxyAuthProvider.requests[details.requestId]) { - delete ProxyAuthProvider.requests[details.requestId] + delete ProxyAuthProvider.requests[details.requestId]; } } - static onAuthRequired(details: chrome.webRequest.WebAuthenticationChallengeDetails, - callback?: (response: chrome.webRequest.BlockingResponse) => void) { - + static onAuthRequired( + details: WebAuthenticationChallengeDetails, + callback?: (response: BlockingResponse) => void + ) { if (!details.isProxy) { - callback && callback({ cancel: true }) - return + callback && callback({ cancel: true }); + return; } if (ProxyAuthProvider.requests[details.requestId] === undefined) { // 0 means the 1st attempt - ProxyAuthProvider.requests[details.requestId] = 0 + ProxyAuthProvider.requests[details.requestId] = 0; } else { - ProxyAuthProvider.requests[details.requestId]++ + ProxyAuthProvider.requests[details.requestId]++; } - getAuthInfos(details.challenger.host, details.challenger.port).then((authInfos) => { - const auth = authInfos.at(ProxyAuthProvider.requests[details.requestId]) - if (!auth) { - callback && callback({ cancel: true }) - return - } - - callback && callback({ - authCredentials: { - username: auth.username, - password: auth.password, + getAuthInfos(details.challenger.host, details.challenger.port).then( + (authInfos) => { + const auth = authInfos.at( + ProxyAuthProvider.requests[details.requestId] + ); + if (!auth) { + callback && callback({ cancel: true }); + return; } - }) - }) + + callback && + callback({ + authCredentials: { + username: auth.username, + password: auth.password, + }, + }); + } + ); } } -chrome.webRequest.onAuthRequired.addListener(ProxyAuthProvider.onAuthRequired, { urls: [""] }, ['asyncBlocking']) -chrome.webRequest.onCompleted.addListener(ProxyAuthProvider.onCompleted, { urls: [""] }) -chrome.webRequest.onErrorOccurred.addListener(ProxyAuthProvider.onCompleted, { urls: [""] }) -chrome.proxy.onProxyError.addListener(console.warn) +Host.onWebRequestAuthRequired(ProxyAuthProvider.onAuthRequired); +Host.onWebRequestCompleted(ProxyAuthProvider.onCompleted); +Host.onWebRequestErrorOccurred(ProxyAuthProvider.onCompleted); +Host.onProxyError(console.warn); diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index 0e31571..0000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/src/components/ProfileConfig.vue b/src/components/ProfileConfig.vue index 6bef66d..3825191 100644 --- a/src/components/ProfileConfig.vue +++ b/src/components/ProfileConfig.vue @@ -1,174 +1,272 @@ - \ No newline at end of file + diff --git a/src/components/configs/AutoSwitchInput.vue b/src/components/configs/AutoSwitchInput.vue new file mode 100644 index 0000000..16801f3 --- /dev/null +++ b/src/components/configs/AutoSwitchInput.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/src/components/configs/AutoSwitchPacPreview.vue b/src/components/configs/AutoSwitchPacPreview.vue new file mode 100644 index 0000000..09e375a --- /dev/null +++ b/src/components/configs/AutoSwitchPacPreview.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/src/components/configs/ProfileSelector.vue b/src/components/configs/ProfileSelector.vue new file mode 100644 index 0000000..a4d0c39 --- /dev/null +++ b/src/components/configs/ProfileSelector.vue @@ -0,0 +1,115 @@ + + + diff --git a/src/components/configs/ProxyServerInput.vue b/src/components/configs/ProxyServerInput.vue index a77f8b5..cc8f064 100644 --- a/src/components/configs/ProxyServerInput.vue +++ b/src/components/configs/ProxyServerInput.vue @@ -1,56 +1,60 @@ - \ No newline at end of file + diff --git a/src/components/configs/ScriptInput.vue b/src/components/configs/ScriptInput.vue index 9e6b09c..381c52a 100644 --- a/src/components/configs/ScriptInput.vue +++ b/src/components/configs/ScriptInput.vue @@ -1,37 +1,40 @@ - - \ No newline at end of file + diff --git a/src/components/controls/ThemeSwitcher.vue b/src/components/controls/ThemeSwitcher.vue index 50bb545..6d18c7c 100644 --- a/src/components/controls/ThemeSwitcher.vue +++ b/src/components/controls/ThemeSwitcher.vue @@ -1,42 +1,56 @@ - \ No newline at end of file + diff --git a/src/main.ts b/src/main.ts index e4d30f8..b877e96 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,17 +1,22 @@ -import { createApp } from 'vue' -import './style.css' -import App from './App.vue' -import { router } from './router' -import { transify } from './models/i18n' +import { createApp } from "vue"; +import "./style.css"; +import App from "./App.vue"; +import { router } from "./router"; +import { Host } from "./adapters"; -const app = createApp(App) +// Highlight.js +import hljs from "highlight.js/lib/core"; +import javascript from "highlight.js/lib/languages/javascript"; +hljs.registerLanguage("javascript", javascript); + +const app = createApp(App); // i18n -declare module '@vue/runtime-core' { +declare module "@vue/runtime-core" { interface ComponentCustomProperties { $t: (key: string, substitutions?: any) => string; } } -app.config.globalProperties.$t = transify +app.config.globalProperties.$t = Host.getMessage; -app.use(router).mount('#app') +app.use(router).mount("#app"); diff --git a/src/models/i18n.ts b/src/models/i18n.ts deleted file mode 100644 index 90efb6a..0000000 --- a/src/models/i18n.ts +++ /dev/null @@ -1,19 +0,0 @@ -import msg from '../../public/_locales/en/messages.json' - -export function transify(key: string, substitutions?: string | string[]) { - if (chrome?.i18n) return chrome.i18n.getMessage(key, substitutions) - - // @ts-ignore - if (msg && msg[key]) { - // @ts-ignore - return msg[key]['message'] || key - } - - return key -} - -export function getCurrentLocale() { - if (chrome?.i18n) return chrome.i18n.getUILanguage() - - return 'en-US' -} \ No newline at end of file diff --git a/src/models/indicator.ts b/src/models/indicator.ts deleted file mode 100644 index 4eccdff..0000000 --- a/src/models/indicator.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { SystemProfile } from "./profile"; -import { ProxySetting } from "./proxy"; - -export async function setIndicator(proxy: ProxySetting) { - const curMode = proxy.setting.value?.mode - let profile = proxy.setting.levelOfControl == 'controlled_by_this_extension' ? - proxy.activeProfile : undefined - - // overide profile - switch (curMode) { - case 'system': - profile = SystemProfile.SYSTEM - break - case 'direct': - profile = SystemProfile.DIRECT - break - } - - if (profile) { - await setBadge(profile.profileName, profile.color) - } else { - // clear badge - await setBadge('', SystemProfile.SYSTEM.color) - } -} - -async function setBadge(text: string, color: string) { - await chrome.action.setBadgeText({ - text: text.trimStart().substring(0, 2) - }) - await chrome.action.setBadgeBackgroundColor({ - color: color - }) -} \ No newline at end of file diff --git a/src/models/preference.ts b/src/models/preference.ts deleted file mode 100644 index e44f1db..0000000 --- a/src/models/preference.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { set, getWithDefault } from "./store" - -const keyDarkMode = 'theme.darkmode' - -export enum DarkMode { - Default = 0, - Dark = 1, - Light = 2, -} - -function detectDeviceDarkMode() { - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - return DarkMode.Dark - } - - return DarkMode.Light -} - -/** - * Get the current DarkMode setting. If not set, then returns `DarkMode.Default` - */ -export async function getDarkModeSetting(): Promise { - return getWithDefault(keyDarkMode, DarkMode.Default) -} - - -/** - * Return the current real DarkMode, can only be either Light or Dark. - * @returns {DarkMode.Dark | DarkMode.Light} - */ -export async function currentDarkMode(): Promise { - const ret = await getWithDefault(keyDarkMode, DarkMode.Default) - if (ret != DarkMode.Default) { - return ret - } - - return detectDeviceDarkMode() -} - -export async function changeDarkMode(newMode: DarkMode) { - // window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => { - // const newColorScheme = event.matches ? "dark" : "light"; - // }); - - set(keyDarkMode, newMode) - - if (newMode == DarkMode.Default) { - newMode = detectDeviceDarkMode() - } - - switch (newMode) { - case DarkMode.Dark: - document && document.body.setAttribute('arco-theme', 'dark') - break - case DarkMode.Light: - document && document.body.removeAttribute('arco-theme') - break - } -} \ No newline at end of file diff --git a/src/models/profile.ts b/src/models/profile.ts deleted file mode 100644 index d36cb92..0000000 --- a/src/models/profile.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { get, set } from "./store" - -export type ProxyAuthInfo = { - username: string, - password: string, -} - -export interface ProxyServer extends chrome.proxy.ProxyServer { - auth?: ProxyAuthInfo - scheme: 'direct' | 'http' | 'https' | 'socks4' | 'socks5' -} - -export function sanitizeProxyServer(v: ProxyServer): chrome.proxy.ProxyServer { - return { - host: v.host, - port: v.port - } -} - -type ProxyConfigMeta = { - profileID: string, - color: string, - profileName: string, -} - -export type ProxyConfigSimple = ProxyConfigMeta & { - proxyType: 'proxy' | 'pac', - proxyRules: { - default: ProxyServer, - http?: ProxyServer, - https?: ProxyServer, - ftp?: ProxyServer, - bypassList: string[] - }, - pacScript: chrome.proxy.PacScript -} - -export type ProxyConfigPreset = ProxyConfigMeta & { - proxyType: 'system' | 'direct' -} - -export type ProfileConfig = ProxyConfigSimple | ProxyConfigPreset - - -export const SystemProfile: Record = { - DIRECT: { - profileID: '367DEDBC-6750-4454-8321-4E4B088E20B1', - color: '#7ad39e', - profileName: 'DIRECT', - proxyType: 'direct' - }, - SYSTEM: { - profileID: '4FDEF36F-F389-4AF3-9BBC-B2E01B3B09E6', - color: '#0000', - profileName: '', // no name needed for `system` - proxyType: 'system' - }, -} - - -const keyProfileStorage = 'profiles' -export type ProfilesStorage = { - [key: string]: ProfileConfig -} -const onProfileUpdateListeners: ((p: ProfilesStorage) => void)[] = [] - -export async function listProfiles(): Promise { - const s = await get(keyProfileStorage) - return s || {} -} - -export function onProfileUpdate(callback: (p: ProfilesStorage) => void) { - onProfileUpdateListeners.push(callback) -} - -async function overwriteProfiles(profiles: ProfilesStorage) { - await set(keyProfileStorage, profiles) - onProfileUpdateListeners.map(cb => cb(profiles)) -} - -export async function saveProfile(profile: ProfileConfig) { - const data = await listProfiles() - data[profile.profileID] = profile - await overwriteProfiles(data) -} - -export async function getProfile(profileID: string): Promise { - const data = await listProfiles() - return data[profileID] -} - -export async function deleteProfile(profileID: string) { - const data = await listProfiles() - delete data[profileID] - await overwriteProfiles(data) -} \ No newline at end of file diff --git a/src/models/proxy/index.ts b/src/models/proxy/index.ts deleted file mode 100644 index 7d0caeb..0000000 --- a/src/models/proxy/index.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { ProfileConfig, ProxyAuthInfo, SystemProfile } from "../profile" -import { get, set } from "../store" -import { genSimpleProxyCfg } from "./proxyRules" - -export type ProxySetting = { - activeProfile?: ProfileConfig - setting: chrome.types.ChromeSettingGetResultDetails -} - -const keyActiveProfile = 'active-profile' - -async function wrapProxySetting(setting: chrome.types.ChromeSettingGetResultDetails) { - const ret: ProxySetting = { - setting - } - - if (setting.levelOfControl == 'controlled_by_this_extension') { - ret.activeProfile = await get(keyActiveProfile) || undefined - } - - switch (setting.value?.mode) { - case 'system': - ret.activeProfile = SystemProfile.SYSTEM - break - case 'direct': - ret.activeProfile = SystemProfile.DIRECT - break - } - - return ret -} - -export async function getCurrentProxySetting() { - const setting: chrome.types.ChromeSettingGetResultDetails = await chrome.proxy.settings.get({}) as any - return await wrapProxySetting(setting) -} - -export function onCurrentProxySettingChanged(cb: (setting: ProxySetting) => void) { - chrome.proxy.settings.onChange.addListener(async (details) => { - const ret = await wrapProxySetting(details) - cb(ret) - }) -} - -export async function setProxy(val: ProfileConfig) { - switch (val.proxyType) { - case 'system': - await defaultClearProxy() - break - - case 'direct': - case 'proxy': - case 'pac': - await defaultSetProxy(genSimpleProxyCfg(val)) - break - } - - await set(keyActiveProfile, val) -} - - -async function defaultSetProxy(cfg: chrome.proxy.ProxyConfig) { - await chrome.proxy.settings.set({ - value: cfg, - scope: "regular", - }) -} - -async function defaultClearProxy() { - await chrome.proxy.settings.clear({ scope: 'regular' }) -} - - - -export async function getAuthInfos(host: string, port: number): Promise { - const profile = await get(keyActiveProfile) - if (!profile || profile.proxyType !== 'proxy') { - return [] - } - - const ret: ProxyAuthInfo[] = [] - const auths = [profile.proxyRules.default, profile.proxyRules.ftp, profile.proxyRules.http, profile.proxyRules.https] - - // check if there's any matching host and port - auths.map((item) => { - if (!item) return - - if (item.host == host && (item.port === undefined || item.port == port) && item.auth) { - ret.push(item.auth) - } - }) - - return ret -} \ No newline at end of file diff --git a/src/models/proxy/proxyRules.ts b/src/models/proxy/proxyRules.ts deleted file mode 100644 index d33e963..0000000 --- a/src/models/proxy/proxyRules.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { generate as generateJS } from 'escodegen' -import { Program, FunctionDeclaration, Identifier, Statement, Literal, ReturnStatement, Expression, IfStatement, CallExpression, MemberExpression } from 'estree' -import { IPv4, IPv6, isValidCIDR, parseCIDR } from "ipaddr.js"; -import { ProxyConfigPreset, ProxyConfigSimple, ProxyServer, sanitizeProxyServer } from "../profile"; - - -export function genSimpleProxyCfg(val: ProxyConfigSimple | ProxyConfigPreset): chrome.proxy.ProxyConfig { - switch (val.proxyType) { - case 'direct': - case 'system': - return { mode: val.proxyType } - - case 'pac': - return { - mode: 'pac_script', - pacScript: val.pacScript - } - - case 'proxy': - if (containsDirectRules(val)) { - // @ts-ignore - return genComplexRuleForProfile(val) - } - return genFixServerRuleForProfile(val) - } - - // this case should never be happen - console.error("unexpected proxy profile:", val) - return { mode: 'system' } -} - - - -function genFixServerRuleForProfile(val: ProxyConfigSimple): chrome.proxy.ProxyConfig { - const rules = val.proxyRules - const ret: chrome.proxy.ProxyConfig & { rules: chrome.proxy.ProxyRules } = { - mode: "fixed_servers", - rules: { - bypassList: rules.bypassList, - } - } - - // simple proxy - if (!rules.ftp && !rules.http && !rules.https) { - ret.rules.singleProxy = sanitizeProxyServer(rules.default) - return ret - } - - // advanced setting - ret.rules.fallbackProxy = sanitizeProxyServer(rules.default) - if (rules.ftp) ret.rules.proxyForFtp = sanitizeProxyServer(rules.ftp) - if (rules.http) ret.rules.proxyForHttp = sanitizeProxyServer(rules.http) - if (rules.https) ret.rules.proxyForHttps = sanitizeProxyServer(rules.https) - - return ret -} - -function containsDirectRules(val: ProxyConfigSimple): boolean { - return [ - val.proxyRules.default.scheme, - val.proxyRules.ftp?.scheme, - val.proxyRules.http?.scheme, - val.proxyRules.https?.scheme, - ].includes("direct") -} - -function allDirectRules(val: ProxyConfigSimple): boolean { - return [ - val.proxyRules.default.scheme, - val.proxyRules.ftp?.scheme, - val.proxyRules.http?.scheme, - val.proxyRules.https?.scheme, - ].every(val => val === 'direct' || val === undefined) -} - - -function genComplexRuleForProfile(val: ProxyConfigSimple & { proxyType: 'proxy' }): chrome.proxy.ProxyConfig { - if (allDirectRules(val)) { - return { - mode: 'direct' - } - } - - return { - mode: 'pac_script', - pacScript: { - data: (new SimplePacScriptForProfile).gen(val) - } - } -} - - -class SimplePacScriptForProfile { - private newIdentifier(name: string): Identifier { - return { - type: "Identifier", - name: name - } - } - private newSimpleLiteral(value: string | boolean | number | null): Literal { - return { - type: "Literal", - value: value, - } - } - - private newFunctionDeclartion(name: string, params: string[], body: Statement[]): FunctionDeclaration { - return { - type: "FunctionDeclaration", - id: this.newIdentifier(name), - params: params.map(v => this.newIdentifier(v)), - body: { - type: "BlockStatement", - body: body - } - } - } - - private newReturnStatment(argument?: Expression): ReturnStatement { - return { - type: "ReturnStatement", - argument - } - } - - private newMemberExpression(object: Expression, property: Expression): MemberExpression { - return { - type: "MemberExpression", - object, - property, - computed: false, - optional: false, - } - } - - private newCallExpression(callee: Expression, _arguments: Expression[]): CallExpression { - return { - type: "CallExpression", - optional: false, - callee, - arguments: _arguments - } - } - - private newIfStatement(test: Expression, consequent: Statement[], alternate?: Statement | null): IfStatement { - return { - type: "IfStatement", - test: test, - consequent: { - type: "BlockStatement", - body: consequent - }, - alternate - } - } - - newProxyString(cfg: ProxyServer): Literal { - if (cfg.scheme == 'direct') { - return this.newSimpleLiteral('DIRECT') - } - - let host = cfg.host - if (cfg.port !== undefined) { - host += `:${cfg.port}` - } - - if (['http', 'https'].includes(cfg.scheme)) { - return this.newSimpleLiteral(`${cfg.scheme == 'http' ? 'PROXY' : 'HTTPS'} ${host}`) - } - - return this.newSimpleLiteral(`${cfg.scheme.toUpperCase()} ${host}; SOCKS ${host}`) - } - - private genAdvancedRules(val: T) { - const ret = [] - - type KeyVal = 'ftp' | 'https' | 'http' - const keys: KeyVal[] = ['ftp', 'https', 'http'] - const rules = val.proxyRules as Record - for (let item of keys) { - const cfg = rules[item] - if (!cfg) { - continue - } - - ret.push( - this.newIfStatement( - this.newCallExpression( - this.newMemberExpression(this.newIdentifier('url'), this.newIdentifier('startsWith')), - [this.newSimpleLiteral(`${item}:`)] - ), - [this.newReturnStatment( - this.newProxyString(cfg) - )] - ) - ) - } - return ret - } - - - private genBypassList(val: T) { - const directExpr = this.newReturnStatment(this.newSimpleLiteral("DIRECT")) - return val.proxyRules.bypassList.map((item) => { - if (item == '') { - return this.newIfStatement( - this.newCallExpression(this.newIdentifier('isPlainHostName'), [this.newIdentifier('host')]), - [directExpr] - ) - } - - // if it's a CIDR - if (isValidCIDR(item)) { - try { - const [ip, maskPrefixLen] = parseCIDR(item) - let mask = (ip.kind() == 'ipv4' ? IPv4 : IPv6).subnetMaskFromPrefixLength(maskPrefixLen) - - return this.newIfStatement( - this.newCallExpression( - this.newIdentifier('isInNet'), [ - this.newIdentifier('host'), - this.newSimpleLiteral(ip.toString()), - this.newSimpleLiteral(mask.toNormalizedString()), - ]), - [directExpr] - ) - } catch (e) { - console.error(e) - } - } - - return this.newIfStatement( - this.newCallExpression( - this.newIdentifier('shExpMatch'), [ - this.newIdentifier('host'), - this.newSimpleLiteral(item) - ]), - [directExpr] - ) - }) - } - - gen(val: T) { - const astFindProxyForURL = this.newFunctionDeclartion( - 'FindProxyForURL', ['url', 'host'], - [ - ...this.genBypassList(val), - ...this.genAdvancedRules(val), - this.newReturnStatment(this.newProxyString(val.proxyRules.default)) - ]) - - const astProgram: Program = { - type: "Program", - sourceType: "script", - body: [ - astFindProxyForURL - ] - } - - return generateJS(astProgram) - } -} diff --git a/src/models/store.ts b/src/models/store.ts deleted file mode 100644 index f009565..0000000 --- a/src/models/store.ts +++ /dev/null @@ -1,40 +0,0 @@ -const localSet = async (key: string, val: any) => { - if (chrome.storage) { - return await chrome.storage.local.set({ - [key]: val - }) - } - localStorage.setItem(key, JSON.stringify(val)) -} - -const localGet = async (key: string): Promise => { - let s: any - if (chrome.storage) { - const ret = await chrome.storage.local.get(key) - return ret[key] - } else { - s = localStorage.getItem(key) - } - - return s && JSON.parse(s) -} - - -export async function set(key: string, val: T) { - await localSet(key, val) -} - -export async function get(key: string): Promise { - const ret = await localGet(key) - return ret -} - - -export async function getWithDefault(key: string, defaultVal: T): Promise { - const ret = await localGet(key) - if (ret == null) { - return defaultVal - } - - return ret -} \ No newline at end of file diff --git a/src/pages/ConfigPage.vue b/src/pages/ConfigPage.vue index d03d5bd..e19d5fd 100644 --- a/src/pages/ConfigPage.vue +++ b/src/pages/ConfigPage.vue @@ -1,15 +1,19 @@ - \ No newline at end of file + diff --git a/src/router.ts b/src/router.ts index f05c4cf..42ff6ab 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,30 +1,42 @@ -import { type RouteRecordRaw, createRouter, createWebHashHistory } from "vue-router"; -import HelloWorld from "./components/HelloWorld.vue"; +import { + type RouteRecordRaw, + createRouter, + createWebHashHistory, +} from "vue-router"; import ProfileConfig from "./components/ProfileConfig.vue"; import ConfigPage from "./pages/ConfigPage.vue"; import PopupPage from "./pages/PopupPage.vue"; - const routes: RouteRecordRaw[] = [ { - path: '/', + path: "/", component: ConfigPage, children: [ - { path: '', name: 'profile.autoswitch', redirect: 'profiles/new' }, - { path: 'profiles/new', name: 'profile.create', component: ProfileConfig, props: {profileID: undefined} }, - { path: 'profiles/:id', name: 'profile.custom', component: ProfileConfig, props: route => ({ profileID: route.params.id }) }, + { path: "", name: "profile.autoswitch", redirect: "profiles/new" }, + { + path: "profiles/new", + name: "profile.create", + component: ProfileConfig, + props: { profileID: undefined }, + }, + { + path: "profiles/:id", + name: "profile.custom", + component: ProfileConfig, + props: (route) => ({ profileID: route.params.id }), + }, - { path: 'preference', name: 'preference', component: HelloWorld }, - { path: '/:pathMatch(.*)*', name: 'NotFound', redirect: '/' } - ] + // { path: 'preference', name: 'preference', component: HelloWorld }, + { path: "/:pathMatch(.*)*", name: "NotFound", redirect: "/" }, + ], }, { - path: '/popup', + path: "/popup", component: PopupPage, - } -] + }, +]; export const router = createRouter({ history: createWebHashHistory(), - routes -}) \ No newline at end of file + routes, +}); diff --git a/src/services/indicator.ts b/src/services/indicator.ts new file mode 100644 index 0000000..87aa55c --- /dev/null +++ b/src/services/indicator.ts @@ -0,0 +1,28 @@ +import { Host } from "@/adapters"; +import { SystemProfile } from "./profile"; +import { ProxySetting } from "./proxy"; + +export async function setIndicator(proxy: ProxySetting) { + const curMode = proxy.setting.value?.mode; + let profile = + proxy.setting.levelOfControl == "controlled_by_this_extension" + ? proxy.activeProfile + : undefined; + + // override profile + switch (curMode) { + case "system": + profile = SystemProfile.SYSTEM; + break; + case "direct": + profile = SystemProfile.DIRECT; + break; + } + + if (profile) { + await Host.setBadge(profile.profileName, profile.color); + } else { + // clear badge + await Host.setBadge("", SystemProfile.SYSTEM.color); + } +} diff --git a/src/services/preference.ts b/src/services/preference.ts new file mode 100644 index 0000000..db5928a --- /dev/null +++ b/src/services/preference.ts @@ -0,0 +1,62 @@ +import { Host } from "@/adapters"; + +const keyDarkMode = "theme.darkmode"; + +export enum DarkMode { + Default = 0, + Dark = 1, + Light = 2, +} + +function detectDeviceDarkMode() { + if ( + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + return DarkMode.Dark; + } + + return DarkMode.Light; +} + +/** + * Get the current DarkMode setting. If not set, then returns `DarkMode.Default` + */ +export async function getDarkModeSetting(): Promise { + return Host.getWithDefault(keyDarkMode, DarkMode.Default); +} + +/** + * Return the current real DarkMode, can only be either Light or Dark. + * @returns {DarkMode.Dark | DarkMode.Light} + */ +export async function currentDarkMode(): Promise< + DarkMode.Dark | DarkMode.Light +> { + const ret = await Host.getWithDefault( + keyDarkMode, + DarkMode.Default + ); + if (ret != DarkMode.Default) { + return ret; + } + + return detectDeviceDarkMode(); +} + +export async function changeDarkMode(newMode: DarkMode) { + await Host.set(keyDarkMode, newMode); + + if (newMode == DarkMode.Default) { + newMode = detectDeviceDarkMode(); + } + + switch (newMode) { + case DarkMode.Dark: + document && document.body.setAttribute("arco-theme", "dark"); + break; + case DarkMode.Light: + document && document.body.removeAttribute("arco-theme"); + break; + } +} diff --git a/src/services/profile.ts b/src/services/profile.ts new file mode 100644 index 0000000..cc8556a --- /dev/null +++ b/src/services/profile.ts @@ -0,0 +1,129 @@ +import { Host, PacScript, SimpleProxyServer } from "@/adapters"; + +export type ProxyAuthInfo = { + username: string; + password: string; +}; + +export interface ProxyServer extends SimpleProxyServer { + auth?: ProxyAuthInfo; + scheme: "direct" | "http" | "https" | "socks4" | "socks5"; +} + +export function sanitizeProxyServer(v: ProxyServer): SimpleProxyServer { + return { + host: v.host, + port: v.port, + }; +} + +export type ProxyConfigMeta = { + profileID: string; + color: string; + profileName: string; + proxyType: "proxy" | "pac" | "system" | "direct" | "auto"; +}; + +// the basic proxy config, with authentication and pac script support +export type ProxyConfigSimple = { + proxyRules: { + default: ProxyServer; + http?: ProxyServer; + https?: ProxyServer; + ftp?: ProxyServer; + bypassList: string[]; + }; + pacScript: PacScript; +}; + +// advanced proxy config, with auto switch support +export type AutoSwitchType = "domain" | "cidr" | "url" | "disabled"; +export type AutoSwitchRule = { + type: AutoSwitchType; + condition: string; + profileID: string; +}; + +export type ProxyConfigAutoSwitch = { + rules: AutoSwitchRule[]; + defaultProfileID: string; +}; + +export type ProfileSimple = ProxyConfigMeta & { + proxyType: "proxy" | "pac"; +} & ProxyConfigSimple; + +export type ProfilePreset = ProxyConfigMeta & { + proxyType: "system" | "direct"; +}; + +export type ProfileAuthSwitch = ProxyConfigMeta & { + proxyType: "auto"; +} & ProxyConfigAutoSwitch; + +export type ProxyProfile = ProfileSimple | ProfilePreset | ProfileAuthSwitch; + +export const SystemProfile: Record = { + DIRECT: { + profileID: "direct", + color: "#7ad39e", + profileName: "Direct", + proxyType: "direct", + }, + SYSTEM: { + profileID: "system", + color: "#0000", + profileName: "System", + proxyType: "system", + }, +}; + +const keyProfileStorage = "profiles"; +export type ProfilesStorage = { + [key: string]: ProxyProfile; +}; +const onProfileUpdateListeners: ((p: ProfilesStorage) => void)[] = []; + +// list all user defined profiles. System profiles are not included +export async function listProfiles(): Promise { + const s = await Host.get(keyProfileStorage); + return s || {}; +} + +export function onProfileUpdate(callback: (p: ProfilesStorage) => void) { + onProfileUpdateListeners.push(callback); +} + +async function overwriteProfiles(profiles: ProfilesStorage) { + await Host.set(keyProfileStorage, profiles); + onProfileUpdateListeners.map((cb) => cb(profiles)); +} + +export async function saveProfile(profile: ProxyProfile) { + const data = await listProfiles(); + data[profile.profileID] = profile; + await overwriteProfiles(data); +} + +export async function getProfile( + profileID: string, + userProfileOnly?: boolean +): Promise { + if (!userProfileOnly) { + // check if it's a system profile + for (const p of Object.values(SystemProfile)) { + if (p.profileID === profileID) { + return p; + } + } + } + + const data = await listProfiles(); + return data[profileID]; +} + +export async function deleteProfile(profileID: string) { + const data = await listProfiles(); + delete data[profileID]; + await overwriteProfiles(data); +} diff --git a/src/services/proxy/index.ts b/src/services/proxy/index.ts new file mode 100644 index 0000000..d277a56 --- /dev/null +++ b/src/services/proxy/index.ts @@ -0,0 +1,127 @@ +import { Host } from "@/adapters"; +import { + ProxyProfile, + ProxyAuthInfo, + SystemProfile, + getProfile, + ProfileAuthSwitch, +} from "../profile"; +import { ProxySettingResultDetails } from "@/adapters"; +import { ProfileConverter } from "./profile2config"; + +export type ProxySetting = { + activeProfile?: ProxyProfile; + setting: ProxySettingResultDetails; +}; + +const keyActiveProfile = "active-profile"; + +async function wrapProxySetting(setting: ProxySettingResultDetails) { + const ret: ProxySetting = { + setting, + }; + + if (setting.levelOfControl == "controlled_by_this_extension") { + ret.activeProfile = + (await Host.get(keyActiveProfile)) || undefined; + } + + switch (setting.value?.mode) { + case "system": + ret.activeProfile = SystemProfile.SYSTEM; + break; + case "direct": + ret.activeProfile = SystemProfile.DIRECT; + break; + } + + return ret; +} + +export async function getCurrentProxySetting() { + const setting = await Host.getProxySettings(); + return await wrapProxySetting(setting); +} + +export function onCurrentProxySettingChanged( + cb: (setting: ProxySetting) => void +) { + Host.onProxyChanged(async (setting) => { + const ret = await wrapProxySetting(setting); + cb(ret); + }); +} + +export async function setProxy(val: ProxyProfile) { + switch (val.proxyType) { + case "system": + await Host.clearProxy(); + break; + + default: + const profile = new ProfileConverter(val, getProfile); + await Host.setProxy(await profile.toProxyConfig()); + break; + } + + await Host.set(keyActiveProfile, val); +} + +/** + * Refresh the current proxy setting. This is useful when the proxy setting is changed by user. + * @returns + */ +export async function refreshProxy() { + const current = await getCurrentProxySetting(); + + // if it's not controlled by this extension, then do nothing + if (!current.activeProfile) { + return; + } + + // if it's preset profiles, then do nothing + if (current.activeProfile.proxyType in ["system", "direct"]) { + return; + } + + const profile = new ProfileConverter(current.activeProfile, getProfile); + await Host.setProxy(await profile.toProxyConfig()); +} + +export async function previewAutoSwitchPac(val: ProfileAuthSwitch) { + const profile = new ProfileConverter(val, getProfile); + return await profile.toPAC(); +} + +export async function getAuthInfos( + host: string, + port: number +): Promise { + const profile = await Host.get(keyActiveProfile); + if (!profile || profile.proxyType !== "proxy") { + return []; + } + + const ret: ProxyAuthInfo[] = []; + const auths = [ + profile.proxyRules.default, + profile.proxyRules.ftp, + profile.proxyRules.http, + profile.proxyRules.https, + ]; + + // check if there's any matching host and port + auths.map((item) => { + if (!item) return; + + if ( + item.host == host && + (item.port === undefined || item.port == port) && + item.auth + ) { + ret.push(item.auth); + } + }); + + return ret; +} diff --git a/src/services/proxy/profile2config.ts b/src/services/proxy/profile2config.ts new file mode 100644 index 0000000..9b07565 --- /dev/null +++ b/src/services/proxy/profile2config.ts @@ -0,0 +1,476 @@ +import { generate as generateJS } from "escodegen"; +import { Program, Statement } from "estree"; +import { + AutoSwitchRule, + ProfileAuthSwitch, + ProxyProfile, + ProxyServer, +} from "../profile"; +import { IPv4, IPv6, isValidCIDR, parseCIDR } from "ipaddr.js"; +import { + newProxyString, + PACScriptHelper, + parsePACScript, +} from "./scriptHelper"; +import { ProxyConfig } from "@/adapters"; + +type ProfileLoader = (profileID: string) => Promise; + +export class ProfileConverter { + constructor( + private profile: ProxyProfile, + private profileLoader?: ProfileLoader + ) {} + + async toProxyConfig(): Promise { + switch (this.profile.proxyType) { + case "direct": + case "system": + return { mode: this.profile.proxyType }; + + case "pac": + return { + mode: "pac_script", + pacScript: this.profile.pacScript, + }; + + default: + return { + mode: "pac_script", + pacScript: { + data: await this.toPAC(), + }, + }; + } + } + + async toPAC() { + const astProgram: Program = { + type: "Program", + sourceType: "script", + body: await this.genStatements(), + }; + + return generateJS(astProgram); + } + + /** + * Convert the profile to a closure, which can be used for auto profiles + * (function() { + * // the definition of FindProxyForURL + * return FindProxyForURL; + * })() + * @returns + */ + async toClosure() { + const stmts = await this.genStatements(); + stmts.push( + PACScriptHelper.newReturnStatement( + PACScriptHelper.newIdentifier("FindProxyForURL") + ) + ); + + return PACScriptHelper.newCallExpression( + PACScriptHelper.newFunctionExpression([], stmts), + [] + ); + } + + /** + * genStatements that returns a list of statements, containing the main function `FindProxyForURL` + * @returns + */ + private async genStatements() { + switch (this.profile.proxyType) { + case "system": + case "direct": + case "proxy": + return this.genFindProxyForURLFunction(); + case "pac": + return this.genFindProxyForURLFunctionForPAC(); + case "auto": + return await this.genFindProxyForURLFunctionForAutoProfile(); + } + } + private genFindProxyForURLFunctionForPAC(): Statement[] { + if (this.profile.proxyType != "pac") { + throw new Error("this function should only be called for pac profile"); + } + + if (!this.profile.pacScript.data) { + return []; + } + + return parsePACScript(this.profile.pacScript.data); + } + + /** + * genFindProxyForURLFunction for `ProxySimple` and `ProxyPreset` + * @returns + */ + private genFindProxyForURLFunction(): Statement[] { + const body: Statement[] = []; + + switch (this.profile.proxyType) { + case "direct": + body.push( + PACScriptHelper.newReturnStatement( + PACScriptHelper.newSimpleLiteral("DIRECT") + ) + ); + break; + case "proxy": + body.push( + ...this.genBypassList(), + ...this.genAdvancedRules(), + PACScriptHelper.newReturnStatement( + newProxyString(this.profile.proxyRules.default) + ) + ); + break; + + default: + throw new Error("unexpected proxy type"); + } + + return [ + PACScriptHelper.newFunctionDeclaration( + "FindProxyForURL", + ["url", "host"], + body + ), + ]; + } + + private async genFindProxyForURLFunctionForAutoProfile() { + if (this.profile.proxyType != "auto") { + throw new Error("this function should only be called for auto profile"); + } + + const { stmt, loadedProfiles } = await this.prepareAutoProfilePrecedence( + this.profile + ); + const body: Statement[] = []; + + // rules + this.profile.rules.forEach((rule) => { + switch (rule.type) { + case "disabled": + return; // skipped + default: + if (loadedProfiles.has(rule.profileID)) { + return body.push(this.genAutoProfileRule(rule)); + } + } + + // if a dependent profile is not loaded, skip it, and add some alerts + body.push(this.genAutoProfileMissingProfileAlert(rule.profileID)); + }); + + // default profile + if (loadedProfiles.has(this.profile.defaultProfileID)) { + body.push( + PACScriptHelper.newReturnStatement( + this.genAutoProfileCallExpression(this.profile.defaultProfileID) + ) + ); + } else { + body.push( + this.genAutoProfileMissingProfileAlert(this.profile.defaultProfileID), + PACScriptHelper.newReturnStatement( + PACScriptHelper.newSimpleLiteral("DIRECT") // fallback to direct + ) + ); + } + + stmt.push( + PACScriptHelper.newFunctionDeclaration( + "FindProxyForURL", + ["url", "host"], + body + ) + ); + + return stmt; + } + + private genAutoProfileRule(rule: AutoSwitchRule): Statement { + switch (rule.type) { + case "domain": + return PACScriptHelper.newIfStatement( + PACScriptHelper.newCallExpression( + PACScriptHelper.newIdentifier("shExpMatch"), + [ + PACScriptHelper.newIdentifier("host"), + PACScriptHelper.newSimpleLiteral(rule.condition), + ] + ), + [ + PACScriptHelper.newReturnStatement( + this.genAutoProfileCallExpression(rule.profileID) + ), + ] + ); + + case "url": + return PACScriptHelper.newIfStatement( + PACScriptHelper.newCallExpression( + PACScriptHelper.newIdentifier("shExpMatch"), + [ + PACScriptHelper.newIdentifier("url"), + PACScriptHelper.newSimpleLiteral(rule.condition), + ] + ), + [ + PACScriptHelper.newReturnStatement( + this.genAutoProfileCallExpression(rule.profileID) + ), + ] + ); + + case "cidr": + // if it's a CIDR + if (isValidCIDR(rule.condition)) { + try { + const [ip, maskPrefixLen] = parseCIDR(rule.condition); + let mask = ( + ip.kind() == "ipv4" ? IPv4 : IPv6 + ).subnetMaskFromPrefixLength(maskPrefixLen); + + return PACScriptHelper.newIfStatement( + PACScriptHelper.newCallExpression( + PACScriptHelper.newIdentifier("isInNet"), + [ + PACScriptHelper.newIdentifier("host"), + PACScriptHelper.newSimpleLiteral(ip.toString()), + PACScriptHelper.newSimpleLiteral(mask.toNormalizedString()), + ] + ), + [ + PACScriptHelper.newReturnStatement( + this.genAutoProfileCallExpression(rule.profileID) + ), + ] + ); + } catch (e) { + console.error(e); + } + } + } + + return PACScriptHelper.newExpressionStatement( + PACScriptHelper.newCallExpression( + PACScriptHelper.newIdentifier("alert"), + [ + PACScriptHelper.newSimpleLiteral( + `Invalid condition ${rule.type}: ${rule.condition}, skipped` + ), + ] + ) + ); + } + + private genAutoProfileCallExpression(profileID: string) { + return PACScriptHelper.newCallExpression( + PACScriptHelper.newMemberExpression( + PACScriptHelper.newIdentifier("profiles"), + PACScriptHelper.newSimpleLiteral(profileID), + true + ), + [ + PACScriptHelper.newIdentifier("url"), + PACScriptHelper.newIdentifier("host"), + ] + ); + } + + private genAutoProfileMissingProfileAlert(profileID: string) { + return PACScriptHelper.newExpressionStatement( + PACScriptHelper.newCallExpression( + PACScriptHelper.newIdentifier("alert"), + [ + PACScriptHelper.newSimpleLiteral( + `Profile ${profileID} not found, skipped` + ), + ] + ) + ); + } + + private async prepareAutoProfilePrecedence(profile: ProfileAuthSwitch) { + const loadedProfiles = new Set(); + const stmt: Statement[] = [ + // var profiles = profiles || {}; + PACScriptHelper.newVariableDeclaration( + "profiles", + PACScriptHelper.newLogicalExpression( + "||", + PACScriptHelper.newIdentifier("profiles"), + PACScriptHelper.newObjectExpression([]) + ) + ), + + /** + * function register(profileID, funcFindProxyForURL) { + * profiles[profileID] = funcFindProxyForURL; + * } + */ + PACScriptHelper.newFunctionDeclaration( + "register", + ["profileID", "funcFindProxyForURL"], + [ + PACScriptHelper.newExpressionStatement( + PACScriptHelper.newAssignmentExpression( + "=", + PACScriptHelper.newMemberExpression( + PACScriptHelper.newIdentifier("profiles"), + PACScriptHelper.newIdentifier("profileID"), + true + ), + PACScriptHelper.newIdentifier("funcFindProxyForURL") + ) + ), + ] + ), + ]; + + // register all profiles + const profileIDs = [ + profile.defaultProfileID, + ...profile.rules.map((r) => r.profileID), + ]; + for (let profileID of profileIDs) { + if (loadedProfiles.has(profileID)) { + continue; + } + + const profile = await this.loadProfile(profileID); + if (!profile) { + continue; + } + + loadedProfiles.add(profileID); + + stmt.push( + PACScriptHelper.newExpressionStatement( + PACScriptHelper.newCallExpression( + PACScriptHelper.newIdentifier("register"), + [ + PACScriptHelper.newSimpleLiteral(profileID), + await profile.toClosure(), + ] + ) + ) + ); + } + + return { stmt, loadedProfiles }; + } + + private async loadProfile( + profileID: string + ): Promise { + if (!this.profileLoader) { + return; + } + + const profile = await this.profileLoader(profileID); + if (!profile) { + return; + } + + return new ProfileConverter(profile, this.profileLoader); + } + + private genBypassList() { + if (this.profile.proxyType != "proxy") { + throw new Error("Only proxy profile can have bypass list"); + } + + const directExpr = PACScriptHelper.newReturnStatement( + PACScriptHelper.newSimpleLiteral("DIRECT") + ); + return this.profile.proxyRules.bypassList.map((item) => { + if (item == "") { + return PACScriptHelper.newIfStatement( + PACScriptHelper.newCallExpression( + PACScriptHelper.newIdentifier("isPlainHostName"), + [PACScriptHelper.newIdentifier("host")] + ), + [directExpr] + ); + } + + // if it's a CIDR + if (isValidCIDR(item)) { + try { + const [ip, maskPrefixLen] = parseCIDR(item); + let mask = ( + ip.kind() == "ipv4" ? IPv4 : IPv6 + ).subnetMaskFromPrefixLength(maskPrefixLen); + + return PACScriptHelper.newIfStatement( + PACScriptHelper.newCallExpression( + PACScriptHelper.newIdentifier("isInNet"), + [ + PACScriptHelper.newIdentifier("host"), + PACScriptHelper.newSimpleLiteral(ip.toString()), + PACScriptHelper.newSimpleLiteral(mask.toNormalizedString()), + ] + ), + [directExpr] + ); + } catch (e) { + console.error(e); + } + } + + return PACScriptHelper.newIfStatement( + PACScriptHelper.newCallExpression( + PACScriptHelper.newIdentifier("shExpMatch"), + [ + PACScriptHelper.newIdentifier("host"), + PACScriptHelper.newSimpleLiteral(item), + ] + ), + [directExpr] + ); + }); + } + + private genAdvancedRules() { + if (this.profile.proxyType != "proxy") { + throw new Error("Only proxy profile can have bypass list"); + } + + const ret = []; + + type KeyVal = "ftp" | "https" | "http"; + const keys: KeyVal[] = ["ftp", "https", "http"]; + const rules = this.profile.proxyRules as Record< + KeyVal, + ProxyServer | undefined + >; + + for (let item of keys) { + const cfg = rules[item]; + if (!cfg) { + continue; + } + + ret.push( + PACScriptHelper.newIfStatement( + PACScriptHelper.newCallExpression( + PACScriptHelper.newMemberExpression( + PACScriptHelper.newIdentifier("url"), + PACScriptHelper.newIdentifier("startsWith") + ), + [PACScriptHelper.newSimpleLiteral(`${item}:`)] + ), + [PACScriptHelper.newReturnStatement(newProxyString(cfg))] + ) + ); + } + return ret; + } +} diff --git a/src/services/proxy/scriptHelper.ts b/src/services/proxy/scriptHelper.ts new file mode 100644 index 0000000..d16cbb4 --- /dev/null +++ b/src/services/proxy/scriptHelper.ts @@ -0,0 +1,214 @@ +import { parseScript } from "esprima"; +import { + AssignmentExpression, + AssignmentOperator, + CallExpression, + Directive, + Expression, + ExpressionStatement, + FunctionDeclaration, + FunctionExpression, + Identifier, + IfStatement, + Literal, + LogicalExpression, + MemberExpression, + ObjectExpression, + Pattern, + Property, + ReturnStatement, + SpreadElement, + Statement, + VariableDeclaration, +} from "estree"; +import { ProxyServer } from "../profile"; + +export function parsePACScript(script: string): Statement[] { + const program = parseScript(script); + + const ret: (Statement | Directive)[] = []; + for (const stmt of program.body) { + switch (stmt.type) { + case "ImportDeclaration": + case "ExportNamedDeclaration": + case "ExportDefaultDeclaration": + case "ExportAllDeclaration": + throw new Error(`${stmt.type} is not allowed in PAC script"`); + } + ret.push(stmt); + } + return ret; +} + +export const newProxyString = (cfg: ProxyServer) => { + if (cfg.scheme == "direct") { + return PACScriptHelper.newSimpleLiteral("DIRECT"); + } + + let host = cfg.host; + if (cfg.port !== undefined) { + host += `:${cfg.port}`; + } + + if (["http", "https"].includes(cfg.scheme)) { + return PACScriptHelper.newSimpleLiteral( + `${cfg.scheme == "http" ? "PROXY" : "HTTPS"} ${host}` + ); + } + + return PACScriptHelper.newSimpleLiteral( + `${cfg.scheme.toUpperCase()} ${host}; SOCKS ${host}` + ); +}; + +export class PACScriptHelper { + static newAssignmentExpression( + operator: AssignmentOperator, + left: Pattern | MemberExpression, + right: Expression + ): AssignmentExpression { + return { + type: "AssignmentExpression", + operator, + left, + right, + }; + } + static newExpressionStatement(expression: Expression): ExpressionStatement { + return { + type: "ExpressionStatement", + expression, + }; + } + static newVariableDeclaration( + name: string, + init?: Expression + ): VariableDeclaration { + return { + type: "VariableDeclaration", + kind: "var", + declarations: [ + { + type: "VariableDeclarator", + id: this.newIdentifier(name), + init: init, + }, + ], + }; + } + + static newLogicalExpression( + operator: "||" | "&&", + left: Expression, + right: Expression + ): LogicalExpression { + return { + type: "LogicalExpression", + operator, + left, + right, + }; + } + + static newObjectExpression( + properties: Array + ): ObjectExpression { + return { + type: "ObjectExpression", + properties, + }; + } + + static newIdentifier(name: string): Identifier { + return { + type: "Identifier", + name: name, + }; + } + + static newSimpleLiteral(value: string | boolean | number | null): Literal { + return { + type: "Literal", + value: value, + }; + } + + static newFunctionDeclaration( + name: string, + params: string[], + body: Statement[] + ): FunctionDeclaration { + return { + type: "FunctionDeclaration", + id: this.newIdentifier(name), + params: params.map((v) => this.newIdentifier(v)), + body: { + type: "BlockStatement", + body: body, + }, + }; + } + + static newFunctionExpression( + params: string[], + body: Statement[] + ): FunctionExpression { + return { + type: "FunctionExpression", + params: params.map((v) => this.newIdentifier(v)), + body: { + type: "BlockStatement", + body: body, + }, + }; + } + + static newReturnStatement(argument?: Expression): ReturnStatement { + return { + type: "ReturnStatement", + argument, + }; + } + + static newMemberExpression( + object: Expression, + property: Expression, + computed: boolean = false + ): MemberExpression { + return { + type: "MemberExpression", + object, + property, + computed, + optional: false, + }; + } + + static newCallExpression( + callee: Expression, + _arguments: Expression[] + ): CallExpression { + return { + type: "CallExpression", + optional: false, + callee, + arguments: _arguments, + }; + } + + static newIfStatement( + test: Expression, + consequent: Statement[], + alternate?: Statement | null + ): IfStatement { + return { + type: "IfStatement", + test: test, + consequent: { + type: "BlockStatement", + body: consequent, + }, + alternate, + }; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 2b299c5..fe36ce1 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,4 @@ /// -/// interface ImportMetaEnv { readonly VITE_APP_TITLE: string; diff --git a/tests/models/proxy/proxyRules.test.ts b/tests/models/proxy/proxyRules.test.ts deleted file mode 100644 index 60e8d28..0000000 --- a/tests/models/proxy/proxyRules.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { expect, test, describe } from "vitest"; -import { genSimpleProxyCfg } from "@/models/proxy/proxyRules.ts"; -import { ProfileConfig, SystemProfile } from "@/models/profile"; - -describe("testing genSimpleProxyCfg for direct and system", () => { - test("proxy config mode", () => { - const cfg = genSimpleProxyCfg(SystemProfile.DIRECT); - expect(cfg.mode).toBe("direct"); - }); -}); - -describe("testing bypass list", () => { - test("bypass list with ipv6", () => { - const profile: ProfileConfig = { - profileID: "", - color: "", - profileName: "", - proxyType: "proxy", - proxyRules: { - default: { - scheme: "http", - host: "127.0.0.1", - port: 8080, - }, - https: { - scheme: "direct", - host: "", - }, - bypassList: [ - "", - "127.0.0.1", - "192.168.0.1/16", - "[::1]", - "fefe:13::abc/33", - ], - }, - pacScript: {}, - }; - const cfg = genSimpleProxyCfg(profile); - expect(cfg.pacScript?.data).toMatch( - /.*?isInNet\(host, '192\.168\.0\.1', '255\.255\.0\.0'\).*?/ - ); - expect(cfg.pacScript?.data).toMatch( - /.*?isInNet\(host, 'fefe:13::abc', 'ffff:ffff:8000:0:0:0:0:0'\).*?/ - ); - }); -}); diff --git a/tests/services/proxy/profile2config.test.ts b/tests/services/proxy/profile2config.test.ts new file mode 100644 index 0000000..8a2776a --- /dev/null +++ b/tests/services/proxy/profile2config.test.ts @@ -0,0 +1,169 @@ +import { expect, test, describe } from "vitest"; +import { ProxyProfile, SystemProfile } from "@/services/profile"; +import { ProfileConverter } from "@/services/proxy/profile2config"; + +const profiles: Record = { + simpleProxy: { + profileID: "simpleProxy", + color: "", + profileName: "", + proxyType: "proxy", + proxyRules: { + default: { + scheme: "http", + host: "127.0.0.1", + port: 8080, + }, + https: { + scheme: "direct", + host: "", + }, + bypassList: [ + "", + "127.0.0.1", + "192.168.0.1/16", + "[::1]", + "fefe:13::abc/33", + ], + }, + pacScript: {}, + }, + + pacProxy: { + profileID: "pacProxy", + color: "", + profileName: "", + proxyType: "pac", + proxyRules: { + default: { + scheme: "http", + host: "", + }, + bypassList: [], + }, + pacScript: { + data: "function FindProxyForURL(url, host) { return 'DIRECT'; }", + }, + }, + + autoProxy: { + profileID: "autoProxy", + color: "", + profileName: "", + proxyType: "auto", + rules: [ + { + type: "domain", + condition: "*.example.com", + profileID: "simpleProxy", + }, + { + type: "url", + condition: "http://example.com/api/*", + profileID: "pacProxy", + }, + { + type: "cidr", + condition: "192.168.10.1/24", + profileID: "simpleProxy", + }, + { + type: "domain", + condition: "*.404.com", + profileID: "non-exists", + }, + ], + defaultProfileID: "direct", + }, + + direct: { + profileID: "direct", + color: "", + profileName: "", + proxyType: "direct", + }, + + autoProxy2: { + profileID: "autoProxy2", + color: "", + profileName: "", + proxyType: "auto", + rules: [ + { + type: "domain", + condition: "*.example.com", + profileID: "autoProxy", + }, + ], + defaultProfileID: "direct", + }, +}; + +describe("testing generating ProxyConfig for direct and system", () => { + test("proxy config mode", async () => { + const profile = new ProfileConverter(SystemProfile.DIRECT); + const cfg = await profile.toProxyConfig(); + expect(cfg.mode).toBe("direct"); + }); + + test("proxy config mode for others", async () => { + const profile = new ProfileConverter(profiles.simpleProxy); + const cfg = await profile.toProxyConfig(); + expect(cfg.mode).toBe("pac_script"); + }); +}); + +describe("testing bypass list", () => { + test("bypass list with ipv6", async () => { + const profile = new ProfileConverter(profiles.simpleProxy); + const cfg = await profile.toProxyConfig(); + expect(cfg.pacScript?.data).toMatch( + /.*?isInNet\(host, '192\.168\.0\.1', '255\.255\.0\.0'\).*?/ + ); + expect(cfg.pacScript?.data).toMatch( + /.*?isInNet\(host, 'fefe:13::abc', 'ffff:ffff:8000:0:0:0:0:0'\).*?/ + ); + }); +}); + +describe("testing auto switch profile", () => { + test("auto switch profile", async () => { + const profile = new ProfileConverter(profiles.autoProxy, async (id) => { + return profiles[id]; + }); + const cfg = await profile.toProxyConfig(); + expect(cfg.mode).toBe("pac_script"); + + expect(cfg.pacScript?.data).toContain(` +register('pacProxy', function () { + function FindProxyForURL(url, host) { + return 'DIRECT'; + } + return FindProxyForURL; +}());`); + + expect(cfg.pacScript?.data).toContain(` + if (isInNet(host, '192.168.10.1', '255.255.255.0')) { + return profiles['simpleProxy'](url, host); + }`); + + expect(cfg.pacScript?.data).toContain( + `alert('Profile non-exists not found, skipped');` + ); + expect(cfg.pacScript?.data).toContain( + `return profiles['direct'](url, host);` + ); + }); + test("nested auto switch profile", async () => { + const profile = new ProfileConverter(profiles.autoProxy2, async (id) => { + return profiles[id]; + }); + const cfg = await profile.toProxyConfig(); + expect(cfg.mode).toBe("pac_script"); + + expect(cfg.pacScript?.data).toContain(` + if (shExpMatch(host, '*.example.com')) { + return profiles['autoProxy'](url, host); + }`); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 6e02f8b..96bcc66 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,7 @@ "paths": { "@/*": [ "src/*" - ] + ], } }, "include": [