├── .github └── workflows │ └── build.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── Makefile ├── README.md ├── assets ├── logo.png └── subconverter_rules │ ├── ACL4SSR_Online.ini │ └── dl_rules.sh ├── backend ├── .gitignore ├── Cargo.toml ├── Makefile ├── build.sh └── src │ ├── api.rs │ ├── control.rs │ ├── external_web.rs │ ├── helper.rs │ ├── main.rs │ ├── mod.rs │ ├── settings.rs │ └── test.rs ├── decky.pyi ├── main.py ├── package-lock.json ├── package.json ├── plugin.json ├── py_modules ├── config.py ├── dashboard.py ├── update.py └── utils.py ├── rollup.config.js ├── src ├── backend │ ├── backend.ts │ ├── enum.ts │ └── index.ts ├── components │ ├── SubList.tsx │ ├── Version.tsx │ ├── actionButtonItem.tsx │ └── index.ts ├── i18n │ ├── bulgarian.json │ ├── english.json │ ├── french.json │ ├── german.json │ ├── index.ts │ ├── italian.json │ ├── japanese.json │ ├── koreana.json │ ├── localization.ts │ ├── localizeMap.ts │ ├── schinese.json │ ├── tchinese.json │ └── thai.json ├── index.tsx ├── pages │ ├── About.tsx │ ├── Debug.tsx │ ├── Subscriptions.tsx │ └── index.ts ├── style.tsx ├── types.d.ts └── types │ └── global.d.ts ├── tomoon-web ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public │ └── vite.svg ├── src │ ├── App.css │ ├── App.jsx │ ├── assets │ │ └── react.svg │ ├── index.css │ └── main.jsx ├── tailwind.config.cjs └── vite.config.js ├── tsconfig.json └── usdpl ├── README.md ├── package.json ├── usdpl_front.d.ts ├── usdpl_front.js ├── usdpl_front_bg.wasm └── usdpl_front_bg.wasm.d.ts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: ToMoon Auto Build 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | build_plugin: 12 | name: Build Plugin 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | steps: 17 | #build tomoon start 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install Tools 21 | run: | 22 | sudo apt-get update 23 | sudo apt-get install -y wget unzip upx 24 | 25 | - name: Download Clash and Yacd and Subconverter 26 | run: | 27 | mkdir tmp && cd tmp 28 | mkdir core && cd core 29 | # Mihomo (Clash Meta) 30 | LATEST_URL=$(curl -s https://api.github.com/repos/MetaCubeX/mihomo/releases/latest | grep "browser_download_url.*linux-amd64-v.*gz\"" | cut -d '"' -f 4) 31 | wget -O clash.gz $LATEST_URL 32 | gzip -d clash.gz 33 | 34 | # upx 35 | chmod +x clash 36 | upx clash 37 | 38 | # country.mmdb & geosite.dat 39 | wget https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb 40 | wget https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat 41 | wget -O asn.mmdb https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-ASN.mmdb 42 | 43 | # dashboard 44 | mkdir web 45 | # yacd 46 | wget -O yacd.zip https://github.com/haishanh/yacd/archive/refs/heads/gh-pages.zip 47 | unzip yacd.zip 48 | mv yacd-gh-pages web/yacd 49 | # yacd-meta 50 | wget -O yacd-meta.zip https://github.com/MetaCubeX/yacd/archive/gh-pages.zip 51 | unzip yacd-meta.zip 52 | mv Yacd-meta-gh-pages web/yacd-meta 53 | # metacubexd 54 | wget -O metacubexd.zip https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip 55 | unzip metacubexd.zip 56 | mv metacubexd-gh-pages web/metacubexd 57 | # zashboard 58 | wget -O zashboard.zip https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip 59 | unzip zashboard.zip 60 | mv dist web/zashboard 61 | 62 | echo "clean zips" 63 | rm -f *.zip 64 | 65 | cd $GITHUB_WORKSPACE 66 | wget -O subconverter_linux64.tar.gz https://github.com/MetaCubeX/subconverter/releases/download/Alpha/subconverter_linux64.tar.gz 67 | tar xvf subconverter_linux64.tar.gz 68 | 69 | # upx 70 | chmod +x subconverter/subconverter 71 | upx subconverter/subconverter 72 | 73 | # build front-end start 74 | - uses: actions/setup-node@v4 75 | with: 76 | node-version: 20 77 | - name: Install Requestment 78 | run: | 79 | cp -r usdpl src/ 80 | npm i 81 | - name: build front end 82 | run: npm run build 83 | working-directory: . 84 | - name: build external web page 85 | run: | 86 | npm i 87 | npm run build 88 | working-directory: tomoon-web 89 | # build front-end end 90 | # build backend start 91 | - uses: dtolnay/rust-toolchain@stable 92 | - uses: ClementTsang/cargo-action@v0.0.6 93 | with: 94 | command: build 95 | directory: ./backend 96 | args: --target x86_64-unknown-linux-gnu --release 97 | use-cross: false 98 | 99 | - name: upx tomoon 100 | run: | 101 | upx ./backend/target/x86_64-unknown-linux-gnu/release/tomoon 102 | 103 | - name: Collect Files 104 | run: | 105 | mkdir -p ./release/tomoon/bin/core/web 106 | mkdir -p ./release/tomoon/dist 107 | cp backend/target/x86_64-unknown-linux-gnu/release/tomoon ./release/tomoon/bin/tomoon 108 | cp ./dist/index.js ./release/tomoon/dist/index.js 109 | cp main.py ./release/tomoon/main.py 110 | cp plugin.json ./release/tomoon/plugin.json 111 | cp package.json ./release/tomoon/package.json 112 | cp -r ./tmp/core ./release/tomoon/bin/ 113 | cp -r ./tomoon-web/dist ./release/tomoon/web 114 | cp -r ./py_modules ./release/tomoon/py_modules 115 | mkdir -p ./release/tomoon/web/rules 116 | bash ./assets/subconverter_rules/dl_rules.sh ./release/tomoon/web/rules 117 | cp ./assets/subconverter_rules/ACL4SSR_Online.ini ./release/tomoon/web/ACL4SSR_Online.ini 118 | cp ./subconverter/subconverter ./release/tomoon/bin/subconverter 119 | cd ./release 120 | zip -r tomoon.zip tomoon 121 | cd .. 122 | 123 | - name: Publish Artifacts 124 | uses: actions/upload-artifact@v4 125 | with: 126 | name: tomoon-artifacts 127 | path: ./release/tomoon.zip 128 | if-no-files-found: error 129 | 130 | 131 | publish: 132 | name: Publish Release 133 | if: startsWith(github.ref, 'refs/tags/v') 134 | runs-on: ubuntu-latest 135 | needs: build_plugin 136 | steps: 137 | - run: mkdir /tmp/artifacts 138 | 139 | - name: download artifact 140 | uses: actions/download-artifact@v4 141 | with: 142 | path: /tmp/artifacts 143 | 144 | - run: ls -R /tmp/artifacts 145 | - run: | 146 | mv /tmp/artifacts/tomoon-artifacts/tomoon.zip /tmp/artifacts/tomoon-artifacts/tomoon-$GITHUB_REF_NAME.zip 147 | env: 148 | GITHUB_REF_NAME: ${{ github.ref_name }} 149 | 150 | - name: publish to github release 151 | uses: softprops/action-gh-release@v2 152 | with: 153 | files: /tmp/artifacts/tomoon-artifacts/tomoon*.zip 154 | name: Release ${{ github.ref_name }} 155 | draft: false 156 | generate_release_notes: true 157 | prerelease: contains(github.ref, 'pre') 158 | env: 159 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Coverage reports 17 | coverage 18 | 19 | # API keys and secrets 20 | .env 21 | 22 | # Dependency directory 23 | node_modules 24 | bower_components 25 | 26 | # Editors 27 | .idea 28 | *.iml 29 | 30 | # OS metadata 31 | .DS_Store 32 | Thumbs.db 33 | 34 | # Ignore built ts files 35 | dist/ 36 | 37 | __pycache__/ 38 | 39 | /.yalc 40 | yalc.lock 41 | 42 | .vscode/settings.json 43 | 44 | # Ignore output folder 45 | 46 | backend/out 47 | 48 | bin 49 | 50 | build.*.sh 51 | 52 | .vscode 53 | 54 | release 55 | 56 | clash 57 | .history 58 | web 59 | 60 | pnpm-lock.yaml 61 | src/usdpl 62 | 63 | #clash 配置文件 64 | *.list 65 | .pnpm-store -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Steam Deck Homebrew 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifneq (,$(wildcard ./.env)) 2 | include .env 3 | export 4 | endif 5 | 6 | SHELL=bash 7 | 8 | help: ## Display list of tasks with descriptions 9 | @echo "+ $@" 10 | @fgrep -h ": ## " $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed 's/-default//' | awk 'BEGIN {FS = ": ## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 11 | 12 | vendor: ## Install project dependencies 13 | @echo "+ $@" 14 | @cp -r usdpl src/ 15 | @pnpm i 16 | @cd tomoon-web && pnpm i 17 | 18 | env: ## Create default .env file 19 | @echo "+ $@" 20 | @echo -e '# Makefile tools\nDECK_USER=deck\nDECK_HOST=\nDECK_PORT=22\nDECK_HOME=/home/deck\nDECK_KEY=~/.ssh/id_rsa' >> .env 21 | @echo -n "PLUGIN_FOLDER=" >> .env 22 | @jq -r .name package.json >> .env 23 | 24 | init: ## Initialize project 25 | @$(MAKE) env 26 | @$(MAKE) vendor 27 | @echo -e "\n\033[1;36m Almost ready! Just a few things left to do:\033[0m\n" 28 | @echo -e "1. Open .env file and make sure every DECK_* variable matches your steamdeck's ip/host, user, etc" 29 | @echo -e "2. Run \`\033[0;36mmake copy-ssh-key\033[0m\` to copy your public ssh key to steamdeck" 30 | @echo -e "3. Build your code with \`\033[0;36mmake build\033[0m\` or \`\033[0;36mmake docker-build\033[0m\` to build inside a docker container" 31 | @echo -e "4. Deploy your plugin code to steamdeck with \`\033[0;36mmake deploy\033[0m\`" 32 | 33 | update-decky-ui: ## Update @decky/ui @decky/api 34 | @echo "+ $@" 35 | @pnpm update @decky/ui --latest 36 | @pnpm update @decky/api --latest 37 | @pnpm update @decky/rollup --latest 38 | 39 | download: 40 | @echo "+ $@" 41 | @$(MAKE) clean_tmp 42 | @$(MAKE) download_core 43 | @$(MAKE) upx_core 44 | @$(MAKE) download_mmdb 45 | @$(MAKE) download_dashboard 46 | @$(MAKE) download_rules 47 | @$(MAKE) download_subconverter 48 | @$(MAKE) upx_subconverter 49 | 50 | clean_tmp: 51 | @echo "+ $@" 52 | @rm -rf ./tmp/* 53 | 54 | download_core: 55 | @echo "+ $@" 56 | @mkdir -p ./tmp 57 | @mkdir -p ./tmp/core 58 | @echo "Fetching latest release info from GitHub..." 59 | @LATEST_URL=$$(curl -s https://api.github.com/repos/MetaCubeX/mihomo/releases/latest | grep "browser_download_url.*linux-amd64-v.*gz\"" | cut -d '"' -f 4) && \ 60 | echo "Downloading from: $$LATEST_URL" && \ 61 | wget -O clash.gz $$LATEST_URL && \ 62 | gzip -d clash.gz -c > ./tmp/core/clash && \ 63 | rm -f clash.gz 64 | 65 | upx_core: ## Compress core/clash with UPX 66 | @echo "+ $@" 67 | @chmod +x ./tmp/core/clash 68 | @upx ./tmp/core/clash 69 | 70 | download_mmdb: 71 | @echo "+ $@" 72 | @mkdir -p ./tmp 73 | @mkdir -p ./tmp/core 74 | @wget -O ./tmp/core/country.mmdb https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb 75 | @wget -O ./tmp/core/geosite.dat https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat 76 | @wget -O ./tmp/core/asn.mmdb https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-ASN.mmdb 77 | 78 | download_dashboard: 79 | @echo "+ $@" 80 | @mkdir -p ./tmp 81 | @mkdir -p ./tmp/core 82 | @mkdir -p ./tmp/core/web 83 | @rm -rf ./tmp/core/web/* 84 | 85 | $(MAKE) download_yacd 86 | $(MAKE) download_yacd_meta 87 | $(MAKE) download_metacubexd 88 | $(MAKE) download_zashboard 89 | 90 | @echo "clean tmp" 91 | @rm -f ./tmp/*.zip 92 | 93 | download_yacd: 94 | @echo "+ $@" 95 | @wget -O ./tmp/yacd.zip https://github.com/haishanh/yacd/archive/refs/heads/gh-pages.zip 96 | @unzip -o ./tmp/yacd.zip -d ./tmp 97 | @mv ./tmp/yacd-gh-pages ./tmp/core/web/yacd 98 | 99 | download_yacd_meta: 100 | @echo "+ $@" 101 | @wget -O ./tmp/yacd-meta.zip https://github.com/MetaCubeX/yacd/archive/gh-pages.zip 102 | @unzip -o ./tmp/yacd-meta.zip -d ./tmp 103 | @mv ./tmp/Yacd-meta-gh-pages ./tmp/core/web/yacd-meta 104 | 105 | download_metacubexd: 106 | @echo "+ $@" 107 | @wget -O ./tmp/metacubexd.zip https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip 108 | @unzip -o ./tmp/metacubexd.zip -d ./tmp 109 | @mv ./tmp/metacubexd-gh-pages ./tmp/core/web/metacubexd 110 | 111 | download_zashboard: 112 | @echo "+ $@" 113 | @wget -O ./tmp/zashboard.zip https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip 114 | @unzip -o ./tmp/zashboard.zip -d ./tmp 115 | @mv ./tmp/dist ./tmp/core/web/zashboard 116 | 117 | 118 | download_rules: 119 | @echo "+ $@" 120 | @mkdir -p ./assets/subconverter_rules 121 | @rm -rf ./assets/subconverter_rules/*.list* 122 | @bash ./assets/subconverter_rules/dl_rules.sh ./assets/subconverter_rules 123 | 124 | download_subconverter: 125 | @echo "+ $@" 126 | @mkdir -p ./tmp/subconverter 127 | @mkdir -p ./tmp/subconverter_tmp 128 | @wget -O subconverter_linux64.tar.gz https://github.com/MetaCubeX/subconverter/releases/download/Alpha/subconverter_linux64.tar.gz 129 | @tar xvf subconverter_linux64.tar.gz -C ./tmp/subconverter_tmp/ 130 | @cp ./tmp/subconverter_tmp/subconverter/subconverter ./tmp/subconverter/ 131 | @rm -r ./tmp/subconverter_tmp 132 | 133 | upx_subconverter: ## Compress subconverter with UPX 134 | @echo "+ $@" 135 | @chmod +x ./tmp/subconverter/subconverter 136 | @upx ./tmp/subconverter/subconverter 137 | 138 | build-front: ## Build frontend 139 | @echo "+ $@" 140 | @pnpm run build 141 | @$(MAKE) build-front-sub 142 | @$(MAKE) copy-file 143 | 144 | build-front-sub: 145 | @echo "+ $@" 146 | @pnpm --prefix ./tomoon-web run build 147 | @mkdir -p ./web/rules 148 | @cp -r ./tomoon-web/dist/* ./web 149 | @cp -r ./assets/subconverter_rules/*.list ./web/rules 150 | @cp -r ./assets/subconverter_rules/ACL4SSR_Online.ini ./web/ 151 | 152 | copy-file: 153 | @echo "+ $@" 154 | @mkdir -p ./bin 155 | @rm -rf ./bin/core 156 | @cp -rv ./tmp/core ./bin/ 157 | @cp ./tmp/subconverter/subconverter ./bin/subconverter 158 | 159 | build-back: ## Build backend 160 | @echo "+ $@" 161 | @make -C ./backend 162 | 163 | build: ## Build everything 164 | @$(MAKE) build-front build-back 165 | 166 | copy-ssh-key: ## Copy public ssh key to steamdeck 167 | @echo "+ $@" 168 | @ssh-copy-id -i $(DECK_KEY) $(DECK_USER)@$(DECK_HOST) 169 | 170 | deploy-steamdeck: ## Deploy plugin build to steamdeck 171 | @echo "+ $@" 172 | @ssh $(DECK_USER)@$(DECK_HOST) -p $(DECK_PORT) -i $(DECK_KEY) \ 173 | 'chmod -v 755 $(DECK_HOME)/homebrew/plugins/ && mkdir -p $(DECK_HOME)/homebrew/plugins/$(PLUGIN_FOLDER)' 174 | @rsync -azp --delete --progress -e "ssh -p $(DECK_PORT) -i $(DECK_KEY)" \ 175 | --chmod=Du=rwx,Dg=rx,Do=rx,Fu=rwx,Fg=rx,Fo=rx \ 176 | --exclude='.git/' \ 177 | --exclude='.github/' \ 178 | --exclude='.vscode/' \ 179 | --exclude='node_modules/' \ 180 | --exclude='.pnpm-store/' \ 181 | --exclude='src/' \ 182 | --exclude='tomoon-web/' \ 183 | --exclude='backend/' \ 184 | --exclude='tmp/' \ 185 | --exclude='*.log' \ 186 | --exclude='.gitignore' . \ 187 | --exclude='.idea' . \ 188 | --exclude='.env' . \ 189 | --exclude='Makefile' . \ 190 | --exclude='usdpl/' \ 191 | --exclude='./assets/' \ 192 | ./ $(DECK_USER)@$(DECK_HOST):$(DECK_HOME)/homebrew/plugins/$(PLUGIN_FOLDER)/ 193 | @ssh $(DECK_USER)@$(DECK_HOST) -p $(DECK_PORT) -i $(DECK_KEY) \ 194 | 'chmod -v 755 $(DECK_HOME)/homebrew/plugins/' 195 | 196 | restart-decky: ## Restart Decky on remote steamdeck 197 | @echo "+ $@" 198 | @ssh -t $(DECK_USER)@$(DECK_HOST) -p $(DECK_PORT) -i $(DECK_KEY) \ 199 | 'sudo systemctl restart plugin_loader.service' 200 | @echo -e '\033[0;32m+ all is good, restarting Decky...\033[0m' 201 | 202 | deploy: ## Deploy code to steamdeck and restart Decky 203 | @$(MAKE) deploy-steamdeck 204 | @$(MAKE) restart-decky 205 | 206 | it: ## Build all code, deploy it to steamdeck, restart Decky 207 | @$(MAKE) build deploy 208 | 209 | cleanup: ## Delete all generated files and folders 210 | @echo "+ $@" 211 | @rm -f .env 212 | @rm -rf ./dist 213 | @rm -rf ./tmp 214 | @rm -rf ./node_modules 215 | @rm -rf ./.pnpm-store 216 | @rm -rf ./backend/out 217 | 218 | uninstall-plugin: ## Uninstall plugin from steamdeck, restart Decky 219 | @echo "+ $@" 220 | @ssh -t $(DECK_USER)@$(DECK_HOST) -p $(DECK_PORT) -i $(DECK_KEY) \ 221 | "sudo sh -c 'rm -rf $(DECK_HOME)/homebrew/plugins/$(PLUGIN_FOLDER)/ && systemctl restart plugin_loader.service'" 222 | @echo -e '\033[0;32m+ all is good, restarting Decky...\033[0m' 223 | 224 | docker-rebuild-image: ## Rebuild docker image 225 | @echo "+ $@" 226 | @docker compose build --pull 227 | 228 | docker-build: ## Build project inside docker container 229 | @$(MAKE) build-back 230 | @echo "+ $@" 231 | @docker run --rm -i -v $(PWD):/plugin -v $(PWD)/tmp/out:/out ghcr.io/steamdeckhomebrew/builder:latest 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ToMoon 2 | ## 功能 3 | **使用 ToMoon,让您在恶劣的网络环境下可以打开任何界面,体验到完整的 Steam Deck** 4 | 1. 提供开箱即用的 Clash SteamOS 客户端,由 Rust 驱动 5 | 2. 自动配置 FAKE-IP 模式,让游戏流量通过 TCP / UDP 加速 6 | 3. 基于 [yacd](https://github.com/MetaCubeX/Yacd-meta) 的策略管理仪表盘 7 | 8 | ## 安装 9 | 如果已经安装过 Plugin Loader 2.0 以上版本,直接从第 8 点开始即可。 10 | 11 | 1. 打开到 Steam Deck 设置界面 12 | 2. 系统 -> 系统设置 -> 打开开发者模式 13 | 3. 回到设置向下翻,找到开发者 -> 打开 CEF 远程调试 14 | 4. 等待 Steam Deck 重启 15 | 5. 按电源键切换到 Desktop 桌面模式 16 | 6. 打开 Konsole,如果事先没有创建过终端密码,使用 `passwd` 设置你的密码 17 | 7. 输入 `curl -L http://dl.ohmydeck.net | sh` 安装 Plugin Loader 18 | 8. 输入 `curl -L http://i.ohmydeck.net | sh` 安装 Tomoon 19 | 9. 切换回到 Gamming 游戏模式,按下右侧摇杆下的快捷按钮(三个点的按钮),可以看到多了一个 Decky 插件面板 20 | 21 | ## 使用 22 | 1. 打开 Manage Subscriptions,添加你服务商提供的 Clash 订阅链接并下载 23 | > **若使用扫描二维码添加订阅功能,请确保手机和 Steam Deck 在同一局域网下** 24 | 25 | > 如果需要添加本地文件,使用 `file://` 加绝对路径作为下载链接填入即可,如 `file:///home/deck/config.yaml` 26 | 2. 下载完成后,切换回主界面选择订阅并点击启动 27 | 3. 在桌面模式可通过浏览器 http://127.0.0.1:9090/ui 打开仪表盘 28 | 29 | ## 演示 30 | ![Gamming](https://github.com/YukiCoco/StaticFilesCDN/blob/main/deck_gaming.jpg?raw=true) 31 | ![Dashboard](https://github.com/YukiCoco/StaticFilesCDN/blob/main/deck_dashboard2.jpg?raw=true) 32 | ![Subs](https://github.com/YukiCoco/StaticFilesCDN/blob/main/deck_subs.jpg?raw=true) 33 | 34 | ## 支持 35 | 加入我们的讨论社群,提交 Bug & Feature Request 36 | [Telegram Group](https://t.me/steamdecktalk) 37 | ## 已知 BUG 38 | 当 SteamOS 系统更新等某些外部原因导致 Decky Loader 失效,ToMoon 没有正确关闭 Clash,会出现**无法上网**的情况。此时请进入桌面模式,使用 Konsole 复原 DNS. 39 | ````shell 40 | sudo chattr -i /etc/resolv.conf 41 | sudo systemctl stop systemd-resolved 42 | sudo chmod a+w /etc/NetworkManager/conf.d/dns.conf 43 | sudo echo -e "[main]\ndns=auto" > /etc/NetworkManager/conf.d/dns.conf 44 | sudo nmcli general reload 45 | ```` 46 | 如果安装的是 `v0.0.5` *(2022/11/18)* 以上版本,可以使用脚本直接恢复。 47 | ````shell 48 | bash ~/tomoon_recover.sh 49 | ```` 50 | 51 | ## Reference 52 | [decky-loader](https://github.com/SteamDeckHomebrew/decky-loader) 53 | [PowerTools](https://github.com/NGnius/PowerTools/) 54 | [usdpl-rs](https://github.com/NGnius/usdpl-rs) 55 | 56 | ## Sponsor 57 | [CloudFlare](https://www.cloudflare.com/) 58 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YukiCoco/ToMoon/697d7da6e6b95f5b50306a4a6e76a7a1d196b326/assets/logo.png -------------------------------------------------------------------------------- /assets/subconverter_rules/ACL4SSR_Online.ini: -------------------------------------------------------------------------------- 1 | [custom] 2 | ;不要随意改变关键字,否则会导致出错 3 | ;acl4SSR规则-在线更新版 4 | 5 | ;去广告:支持 6 | ;自动测速:支持 7 | ;微软分流:支持 8 | ;苹果分流:支持 9 | ;增强中国IP段:不支持 10 | ;增强国外GFW:不支持 11 | 12 | ruleset=🎮 Steam,http://127.0.0.1:55556/rules/Steam.list 13 | ruleset=✈️ Steam 中国下载CDN,http://127.0.0.1:55556/rules/SteamCN.list 14 | ruleset=❄️ Blizzard,http://127.0.0.1:55556/rules/Blizzard.list 15 | ruleset=🔥 Origin,http://127.0.0.1:55556/rules/Origin.list 16 | ruleset=🎮 Xbox,http://127.0.0.1:55556/rules/Xbox.list 17 | ruleset=🕹️ Epic,http://127.0.0.1:55556/rules/Epic.list 18 | ruleset=🎯 全球直连,http://127.0.0.1:55556/rules/LocalAreaNetwork.list 19 | ruleset=🎯 全球直连,http://127.0.0.1:55556/rules/UnBan.list 20 | ruleset=🎯 全球直连,http://127.0.0.1:55556/rules/GoogleCN.list 21 | ruleset=📲 电报信息,http://127.0.0.1:55556/rules/Telegram.list 22 | ruleset=🌍 国外媒体,http://127.0.0.1:55556/rules/ProxyMedia.list 23 | ruleset=🚀 节点选择,http://127.0.0.1:55556/rules/ProxyLite.list 24 | ruleset=🎯 全球直连,http://127.0.0.1:55556/rules/ChinaDomain.list 25 | ruleset=🎯 全球直连,http://127.0.0.1:55556/rules/ChinaCompanyIp.list 26 | ;ruleset=🎯 全球直连,[]GEOIP,LAN 27 | ruleset=🎯 全球直连,[]GEOIP,CN 28 | ruleset=🐟 漏网之鱼,[]FINAL 29 | 30 | custom_proxy_group=🚀 节点选择`select`[]♻️ 自动选择`[]DIRECT`.* 31 | custom_proxy_group=♻️ 自动选择`url-test`.*`http://www.gstatic.com/generate_204`300,,50 32 | custom_proxy_group=🎮 Steam`select`[]🚀 节点选择`[]🎯 全球直连`.* 33 | custom_proxy_group=✈️ Steam 中国下载CDN`select`[]DIRECT`[]🚀 节点选择`[]♻️ 自动选择 34 | custom_proxy_group=❄️ Blizzard`select`[]🚀 节点选择`[]🎯 全球直连`.* 35 | custom_proxy_group=🔥 Origin`select`[]🚀 节点选择`[]🎯 全球直连`.* 36 | custom_proxy_group=🎮 Xbox`select`[]🚀 节点选择`[]🎯 全球直连`.* 37 | custom_proxy_group=🕹️ Epic`select`[]🚀 节点选择`[]🎯 全球直连`.* 38 | custom_proxy_group=🌍 国外媒体`select`[]🚀 节点选择`[]♻️ 自动选择`[]🎯 全球直连`.* 39 | custom_proxy_group=📲 电报信息`select`[]🚀 节点选择`[]🎯 全球直连`.* 40 | custom_proxy_group=🎯 全球直连`select`[]DIRECT`[]🚀 节点选择`[]♻️ 自动选择 41 | custom_proxy_group=🐟 漏网之鱼`select`[]🚀 节点选择`[]🎯 全球直连`[]♻️ 自动选择`.* 42 | 43 | enable_rule_generator=true 44 | overwrite_original_rules=true -------------------------------------------------------------------------------- /assets/subconverter_rules/dl_rules.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 检查是否提供了下载路径 4 | if [ -z "$1" ]; then 5 | path="." 6 | else 7 | path="$1" 8 | fi 9 | 10 | # 检查路径是否存在,不存在则创建 11 | if [ ! -d "$path" ]; then 12 | mkdir -p "$path" 13 | fi 14 | 15 | # URLs to download 16 | urls=( 17 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/ChinaCompanyIp.list" 18 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/LocalAreaNetwork.list" 19 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/UnBan.list" 20 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/GoogleCN.list" 21 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Ruleset/SteamCN.list" 22 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Ruleset/Steam.list" 23 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Telegram.list" 24 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/ProxyMedia.list" 25 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/ProxyLite.list" 26 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/ChinaDomain.list" 27 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Ruleset/Blizzard.list" 28 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Ruleset/Origin.list" 29 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Ruleset/Xbox.list" 30 | "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Ruleset/Epic.list" 31 | ) 32 | 33 | # Download each file to the specified path 34 | for url in "${urls[@]}"; do 35 | wget -P "$path" "$url" 36 | done 37 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | bin 16 | 17 | config.yaml 18 | 19 | web/ -------------------------------------------------------------------------------- /backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tomoon" 3 | version = "0.2.8" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | usdpl-back = { version = "0.7.0", features = ["blocking"]} 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_json = "1.0" 12 | # logging 13 | log = "0.4" 14 | simplelog = "0.12" 15 | # yaml 16 | serde_yaml = "0.9" 17 | regex = "1.6" 18 | sysinfo = "0.26" 19 | minreq = { version = "2.6", features = ["https"]} 20 | rand = "0.8" 21 | actix-web = "4" 22 | actix-files = "0.6" 23 | env_logger = "0.10.0" 24 | local-ip-address = "0.5.1" 25 | actix-cors = "0.6.4" 26 | tokio = { version = "1.24.1", features = ["macros", "process"]} 27 | urlencoding = "2.1.3" -------------------------------------------------------------------------------- /backend/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: 3 | @echo "+ $@" 4 | @rustup default stable 5 | @cross build --release --target x86_64-unknown-linux-gnu 6 | # @cargo build --release --target x86_64-unknown-linux-gnu 7 | @mkdir -p ../bin 8 | @cp ./target/x86_64-unknown-linux-gnu/release/tomoon ../bin/tomoon 9 | 10 | 11 | .PHONY: clean 12 | clean: 13 | @echo "+ $@" 14 | @rm -rf ./target 15 | @rm -rf ../bin/tomoon -------------------------------------------------------------------------------- /backend/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | rustup default stable 5 | cross build --release 6 | mkdir -p ../bin 7 | cp ./target/x86_64-unknown-linux-gnu/release/tomoon ../bin/tomoon 8 | -------------------------------------------------------------------------------- /backend/src/api.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf, thread}; 2 | 3 | use crate::{ 4 | control::{DownloadStatus, RunningStatus}, 5 | helper, 6 | settings::{State, Subscription}, 7 | }; 8 | 9 | use super::control::ControlRuntime; 10 | 11 | use rand::{distributions::Alphanumeric, Rng}; 12 | use usdpl_back::core::serdes::Primitive; 13 | 14 | pub const VERSION: &'static str = env!("CARGO_PKG_VERSION"); 15 | pub const NAME: &'static str = env!("CARGO_PKG_NAME"); 16 | 17 | pub fn get_clash_status(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 18 | let runtime_settings = runtime.settings_clone(); 19 | move |_| { 20 | let mut lock = match runtime_settings.write() { 21 | Ok(x) => x, 22 | Err(e) => { 23 | log::error!("get_enable failed to acquire settings read lock: {}", e); 24 | return vec![]; 25 | } 26 | }; 27 | let is_clash_running = helper::is_clash_running(); 28 | if !is_clash_running && lock.enable 29 | //Clash 不在后台但设置里却表示打开 30 | { 31 | lock.enable = false; 32 | log::debug!( 33 | "Error occurred while Clash is not running in background but settings defined running." 34 | ); 35 | return vec![is_clash_running.into()]; 36 | } 37 | log::debug!("get_enable() success"); 38 | log::info!("get clash status with {}", is_clash_running); 39 | vec![is_clash_running.into()] 40 | } 41 | } 42 | 43 | pub fn set_clash_status(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 44 | let runtime_settings = runtime.settings_clone(); 45 | let runtime_state = runtime.state_clone(); 46 | let clash = runtime.clash_state_clone(); 47 | let running_status = runtime.running_status_clone(); 48 | move |params| { 49 | if let Some(Primitive::Bool(enabled)) = params.get(0) { 50 | let mut settings = match runtime_settings.write() { 51 | Ok(x) => x, 52 | Err(e) => { 53 | log::error!("set_enable failed to acquire settings write lock: {}", e); 54 | return vec![false.into()]; 55 | } 56 | }; 57 | log::info!("set clash status to {}", enabled); 58 | if settings.enable != *enabled { 59 | let mut clash = match clash.write() { 60 | Ok(x) => x, 61 | Err(e) => { 62 | log::error!("set_enable failed to acquire state write lock: {}", e); 63 | return vec![false.into()]; 64 | } 65 | }; 66 | let mut run_status = match running_status.write() { 67 | Ok(x) => x, 68 | Err(e) => { 69 | log::error!("set_enable failed to acquire run status write lock: {}", e); 70 | return vec![false.into()]; 71 | } 72 | }; 73 | *run_status = RunningStatus::Loading; 74 | // 有些时候第一次没有选择订阅 75 | if settings.current_sub == "" { 76 | log::info!("no profile provided, try to use first profile."); 77 | if let Some(sub) = settings.subscriptions.get(0) { 78 | settings.current_sub = sub.path.clone(); 79 | } else { 80 | log::error!("no profile provided."); 81 | *run_status = RunningStatus::Failed; 82 | return vec![false.into()]; 83 | } 84 | } 85 | if *enabled { 86 | match clash.run( 87 | &settings.current_sub, 88 | settings.skip_proxy, 89 | settings.override_dns, 90 | settings.allow_remote_access, 91 | settings.enhanced_mode, 92 | settings.dashboard.clone(), 93 | ) { 94 | Ok(_) => (), 95 | Err(e) => { 96 | log::error!("Run clash error: {}", e); 97 | *run_status = RunningStatus::Failed; 98 | return vec![false.into()]; 99 | } 100 | } 101 | } else { 102 | // Disable Clash 103 | match clash.stop() { 104 | Ok(_) => { 105 | log::info!("successfully disable clash"); 106 | } 107 | Err(e) => { 108 | log::error!("Disable clash error: {}", e); 109 | *run_status = RunningStatus::Failed; 110 | return vec![false.into()]; 111 | } 112 | } 113 | } 114 | settings.enable = *enabled; 115 | let mut state = match runtime_state.write() { 116 | Ok(x) => x, 117 | Err(e) => { 118 | log::error!("set_enable failed to acquire state write lock: {}", e); 119 | *run_status = RunningStatus::Failed; 120 | return vec![]; 121 | } 122 | }; 123 | state.dirty = true; 124 | *run_status = RunningStatus::Success; 125 | drop(run_status); 126 | log::debug!("set_enable({}) success", enabled); 127 | } 128 | vec![(*enabled).into()] 129 | } else { 130 | return vec![false.into()]; 131 | } 132 | } 133 | } 134 | 135 | pub fn reset_network() -> impl Fn(Vec) -> Vec { 136 | |_| { 137 | match helper::reset_system_network() { 138 | Ok(_) => (), 139 | Err(e) => { 140 | log::error!("Error occured while reset_network() : {}", e); 141 | return vec![]; 142 | } 143 | } 144 | log::info!("Successfully reset network"); 145 | return vec![]; 146 | } 147 | } 148 | 149 | pub fn download_sub(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 150 | let download_status = runtime.downlaod_status_clone(); 151 | let runtime_state = runtime.state_clone(); 152 | let runtime_setting = runtime.settings_clone(); 153 | move |params| { 154 | if let Some(Primitive::String(url)) = params.get(0) { 155 | match download_status.write() { 156 | Ok(mut x) => { 157 | let path = match runtime_state.read() { 158 | Ok(x) => x.home.as_path().join(".config/tomoon/subs/"), 159 | Err(e) => { 160 | log::error!("download_sub() faild to acquire state read {}", e); 161 | return vec![]; 162 | } 163 | }; 164 | *x = DownloadStatus::Downloading; 165 | //新线程复制准备 166 | let url = url.clone(); 167 | let download_status = download_status.clone(); 168 | let runtime_setting = runtime_setting.clone(); 169 | let runtime_state = runtime_state.clone(); 170 | //开始下载 171 | thread::spawn(move || { 172 | let update_status = |status: DownloadStatus| { 173 | //修改下载状态 174 | match download_status.write() { 175 | Ok(mut x) => { 176 | *x = status; 177 | } 178 | Err(e) => { 179 | log::error!( 180 | "download_sub() faild to acquire download_status write {}", 181 | e 182 | ); 183 | } 184 | } 185 | }; 186 | //是一个本地文件 187 | if let Some(local_file) = helper::get_file_path(url.clone()) { 188 | let local_file = PathBuf::from(local_file); 189 | if local_file.exists() { 190 | let file_content = match fs::read_to_string(local_file) { 191 | Ok(x) => x, 192 | Err(e) => { 193 | log::error!("Failed while creating sub dir."); 194 | log::error!("Error Message:{}", e); 195 | update_status(DownloadStatus::Error); 196 | return; 197 | } 198 | }; 199 | if !helper::check_yaml(&file_content) { 200 | log::error!( 201 | "The downloaded subscription is not a legal profile." 202 | ); 203 | update_status(DownloadStatus::Error); 204 | return; 205 | } 206 | //保存订阅 207 | let s: String = rand::thread_rng() 208 | .sample_iter(&Alphanumeric) 209 | .take(5) 210 | .map(char::from) 211 | .collect(); 212 | let path = path.join(s + ".yaml"); 213 | if let Some(parent) = path.parent() { 214 | if let Err(e) = std::fs::create_dir_all(parent) { 215 | log::error!("Failed while creating sub dir."); 216 | log::error!("Error Message:{}", e); 217 | update_status(DownloadStatus::Error); 218 | return; 219 | } 220 | } 221 | let path = path.to_str().unwrap(); 222 | if let Err(e) = fs::write(path, file_content) { 223 | log::error!("Failed while saving sub, path: {}", path); 224 | log::error!("Error Message:{}", e); 225 | return; 226 | } 227 | //修改下载状态 228 | log::info!("Download profile successfully."); 229 | update_status(DownloadStatus::Success); 230 | //存入设置 231 | match runtime_setting.write() { 232 | Ok(mut x) => { 233 | x.subscriptions 234 | .push(Subscription::new(path.to_string(), url.clone())); 235 | let mut state = match runtime_state.write() { 236 | Ok(x) => x, 237 | Err(e) => { 238 | log::error!("set_enable failed to acquire state write lock: {}", e); 239 | update_status(DownloadStatus::Error); 240 | return; 241 | } 242 | }; 243 | state.dirty = true; 244 | } 245 | Err(e) => { 246 | log::error!( 247 | "download_sub() faild to acquire runtime_setting write {}", 248 | e 249 | ); 250 | update_status(DownloadStatus::Error); 251 | } 252 | } 253 | } else { 254 | log::error!("Cannt found file {}", local_file.to_str().unwrap()); 255 | update_status(DownloadStatus::Error); 256 | return; 257 | } 258 | // 是一个链接 259 | } else { 260 | match minreq::get(url.clone()) 261 | .with_header( 262 | "User-Agent", 263 | format!("ToMoonClash/{}", env!("CARGO_PKG_VERSION")), 264 | ) 265 | .with_timeout(15) 266 | .send() 267 | { 268 | Ok(x) => { 269 | let response = x.as_str().unwrap(); 270 | if !helper::check_yaml(&String::from(response)) { 271 | log::error!( 272 | "The downloaded subscription is not a legal profile." 273 | ); 274 | update_status(DownloadStatus::Error); 275 | return; 276 | } 277 | let s: String = rand::thread_rng() 278 | .sample_iter(&Alphanumeric) 279 | .take(5) 280 | .map(char::from) 281 | .collect(); 282 | let path = path.join(s + ".yaml"); 283 | //保存订阅 284 | if let Some(parent) = path.parent() { 285 | if let Err(e) = std::fs::create_dir_all(parent) { 286 | log::error!("Failed while creating sub dir."); 287 | log::error!("Error Message:{}", e); 288 | update_status(DownloadStatus::Error); 289 | return; 290 | } 291 | } 292 | let path = path.to_str().unwrap(); 293 | if let Err(e) = fs::write(path, response) { 294 | log::error!("Failed while saving sub."); 295 | log::error!("Error Message:{}", e); 296 | } 297 | //下载成功 298 | //修改下载状态 299 | log::info!("Download profile successfully."); 300 | update_status(DownloadStatus::Success); 301 | //存入设置 302 | match runtime_setting.write() { 303 | Ok(mut x) => { 304 | x.subscriptions 305 | .push(Subscription::new(path.to_string(), url)); 306 | let mut state = match runtime_state.write() { 307 | Ok(x) => x, 308 | Err(e) => { 309 | log::error!("set_enable failed to acquire state write lock: {}", e); 310 | update_status(DownloadStatus::Error); 311 | return; 312 | } 313 | }; 314 | state.dirty = true; 315 | } 316 | Err(e) => { 317 | log::error!( 318 | "download_sub() faild to acquire runtime_setting write {}", 319 | e 320 | ); 321 | update_status(DownloadStatus::Error); 322 | } 323 | } 324 | } 325 | Err(e) => { 326 | log::error!("Failed while downloading sub."); 327 | log::error!("Error Message:{}", e); 328 | update_status(DownloadStatus::Failed); 329 | } 330 | }; 331 | } 332 | }); 333 | } 334 | Err(_) => { 335 | log::error!("download_sub() faild to acquire state write"); 336 | return vec![]; 337 | } 338 | } 339 | } else { 340 | } 341 | return vec![]; 342 | } 343 | } 344 | 345 | pub fn get_download_status(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 346 | let download_status = runtime.downlaod_status_clone(); 347 | move |_| { 348 | match download_status.read() { 349 | Ok(x) => { 350 | let status = x.to_string(); 351 | return vec![status.into()]; 352 | } 353 | Err(_) => { 354 | log::error!("Error occured while get_download_status()"); 355 | } 356 | } 357 | return vec![]; 358 | } 359 | } 360 | 361 | pub fn get_running_status(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 362 | let running_status = runtime.running_status_clone(); 363 | move |_| { 364 | match running_status.read() { 365 | Ok(x) => { 366 | let status = x.to_string(); 367 | return vec![status.into()]; 368 | } 369 | Err(_) => { 370 | log::error!("Error occured while get_running_status()"); 371 | } 372 | } 373 | return vec![]; 374 | } 375 | } 376 | 377 | pub fn get_sub_list(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 378 | let runtime_setting = runtime.settings_clone(); 379 | move |_| { 380 | match runtime_setting.read() { 381 | Ok(x) => { 382 | match serde_json::to_string(&x.subscriptions) { 383 | Ok(x) => { 384 | //返回 json 编码的订阅 385 | return vec![x.into()]; 386 | } 387 | Err(e) => { 388 | log::error!("Error while serializing data structures"); 389 | log::error!("Error message: {}", e); 390 | return vec![]; 391 | } 392 | }; 393 | } 394 | Err(e) => { 395 | log::error!( 396 | "get_sub_list() faild to acquire runtime_setting write {}", 397 | e 398 | ); 399 | } 400 | } 401 | return vec![]; 402 | } 403 | } 404 | 405 | // get_current_sub 获取当前订阅 406 | pub fn get_current_sub(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 407 | let runtime_setting = runtime.settings_clone(); 408 | move |_| { 409 | match runtime_setting.read() { 410 | Ok(x) => { 411 | return vec![x.current_sub.clone().into()]; 412 | } 413 | Err(e) => { 414 | log::error!("get_current_sub() faild , {}", e); 415 | } 416 | } 417 | return vec![]; 418 | } 419 | } 420 | 421 | pub fn delete_sub(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 422 | let runtime_setting = runtime.settings_clone(); 423 | let runtime_state = runtime.state_clone(); 424 | move |params| { 425 | if let Some(Primitive::F64(id)) = params.get(0) { 426 | match runtime_setting.write() { 427 | Ok(mut x) => { 428 | if let Some(item) = x.subscriptions.get(*id as usize) { 429 | match fs::remove_file(item.path.as_str()) { 430 | Ok(_) => {} 431 | Err(e) => { 432 | log::error!("delete file error: {}", e); 433 | } 434 | } 435 | } 436 | if let Some(item) = x.subscriptions.get(*id as usize) { 437 | if x.current_sub == item.path { 438 | x.current_sub = "".to_string(); 439 | } 440 | x.subscriptions.remove(*id as usize); 441 | } 442 | //log::info!("delete {:?}", x.subscriptions.get(*id as usize).unwrap()); 443 | drop(x); 444 | let mut state = match runtime_state.write() { 445 | Ok(x) => x, 446 | Err(e) => { 447 | log::error!("set_enable failed to acquire state write lock: {}", e); 448 | return vec![]; 449 | } 450 | }; 451 | state.dirty = true; 452 | } 453 | Err(e) => { 454 | log::error!("delete_sub() faild to acquire runtime_setting write {}", e); 455 | } 456 | } 457 | } 458 | return vec![]; 459 | } 460 | } 461 | 462 | pub fn set_sub(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 463 | let runtime_clash = runtime.clash_state_clone(); 464 | let runtime_state = runtime.state_clone(); 465 | let runtime_setting = runtime.settings_clone(); 466 | move |params: Vec| { 467 | if let Some(Primitive::String(path)) = params.get(0) { 468 | //更新到配置文件中 469 | match runtime_setting.write() { 470 | Ok(mut x) => { 471 | x.current_sub = (*path).clone(); 472 | let mut state = match runtime_state.write() { 473 | Ok(x) => x, 474 | Err(e) => { 475 | log::error!("set_sub failed to acquire state write lock: {}", e); 476 | return vec![]; 477 | } 478 | }; 479 | state.dirty = true; 480 | drop(x); 481 | drop(state); 482 | } 483 | Err(e) => { 484 | log::error!("get_enable failed to acquire settings read lock: {}", e); 485 | return vec![]; 486 | } 487 | }; 488 | //更新到当前内存中 489 | match runtime_clash.write() { 490 | Ok(mut x) => { 491 | x.update_config_path(path); 492 | log::info!("set profile path to {}", path); 493 | } 494 | Err(e) => { 495 | log::error!("set_sub() failed to acquire clash write lock: {}", e); 496 | } 497 | } 498 | } 499 | return vec![]; 500 | } 501 | } 502 | 503 | pub fn update_subs(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 504 | let runtime_update_status = runtime.update_status_clone(); 505 | let runtime_setting = runtime.settings_clone(); 506 | move |_| { 507 | if let Ok(mut x) = runtime_update_status.write() { 508 | *x = DownloadStatus::Downloading; 509 | drop(x); 510 | if let Ok(v) = runtime_setting.write() { 511 | let subs = v.subscriptions.clone(); 512 | drop(v); 513 | let runtime_update_status = runtime_update_status.clone(); 514 | thread::spawn(move || { 515 | for i in subs { 516 | //是一个本地文件 517 | if helper::get_file_path(i.url.clone()).is_some() { 518 | continue; 519 | } 520 | thread::spawn(move || { 521 | match minreq::get(i.url.clone()) 522 | .with_header( 523 | "User-Agent", 524 | format!( 525 | "ToMoon/{} mihomo/1.18.3 Clash/v1.18.0", 526 | env!("CARGO_PKG_VERSION") 527 | ), 528 | ) 529 | .with_timeout(15) 530 | .send() 531 | { 532 | Ok(response) => { 533 | let response = match response.as_str() { 534 | Ok(x) => x, 535 | Err(_) => { 536 | log::error!("Error occurred while parsing response."); 537 | return; 538 | } 539 | }; 540 | if !helper::check_yaml(&response.to_string()) { 541 | log::error!( 542 | "The downloaded subscription is not a legal profile." 543 | ); 544 | return; 545 | } 546 | match fs::write(i.path.clone(), response) { 547 | Ok(_) => { 548 | log::info!("Subscription {} updated.", i.path); 549 | } 550 | Err(e) => { 551 | log::error!( 552 | "Error occurred while write to file in update_subs(). {}", 553 | e 554 | ); 555 | return; 556 | } 557 | } 558 | } 559 | Err(e) => { 560 | log::error!("Error occurred while download sub {}", i.url); 561 | log::error!("Error Message : {}", e); 562 | } 563 | } 564 | }); 565 | } 566 | //下载执行完毕 567 | if let Ok(mut x) = runtime_update_status.write() { 568 | *x = DownloadStatus::Success; 569 | } else { 570 | log::error!( 571 | "Error occurred while acquire runtime_update_status write lock." 572 | ); 573 | } 574 | }); 575 | } 576 | } 577 | return vec![]; 578 | } 579 | } 580 | 581 | pub fn get_update_status(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 582 | let update_status = runtime.update_status_clone(); 583 | move |_| { 584 | match update_status.read() { 585 | Ok(x) => { 586 | let status = x.to_string(); 587 | return vec![status.into()]; 588 | } 589 | Err(_) => { 590 | log::error!("Error occured while get_update_status()"); 591 | } 592 | } 593 | return vec![]; 594 | } 595 | } 596 | 597 | pub fn create_debug_log(runtime: &ControlRuntime) -> impl Fn(Vec) -> Vec { 598 | //let update_status = runtime.update_status_clone(); 599 | let home = match runtime.state_clone().read() { 600 | Ok(state) => state.home.clone(), 601 | Err(_) => State::default().home, 602 | }; 603 | move |_| { 604 | let running_status = format!("Clash status : {}\n", helper::is_clash_running()); 605 | let tomoon_config = match fs::read_to_string(home.join(".config/tomoon/tomoon.json")) { 606 | Ok(x) => x, 607 | Err(e) => { 608 | format!("can not get Tomoon config, error message: {} \n", e) 609 | } 610 | }; 611 | let tomoon_log = match fs::read_to_string("/tmp/tomoon.log") { 612 | Ok(x) => x, 613 | Err(e) => { 614 | format!("can not get Tomoon log, error message: {} \n", e) 615 | } 616 | }; 617 | let clash_log = match fs::read_to_string("/tmp/tomoon.clash.log") { 618 | Ok(x) => x, 619 | Err(e) => { 620 | format!("can not get Clash log, error message: {} \n", e) 621 | } 622 | }; 623 | 624 | let log = format!( 625 | " 626 | {}\n 627 | ToMoon config:\n 628 | {}\n 629 | ToMoon log:\n 630 | {}\n 631 | Clash log:\n 632 | {}\n 633 | ", 634 | running_status, tomoon_config, tomoon_log, clash_log, 635 | ); 636 | fs::write("/tmp/tomoon.debug.log", log).unwrap(); 637 | return vec![true.into()]; 638 | } 639 | } 640 | -------------------------------------------------------------------------------- /backend/src/control.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::path::{Path, PathBuf}; 3 | use std::process::{Child, Command}; 4 | use std::sync::{Arc, RwLock}; 5 | 6 | use std::time::Duration; 7 | use std::{error, fs, thread}; 8 | 9 | use serde::{Deserialize, Serialize}; 10 | use serde_yaml::{Mapping, Value}; 11 | 12 | use super::helper; 13 | use super::settings::{Settings, State}; 14 | 15 | use serde_json::json; 16 | 17 | pub struct ControlRuntime { 18 | settings: Arc>, 19 | state: Arc>, 20 | clash_state: Arc>, 21 | downlaod_status: Arc>, 22 | update_status: Arc>, 23 | running_status: Arc>, 24 | } 25 | 26 | #[derive(Debug)] 27 | pub enum RunningStatus { 28 | Loading, 29 | Failed, 30 | Success, 31 | None, 32 | } 33 | 34 | #[derive(Debug)] 35 | pub enum DownloadStatus { 36 | Downloading, 37 | Failed, 38 | Success, 39 | Error, 40 | None, 41 | } 42 | 43 | #[derive(Debug, Serialize, Deserialize, Copy, Clone)] 44 | pub enum EnhancedMode { 45 | RedirHost, 46 | FakeIp, 47 | } 48 | 49 | impl std::fmt::Display for DownloadStatus { 50 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 51 | write!(f, "{:?}", self) 52 | // or, alternatively: 53 | // fmt::Debug::fmt(self, f) 54 | } 55 | } 56 | 57 | impl std::fmt::Display for RunningStatus { 58 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 59 | write!(f, "{:?}", self) 60 | // or, alternatively: 61 | // fmt::Debug::fmt(self, f) 62 | } 63 | } 64 | 65 | // pub struct DownloadStatus { 66 | 67 | // } 68 | 69 | impl ControlRuntime { 70 | pub fn new() -> Self { 71 | let new_state = State::new(); 72 | let settings_p = settings_path(&new_state.home); 73 | //TODO: Clash 路径 74 | let clash = Clash::default(); 75 | let download_status = DownloadStatus::None; 76 | let update_status = DownloadStatus::None; 77 | let running_status = RunningStatus::None; 78 | Self { 79 | settings: Arc::new(RwLock::new( 80 | super::settings::Settings::open(settings_p) 81 | .unwrap_or_default() 82 | .into(), 83 | )), 84 | state: Arc::new(RwLock::new(new_state)), 85 | clash_state: Arc::new(RwLock::new(clash)), 86 | downlaod_status: Arc::new(RwLock::new(download_status)), 87 | update_status: Arc::new(RwLock::new(update_status)), 88 | running_status: Arc::new(RwLock::new(running_status)), 89 | } 90 | } 91 | 92 | pub(crate) fn settings_clone(&self) -> Arc> { 93 | self.settings.clone() 94 | } 95 | 96 | pub(crate) fn state_clone(&self) -> Arc> { 97 | self.state.clone() 98 | } 99 | 100 | pub fn clash_state_clone(&self) -> Arc> { 101 | self.clash_state.clone() 102 | } 103 | 104 | pub fn downlaod_status_clone(&self) -> Arc> { 105 | self.downlaod_status.clone() 106 | } 107 | 108 | pub fn update_status_clone(&self) -> Arc> { 109 | self.update_status.clone() 110 | } 111 | 112 | pub fn running_status_clone(&self) -> Arc> { 113 | self.running_status.clone() 114 | } 115 | 116 | pub fn run(&self) -> thread::JoinHandle<()> { 117 | let runtime_settings = self.settings_clone(); 118 | let runtime_state = self.state_clone(); 119 | 120 | //health check 121 | //当程序上次异常退出时的处理 122 | if let Ok(mut v) = runtime_settings.write() { 123 | if !helper::is_clash_running() && v.enable { 124 | v.enable = false; 125 | drop(v); 126 | //刷新网卡 127 | match helper::reset_system_network() { 128 | Ok(_) => {} 129 | Err(e) => { 130 | log::error!("runtime failed to acquire settings write lock: {}", e); 131 | } 132 | } 133 | } 134 | } 135 | 136 | //save config 137 | thread::spawn(move || { 138 | let sleep_duration = Duration::from_millis(1000); 139 | loop { 140 | //let start_time = Instant::now(); 141 | { 142 | // save to file 143 | let state = match runtime_state.read() { 144 | Ok(x) => x, 145 | Err(e) => { 146 | log::error!("runtime failed to acquire state read lock: {}", e); 147 | continue; 148 | } 149 | }; 150 | if state.dirty { 151 | // save settings to file 152 | let settings = match runtime_settings.read() { 153 | Ok(x) => x, 154 | Err(e) => { 155 | log::error!("runtime failed to acquire settings read lock: {}", e); 156 | continue; 157 | } 158 | }; 159 | let settings_json: Settings = settings.clone().into(); 160 | if let Err(e) = settings_json.save(settings_path(&state.home)) { 161 | log::error!( 162 | "SettingsJson.save({}) error: {}", 163 | settings_path(&state.home).display(), 164 | e 165 | ); 166 | } 167 | //Self::on_set_enable(&settings, &state); 168 | drop(state); 169 | let mut state = match runtime_state.write() { 170 | Ok(x) => x, 171 | Err(e) => { 172 | log::error!("runtime failed to acquire state write lock: {}", e); 173 | continue; 174 | } 175 | }; 176 | state.dirty = false; 177 | } 178 | } 179 | thread::sleep(sleep_duration); 180 | } 181 | }) 182 | } 183 | } 184 | 185 | fn settings_path>(home: P) -> std::path::PathBuf { 186 | home.as_ref().join(".config/tomoon/tomoon.json") 187 | } 188 | 189 | fn get_current_working_dir() -> std::io::Result { 190 | std::env::current_dir() 191 | } 192 | 193 | fn get_decky_data_dir() -> std::io::Result { 194 | let data_dir = get_current_working_dir().unwrap().join("../../data/tomoon"); 195 | Ok(data_dir) 196 | } 197 | 198 | pub struct Clash { 199 | pub path: std::path::PathBuf, 200 | pub config: std::path::PathBuf, 201 | pub instence: Option, 202 | } 203 | 204 | #[derive(Debug, PartialEq, Eq)] 205 | pub enum ClashErrorKind { 206 | ConfigFormatError, 207 | ConfigNotFound, 208 | NetworkError, 209 | InnerError, 210 | Default, 211 | CpDbError, 212 | } 213 | 214 | #[derive(Debug)] 215 | pub struct ClashError { 216 | pub Message: String, 217 | pub ErrorKind: ClashErrorKind, 218 | } 219 | 220 | impl error::Error for ClashError {} 221 | 222 | impl Display for ClashError { 223 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 224 | write!( 225 | f, 226 | "Error Kind: {:?}, Error Message: {})", 227 | self.ErrorKind, self.Message 228 | ) 229 | } 230 | } 231 | 232 | impl ClashError { 233 | pub fn new() -> Self { 234 | Self { 235 | Message: "".to_string(), 236 | ErrorKind: ClashErrorKind::Default, 237 | } 238 | } 239 | } 240 | 241 | impl Default for Clash { 242 | fn default() -> Self { 243 | Self { 244 | path: get_current_working_dir().unwrap().join("bin/core/clash"), 245 | config: get_current_working_dir() 246 | .unwrap() 247 | .join("bin/core/config.yaml"), 248 | instence: None, 249 | } 250 | } 251 | } 252 | 253 | impl Clash { 254 | pub fn run( 255 | &mut self, 256 | config_path: &String, 257 | skip_proxy: bool, 258 | override_dns: bool, 259 | allow_remote_access: bool, 260 | enhanced_mode: EnhancedMode, 261 | dashboard: String, 262 | ) -> Result<(), ClashError> { 263 | // decky 插件数据目录 264 | let decky_data_dir = get_decky_data_dir().unwrap(); 265 | let new_country_db_path = get_current_working_dir() 266 | .unwrap() 267 | .join("bin/core/country.mmdb"); 268 | let new_asn_db_path = get_current_working_dir().unwrap().join("bin/core/asn.mmdb"); 269 | let new_geosite_path = get_current_working_dir() 270 | .unwrap() 271 | .join("bin/core/geosite.dat"); 272 | let country_db_path = decky_data_dir.join("country.mmdb"); 273 | let asn_db_path = decky_data_dir.join("asn.mmdb"); 274 | let geosite_path = decky_data_dir.join("geosite.dat"); 275 | 276 | // 检查 decky_data_dir 是否存在,不存在则创建 277 | if !decky_data_dir.exists() { 278 | fs::create_dir_all(&decky_data_dir).unwrap(); 279 | } 280 | 281 | // 检查数据库文件是否存在,不存在则复制 282 | if !PathBuf::from(country_db_path.clone()).is_file() { 283 | match fs::copy(new_country_db_path.clone(), country_db_path.clone()) { 284 | Ok(_) => { 285 | log::info!("Copy country.mmdb to decky data dir") 286 | } 287 | Err(e) => { 288 | return Err(ClashError { 289 | Message: e.to_string(), 290 | ErrorKind: ClashErrorKind::CpDbError, 291 | }); 292 | } 293 | } 294 | } 295 | 296 | if !PathBuf::from(asn_db_path.clone()).is_file() { 297 | match fs::copy(new_asn_db_path.clone(), asn_db_path.clone()) { 298 | Ok(_) => { 299 | log::info!("Copy asn.mmdb to decky data dir") 300 | } 301 | Err(e) => { 302 | return Err(ClashError { 303 | Message: e.to_string(), 304 | ErrorKind: ClashErrorKind::CpDbError, 305 | }); 306 | } 307 | } 308 | } 309 | 310 | if !PathBuf::from(geosite_path.clone()).is_file() { 311 | match fs::copy(new_geosite_path.clone(), geosite_path.clone()) { 312 | Ok(_) => { 313 | log::info!("Copy geosite.dat to decky data dir") 314 | } 315 | Err(e) => { 316 | return Err(ClashError { 317 | Message: e.to_string(), 318 | ErrorKind: ClashErrorKind::CpDbError, 319 | }); 320 | } 321 | } 322 | } 323 | 324 | self.update_config_path(config_path); 325 | // 修改配置文件为推荐配置 326 | match self.change_config( 327 | skip_proxy, 328 | override_dns, 329 | allow_remote_access, 330 | enhanced_mode, 331 | dashboard, 332 | ) { 333 | Ok(_) => (), 334 | Err(e) => { 335 | return Err(ClashError { 336 | Message: e.to_string(), 337 | ErrorKind: ClashErrorKind::ConfigFormatError, 338 | }); 339 | } 340 | } 341 | 342 | //log::info!("Pre-setting network"); 343 | //TODO: 未修改的 unwarp 344 | let run_config = self.get_running_config().unwrap(); 345 | let outputs = fs::File::create("/tmp/tomoon.clash.log").unwrap(); 346 | let errors = outputs.try_clone().unwrap(); 347 | 348 | log::info!("Starting Clash..."); 349 | 350 | let clash = Command::new(self.path.clone()) 351 | .arg("-d") 352 | .arg(decky_data_dir) 353 | .arg("-f") 354 | .arg(run_config) 355 | .stdout(outputs) 356 | .stderr(errors) 357 | .spawn(); 358 | let clash: Result = match clash { 359 | Ok(x) => Ok(x), 360 | Err(e) => { 361 | log::error!("run Clash failed: {}", e); 362 | //TODO: 开启 Clash 的错误处理 363 | return Err(ClashError::new()); 364 | } 365 | }; 366 | self.instence = Some(clash.unwrap()); 367 | Ok(()) 368 | } 369 | 370 | pub fn stop(&mut self) -> Result<(), Box> { 371 | let instance = self.instence.as_mut(); 372 | match instance { 373 | Some(x) => { 374 | x.kill()?; 375 | x.wait()?; 376 | 377 | //直接重置网络 378 | helper::reset_system_network()?; 379 | } 380 | None => { 381 | //Not launch Clash yet... 382 | log::error!("Error occurred while disabling Clash: Not launch Clash yet"); 383 | } 384 | }; 385 | Ok(()) 386 | } 387 | 388 | pub fn update_config_path(&mut self, path: &String) { 389 | self.config = std::path::PathBuf::from((*path).clone()); 390 | } 391 | 392 | pub fn get_running_config(&self) -> std::io::Result { 393 | let decky_data_dir = get_decky_data_dir().unwrap(); 394 | let run_config = decky_data_dir.join("running_config.yaml"); 395 | Ok(run_config) 396 | } 397 | 398 | pub async fn reload_config(&self) -> Result<(), ClashError> { 399 | let run_config = self.get_running_config().unwrap(); 400 | log::info!("Reloading Clash config, config: {}", run_config.display()); 401 | 402 | let url = "http://127.0.0.1:9090/configs?reload=true"; 403 | let body = json!({ 404 | "path": run_config, 405 | "payload": "" 406 | }); 407 | 408 | let body_str = serde_json::to_string(&body).unwrap(); 409 | 410 | let res = match minreq::put(url) 411 | .with_header("Content-Type", "application/json") 412 | .with_body(body_str) 413 | .send() 414 | { 415 | Ok(x) => x, 416 | Err(e) => { 417 | log::error!("Failed to restart Clash core: {}", e); 418 | return Err(ClashError { 419 | Message: e.to_string(), 420 | ErrorKind: ClashErrorKind::InnerError, 421 | }); 422 | } 423 | }; 424 | 425 | if res.status_code == 200 || res.status_code == 204 { 426 | log::info!("Clash config reloaded successfully"); 427 | } else { 428 | log::error!( 429 | "Failed to reload Clash config, status_code {}", 430 | res.status_code 431 | ); 432 | } 433 | 434 | Ok(()) 435 | } 436 | 437 | pub async fn restart_core(&self) -> Result<(), ClashError> { 438 | log::info!("Restarting Clash core..."); 439 | 440 | let url = "http://127.0.0.1:9090/restart"; 441 | let body = json!({ 442 | "payload": "" 443 | }); 444 | let body_str = serde_json::to_string(&body).unwrap(); 445 | 446 | let res = match minreq::post(url) 447 | .with_header("Content-Type", "application/json") 448 | .with_body(body_str) 449 | .send() 450 | { 451 | Ok(x) => x, 452 | Err(e) => { 453 | log::error!("Failed to restart Clash core: {}", e); 454 | return Err(ClashError { 455 | Message: e.to_string(), 456 | ErrorKind: ClashErrorKind::InnerError, 457 | }); 458 | } 459 | }; 460 | 461 | if res.status_code == 200 { 462 | log::info!("Clash restart successfully"); 463 | } else { 464 | let data = res.as_str().unwrap(); 465 | log::error!("Failed to restart Clash core: {}", data); 466 | } 467 | 468 | Ok(()) 469 | } 470 | 471 | pub fn change_config( 472 | &self, 473 | skip_proxy: bool, 474 | override_dns: bool, 475 | allow_remote_access: bool, 476 | enhanced_mode: EnhancedMode, 477 | dashboard: String, 478 | ) -> Result<(), Box> { 479 | let path = self.config.clone(); 480 | log::info!("change_config path: {:?}", path); 481 | 482 | let config = fs::read_to_string(path)?; 483 | let mut yaml: serde_yaml::Value = serde_yaml::from_str(config.as_str())?; 484 | let yaml = yaml.as_mapping_mut().unwrap(); 485 | 486 | log::info!("Changing Clash config..."); 487 | 488 | let external_ip = if allow_remote_access { 489 | "0.0.0.0" 490 | } else { 491 | "127.0.0.1" 492 | }; 493 | 494 | //修改 WebUI 495 | match yaml.get_mut("external-controller") { 496 | Some(x) => { 497 | *x = Value::String(String::from(format!("{}:9090", external_ip))); 498 | } 499 | None => { 500 | yaml.insert( 501 | Value::String(String::from("external-controller")), 502 | Value::String(String::from(format!("{}:9090", external_ip))), 503 | ); 504 | } 505 | } 506 | 507 | //修改 test.steampowered.com 508 | //这个域名用于 Steam Deck 网络连接验证,可以直连 509 | if let Some(x) = yaml.get_mut("rules") { 510 | let rules = x.as_sequence_mut().unwrap(); 511 | rules.insert( 512 | 0, 513 | Value::String(String::from("DOMAIN,test.steampowered.com,DIRECT")), 514 | ); 515 | 516 | if skip_proxy { 517 | rules.insert( 518 | 0, 519 | Value::String(String::from("DOMAIN-SUFFIX,cm.steampowered.com,DIRECT")), 520 | ); 521 | rules.insert( 522 | 0, 523 | Value::String(String::from("DOMAIN-SUFFIX,steamserver.net,DIRECT")), 524 | ); 525 | } 526 | } 527 | 528 | let webui_dir = get_current_working_dir()?.join("bin/core/web"); 529 | 530 | match yaml.get_mut("external-ui") { 531 | Some(x) => { 532 | //TODO: 修改 Web UI 的路径 533 | *x = Value::String(String::from(webui_dir.to_str().unwrap())); 534 | } 535 | None => { 536 | yaml.insert( 537 | Value::String(String::from("external-ui")), 538 | Value::String(String::from(webui_dir.to_str().unwrap())), 539 | ); 540 | } 541 | } 542 | 543 | // 修改 dashboard 名称 544 | match yaml.get_mut("external-ui-name") { 545 | Some(x) => { 546 | *x = Value::String(String::from(dashboard)); 547 | } 548 | None => { 549 | yaml.insert( 550 | Value::String(String::from("external-ui-name")), 551 | Value::String(String::from(dashboard)), 552 | ); 553 | } 554 | } 555 | 556 | //修改 TUN 和 DNS 配置 557 | 558 | let tun_config = " 559 | enable: true 560 | stack: system 561 | auto-route: true 562 | auto-detect-interface: true 563 | dns-hijack: 564 | - any:53 565 | "; 566 | 567 | //部分配置来自 https://www.xkww3n.cyou/2022/02/08/use-clash-dns-anti-dns-hijacking/ 568 | 569 | let dns_config_fakeip = " 570 | enable: true 571 | listen: 127.0.0.1:8853 572 | default-nameserver: 573 | - 223.5.5.5 574 | - 8.8.4.4 575 | ipv6: false 576 | enhanced-mode: fake-ip 577 | nameserver: 578 | - 119.29.29.29 579 | - 223.5.5.5 580 | - tls://223.5.5.5:853 581 | - tls://223.6.6.6:853 582 | fallback: 583 | - https://1.0.0.1/dns-query 584 | - https://public.dns.iij.jp/dns-query 585 | - tls://8.8.4.4:853 586 | fallback-filter: 587 | geoip: false 588 | ipcidr: 589 | - 240.0.0.0/4 590 | - 0.0.0.0/32 591 | - 127.0.0.1/32 592 | fake-ip-filter: 593 | - \"*.lan\" 594 | - \"*.localdomain\" 595 | - \"*.localhost\" 596 | - \"*.local\" 597 | - \"*.home.arpa\" 598 | - stun.*.* 599 | - stun.*.*.* 600 | - +.stun.*.* 601 | - +.stun.*.*.* 602 | - +.stun.*.*.*.* 603 | "; 604 | 605 | let dns_config_redir_host = " 606 | enable: true 607 | ipv6: false 608 | listen: 127.0.0.1:8853 609 | default-nameserver: 610 | - 223.5.5.5 611 | - 8.8.4.4 612 | enhanced-mode: redir-host 613 | nameserver: 614 | - 119.29.29.29 615 | - 223.5.5.5 616 | - tls://223.5.5.5:853 617 | - tls://223.6.6.6:853 618 | fallback: 619 | - https://1.0.0.1/dns-query 620 | - https://public.dns.iij.jp/dns-query 621 | - tls://8.8.4.4:853 622 | fallback-filter: 623 | geoip: false 624 | ipcidr: 625 | - 240.0.0.0/4 626 | - 0.0.0.0/32 627 | - 127.0.0.1/32 628 | "; 629 | 630 | let profile_config = " 631 | store-selected: true 632 | store-fake-ip: false 633 | "; 634 | 635 | let insert_config = |yaml: &mut Mapping, config: &str, key: &str| { 636 | let inner_config: Value = serde_yaml::from_str(config).unwrap(); 637 | yaml.insert(Value::String(String::from(key)), inner_config); 638 | }; 639 | 640 | //开启 tun 模式 641 | match yaml.get("tun") { 642 | Some(_) => { 643 | yaml.remove("tun").unwrap(); 644 | insert_config(yaml, tun_config, "tun"); 645 | } 646 | None => { 647 | insert_config(yaml, tun_config, "tun"); 648 | } 649 | } 650 | 651 | match yaml.get("dns") { 652 | Some(_) => { 653 | //删除 DNS 配置 654 | if override_dns { 655 | log::info!("EnhancedMode: {:?}", enhanced_mode); 656 | yaml.remove("dns").unwrap(); 657 | match enhanced_mode { 658 | EnhancedMode::FakeIp => { 659 | insert_config(yaml, dns_config_fakeip, "dns"); 660 | } 661 | EnhancedMode::RedirHost => { 662 | insert_config(yaml, dns_config_redir_host, "dns"); 663 | } 664 | } 665 | } 666 | } 667 | None => { 668 | insert_config(yaml, dns_config_fakeip, "dns"); 669 | } 670 | } 671 | 672 | // // 如果设置了 secret, 更改 secret 为 "tomoon" 673 | // let secret_config = "tomoon"; 674 | // match yaml.get("secret") { 675 | // Some(_) => { 676 | // yaml.remove("secret").unwrap(); 677 | // insert_config(yaml, secret_config, "secret"); 678 | // } 679 | // None => { 680 | // insert_config(yaml, secret_config, "secret"); 681 | // } 682 | // } 683 | 684 | // 保存上次的配置 685 | match yaml.get("profile") { 686 | Some(_) => { 687 | yaml.remove("profile").unwrap(); 688 | insert_config(yaml, profile_config, "profile"); 689 | } 690 | None => { 691 | insert_config(yaml, profile_config, "profile"); 692 | } 693 | } 694 | 695 | let run_config = self.get_running_config()?; 696 | 697 | let yaml_str = serde_yaml::to_string(&yaml)?; 698 | 699 | match fs::write(run_config, yaml_str) { 700 | Ok(_) => { 701 | log::info!("Clash config changed successfully"); 702 | } 703 | Err(e) => { 704 | log::error!("Error occurred while changing Clash config: {}", e); 705 | } 706 | } 707 | 708 | log::info!("Clash config changed successfully"); 709 | Ok(()) 710 | } 711 | 712 | pub fn get_running_secret(&self) -> Result> { 713 | let path = self.get_running_config()?; 714 | let content = std::fs::read_to_string(path)?; 715 | let yaml: serde_yaml::Value = serde_yaml::from_str(&content)?; 716 | 717 | match yaml.get("secret") { 718 | Some(secret) => { 719 | Ok(secret.as_str().unwrap_or("").to_string()) 720 | } 721 | None => Ok("".to_string()), 722 | } 723 | } 724 | } 725 | -------------------------------------------------------------------------------- /backend/src/external_web.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{body::BoxBody, web, HttpResponse, Result}; 2 | use local_ip_address::local_ip; 3 | use rand::{distributions::Alphanumeric, Rng}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::time::Duration; 6 | use std::{collections::HashMap, fs, path::PathBuf, sync::Mutex}; 7 | use tokio::net::TcpStream; 8 | use tokio::process::Command; 9 | use tokio::sync::mpsc; 10 | use tokio::time::sleep; 11 | 12 | use crate::{ 13 | control::{ClashError, ClashErrorKind, EnhancedMode}, 14 | helper, 15 | settings::State, 16 | }; 17 | 18 | pub struct Runtime(pub *const crate::control::ControlRuntime); 19 | unsafe impl Send for Runtime {} 20 | 21 | pub struct AppState { 22 | pub link_table: Mutex>, 23 | pub runtime: Mutex, 24 | } 25 | 26 | #[derive(Deserialize)] 27 | pub struct GenLinkParams { 28 | link: String, 29 | subconv: bool, 30 | } 31 | 32 | #[derive(Deserialize)] 33 | pub struct SkipProxyParams { 34 | skip_proxy: bool, 35 | } 36 | 37 | #[derive(Deserialize)] 38 | pub struct AllowRemoteAccessParams { 39 | allow_remote_access: bool, 40 | } 41 | 42 | #[derive(Deserialize)] 43 | pub struct OverrideDNSParams { 44 | override_dns: bool, 45 | } 46 | 47 | #[derive(Deserialize)] 48 | pub struct EnhancedModeParams { 49 | enhanced_mode: EnhancedMode, 50 | } 51 | 52 | #[derive(Deserialize)] 53 | pub struct DashboardParams { 54 | dashboard: String, 55 | } 56 | 57 | #[derive(Serialize, Deserialize)] 58 | pub struct GenLinkResponse { 59 | status_code: u16, 60 | message: String, 61 | } 62 | 63 | #[derive(Serialize, Deserialize)] 64 | pub struct SkipProxyResponse { 65 | status_code: u16, 66 | message: String, 67 | } 68 | 69 | #[derive(Serialize, Deserialize)] 70 | pub struct OverrideDNSResponse { 71 | status_code: u16, 72 | message: String, 73 | } 74 | 75 | #[derive(Serialize, Deserialize)] 76 | pub struct AllowRemoteAccessResponse { 77 | status_code: u16, 78 | message: String, 79 | } 80 | 81 | #[derive(Serialize, Deserialize)] 82 | pub struct DashboardResponse { 83 | status_code: u16, 84 | message: String, 85 | } 86 | 87 | #[derive(Deserialize)] 88 | pub struct GetLinkParams { 89 | code: u16, 90 | } 91 | #[derive(Serialize, Deserialize)] 92 | pub struct GetLinkResponse { 93 | status_code: u16, 94 | link: Option, 95 | } 96 | 97 | #[derive(Serialize, Deserialize)] 98 | pub struct GetConfigResponse { 99 | status_code: u16, 100 | skip_proxy: bool, 101 | override_dns: bool, 102 | enhanced_mode: EnhancedMode, 103 | allow_remote_access: bool, 104 | dashboard: String, 105 | secret: String, 106 | } 107 | 108 | #[derive(Serialize, Deserialize)] 109 | pub struct GetLocalIpAddressResponse { 110 | status_code: u16, 111 | ip: Option, 112 | } 113 | 114 | impl actix_web::ResponseError for ClashError { 115 | fn status_code(&self) -> actix_web::http::StatusCode { 116 | if self.ErrorKind == ClashErrorKind::ConfigNotFound { 117 | actix_web::http::StatusCode::NOT_FOUND 118 | } else { 119 | actix_web::http::StatusCode::INTERNAL_SERVER_ERROR 120 | } 121 | } 122 | 123 | fn error_response(&self) -> HttpResponse { 124 | let mut res = HttpResponse::new(self.status_code()); 125 | let mime = "text/plain; charset=utf-8"; 126 | res.headers_mut().insert( 127 | actix_web::http::header::CONTENT_TYPE, 128 | actix_web::http::header::HeaderValue::from_str(mime).unwrap(), 129 | ); 130 | res.set_body(BoxBody::new(self.Message.clone())) 131 | } 132 | } 133 | 134 | pub async fn skip_proxy( 135 | state: web::Data, 136 | params: web::Form, 137 | ) -> Result { 138 | let skip_proxy = params.skip_proxy.clone(); 139 | let runtime = state.runtime.lock().unwrap(); 140 | let runtime_settings; 141 | let runtime_state; 142 | unsafe { 143 | let runtime = runtime.0.as_ref().unwrap(); 144 | runtime_settings = runtime.settings_clone(); 145 | runtime_state = runtime.state_clone(); 146 | } 147 | match runtime_settings.write() { 148 | Ok(mut x) => { 149 | x.skip_proxy = skip_proxy; 150 | let mut state = match runtime_state.write() { 151 | Ok(x) => x, 152 | Err(e) => { 153 | log::error!("set_enable failed to acquire state write lock: {}", e); 154 | return Err(actix_web::Error::from(ClashError { 155 | Message: e.to_string(), 156 | ErrorKind: ClashErrorKind::InnerError, 157 | })); 158 | } 159 | }; 160 | state.dirty = true; 161 | } 162 | Err(e) => { 163 | log::error!("Failed while toggle skip Steam proxy."); 164 | log::error!("Error Message:{}", e); 165 | return Err(actix_web::Error::from(ClashError { 166 | Message: e.to_string(), 167 | ErrorKind: ClashErrorKind::ConfigNotFound, 168 | })); 169 | } 170 | } 171 | let r = SkipProxyResponse { 172 | message: "修改成功".to_string(), 173 | status_code: 200, 174 | }; 175 | Ok(HttpResponse::Ok().json(r)) 176 | } 177 | 178 | pub async fn override_dns( 179 | state: web::Data, 180 | params: web::Form, 181 | ) -> Result { 182 | let override_dns = params.override_dns.clone(); 183 | let runtime = state.runtime.lock().unwrap(); 184 | let runtime_settings; 185 | let runtime_state; 186 | unsafe { 187 | let runtime = runtime.0.as_ref().unwrap(); 188 | runtime_settings = runtime.settings_clone(); 189 | runtime_state = runtime.state_clone(); 190 | } 191 | match runtime_settings.write() { 192 | Ok(mut x) => { 193 | x.override_dns = override_dns; 194 | let mut state = match runtime_state.write() { 195 | Ok(x) => x, 196 | Err(e) => { 197 | log::error!("override_dns failed to acquire state write lock: {}", e); 198 | return Err(actix_web::Error::from(ClashError { 199 | Message: e.to_string(), 200 | ErrorKind: ClashErrorKind::InnerError, 201 | })); 202 | } 203 | }; 204 | state.dirty = true; 205 | } 206 | Err(e) => { 207 | log::error!("Failed while toggle override dns."); 208 | log::error!("Error Message:{}", e); 209 | return Err(actix_web::Error::from(ClashError { 210 | Message: e.to_string(), 211 | ErrorKind: ClashErrorKind::ConfigNotFound, 212 | })); 213 | } 214 | } 215 | let r = OverrideDNSResponse { 216 | message: "修改成功".to_string(), 217 | status_code: 200, 218 | }; 219 | Ok(HttpResponse::Ok().json(r)) 220 | } 221 | 222 | // allow_remote_access 223 | pub async fn allow_remote_access( 224 | state: web::Data, 225 | params: web::Form, 226 | ) -> Result { 227 | let allow_remote_access = params.allow_remote_access.clone(); 228 | let runtime = state.runtime.lock().unwrap(); 229 | let runtime_settings; 230 | let runtime_state; 231 | unsafe { 232 | let runtime = runtime.0.as_ref().unwrap(); 233 | runtime_settings = runtime.settings_clone(); 234 | runtime_state = runtime.state_clone(); 235 | } 236 | match runtime_settings.write() { 237 | Ok(mut x) => { 238 | x.allow_remote_access = allow_remote_access; 239 | let mut state = match runtime_state.write() { 240 | Ok(x) => x, 241 | Err(e) => { 242 | log::error!( 243 | "allow_remote_access failed to acquire state write lock: {}", 244 | e 245 | ); 246 | return Err(actix_web::Error::from(ClashError { 247 | Message: e.to_string(), 248 | ErrorKind: ClashErrorKind::InnerError, 249 | })); 250 | } 251 | }; 252 | state.dirty = true; 253 | } 254 | Err(e) => { 255 | log::error!("Failed while toggle allow_remote_access."); 256 | log::error!("Error Message:{}", e); 257 | return Err(actix_web::Error::from(ClashError { 258 | Message: e.to_string(), 259 | ErrorKind: ClashErrorKind::ConfigNotFound, 260 | })); 261 | } 262 | } 263 | let r = OverrideDNSResponse { 264 | message: "修改成功".to_string(), 265 | status_code: 200, 266 | }; 267 | Ok(HttpResponse::Ok().json(r)) 268 | } 269 | 270 | pub async fn enhanced_mode( 271 | state: web::Data, 272 | params: web::Form, 273 | ) -> Result { 274 | let enhanced_mode = params.enhanced_mode.clone(); 275 | let runtime = state.runtime.lock().unwrap(); 276 | let runtime_settings; 277 | let runtime_state; 278 | unsafe { 279 | let runtime = runtime.0.as_ref().unwrap(); 280 | runtime_settings = runtime.settings_clone(); 281 | runtime_state = runtime.state_clone(); 282 | } 283 | match runtime_settings.write() { 284 | Ok(mut x) => { 285 | x.enhanced_mode = enhanced_mode; 286 | let mut state = match runtime_state.write() { 287 | Ok(x) => x, 288 | Err(e) => { 289 | log::error!("enhanced_mode failed to acquire state write lock: {}", e); 290 | return Err(actix_web::Error::from(ClashError { 291 | Message: e.to_string(), 292 | ErrorKind: ClashErrorKind::InnerError, 293 | })); 294 | } 295 | }; 296 | state.dirty = true; 297 | } 298 | Err(e) => { 299 | log::error!("Failed while toggle enhanced mode."); 300 | log::error!("Error Message:{}", e); 301 | return Err(actix_web::Error::from(ClashError { 302 | Message: e.to_string(), 303 | ErrorKind: ClashErrorKind::ConfigNotFound, 304 | })); 305 | } 306 | } 307 | let r = OverrideDNSResponse { 308 | message: "修改成功".to_string(), 309 | status_code: 200, 310 | }; 311 | Ok(HttpResponse::Ok().json(r)) 312 | } 313 | 314 | // set_dashboard 315 | pub async fn set_dashboard( 316 | state: web::Data, 317 | params: web::Form, 318 | ) -> Result { 319 | let dashboard = params.dashboard.clone(); 320 | let runtime = state.runtime.lock().unwrap(); 321 | let runtime_settings; 322 | let runtime_state; 323 | unsafe { 324 | let runtime = runtime.0.as_ref().unwrap(); 325 | runtime_settings = runtime.settings_clone(); 326 | runtime_state = runtime.state_clone(); 327 | } 328 | 329 | match runtime_settings.write() { 330 | Ok(mut x) => { 331 | x.dashboard = dashboard; 332 | let mut state = match runtime_state.write() { 333 | Ok(x) => x, 334 | Err(e) => { 335 | log::error!("set_dashboard failed to acquire state write lock: {}", e); 336 | return Err(actix_web::Error::from(ClashError { 337 | Message: e.to_string(), 338 | ErrorKind: ClashErrorKind::InnerError, 339 | })); 340 | } 341 | }; 342 | state.dirty = true; 343 | } 344 | Err(e) => { 345 | log::error!("Failed while set dashboard."); 346 | log::error!("Error Message:{}", e); 347 | return Err(actix_web::Error::from(ClashError { 348 | Message: e.to_string(), 349 | ErrorKind: ClashErrorKind::ConfigNotFound, 350 | })); 351 | } 352 | } 353 | let r = DashboardResponse { 354 | message: "修改成功".to_string(), 355 | status_code: 200, 356 | }; 357 | Ok(HttpResponse::Ok().json(r)) 358 | } 359 | 360 | pub async fn restart_clash(state: web::Data) -> Result { 361 | let runtime = state.runtime.lock().unwrap(); 362 | // let runtime_settings; 363 | let clash_state; 364 | unsafe { 365 | let runtime = runtime.0.as_ref().unwrap(); 366 | // runtime_settings = runtime.settings_clone(); 367 | clash_state = runtime.clash_state_clone(); 368 | } 369 | 370 | let clash = match clash_state.write() { 371 | Ok(x) => x, 372 | Err(e) => { 373 | log::error!("read clash_state failed to acquire state write lock: {}", e); 374 | return Err(actix_web::Error::from(ClashError { 375 | Message: e.to_string(), 376 | ErrorKind: ClashErrorKind::InnerError, 377 | })); 378 | } 379 | }; 380 | 381 | // let settings = match runtime_settings.write() { 382 | // Ok(x) => x, 383 | // Err(e) => { 384 | // log::error!( 385 | // "read runtime_settings failed to acquire state write lock: {}", 386 | // e 387 | // ); 388 | // return Err(actix_web::Error::from(ClashError { 389 | // Message: e.to_string(), 390 | // ErrorKind: ClashErrorKind::InnerError, 391 | // })); 392 | // } 393 | // }; 394 | 395 | // match clash.change_config( 396 | // settings.skip_proxy, 397 | // settings.override_dns, 398 | // settings.allow_remote_access, 399 | // settings.enhanced_mode, 400 | // settings.dashboard.clone(), 401 | // ) { 402 | // Ok(_) => {} 403 | // Err(e) => { 404 | // log::error!("Failed while change clash config."); 405 | // log::error!("Error Message:{}", e); 406 | // return Err(actix_web::Error::from(ClashError { 407 | // Message: e.to_string(), 408 | // ErrorKind: ClashErrorKind::InnerError, 409 | // })); 410 | // } 411 | // } 412 | 413 | match clash.restart_core().await { 414 | Ok(_) => {} 415 | Err(e) => { 416 | log::error!("Failed while restart clash."); 417 | log::error!("Error Message:{}", e); 418 | return Err(actix_web::Error::from(ClashError { 419 | Message: e.to_string(), 420 | ErrorKind: ClashErrorKind::InnerError, 421 | })); 422 | } 423 | } 424 | 425 | let r = GenLinkResponse { 426 | message: "重启成功".to_string(), 427 | status_code: 200, 428 | }; 429 | Ok(HttpResponse::Ok().json(r)) 430 | } 431 | 432 | pub async fn reload_clash_config(state: web::Data) -> Result { 433 | let runtime = state.runtime.lock().unwrap(); 434 | let runtime_settings; 435 | let clash_state; 436 | unsafe { 437 | let runtime = runtime.0.as_ref().unwrap(); 438 | runtime_settings = runtime.settings_clone(); 439 | clash_state = runtime.clash_state_clone(); 440 | } 441 | 442 | let clash = match clash_state.write() { 443 | Ok(x) => x, 444 | Err(e) => { 445 | log::error!("read clash_state failed: {}", e); 446 | return Err(actix_web::Error::from(ClashError { 447 | Message: e.to_string(), 448 | ErrorKind: ClashErrorKind::InnerError, 449 | })); 450 | } 451 | }; 452 | 453 | let settings = match runtime_settings.write() { 454 | Ok(x) => x, 455 | Err(e) => { 456 | log::error!("read runtime_settings failed: {}", e); 457 | return Err(actix_web::Error::from(ClashError { 458 | Message: e.to_string(), 459 | ErrorKind: ClashErrorKind::InnerError, 460 | })); 461 | } 462 | }; 463 | 464 | match clash.change_config( 465 | settings.skip_proxy, 466 | settings.override_dns, 467 | settings.allow_remote_access, 468 | settings.enhanced_mode, 469 | settings.dashboard.clone(), 470 | ) { 471 | Ok(_) => {} 472 | Err(e) => { 473 | log::error!("Failed while change clash config."); 474 | log::error!("Error Message:{}", e); 475 | return Err(actix_web::Error::from(ClashError { 476 | Message: e.to_string(), 477 | ErrorKind: ClashErrorKind::InnerError, 478 | })); 479 | } 480 | } 481 | 482 | match clash.reload_config().await { 483 | Ok(_) => {} 484 | Err(e) => { 485 | log::error!("Failed while reload clash config."); 486 | log::error!("Error Message:{}", e); 487 | return Err(actix_web::Error::from(ClashError { 488 | Message: e.to_string(), 489 | ErrorKind: ClashErrorKind::InnerError, 490 | })); 491 | } 492 | } 493 | 494 | let r = GenLinkResponse { 495 | message: "重载成功".to_string(), 496 | status_code: 200, 497 | }; 498 | Ok(HttpResponse::Ok().json(r)) 499 | } 500 | 501 | pub async fn get_config(state: web::Data) -> Result { 502 | let runtime = state.runtime.lock().unwrap(); 503 | let runtime_settings; 504 | let clash_state; 505 | unsafe { 506 | let runtime = runtime.0.as_ref().unwrap(); 507 | runtime_settings = runtime.settings_clone(); 508 | clash_state = runtime.clash_state_clone(); 509 | } 510 | 511 | let clash = match clash_state.read() { 512 | Ok(x) => x, 513 | Err(e) => { 514 | log::error!("read clash_state failed: {}", e); 515 | return Err(actix_web::Error::from(ClashError { 516 | Message: e.to_string(), 517 | ErrorKind: ClashErrorKind::InnerError, 518 | })); 519 | } 520 | }; 521 | 522 | match runtime_settings.read() { 523 | Ok(x) => { 524 | let secret = match clash.get_running_secret() { 525 | Ok(s) => s, 526 | Err(_) => x.secret.clone(), 527 | }; 528 | 529 | let r = GetConfigResponse { 530 | skip_proxy: x.skip_proxy, 531 | override_dns: x.override_dns, 532 | allow_remote_access: x.allow_remote_access, 533 | enhanced_mode: x.enhanced_mode, 534 | dashboard: x.dashboard.clone(), 535 | secret: secret, 536 | status_code: 200, 537 | }; 538 | return Ok(HttpResponse::Ok().json(r)); 539 | } 540 | Err(e) => { 541 | log::error!("Failed while geting skip Steam proxy."); 542 | log::error!("Error Message:{}", e); 543 | return Err(actix_web::Error::from(ClashError { 544 | Message: e.to_string(), 545 | ErrorKind: ClashErrorKind::ConfigNotFound, 546 | })); 547 | } 548 | }; 549 | } 550 | 551 | pub async fn download_sub( 552 | state: web::Data, 553 | params: web::Form, 554 | ) -> Result { 555 | let mut url = params.link.clone(); 556 | let subconv = params.subconv.clone(); 557 | let runtime = state.runtime.lock().unwrap(); 558 | 559 | let runtime_settings; 560 | let runtime_state; 561 | unsafe { 562 | let runtime = runtime.0.as_ref().unwrap(); 563 | runtime_settings = runtime.settings_clone(); 564 | runtime_state = runtime.state_clone(); 565 | } 566 | 567 | let home = match runtime_state.read() { 568 | Ok(state) => state.home.clone(), 569 | Err(_) => State::default().home, 570 | }; 571 | let path: PathBuf = home.join(".config/tomoon/subs/"); 572 | 573 | //是一个本地文件 574 | if let Some(local_file) = helper::get_file_path(url.clone()) { 575 | let local_file = PathBuf::from(local_file); 576 | let filename = (|| -> Result { 577 | // 如果文件名可被读取则采用 578 | let mut filename = String::from(local_file.file_name().ok_or(())?.to_str().ok_or(())?); 579 | if !filename.to_lowercase().ends_with(".yaml") 580 | && !filename.to_lowercase().ends_with(".yml") 581 | { 582 | filename += ".yaml"; 583 | } 584 | Ok(filename) 585 | })() 586 | .unwrap_or({ 587 | log::warn!("The subscription does not have a proper file name."); 588 | // 否则采用随机名字 589 | rand::thread_rng() 590 | .sample_iter(&Alphanumeric) 591 | .take(5) 592 | .map(char::from) 593 | .collect::() 594 | + ".yaml" 595 | }); 596 | if local_file.exists() { 597 | let file_content = match fs::read_to_string(local_file) { 598 | Ok(x) => x, 599 | Err(e) => { 600 | log::error!("Failed while creating sub dir."); 601 | log::error!("Error Message:{}", e); 602 | return Err(actix_web::Error::from(ClashError { 603 | Message: e.to_string(), 604 | ErrorKind: ClashErrorKind::ConfigNotFound, 605 | })); 606 | } 607 | }; 608 | if !helper::check_yaml(&file_content) { 609 | log::error!("The downloaded subscription is not a legal profile."); 610 | return Err(actix_web::Error::from(ClashError { 611 | Message: "The downloaded subscription is not a legal profile.".to_string(), 612 | ErrorKind: ClashErrorKind::ConfigFormatError, 613 | })); 614 | } 615 | //保存订阅 616 | let path = path.join(filename); 617 | if let Some(parent) = path.parent() { 618 | if let Err(e) = std::fs::create_dir_all(parent) { 619 | log::error!("Failed while creating sub dir."); 620 | log::error!("Error Message:{}", e); 621 | return Err(actix_web::Error::from(ClashError { 622 | Message: e.to_string(), 623 | ErrorKind: ClashErrorKind::InnerError, 624 | })); 625 | } 626 | } 627 | let path = path.to_str().unwrap(); 628 | if let Err(e) = fs::write(path, file_content) { 629 | log::error!("Failed while saving sub, path: {}", path); 630 | log::error!("Error Message:{}", e); 631 | return Err(actix_web::Error::from(ClashError { 632 | Message: e.to_string(), 633 | ErrorKind: ClashErrorKind::InnerError, 634 | })); 635 | } 636 | //修改下载状态 637 | log::info!("Download profile successfully."); 638 | //存入设置 639 | match runtime_settings.write() { 640 | Ok(mut x) => { 641 | x.subscriptions.push(crate::settings::Subscription::new( 642 | path.to_string(), 643 | url.clone(), 644 | )); 645 | let mut state = match runtime_state.write() { 646 | Ok(x) => x, 647 | Err(e) => { 648 | log::error!("set_enable failed to acquire state write lock: {}", e); 649 | return Err(actix_web::Error::from(ClashError { 650 | Message: e.to_string(), 651 | ErrorKind: ClashErrorKind::InnerError, 652 | })); 653 | } 654 | }; 655 | state.dirty = true; 656 | } 657 | Err(e) => { 658 | log::error!( 659 | "download_sub() faild to acquire runtime_setting write {}", 660 | e 661 | ); 662 | return Err(actix_web::Error::from(ClashError { 663 | Message: e.to_string(), 664 | ErrorKind: ClashErrorKind::InnerError, 665 | })); 666 | } 667 | }; 668 | } else { 669 | log::error!("Cannt found file {}", local_file.to_str().unwrap()); 670 | return Err(actix_web::Error::from(ClashError { 671 | Message: format!("Cannt found file {}", local_file.to_str().unwrap()), 672 | ErrorKind: ClashErrorKind::InnerError, 673 | })); 674 | } 675 | // 是一个链接 676 | } else { 677 | if subconv { 678 | let base_url = "http://127.0.0.1:25500/sub"; 679 | let target = "clash"; 680 | let config = "http://127.0.0.1:55556/ACL4SSR_Online.ini"; 681 | 682 | // 对参数进行 URL 编码 683 | let encoded_url = urlencoding::encode(url.as_str()); 684 | let encoded_config = urlencoding::encode(config); 685 | 686 | // 构建请求 URL 687 | url = format!( 688 | "{}?target={}&url={}&insert=false&config={}&emoji=true&list=false&tfo=false&scv=true&fdn=false&expand=true&sort=false&new_name=true", 689 | base_url, target, encoded_url, encoded_config 690 | ); 691 | } 692 | match minreq::get(url.clone()) 693 | .with_header( 694 | "User-Agent", 695 | format!( 696 | "ToMoon/{} mihomo/1.18.3 Clash/v1.18.0", 697 | env!("CARGO_PKG_VERSION") 698 | ), 699 | ) 700 | .with_timeout(120) 701 | .send() 702 | { 703 | Ok(x) => { 704 | let response = x.as_str().unwrap(); 705 | 706 | if !helper::check_yaml(&String::from(response)) { 707 | log::error!("The downloaded subscription is not a legal profile."); 708 | return Err(actix_web::Error::from(ClashError { 709 | Message: "The downloaded subscription is not a legal profile.".to_string(), 710 | ErrorKind: ClashErrorKind::ConfigFormatError, 711 | })); 712 | } 713 | let filename = x.headers.get("content-disposition"); 714 | let filename = match filename { 715 | Some(x) => { 716 | let filename = x.split("filename=").collect::>()[1] 717 | .split(";") 718 | .collect::>()[0] 719 | .replace("\"", ""); 720 | filename.to_string() 721 | } 722 | None => { 723 | let slash_split = *url.split("/").collect::>().last().unwrap(); 724 | slash_split 725 | .split("?") 726 | .collect::>() 727 | .first() 728 | .unwrap() 729 | .to_string() 730 | } 731 | }; 732 | let filename = if filename.is_empty() { 733 | log::warn!("The downloaded subscription does not have a file name."); 734 | gen_random_name() 735 | } else { 736 | filename 737 | }; 738 | let filename = if filename.to_lowercase().ends_with(".yaml") 739 | || filename.to_lowercase().ends_with(".yml") 740 | { 741 | filename 742 | } else { 743 | filename + ".yaml" 744 | }; 745 | let mut path = path.join(filename); 746 | if fs::metadata(&path).is_ok() { 747 | path = path.parent().unwrap().join(gen_random_name() + ".yaml"); 748 | } 749 | //保存订阅 750 | if let Some(parent) = path.parent() { 751 | if let Err(e) = std::fs::create_dir_all(parent) { 752 | log::error!("Failed while creating sub dir."); 753 | log::error!("Error Message:{}", e); 754 | return Err(actix_web::Error::from(ClashError { 755 | Message: e.to_string(), 756 | ErrorKind: ClashErrorKind::InnerError, 757 | })); 758 | } 759 | } 760 | let path = path.to_str().unwrap(); 761 | log::info!("Writing to path: {}", path); 762 | if let Err(e) = fs::write(path, response) { 763 | log::error!("Failed while saving sub."); 764 | log::error!("Error Message:{}", e); 765 | return Err(actix_web::Error::from(ClashError { 766 | Message: e.to_string(), 767 | ErrorKind: ClashErrorKind::InnerError, 768 | })); 769 | } 770 | //下载成功 771 | //修改下载状态 772 | log::info!("Download profile successfully."); 773 | //存入设置 774 | match runtime_settings.write() { 775 | Ok(mut x) => { 776 | x.subscriptions 777 | .push(crate::settings::Subscription::new(path.to_string(), url)); 778 | let mut state = match runtime_state.write() { 779 | Ok(x) => x, 780 | Err(e) => { 781 | log::error!("set_enable failed to acquire state write lock: {}", e); 782 | return Err(actix_web::Error::from(ClashError { 783 | Message: e.to_string(), 784 | ErrorKind: ClashErrorKind::InnerError, 785 | })); 786 | } 787 | }; 788 | state.dirty = true; 789 | } 790 | Err(e) => { 791 | log::error!( 792 | "download_sub() faild to acquire runtime_setting write {}", 793 | e 794 | ); 795 | return Err(actix_web::Error::from(ClashError { 796 | Message: e.to_string(), 797 | ErrorKind: ClashErrorKind::InnerError, 798 | })); 799 | } 800 | } 801 | } 802 | Err(e) => { 803 | log::error!("Failed while downloading sub."); 804 | log::error!("Error Message:{}", e); 805 | return Err(actix_web::Error::from(ClashError { 806 | Message: e.to_string(), 807 | ErrorKind: ClashErrorKind::NetworkError, 808 | })); 809 | } 810 | }; 811 | } 812 | let r = GenLinkResponse { 813 | message: "下载成功".to_string(), 814 | status_code: 200, 815 | }; 816 | Ok(HttpResponse::Ok().json(r)) 817 | } 818 | 819 | fn gen_random_name() -> String { 820 | rand::thread_rng() 821 | .sample_iter(&Alphanumeric) 822 | .take(5) 823 | .map(char::from) 824 | .collect() 825 | } 826 | 827 | pub async fn get_link( 828 | state: web::Data, 829 | info: web::Query, 830 | ) -> Result> { 831 | let table = state.link_table.lock().unwrap(); 832 | let link = table.get(&info.code); 833 | match link { 834 | Some(x) => { 835 | let r = GetLinkResponse { 836 | link: Some((*x).clone()), 837 | status_code: 200, 838 | }; 839 | return Ok(web::Json(r)); 840 | } 841 | None => { 842 | let r = GetLinkResponse { 843 | link: None, 844 | status_code: 404, 845 | }; 846 | return Ok(web::Json(r)); 847 | } 848 | } 849 | } 850 | 851 | pub async fn get_local_web_address() -> Result { 852 | match local_ip() { 853 | Ok(x) => { 854 | let r = GetLocalIpAddressResponse { 855 | status_code: 200, 856 | ip: Some(x.to_string()), 857 | }; 858 | return Ok(HttpResponse::Ok().json(r)); 859 | } 860 | Err(_) => { 861 | let r = GetLocalIpAddressResponse { 862 | status_code: 404, 863 | ip: None, 864 | }; 865 | return Ok(HttpResponse::Ok().json(r)); 866 | } 867 | }; 868 | } 869 | -------------------------------------------------------------------------------- /backend/src/helper.rs: -------------------------------------------------------------------------------- 1 | use std::{path::Path, process::Command}; 2 | 3 | use regex::Regex; 4 | 5 | use std::fs; 6 | 7 | use sysinfo::{ProcessExt, System, SystemExt}; 8 | 9 | pub fn reset_system_network() -> Result<(), Box> { 10 | //读入程序的 DNS 11 | let default_config = "[main]\ndns=auto"; 12 | fs::write("/etc/NetworkManager/conf.d/dns.conf", default_config)?; 13 | // 修改 DNS 为可写 14 | Command::new("chattr") 15 | .arg("-i") 16 | .arg("/etc/resolv.conf") 17 | .spawn() 18 | .unwrap() 19 | .wait() 20 | .unwrap(); 21 | //fs::copy("./resolv.conf.bk", "/etc/resolv.conf")?; 22 | 23 | // 更新 NetworkManager 24 | Command::new("nmcli") 25 | .arg("general") 26 | .arg("reload") 27 | .spawn() 28 | .unwrap() 29 | .wait() 30 | .unwrap(); 31 | // match fs::copy("./resolv.conf.bk", "/etc/resolv.conf") { 32 | // Ok(_) => (), 33 | // Err(e) => { 34 | // log::error!("reset_network() error: {}", e); 35 | // return vec![]; 36 | // } 37 | // } 38 | log::info!("Successfully reset network"); 39 | Ok(()) 40 | } 41 | 42 | pub fn get_current_working_dir() -> std::io::Result { 43 | std::env::current_dir() 44 | } 45 | 46 | pub fn check_yaml(str: &String) -> bool { 47 | if let Ok(x) = serde_yaml::from_str::(str) { 48 | if let Some(v) = x.as_mapping() { 49 | if v.contains_key("rules") { 50 | return true; 51 | } else { 52 | return false; 53 | } 54 | } else { 55 | return false; 56 | } 57 | } else { 58 | return false; 59 | } 60 | } 61 | 62 | pub fn is_clash_running() -> bool { 63 | //关闭 systemd-resolved 64 | let mut sys = System::new_all(); 65 | sys.refresh_all(); 66 | for (_, process) in sys.processes() { 67 | if process.name() == "clash" { 68 | return true; 69 | } 70 | } 71 | return false; 72 | } 73 | 74 | pub fn get_file_path(url: String) -> Option { 75 | let r = Regex::new(r"^file://").unwrap(); 76 | if let Some(x) = r.find(url.clone().as_str()) { 77 | let file_path = url[x.end()..url.len()].to_string(); 78 | return Some(file_path); 79 | }; 80 | return None; 81 | } 82 | -------------------------------------------------------------------------------- /backend/src/main.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | mod control; 3 | mod external_web; 4 | mod helper; 5 | mod settings; 6 | mod test; 7 | 8 | use std::{collections::HashMap, sync::Mutex, thread}; 9 | 10 | use actix_cors::Cors; 11 | use actix_files as fs; 12 | use actix_web::{middleware, web, App, HttpServer}; 13 | use simplelog::{LevelFilter, WriteLogger}; 14 | use usdpl_back::Instance; 15 | 16 | use crate::{ 17 | control::{ControlRuntime, RunningStatus}, 18 | external_web::Runtime, 19 | }; 20 | 21 | const PORT: u16 = 55555; 22 | const WEB_PORT: u16 = 55556; 23 | 24 | #[actix_web::main] 25 | async fn main() -> Result<(), std::io::Error> { 26 | WriteLogger::init( 27 | #[cfg(debug_assertions)] 28 | { 29 | LevelFilter::Debug 30 | }, 31 | #[cfg(not(debug_assertions))] 32 | { 33 | LevelFilter::Info 34 | }, 35 | Default::default(), 36 | std::fs::File::create("/tmp/tomoon.log").unwrap(), 37 | ) 38 | .unwrap(); 39 | 40 | log::info!("Starting back-end ({} v{})", api::NAME, api::VERSION); 41 | log::info!("{}", std::env::current_dir().unwrap().to_str().unwrap()); 42 | println!("Starting back-end ({} v{})", api::NAME, api::VERSION); 43 | 44 | let runtime: ControlRuntime = control::ControlRuntime::new(); 45 | runtime.run(); 46 | 47 | let runtime_pr = Runtime(&runtime as *const ControlRuntime); 48 | 49 | thread::spawn(move || { 50 | Instance::new(PORT) 51 | .register("set_clash_status", api::set_clash_status(&runtime)) 52 | .register("get_clash_status", api::get_clash_status(&runtime)) 53 | .register("reset_network", api::reset_network()) 54 | .register("download_sub", api::download_sub(&runtime)) 55 | .register("get_download_status", api::get_download_status(&runtime)) 56 | .register("get_sub_list", api::get_sub_list(&runtime)) 57 | .register("get_current_sub", api::get_current_sub(&runtime)) 58 | .register("delete_sub", api::delete_sub(&runtime)) 59 | .register("set_sub", api::set_sub(&runtime)) 60 | .register("update_subs", api::update_subs(&runtime)) 61 | .register("get_update_status", api::get_update_status(&runtime)) 62 | .register("create_debug_log", api::create_debug_log(&runtime)) 63 | .register("get_running_status", api::get_running_status(&runtime)) 64 | .run_blocking() 65 | .unwrap(); 66 | }); 67 | 68 | // 启动一个 tokio 任务来运行 subconverter 69 | let subconverter_path = helper::get_current_working_dir() 70 | .unwrap() 71 | .join("bin/subconverter"); 72 | tokio::spawn(async move { 73 | if subconverter_path.exists() && subconverter_path.is_file() { 74 | let mut command = tokio::process::Command::new(subconverter_path); 75 | // 可以在这里添加命令行参数 76 | // command.arg("some_argument"); 77 | 78 | match command.spawn() { 79 | Ok(mut child) => { 80 | log::info!("Subconverter started with PID: {}", child.id().unwrap()); 81 | 82 | loop { 83 | tokio::select! { 84 | _ = child.wait() => { 85 | log::info!("Subconverter process exited."); 86 | break; 87 | } 88 | } 89 | } 90 | } 91 | Err(e) => log::error!("Failed to start subconverter: {}", e), 92 | } 93 | } else { 94 | log::error!( 95 | "Subconverter path does not exist or is not a file: {:?}", 96 | subconverter_path 97 | ); 98 | } 99 | }); 100 | 101 | let app_state = web::Data::new(external_web::AppState { 102 | link_table: Mutex::new(HashMap::new()), 103 | runtime: Mutex::new(runtime_pr), 104 | }); 105 | HttpServer::new(move || { 106 | let cors = Cors::default() 107 | .allow_any_origin() 108 | .allow_any_method() 109 | .allow_any_header(); 110 | App::new() 111 | .app_data(app_state.clone()) 112 | // enable logger 113 | .wrap(middleware::Logger::default()) 114 | .wrap(cors) 115 | .service( 116 | web::resource("/download_sub").route(web::post().to(external_web::download_sub)), 117 | ) 118 | .service(web::resource("/get_link").route(web::get().to(external_web::get_link))) 119 | .service( 120 | web::resource("/get_ip_address") 121 | .route(web::get().to(external_web::get_local_web_address)), 122 | ) 123 | .service(web::resource("/skip_proxy").route(web::post().to(external_web::skip_proxy))) 124 | .service( 125 | web::resource("/override_dns").route(web::post().to(external_web::override_dns)), 126 | ) 127 | .service( 128 | web::resource("/enhanced_mode").route(web::post().to(external_web::enhanced_mode)), 129 | ) 130 | .service(web::resource("/get_config").route(web::get().to(external_web::get_config))) 131 | //.service(web::resource("/manual").route(web::get().to(external_web.web_download_sub))) 132 | // allow_remote_access 133 | .service( 134 | web::resource("/allow_remote_access") 135 | .route(web::post().to(external_web::allow_remote_access)), 136 | ) 137 | // reload_clash_config 138 | .service( 139 | web::resource("/reload_clash_config") 140 | .route(web::get().to(external_web::reload_clash_config)), 141 | ) 142 | // restart_clash 143 | .service( 144 | web::resource("/restart_clash").route(web::get().to(external_web::restart_clash)), 145 | ) 146 | // set_dashboard 147 | .service( 148 | web::resource("/set_dashboard").route(web::post().to(external_web::set_dashboard)), 149 | ) 150 | // web 151 | .service( 152 | fs::Files::new("/", "./web") 153 | .index_file("index.html") 154 | .show_files_listing(), 155 | ) 156 | }) 157 | .bind(("0.0.0.0", WEB_PORT)) 158 | .unwrap() 159 | .run() 160 | .await 161 | } 162 | -------------------------------------------------------------------------------- /backend/src/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api; -------------------------------------------------------------------------------- /backend/src/settings.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::{fmt::Display, path::PathBuf}; 3 | 4 | use crate::helper; 5 | 6 | use crate::control::EnhancedMode; 7 | 8 | #[derive(Serialize, Deserialize, Clone)] 9 | pub struct Settings { 10 | #[serde(default = "default_enable")] 11 | pub enable: bool, 12 | #[serde(default = "default_skip_proxy")] 13 | pub skip_proxy: bool, 14 | #[serde(default = "default_override_dns")] 15 | pub override_dns: bool, 16 | #[serde(default = "default_enhanced_mode")] 17 | pub enhanced_mode: EnhancedMode, 18 | #[serde(default = "default_current_sub")] 19 | pub current_sub: String, 20 | #[serde(default = "default_subscriptions")] 21 | pub subscriptions: Vec, 22 | #[serde(default = "default_allow_remote_access")] 23 | pub allow_remote_access: bool, 24 | #[serde(default = "default_dashboard")] 25 | pub dashboard: String, 26 | #[serde(default = "default_secret")] 27 | pub secret: String, 28 | } 29 | 30 | fn default_skip_proxy() -> bool { 31 | true 32 | } 33 | 34 | fn default_enable() -> bool { 35 | false 36 | } 37 | 38 | fn default_override_dns() -> bool { 39 | true 40 | } 41 | 42 | fn default_allow_remote_access() -> bool { 43 | false 44 | } 45 | 46 | fn default_enhanced_mode() -> EnhancedMode { 47 | EnhancedMode::FakeIp 48 | } 49 | 50 | fn default_dashboard() -> String { 51 | "yacd-meta".to_string() 52 | } 53 | 54 | fn default_secret() -> String { 55 | "".to_string() 56 | } 57 | 58 | fn default_current_sub() -> String { 59 | let default_profile = helper::get_current_working_dir() 60 | .unwrap() 61 | .join("bin/core/config.yaml"); 62 | default_profile.to_string_lossy().to_string() 63 | } 64 | 65 | fn default_subscriptions() -> Vec { 66 | Vec::new() 67 | } 68 | 69 | #[derive(Serialize, Deserialize, Clone, Debug)] 70 | pub struct Subscription { 71 | pub path: String, 72 | pub url: String, 73 | } 74 | 75 | #[derive(Debug)] 76 | pub enum JsonError { 77 | Serde(serde_json::Error), 78 | Io(std::io::Error), 79 | } 80 | 81 | impl Display for JsonError { 82 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 83 | match self { 84 | Self::Serde(e) => (e as &dyn Display).fmt(f), 85 | Self::Io(e) => (e as &dyn Display).fmt(f), 86 | } 87 | } 88 | } 89 | 90 | impl Subscription { 91 | pub fn new(path: String, url: String) -> Self { 92 | Self { 93 | path: path, 94 | url: url, 95 | } 96 | } 97 | } 98 | 99 | #[derive(Debug)] 100 | pub struct State { 101 | pub home: PathBuf, 102 | pub dirty: bool, 103 | } 104 | 105 | impl State { 106 | pub fn new() -> Self { 107 | let def = Self::default(); 108 | if cfg!(debug_assertions) { 109 | return Self { 110 | home: "./tmp".into(), 111 | dirty: true, 112 | }; 113 | } 114 | Self { 115 | home: usdpl_back::api::dirs::home().unwrap_or(def.home), 116 | dirty: true, 117 | } 118 | } 119 | } 120 | 121 | impl Default for State { 122 | fn default() -> Self { 123 | Self { 124 | home: "/root".into(), 125 | dirty: true, 126 | } 127 | } 128 | } 129 | 130 | impl Settings { 131 | pub fn save>(&self, path: P) -> Result<(), JsonError> { 132 | let path = path.as_ref(); 133 | if let Some(parent) = path.parent() { 134 | std::fs::create_dir_all(parent).map_err(JsonError::Io)?; 135 | } 136 | let mut file = std::fs::File::create(path).map_err(JsonError::Io)?; 137 | serde_json::to_writer_pretty(&mut file, &self).map_err(JsonError::Serde) 138 | } 139 | 140 | pub fn open>(path: P) -> Result { 141 | let mut file = std::fs::File::open(path).map_err(JsonError::Io)?; 142 | serde_json::from_reader(&mut file).map_err(JsonError::Serde) 143 | } 144 | } 145 | 146 | impl Default for Settings { 147 | fn default() -> Self { 148 | let default_profile = helper::get_current_working_dir() 149 | .unwrap() 150 | .join("bin/core/config.yaml"); 151 | Self { 152 | enable: false, 153 | skip_proxy: true, 154 | override_dns: true, 155 | enhanced_mode: EnhancedMode::FakeIp, 156 | current_sub: default_profile.to_string_lossy().to_string(), 157 | subscriptions: Vec::new(), 158 | allow_remote_access: default_allow_remote_access(), 159 | dashboard: default_dashboard(), 160 | secret: default_secret(), 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /backend/src/test.rs: -------------------------------------------------------------------------------- 1 | mod tests { 2 | 3 | use crate::{control, helper}; 4 | use regex::Regex; 5 | use serde_yaml::{Mapping, Number, Value}; 6 | use std::{ 7 | fs, 8 | path::PathBuf, 9 | process::{Command, Stdio}, 10 | thread, 11 | time::Duration, 12 | }; 13 | 14 | use sysinfo::{Pid, ProcessExt, System, SystemExt}; 15 | 16 | #[test] 17 | fn check_systemd_resolved() {} 18 | 19 | #[test] 20 | fn it_works() { 21 | assert_eq!(2 + 3, 4); 22 | } 23 | 24 | #[test] 25 | fn read_dns() { 26 | assert_eq!(helper::is_clash_running(), true); 27 | } 28 | 29 | #[test] 30 | fn get_version() { 31 | let version = env!("CARGO_PKG_VERSION"); 32 | println!("{}",version); 33 | } 34 | 35 | #[test] 36 | fn run_clash() { 37 | //TODO: no such files 38 | println!("{}", std::env::current_dir().unwrap().to_str().unwrap()); 39 | // clash 40 | // .run(&String::from("/home/deck/.config/tomoon/subs/Ob3jZ.yaml")) 41 | // .unwrap(); 42 | } 43 | 44 | #[test] 45 | fn test_network() { 46 | let is_resolve_running = || { 47 | let mut sys = System::new_all(); 48 | // First we update all information of our `System` struct. 49 | sys.refresh_all(); 50 | for (_, process) in sys.processes() { 51 | if process.name() == "systemd-resolve" { 52 | return true; 53 | } 54 | } 55 | return false; 56 | }; 57 | assert_eq!(false, is_resolve_running()); 58 | } 59 | 60 | #[test] 61 | fn find_process() { 62 | let mut sys = System::new_all(); 63 | sys.refresh_all(); 64 | for (pid, process) in sys.processes() { 65 | if process.name() == "systemd-resolve" { 66 | println!("[{}] {} {:?}", pid, process.name(), process.disk_usage()); 67 | } 68 | } 69 | } 70 | 71 | // #[test] 72 | // fn test_yaml() { 73 | // println!("{}", std::env::current_dir().unwrap().to_str().unwrap()); 74 | // let mut clash = control::Clash::default(); 75 | // clash.change_config(true, true, true, true); 76 | // } 77 | 78 | #[test] 79 | fn regex_test() { 80 | let url = String::from("file:///home/dek/b.yaml"); 81 | if let Some(path) = helper::get_file_path(url) { 82 | println!("{}", path); 83 | } 84 | } 85 | 86 | fn fun_name(url: String) { 87 | let r = Regex::new(r"^file://").unwrap(); 88 | if let Some(x) = r.find(url.clone().as_str()) { 89 | let file_path = url[x.end()..url.len()].to_string(); 90 | println!("{}", file_path); 91 | }; 92 | } 93 | 94 | #[test] 95 | fn test_privider_path() { 96 | let test_yaml = "./Rules/IPfake.yaml"; 97 | let r = Regex::new(r"^\./").unwrap(); 98 | let result = r.replace(test_yaml, ""); 99 | let save_path = PathBuf::from("/root/.config/clash/").join(result.to_string()); 100 | println!("Rule-Provider {} updated.", save_path.display()); 101 | } 102 | 103 | #[test] 104 | fn test_rules_provider() { 105 | let path = "./bin/config.yaml"; 106 | let config = fs::read_to_string(path).unwrap(); 107 | let mut yaml: serde_yaml::Value = serde_yaml::from_str(config.as_str()).unwrap(); 108 | let yaml = yaml.as_mapping_mut().unwrap(); 109 | if let Some(x) = yaml.get_mut("rule-providers") { 110 | let provider = x.as_mapping().unwrap(); 111 | for (key, value) in provider { 112 | if let Some(url) = value.get("url") { 113 | if let Some(path) = value.get("path") { 114 | println!("{} {}", path.as_str().unwrap(), url.as_str().unwrap()); 115 | } 116 | } 117 | } 118 | } else { 119 | log::info!("no rule-providers found."); 120 | } 121 | } 122 | 123 | #[test] 124 | fn run_yaml() { 125 | let path = "./bin/config.yaml"; 126 | let config = fs::read_to_string(path).unwrap(); 127 | let mut yaml: serde_yaml::Value = serde_yaml::from_str(config.as_str()).unwrap(); 128 | let yaml = yaml.as_mapping_mut().unwrap(); 129 | 130 | //修改 WebUI 131 | 132 | match yaml.get_mut("external-controller") { 133 | Some(x) => { 134 | *x = Value::String(String::from("127.0.0.1:9090")); 135 | } 136 | None => { 137 | yaml.insert( 138 | Value::String(String::from("external-controller")), 139 | Value::String(String::from("127.0.0.1:9090")), 140 | ); 141 | } 142 | } 143 | 144 | match yaml.get_mut("external-ui") { 145 | Some(x) => { 146 | //TODO: 修改 Web UI 的路径 147 | *x = Value::String(String::from( 148 | "/home/deck/homebrew/plugins/tomoon/bin/core/web", 149 | )); 150 | } 151 | None => { 152 | yaml.insert( 153 | Value::String(String::from("external-controller")), 154 | Value::String(String::from( 155 | "/home/deck/homebrew/plugins/tomoon/bin/core/web", 156 | )), 157 | ); 158 | } 159 | } 160 | 161 | //修改 TUN 和 DNS 配置 162 | 163 | let tun_config = " 164 | enable: true 165 | stack: system 166 | auto-route: true 167 | auto-detect-interface: true 168 | "; 169 | 170 | // let dns_config = match helper::is_resolve_running() { 171 | // true => { 172 | // " 173 | // enable: true 174 | // listen: 0.0.0.0:5354 175 | // enhanced-mode: fake-ip 176 | // fake-ip-range: 198.18.0.1/16 177 | // nameserver: 178 | // - https://doh.pub/dns-query 179 | // - https://dns.alidns.com/dns-query 180 | // - '114.114.114.114' 181 | // - '223.5.5.5' 182 | // default-nameserver: 183 | // - 119.29.29.29 184 | // - 223.5.5.5 185 | // fallback: 186 | // - https://1.1.1.1/dns-query 187 | // - https://dns.google/dns-query 188 | // - https://doh.opendns.com/dns-query 189 | // - https://doh.pub/dns-query 190 | // fallback-filter: 191 | // geoip: true 192 | // geoip-code: CN 193 | // ipcidr: 194 | // - 240.0.0.0/4 195 | // " 196 | // } 197 | // false => { 198 | // " 199 | // enable: true 200 | // listen: 0.0.0.0:53 201 | // enhanced-mode: fake-ip 202 | // fake-ip-range: 198.18.0.1/16 203 | // nameserver: 204 | // - https://doh.pub/dns-query 205 | // - https://dns.alidns.com/dns-query 206 | // - '114.114.114.114' 207 | // - '223.5.5.5' 208 | // default-nameserver: 209 | // - 119.29.29.29 210 | // - 223.5.5.5 211 | // fallback: 212 | // - https://1.1.1.1/dns-query 213 | // - https://dns.google/dns-query 214 | // - https://doh.opendns.com/dns-query 215 | // - https://doh.pub/dns-query 216 | // fallback-filter: 217 | // geoip: true 218 | // geoip-code: CN 219 | // ipcidr: 220 | // - 240.0.0.0/4 221 | // " 222 | // } 223 | // }; 224 | 225 | //部分配置来自 https://www.xkww3n.cyou/2022/02/08/use-clash-dns-anti-dns-hijacking/ 226 | 227 | let insert_config = |yaml: &mut Mapping, config: &str, key: &str| { 228 | let inner_config: Value = serde_yaml::from_str(config).unwrap(); 229 | yaml.insert(Value::String(String::from(key)), inner_config); 230 | }; 231 | 232 | //开启 tun 模式 233 | match yaml.get("tun") { 234 | Some(_) => { 235 | yaml.remove("tun").unwrap(); 236 | insert_config(yaml, tun_config, "tun"); 237 | } 238 | None => { 239 | insert_config(yaml, tun_config, "tun"); 240 | } 241 | } 242 | 243 | // match yaml.get("dns") { 244 | // Some(_) => { 245 | // //删除 DNS 配置 246 | // yaml.remove("dns").unwrap(); 247 | // insert_config(yaml, dns_config, "dns"); 248 | // } 249 | // None => { 250 | // insert_config(yaml, dns_config, "dns"); 251 | // } 252 | // } 253 | 254 | let yaml_str = serde_yaml::to_string(&yaml).unwrap(); 255 | fs::write("./bin/config.new.yaml", yaml_str).unwrap(); 256 | } 257 | 258 | #[test] 259 | fn debug_log() { 260 | let running_status = format!( 261 | "Clash status : {} \n", 262 | helper::is_clash_running() 263 | ); 264 | let tomoon_log = match fs::read_to_string("/tmp/tomoon.log") { 265 | Ok(x) => x, 266 | Err(e) => { 267 | format!("can not find Tomoon log, error message: {} \n", e) 268 | } 269 | }; 270 | let clash_log = match fs::read_to_string("/tmp/tomoon.clash.log") { 271 | Ok(x) => x, 272 | Err(e) => { 273 | format!("can not find Clash log, error message: {} \n", e) 274 | } 275 | }; 276 | let dns_resolve_config = match fs::read_to_string("/etc/resolv.conf") { 277 | Ok(x) => x, 278 | Err(e) => { 279 | format!("can not find /etc/resolv.conf, error message: {} \n", e) 280 | } 281 | }; 282 | 283 | let network_config = match fs::read_to_string("/etc/NetworkManager/conf.d/dns.conf") { 284 | Ok(x) => x, 285 | Err(e) => { 286 | format!( 287 | "can not find /etc/NetworkManager/conf.d/dns.conf, error message: {} \n", 288 | e 289 | ) 290 | } 291 | }; 292 | 293 | let log = format!( 294 | " 295 | {}\n 296 | ToMoon log:\n 297 | {}\n 298 | Clash log:\n 299 | {}\n 300 | resolv log:\n 301 | {}\n 302 | network log:\n 303 | {}\n 304 | ", 305 | running_status, tomoon_log, clash_log, dns_resolve_config, network_config 306 | ); 307 | fs::write("/tmp/tomoon.debug.log", log).unwrap(); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /decky.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This module exposes various constants and helpers useful for decky plugins. 3 | 4 | * Plugin's settings and configurations should be stored under `DECKY_PLUGIN_SETTINGS_DIR`. 5 | * Plugin's runtime data should be stored under `DECKY_PLUGIN_RUNTIME_DIR`. 6 | * Plugin's persistent log files should be stored under `DECKY_PLUGIN_LOG_DIR`. 7 | 8 | Avoid writing outside of `DECKY_HOME`, storing under the suggested paths is strongly recommended. 9 | 10 | Some basic migration helpers are available: `migrate_any`, `migrate_settings`, `migrate_runtime`, `migrate_logs`. 11 | 12 | A logging facility `logger` is available which writes to the recommended location. 13 | """ 14 | 15 | __version__ = '1.0.0' 16 | 17 | import logging 18 | 19 | from typing import Any 20 | 21 | """ 22 | Constants 23 | """ 24 | 25 | HOME: str 26 | """ 27 | The home directory of the effective user running the process. 28 | Environment variable: `HOME`. 29 | If `root` was specified in the plugin's flags it will be `/root` otherwise the user whose home decky resides in. 30 | e.g.: `/home/deck` 31 | """ 32 | 33 | USER: str 34 | """ 35 | The effective username running the process. 36 | Environment variable: `USER`. 37 | It would be `root` if `root` was specified in the plugin's flags otherwise the user whose home decky resides in. 38 | e.g.: `deck` 39 | """ 40 | 41 | DECKY_VERSION: str 42 | """ 43 | The version of the decky loader. 44 | Environment variable: `DECKY_VERSION`. 45 | e.g.: `v2.5.0-pre1` 46 | """ 47 | 48 | DECKY_USER: str 49 | """ 50 | The user whose home decky resides in. 51 | Environment variable: `DECKY_USER`. 52 | e.g.: `deck` 53 | """ 54 | 55 | DECKY_USER_HOME: str 56 | """ 57 | The home of the user where decky resides in. 58 | Environment variable: `DECKY_USER_HOME`. 59 | e.g.: `/home/deck` 60 | """ 61 | 62 | DECKY_HOME: str 63 | """ 64 | The root of the decky folder. 65 | Environment variable: `DECKY_HOME`. 66 | e.g.: `/home/deck/homebrew` 67 | """ 68 | 69 | DECKY_PLUGIN_SETTINGS_DIR: str 70 | """ 71 | The recommended path in which to store configuration files (created automatically). 72 | Environment variable: `DECKY_PLUGIN_SETTINGS_DIR`. 73 | e.g.: `/home/deck/homebrew/settings/decky-plugin-template` 74 | """ 75 | 76 | DECKY_PLUGIN_RUNTIME_DIR: str 77 | """ 78 | The recommended path in which to store runtime data (created automatically). 79 | Environment variable: `DECKY_PLUGIN_RUNTIME_DIR`. 80 | e.g.: `/home/deck/homebrew/data/decky-plugin-template` 81 | """ 82 | 83 | DECKY_PLUGIN_LOG_DIR: str 84 | """ 85 | The recommended path in which to store persistent logs (created automatically). 86 | Environment variable: `DECKY_PLUGIN_LOG_DIR`. 87 | e.g.: `/home/deck/homebrew/logs/decky-plugin-template` 88 | """ 89 | 90 | DECKY_PLUGIN_DIR: str 91 | """ 92 | The root of the plugin's directory. 93 | Environment variable: `DECKY_PLUGIN_DIR`. 94 | e.g.: `/home/deck/homebrew/plugins/decky-plugin-template` 95 | """ 96 | 97 | DECKY_PLUGIN_NAME: str 98 | """ 99 | The name of the plugin as specified in the 'plugin.json'. 100 | Environment variable: `DECKY_PLUGIN_NAME`. 101 | e.g.: `Example Plugin` 102 | """ 103 | 104 | DECKY_PLUGIN_VERSION: str 105 | """ 106 | The version of the plugin as specified in the 'package.json'. 107 | Environment variable: `DECKY_PLUGIN_VERSION`. 108 | e.g.: `0.0.1` 109 | """ 110 | 111 | DECKY_PLUGIN_AUTHOR: str 112 | """ 113 | The author of the plugin as specified in the 'plugin.json'. 114 | Environment variable: `DECKY_PLUGIN_AUTHOR`. 115 | e.g.: `John Doe` 116 | """ 117 | 118 | DECKY_PLUGIN_LOG: str 119 | """ 120 | The path to the plugin's main logfile. 121 | Environment variable: `DECKY_PLUGIN_LOG`. 122 | e.g.: `/home/deck/homebrew/logs/decky-plugin-template/plugin.log` 123 | """ 124 | 125 | """ 126 | Migration helpers 127 | """ 128 | 129 | 130 | def migrate_any(target_dir: str, *files_or_directories: str) -> dict[str, str]: 131 | """ 132 | Migrate files and directories to a new location and remove old locations. 133 | Specified files will be migrated to `target_dir`. 134 | Specified directories will have their contents recursively migrated to `target_dir`. 135 | 136 | Returns the mapping of old -> new location. 137 | """ 138 | 139 | 140 | def migrate_settings(*files_or_directories: str) -> dict[str, str]: 141 | """ 142 | Migrate files and directories relating to plugin settings to the recommended location and remove old locations. 143 | Specified files will be migrated to `DECKY_PLUGIN_SETTINGS_DIR`. 144 | Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_SETTINGS_DIR`. 145 | 146 | Returns the mapping of old -> new location. 147 | """ 148 | 149 | 150 | def migrate_runtime(*files_or_directories: str) -> dict[str, str]: 151 | """ 152 | Migrate files and directories relating to plugin runtime data to the recommended location and remove old locations 153 | Specified files will be migrated to `DECKY_PLUGIN_RUNTIME_DIR`. 154 | Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_RUNTIME_DIR`. 155 | 156 | Returns the mapping of old -> new location. 157 | """ 158 | 159 | 160 | def migrate_logs(*files_or_directories: str) -> dict[str, str]: 161 | """ 162 | Migrate files and directories relating to plugin logs to the recommended location and remove old locations. 163 | Specified files will be migrated to `DECKY_PLUGIN_LOG_DIR`. 164 | Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_LOG_DIR`. 165 | 166 | Returns the mapping of old -> new location. 167 | """ 168 | 169 | 170 | """ 171 | Logging 172 | """ 173 | 174 | logger: logging.Logger 175 | """The main plugin logger writing to `DECKY_PLUGIN_LOG`.""" 176 | 177 | """ 178 | Event handling 179 | """ 180 | # TODO better docstring im lazy 181 | async def emit(event: str, *args: Any) -> None: 182 | """ 183 | Send an event to the frontend. 184 | """ -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import subprocess 4 | 5 | import dashboard 6 | import decky 7 | import update 8 | import utils 9 | from config import logger, setup_logger 10 | from settings import SettingsManager 11 | 12 | 13 | class Plugin: 14 | backend_proc = None 15 | 16 | # Asyncio-compatible long-running code, executed in a task when the plugin is loaded 17 | async def _main(self): 18 | logger = setup_logger() 19 | 20 | self.settings = SettingsManager( 21 | name="config", settings_directory=decky.DECKY_PLUGIN_SETTINGS_DIR 22 | ) 23 | 24 | utils.write_font_config() 25 | 26 | dashboard_list = dashboard.get_dashboard_list() 27 | logger.info(f"dashboard_list: {dashboard_list}") 28 | 29 | logger.info("Start Tomoon.") 30 | os.system("chmod -R a+x " + decky.DECKY_PLUGIN_DIR) 31 | # 切换到工作目录 32 | os.chdir(decky.DECKY_PLUGIN_DIR) 33 | self.backend_proc = subprocess.Popen([decky.DECKY_PLUGIN_DIR + "/bin/tomoon"]) 34 | while True: 35 | await asyncio.sleep(1) 36 | 37 | # Function called first during the unload process, utilize this to handle your plugin being removed 38 | async def _unload(self): 39 | logger.info("Stop Tomoon.") 40 | self.backend_proc.kill() 41 | utils.remove_font_config() 42 | pass 43 | 44 | async def get_settings(self): 45 | return self.settings.getSetting(CONFIG_KEY) 46 | 47 | async def set_settings(self, settings): 48 | self.settings.setSetting(CONFIG_KEY, settings) 49 | logger.info(f"save Settings: {settings}") 50 | return True 51 | 52 | async def get_config_value(self, key): 53 | return self.settings.getSetting(key) 54 | 55 | async def set_config_value(self, key, value): 56 | self.settings.setSetting(key, value) 57 | logger.info(f"save config: {key} : {value}") 58 | return True 59 | 60 | async def update_latest(self): 61 | logger.info("Updating latest") 62 | return update.update_latest() 63 | 64 | async def get_version(self): 65 | version = update.get_version() 66 | logger.info(f"Current version: {version}") 67 | return version 68 | 69 | async def get_latest_version(self): 70 | version = update.get_latest_version() 71 | logger.info(f"Latest version: {version}") 72 | return version 73 | 74 | async def get_dashboard_list(self): 75 | return dashboard.get_dashboard_list() 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tomoon", 3 | "version": "0.2.8", 4 | "description": "SteamOS Network Tools Box.", 5 | "type": "module", 6 | "scripts": { 7 | "preinstall": "cp -r usdpl src/", 8 | "build": "shx rm -rf dist && rollup -c", 9 | "watch": "rollup -c -w", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/SteamDeckHomebrew/decky-plugin-template.git" 15 | }, 16 | "keywords": [ 17 | "decky", 18 | "plugin", 19 | "plugin-template", 20 | "steam-deck", 21 | "deck" 22 | ], 23 | "author": "Sayo Kurisu ", 24 | "license": "GPL-3.0", 25 | "bugs": { 26 | "url": "https://github.com/SteamDeckHomebrew/decky-plugin-template/issues" 27 | }, 28 | "homepage": "https://github.com/SteamDeckHomebrew/decky-plugin-template#readme", 29 | "devDependencies": { 30 | "@decky/rollup": "^1.0.1", 31 | "@decky/ui": "^4.9.1", 32 | "@rollup/plugin-commonjs": "^28.0.2", 33 | "@rollup/plugin-json": "^6.1.0", 34 | "@rollup/plugin-node-resolve": "^16.0.0", 35 | "@rollup/plugin-replace": "^6.0.2", 36 | "@rollup/plugin-typescript": "^12.1.2", 37 | "@types/react": "19.0.8", 38 | "@types/react-dom": "^19.0.3", 39 | "@types/webpack": "^5.28.5", 40 | "rollup": "^4.32.1", 41 | "shx": "^0.3.4", 42 | "tslib": "^2.8.1", 43 | "typescript": "^5.7.3" 44 | }, 45 | "dependencies": { 46 | "@decky/api": "^1.1.2", 47 | "axios": "^1.7.9", 48 | "i18next": "^23.15.1", 49 | "qrcode.react": "^4.2.0", 50 | "react": "^19.0.0", 51 | "react-icons": "^5.4.0", 52 | "usdpl-front": "file:src/usdpl" 53 | }, 54 | "pnpm": { 55 | "peerDependencyRules": { 56 | "ignoreMissing": [ 57 | "react", 58 | "react-dom" 59 | ] 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "To Moon", 3 | "author": "Sayo Kurisu", 4 | "flags": ["root"], 5 | "api_version": 1, 6 | "publish": { 7 | "tags": ["root", "network"], 8 | "description": "SteamOS Network Tools Box.", 9 | "image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /py_modules/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def setup_logger(): 5 | logging.basicConfig( 6 | level=logging.INFO, 7 | filename="/tmp/tomoon.py.log", 8 | format="[%(asctime)s | %(filename)s:%(lineno)s:%(funcName)s] %(levelname)s: %(message)s", 9 | filemode="w+", 10 | force=True, 11 | ) 12 | return logging.getLogger() 13 | 14 | 15 | logger = setup_logger() 16 | 17 | # can be changed to logging.DEBUG for debugging issues 18 | logger.setLevel(logging.INFO) 19 | 20 | API_URL = "https://api.github.com/repos/YukiCoco/ToMoon/releases/latest" 21 | 22 | CONFIG_KEY = "tomoon" 23 | -------------------------------------------------------------------------------- /py_modules/dashboard.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import decky_plugin 5 | from config import logger 6 | 7 | defalut_dashboard = os.path.join( 8 | decky_plugin.DECKY_PLUGIN_DIR, "bin", "core", "dashboard", "yacd-meta" 9 | ) 10 | defalut_dashboard_list = [defalut_dashboard] 11 | 12 | 13 | def get_dashboard_list(): 14 | dashboard_list = [] 15 | 16 | try: 17 | # 遍历 dashboard_dir 下深度 1 的路径, 如果存在 xxx/index.html 则认为是一个 dashboard 18 | dashboard_dir_path = Path(f"{decky_plugin.DECKY_PLUGIN_DIR}/bin/core/web") 19 | 20 | for path in dashboard_dir_path.iterdir(): 21 | if path.is_dir() and (path / "index.html").exists(): 22 | dashboard_list.append(str(path)) 23 | 24 | custom_dashboard_dir_path = Path( 25 | f"{decky_plugin.DECKY_PLUGIN_SETTINGS_DIR}/dashboard" 26 | ) 27 | # 如果 custom_dashboard_dir_path 不存在 创建 28 | if not custom_dashboard_dir_path.is_dir(): 29 | custom_dashboard_dir_path.mkdir(parents=True) 30 | for path in custom_dashboard_dir_path.iterdir(): 31 | if path.is_dir() and (path / "index.html").exists(): 32 | dashboard_list.append(str(path)) 33 | 34 | logger.info(f"get_dashboard_list: {dashboard_list}") 35 | 36 | return dashboard_list 37 | except Exception as e: 38 | logger.error(f"error during get_dashboard_list: {e}") 39 | return defalut_dashboard_list 40 | -------------------------------------------------------------------------------- /py_modules/update.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | import ssl 5 | import stat 6 | import subprocess 7 | import urllib.request 8 | 9 | import decky 10 | from config import API_URL, logger 11 | from utils import get_env 12 | 13 | 14 | def recursive_chmod(path, perms): 15 | for dirpath, dirnames, filenames in os.walk(path): 16 | current_perms = os.stat(dirpath).st_mode 17 | os.chmod(dirpath, current_perms | perms) 18 | for filename in filenames: 19 | os.chmod(os.path.join(dirpath, filename), current_perms | perms) 20 | 21 | 22 | def update_latest(): 23 | downloaded_filepath = download_latest_build() 24 | 25 | if os.path.exists(downloaded_filepath): 26 | plugin_dir = decky.DECKY_PLUGIN_DIR 27 | 28 | try: 29 | logger.info(f"removing old plugin from {plugin_dir}") 30 | # add write perms to directory 31 | recursive_chmod(plugin_dir, stat.S_IWUSR) 32 | 33 | # remove old plugin 34 | shutil.rmtree(plugin_dir) 35 | except Exception as e: 36 | logger.error(f"ota error during removal of old plugin: {e}") 37 | 38 | try: 39 | logger.info(f"extracting ota file to {plugin_dir}") 40 | # extract files to decky plugins dir 41 | shutil.unpack_archive( 42 | downloaded_filepath, 43 | f"{decky.DECKY_USER_HOME}/homebrew/plugins", 44 | format="zip", 45 | ) 46 | 47 | # cleanup downloaded files 48 | os.remove(downloaded_filepath) 49 | except Exception as e: 50 | logger.error(f"error during ota file extraction {e}") 51 | 52 | logger.info("restarting plugin_loader.service") 53 | cmd = "systemctl restart plugin_loader.service" 54 | result = subprocess.run( 55 | cmd, 56 | shell=True, 57 | check=True, 58 | text=True, 59 | stdout=subprocess.PIPE, 60 | stderr=subprocess.PIPE, 61 | env=get_env(), 62 | ) 63 | logger.info(result.stdout) 64 | return result 65 | 66 | 67 | def download_latest_build(): 68 | gcontext = ssl.SSLContext() 69 | 70 | # response = urllib.request.urlopen(API_URL, context=gcontext) 71 | # json_data = json.load(response) 72 | 73 | # download_url = json_data.get("assets")[0].get("browser_download_url") 74 | 75 | # 固定使用镜像站下载地址 76 | download_url = "https://moon.ohmydeck.net" 77 | 78 | logger.info(download_url) 79 | 80 | file_path = f"/tmp/{decky.DECKY_PLUGIN_NAME}.zip" 81 | 82 | with urllib.request.urlopen(download_url, context=gcontext) as response, open( 83 | file_path, "wb" 84 | ) as output_file: 85 | output_file.write(response.read()) 86 | output_file.close() 87 | 88 | return file_path 89 | 90 | 91 | def get_version(): 92 | return f"{decky.DECKY_PLUGIN_VERSION}" 93 | 94 | 95 | def get_latest_version(): 96 | gcontext = ssl.SSLContext() 97 | 98 | response = urllib.request.urlopen(API_URL, context=gcontext) 99 | json_data = json.load(response) 100 | 101 | tag = json_data.get("tag_name") 102 | # if tag is a v* tag, remove the v 103 | if tag.startswith("v"): 104 | tag = tag[1:] 105 | else: 106 | return "" 107 | return tag 108 | -------------------------------------------------------------------------------- /py_modules/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import decky 4 | from config import logger 5 | 6 | FONT_CONFIG = """ 7 | 8 | 9 | 10 | 11 | sans-serif 12 | 13 | Noto Sans CJK SC 14 | Noto Sans CJK TC 15 | Noto Sans CJK JP 16 | 17 | 18 | 19 | serif 20 | 21 | Noto Serif CJK SC 22 | Noto Serif CJK TC 23 | Noto Serif CJK JP 24 | 25 | 26 | 27 | monospace 28 | 29 | Noto Sans Mono CJK SC 30 | Noto Sans Mono CJK TC 31 | Noto Sans Mono CJK JP 32 | 33 | 34 | 35 | """ 36 | FONT_CONF_DIR = f"{decky.DECKY_USER_HOME}/.config/fontconfig" 37 | FONT_CONF_D_DIR = f"{FONT_CONF_DIR}/conf.d" 38 | FONT_CONF_FILE = f"{FONT_CONF_D_DIR}/76-noto-cjk.conf" 39 | 40 | 41 | def write_font_config(): 42 | if not os.path.exists(FONT_CONF_D_DIR): 43 | logger.info(f"Creating fontconfig directory: {FONT_CONF_D_DIR}") 44 | os.makedirs(FONT_CONF_D_DIR) 45 | 46 | if not os.path.exists(FONT_CONF_FILE): 47 | logger.info(f"Creating fontconfig file: {FONT_CONF_FILE}") 48 | with open(FONT_CONF_FILE, "w") as f: 49 | f.write(FONT_CONFIG) 50 | f.close() 51 | else: 52 | logger.info(f"Fontconfig file already exists: {FONT_CONF_FILE}") 53 | with open(FONT_CONF_FILE, "r") as f: 54 | content = f.read() 55 | f.close() 56 | # if different, overwrite it 57 | if content != FONT_CONFIG: 58 | logger.info(f"Overwriting fontconfig file: {FONT_CONF_FILE}") 59 | with open(FONT_CONF_FILE, "w") as f: 60 | f.write(FONT_CONFIG) 61 | f.close() 62 | 63 | user = decky.DECKY_USER 64 | # change fontconfig owner 65 | os.system(f"chown -R {user}:{user} {FONT_CONF_DIR}") 66 | 67 | 68 | def remove_font_config(): 69 | if os.path.exists(FONT_CONF_FILE): 70 | # read fontconfig file, if contains '' then remove it 71 | with open(FONT_CONF_FILE, "r") as f: 72 | content = f.read() 73 | f.close() 74 | if "" in content: 75 | logger.info(f"Removing fontconfig file: {FONT_CONF_FILE}") 76 | os.remove(FONT_CONF_FILE) 77 | 78 | 79 | def get_env(): 80 | env = os.environ.copy() 81 | env["LD_LIBRARY_PATH"] = "" 82 | return env 83 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import deckyPlugin from "@decky/rollup"; 2 | 3 | export default deckyPlugin({ 4 | // Add your extra Rollup options here 5 | }) -------------------------------------------------------------------------------- /src/backend/backend.ts: -------------------------------------------------------------------------------- 1 | import { call } from "@decky/api"; 2 | import { init_usdpl, init_embedded, call_backend } from "usdpl-front"; 3 | import axios from "axios"; 4 | import { EnhancedMode } from "."; 5 | 6 | const USDPL_PORT: number = 55555; 7 | 8 | // Utility 9 | 10 | export function resolve(promise: Promise, setter: any) { 11 | (async function () { 12 | let data = await promise; 13 | if (data != null) { 14 | console.debug("Got resolved", data); 15 | setter(data); 16 | } else { 17 | console.warn("Resolve failed:", data); 18 | } 19 | })(); 20 | } 21 | 22 | export function execute(promise: Promise) { 23 | (async function () { 24 | let data = await promise; 25 | console.debug("Got executed", data); 26 | })(); 27 | } 28 | 29 | export async function initBackend() { 30 | // init usdpl 31 | await init_embedded(); 32 | init_usdpl(USDPL_PORT); 33 | //setReady(true); 34 | } 35 | 36 | // Back-end functions 37 | 38 | export async function setEnabled(value: boolean): Promise { 39 | return (await call_backend("set_clash_status", [value]))[0]; 40 | } 41 | 42 | export async function getEnabled(): Promise { 43 | return (await call_backend("get_clash_status", []))[0]; 44 | } 45 | 46 | export async function resetNetwork(): Promise { 47 | return await call_backend("reset_network", []); 48 | } 49 | 50 | export async function downloadSub(value: String): Promise { 51 | return (await call_backend("download_sub", [value]))[0]; 52 | } 53 | 54 | export async function getDownloadStatus(): Promise { 55 | return (await call_backend("get_download_status", []))[0]; 56 | } 57 | 58 | export async function getSubList(): Promise { 59 | return (await call_backend("get_sub_list", []))[0]; 60 | } 61 | 62 | export async function deleteSub(value: Number): Promise { 63 | return (await call_backend("delete_sub", [value]))[0]; 64 | } 65 | 66 | export async function setSub(value: String): Promise { 67 | return (await call_backend("set_sub", [value]))[0]; 68 | } 69 | 70 | export async function updateSubs(): Promise { 71 | return (await call_backend("update_subs", []))[0]; 72 | } 73 | 74 | export async function getUpdateStatus(): Promise { 75 | return (await call_backend("get_update_status", []))[0]; 76 | } 77 | 78 | export async function createDebugLog(): Promise { 79 | return (await call_backend("create_debug_log", []))[0]; 80 | } 81 | 82 | export async function getRunningStatus(): Promise { 83 | return (await call_backend("get_running_status", []))[0]; 84 | } 85 | 86 | export async function getCurrentSub(): Promise { 87 | return (await call_backend("get_current_sub", []))[0]; 88 | } 89 | 90 | export class PyBackendData { 91 | private current_version = ""; 92 | private latest_version = ""; 93 | 94 | public async init() { 95 | const version = ((await call("get_version")) as string) || ""; 96 | if (version) { 97 | this.current_version = version; 98 | } 99 | 100 | const latest_version = ((await call("get_latest_version")) as string) || ""; 101 | if (latest_version) { 102 | this.latest_version = latest_version; 103 | } 104 | } 105 | 106 | public getCurrentVersion() { 107 | return this.current_version; 108 | } 109 | 110 | public setCurrentVersion(version: string) { 111 | this.current_version = version; 112 | } 113 | 114 | public getLatestVersion() { 115 | return this.latest_version; 116 | } 117 | 118 | public setLatestVersion(version: string) { 119 | this.latest_version = version; 120 | } 121 | } 122 | 123 | export class PyBackend { 124 | public static data: PyBackendData; 125 | 126 | public static async init() { 127 | this.data = new PyBackendData(); 128 | this.data.init(); 129 | } 130 | 131 | public static async getLatestVersion(): Promise { 132 | const version = ((await call("get_latest_version")) as string) || ""; 133 | 134 | const versionReg = /^\d+\.\d+\.\d+$/; 135 | if (!versionReg.test(version)) { 136 | return ""; 137 | } 138 | return version; 139 | } 140 | 141 | // updateLatest 142 | public static async updateLatest() { 143 | // await this.serverAPI!.callPluginMethod("update_latest", {}); 144 | await call("update_latest", []); 145 | } 146 | 147 | // get_version 148 | public static async getVersion() { 149 | // return (await this.serverAPI!.callPluginMethod("get_version", {})) 150 | // .result as string; 151 | return (await call("get_version", [])) as string; 152 | } 153 | 154 | // get_dashboard_list 155 | public static async getDashboardList() { 156 | return (await call("get_dashboard_list")) as string[]; 157 | } 158 | 159 | // get_config_value 160 | public static async getConfigValue(key: string) { 161 | return await call<[key: string], string | undefined>( 162 | "get_config_value", 163 | key 164 | ); 165 | } 166 | 167 | // set_config_value 168 | public static async setConfigValue(key: string, value: string) { 169 | return await call<[key: string, value: string], boolean>( 170 | "set_config_value", 171 | key, 172 | value 173 | ); 174 | } 175 | 176 | private static async getDefalutDashboard() { 177 | const dashboardList = await this.getDashboardList(); 178 | return ( 179 | dashboardList.find((x) => 180 | (x.split("/").pop() || "").includes("yacd-meta") 181 | ) || dashboardList[0] 182 | ); 183 | } 184 | 185 | public static async getCurrentDashboard() { 186 | return ( 187 | (await this.getConfigValue("current_dashboard")) || 188 | (await this.getDefalutDashboard()) 189 | ); 190 | } 191 | 192 | public static async setCurrentDashboard(dashboard: string) { 193 | return await this.setConfigValue("current_dashboard", dashboard); 194 | } 195 | } 196 | 197 | export enum ApiCallMethod { 198 | GET = "GET", 199 | POST = "POST", 200 | } 201 | 202 | /** 203 | * 调用后端 API 的通用方法 204 | * @param name API 端点名称 205 | * @param params 请求参数 206 | * @param method 请求方法,默认为 POST 207 | * @returns Promise API 调用的响应 208 | */ 209 | export function apiCallMethod( 210 | name: string, 211 | params: {}, 212 | method: ApiCallMethod = ApiCallMethod.POST 213 | ): Promise { 214 | const url = `http://localhost:55556/${name}`; 215 | const headers = { "content-type": "application/x-www-form-urlencoded" }; 216 | 217 | // 封装请求逻辑,根据 method 参数决定使用 GET 还是 POST 218 | const makeRequest = () => { 219 | if (method === ApiCallMethod.GET) { 220 | return axios.get(url, { headers: headers }); 221 | } else { 222 | return axios.post(url, params, { headers: headers }); 223 | } 224 | }; 225 | 226 | const ignore_list = ["get_config", "reload_clash_config", "restart_clash"]; 227 | 228 | // 在请求完成后自动触发配置重载 229 | if (!ignore_list.includes(name)) { 230 | return makeRequest().then(async (response) => { 231 | // 在原始请求完成后触发配置重载 232 | if (response.status === 200) { 233 | console.log(`^^^^^^^^^^^ apiCallMethod: ${name} done`); 234 | apiCallMethod("reload_clash_config", {}, ApiCallMethod.GET); 235 | } 236 | return response; // 返回原始请求的响应 237 | }); 238 | } 239 | 240 | // 如果是重载配置请求,直接执行并返回结果 241 | return makeRequest(); 242 | } 243 | 244 | export class ApiCallBackend { 245 | public static async getConfig() { 246 | return await apiCallMethod("get_config", {}, ApiCallMethod.GET); 247 | } 248 | 249 | public static async reloadClashConfig() { 250 | return await apiCallMethod("reload_clash_config", {}, ApiCallMethod.GET); 251 | } 252 | 253 | // restart_clash 254 | public static async restartClash() { 255 | return await apiCallMethod("restart_clash", {}, ApiCallMethod.GET); 256 | } 257 | 258 | // enhanced_mode 259 | public static async enhancedMode(value: EnhancedMode) { 260 | return await apiCallMethod("enhanced_mode", { enhanced_mode: value }); 261 | } 262 | 263 | // override_dns 264 | public static async overrideDns(value: boolean) { 265 | return await apiCallMethod("override_dns", { override_dns: value }); 266 | } 267 | 268 | // skip_proxy 269 | public static async skipProxy(value: boolean) { 270 | return await apiCallMethod("skip_proxy", { skip_proxy: value }); 271 | } 272 | 273 | // allow_remote_access 274 | public static async allowRemoteAccess(value: boolean) { 275 | return await apiCallMethod("allow_remote_access", { 276 | allow_remote_access: value, 277 | }); 278 | } 279 | 280 | // set_dashboard 281 | public static async setDashboard(value: String) { 282 | return await apiCallMethod("set_dashboard", { dashboard: value }); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/backend/enum.ts: -------------------------------------------------------------------------------- 1 | export enum EnhancedMode { 2 | RedirHost = "RedirHost", 3 | FakeIp = "FakeIp", 4 | } 5 | -------------------------------------------------------------------------------- /src/backend/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./enum"; 2 | export * from "./backend"; -------------------------------------------------------------------------------- /src/components/SubList.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonItem } from "@decky/ui"; 2 | import { FC } from "react"; 3 | import * as backend from "../backend/backend"; 4 | import { localizationManager, L } from "../i18n"; 5 | interface appProp { 6 | Subscriptions: Array; 7 | UpdateSub: any; 8 | Refresh: Function; 9 | } 10 | 11 | export const SubList: FC = ({ Subscriptions, UpdateSub, Refresh }) => { 12 | return ( 13 |
14 | {Subscriptions.map((x) => { 15 | return ( 16 |
17 | { 21 | //删除订阅 22 | UpdateSub((source: Array) => { 23 | let i = source.indexOf(x); 24 | source.splice(i, 1); 25 | return source; 26 | }); 27 | backend.resolve(backend.deleteSub(x.id), () => {}); 28 | Refresh(); 29 | }} 30 | > 31 | {localizationManager.getString(L.DELETE)} 32 | 33 |
34 | ); 35 | })} 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/Version.tsx: -------------------------------------------------------------------------------- 1 | import { PanelSection, PanelSectionRow, Field } from "@decky/ui"; 2 | import { FC, useEffect, useState } from "react"; 3 | import { PyBackend } from "../backend/backend"; 4 | import { ActionButtonItem } from "."; 5 | import { localizationManager, L } from "../i18n"; 6 | 7 | export const VersionComponent: FC = () => { 8 | const [currentVersion, _] = useState( 9 | PyBackend.data.getCurrentVersion() 10 | ); 11 | const [latestVersion, setLatestVersion] = useState( 12 | PyBackend.data.getLatestVersion() 13 | ); 14 | 15 | useEffect(() => { 16 | const getData = async () => { 17 | const latestVersion = await PyBackend.getLatestVersion(); 18 | setLatestVersion(latestVersion); 19 | PyBackend.data.setLatestVersion(latestVersion); 20 | }; 21 | getData(); 22 | }); 23 | 24 | let uptButtonText = localizationManager.getString(L.REINSTALL_PLUGIN); 25 | 26 | if (currentVersion !== latestVersion && Boolean(latestVersion)) { 27 | uptButtonText = 28 | localizationManager.getString(L.UPDATE_TO) + ` ${latestVersion}`; 29 | } 30 | 31 | return ( 32 | 33 | 34 | { 37 | await PyBackend.updateLatest(); 38 | }} 39 | > 40 | {uptButtonText} 41 | 42 | 43 | 44 | 48 | {currentVersion} 49 | 50 | 51 | {Boolean(latestVersion) && ( 52 | 53 | 57 | {latestVersion} 58 | 59 | 60 | )} 61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/actionButtonItem.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonItem, ButtonItemProps, Spinner } from "@decky/ui"; 2 | import { FC, useState } from "react"; 3 | 4 | export interface ActionButtonItemProps extends ButtonItemProps { 5 | loading?: boolean; 6 | debugLabel?: string; 7 | } 8 | 9 | export const ActionButtonItem: FC = (props) => { 10 | const { onClick, disabled, children, loading, layout, debugLabel } = props; 11 | 12 | const [_loading, setLoading] = useState(loading); 13 | 14 | const handClick = async (event: MouseEvent, onClick?: (e: MouseEvent) => void) => { 15 | try { 16 | console.log(`ActionButtonItem: ${debugLabel}`); 17 | setLoading(true); 18 | await onClick?.(event); 19 | console.log(`ActionButtonItem: ${debugLabel} done`); 20 | } catch (e) { 21 | console.error(`ActionButtonItem error: ${e}`); 22 | } finally { 23 | // console.log(`ActionButtonItem: ${debugLabel} disable loading`); 24 | setLoading(false); 25 | } 26 | } 27 | 28 | const isLoading = _loading; 29 | 30 | return ( 31 | handClick(e, onClick)} 36 | 37 | > 38 | 39 | {children} {isLoading && } 40 | 41 | 42 | ); 43 | } -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./actionButtonItem"; 2 | export * from "./Version"; 3 | export * from "./SubList"; -------------------------------------------------------------------------------- /src/i18n/bulgarian.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/i18n/english.json: -------------------------------------------------------------------------------- 1 | { 2 | "SERVICE": "Service", 3 | "TOOLS": "Tools", 4 | "VERSION": "Version", 5 | "ABOUT": "About", 6 | "DEBUG": "Debug", 7 | "SUBSCRIPTIONS": "Subscriptions", 8 | "SUBSCRIPTIONS_LINK": "Subscriptions", 9 | "SELECT_SUBSCRIPTION": "Select a Subscription", 10 | "DOWNLOAD": "Download", 11 | "UPDATE_ALL": "Update All", 12 | "DELETE": "Delete", 13 | "ENABLE_CLASH": "Enable Clash", 14 | "ENABLE_CLASH_DESC": "Run Clash in background", 15 | "ENABLE_CLASH_FAILED": "Failed to start, please check /tmp/tomoon.log", 16 | "ENABLE_CLASH_LOADING": "Loading ...", 17 | "ENABLE_CLASH_IS_RUNNING": "Clash is running.", 18 | "MANAGE_SUBSCRIPTIONS": "Manage Subscriptions", 19 | "OPEN_DASHBOARD": "Open Dashboard", 20 | "SELECT_DASHBOARD": "Select Dashboard", 21 | "ALLOW_REMOTE_ACCESS": "Allow Remote Access", 22 | "ALLOW_REMOTE_ACCESS_DESC": "Allow Remote Access to Dashboard", 23 | "SKIP_PROXY": "Skip Proxy", 24 | "SKIP_PROXY_DESC": "Enable for direct Steam downloads", 25 | "OVERRIDE_DNS": "Override DNS", 26 | "OVERRIDE_DNS_DESC": "Force Clash to hijack DNS query", 27 | "ENHANCED_MODE": "Enhanced Mode", 28 | "ENHANCED_MODE_DESC": "Enhanced Mode", 29 | "RESTART_CORE": "Restart Core", 30 | "RESET_NETWORK": "Reset Network", 31 | "REINSTALL_PLUGIN": "Reinstall Plugin", 32 | "UPDATE_TO": "Update to", 33 | "INSTALLED_VERSION": "Installed Version", 34 | "LATEST_VERSION": "Latest Version" 35 | } -------------------------------------------------------------------------------- /src/i18n/french.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/i18n/german.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./localization" 2 | export * from "./localizeMap" -------------------------------------------------------------------------------- /src/i18n/italian.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/i18n/japanese.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/i18n/koreana.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/i18n/localization.ts: -------------------------------------------------------------------------------- 1 | import { defaultLocale, localizeMap, LocalizeStrKey } from "./localizeMap"; 2 | 3 | import i18n, { Resource } from "i18next"; 4 | 5 | export class localizationManager { 6 | private static language = "english"; 7 | 8 | public static async init() { 9 | const language = 10 | (await SteamClient.Settings.GetCurrentLanguage()) || "english"; 11 | this.language = language; 12 | console.log(">>>>>>>>>> Language: " + this.language); 13 | 14 | const resources: Resource = Object.keys(localizeMap).reduce( 15 | (acc: Resource, key) => { 16 | acc[localizeMap[key].locale] = { 17 | translation: localizeMap[key].strings, 18 | }; 19 | return acc; 20 | }, 21 | {} 22 | ); 23 | 24 | i18n.init({ 25 | resources: resources, 26 | lng: this.getLocale(), // 目标语言 27 | fallbackLng: defaultLocale, // 回落语言 28 | returnEmptyString: false, // 空字符串不返回, 使用回落语言 29 | interpolation: { 30 | escapeValue: false, 31 | }, 32 | }); 33 | } 34 | 35 | private static getLocale() { 36 | return localizeMap[this.language]?.locale ?? defaultLocale; 37 | } 38 | 39 | public static getString( 40 | defaultString: LocalizeStrKey, 41 | variables?: Record 42 | ) { 43 | console.log(">>>>>>>>>> getString: " + defaultString); 44 | return i18n.t(defaultString, variables); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/i18n/localizeMap.ts: -------------------------------------------------------------------------------- 1 | import * as schinese from "./schinese.json"; 2 | import * as tchinese from "./tchinese.json"; 3 | import * as english from "./english.json"; 4 | import * as german from "./german.json"; 5 | import * as japanese from "./japanese.json"; 6 | import * as koreana from "./koreana.json"; 7 | import * as thai from "./thai.json"; 8 | import * as bulgarian from "./bulgarian.json"; 9 | import * as italian from "./italian.json"; 10 | import * as french from "./french.json"; 11 | 12 | export interface LanguageProps { 13 | label: string; 14 | strings: any; 15 | credit: string[]; 16 | locale: string; 17 | } 18 | 19 | export const defaultLanguage = "english"; 20 | export const defaultLocale = "en"; 21 | export const defaultMessages = english; 22 | 23 | export const localizeMap: { [key: string]: LanguageProps } = { 24 | schinese: { 25 | label: "简体中文", 26 | strings: schinese, 27 | credit: ["yxx"], 28 | locale: "zh-CN", 29 | }, 30 | tchinese: { 31 | label: "繁體中文", 32 | strings: tchinese, 33 | credit: [], 34 | locale: "zh-TW", 35 | }, 36 | english: { 37 | label: "English", 38 | strings: english, 39 | credit: [], 40 | locale: "en", 41 | }, 42 | german: { 43 | label: "Deutsch", 44 | strings: german, 45 | credit: ["dctr"], 46 | locale: "de", 47 | }, 48 | japanese: { 49 | label: "日本語", 50 | strings: japanese, 51 | credit: [], 52 | locale: "ja", 53 | }, 54 | koreana: { 55 | label: "한국어", 56 | strings: koreana, 57 | credit: [], 58 | locale: "ko", 59 | }, 60 | thai: { 61 | label: "ไทย", 62 | strings: thai, 63 | credit: [], 64 | locale: "th", 65 | }, 66 | bulgarian: { 67 | label: "Български", 68 | strings: bulgarian, 69 | credit: [], 70 | locale: "bg", 71 | }, 72 | italian: { 73 | label: "Italiano", 74 | strings: italian, 75 | credit: [], 76 | locale: "it", 77 | }, 78 | french: { 79 | label: "Français", 80 | strings: french, 81 | credit: [], 82 | locale: "fr", 83 | }, 84 | }; 85 | 86 | // 创建一个类型安全的常量生成函数 87 | function createLocalizeConstants(keys: T) { 88 | return keys.reduce((obj, key) => { 89 | obj[key as keyof typeof obj] = key; 90 | return obj; 91 | }, {} as { [K in T[number]]: K }); 92 | } 93 | 94 | // 定义所有键名 95 | const I18N_KEYS = [ 96 | "SERVICE", 97 | "TOOLS", 98 | "VERSION", 99 | "ABOUT", 100 | "DEBUG", 101 | 102 | // Subscriptions manager 103 | "SUBSCRIPTIONS", 104 | "SUBSCRIPTIONS_LINK", 105 | "SELECT_SUBSCRIPTION", 106 | "DOWNLOAD", 107 | "UPDATE_ALL", 108 | "DELETE", 109 | 110 | // QAM 111 | "ENABLE_CLASH", 112 | "ENABLE_CLASH_DESC", 113 | "ENABLE_CLASH_FAILED", 114 | "ENABLE_CLASH_LOADING", 115 | "ENABLE_CLASH_IS_RUNNING", 116 | "MANAGE_SUBSCRIPTIONS", 117 | "OPEN_DASHBOARD", 118 | "SELECT_DASHBOARD", 119 | "ALLOW_REMOTE_ACCESS", 120 | "ALLOW_REMOTE_ACCESS_DESC", 121 | "SKIP_PROXY", 122 | "SKIP_PROXY_DESC", 123 | "OVERRIDE_DNS", 124 | "OVERRIDE_DNS_DESC", 125 | "ENHANCED_MODE", 126 | "ENHANCED_MODE_DESC", 127 | "RESTART_CORE", 128 | "RESET_NETWORK", 129 | "REINSTALL_PLUGIN", 130 | "UPDATE_TO", 131 | "INSTALLED_VERSION", 132 | "LATEST_VERSION", 133 | ] as const; 134 | 135 | // 创建常量对象并导出 136 | export const L = createLocalizeConstants(I18N_KEYS); 137 | 138 | // 导出类型 139 | export type LocalizeStrKey = keyof typeof L; 140 | 141 | // 为了向后兼容,保留 localizeStrEnum 名称 142 | // export const localizeStrEnum = L; 143 | -------------------------------------------------------------------------------- /src/i18n/schinese.json: -------------------------------------------------------------------------------- 1 | { 2 | "SERVICE": "服务", 3 | "TOOLS": "工具", 4 | "VERSION": "版本", 5 | "ABOUT": "关于", 6 | "DEBUG": "调试", 7 | "SUBSCRIPTIONS": "订阅", 8 | "SUBSCRIPTIONS_LINK": "订阅链接", 9 | "SELECT_SUBSCRIPTION": "选择订阅", 10 | "DOWNLOAD": "下载", 11 | "UPDATE_ALL": "更新所有", 12 | "DELETE": "删除", 13 | "ENABLE_CLASH": "启用 Clash", 14 | "ENABLE_CLASH_DESC": "在后台运行 Clash", 15 | "ENABLE_CLASH_FAILED": "启动失败,请检查 /tmp/tomoon.log", 16 | "ENABLE_CLASH_LOADING": "加载中 ...", 17 | "ENABLE_CLASH_IS_RUNNING": "Clash 正在运行", 18 | "MANAGE_SUBSCRIPTIONS": "管理订阅", 19 | "OPEN_DASHBOARD": "打开 Dashboard", 20 | "SELECT_DASHBOARD": "选择 Dashboard", 21 | "ALLOW_REMOTE_ACCESS": "允许远程访问", 22 | "ALLOW_REMOTE_ACCESS_DESC": "允许远程访问 Dashboard", 23 | "SKIP_PROXY": "跳过代理", 24 | "SKIP_PROXY_DESC": "Steam 下载不经过代理", 25 | "OVERRIDE_DNS": "覆盖 DNS 设置", 26 | "OVERRIDE_DNS_DESC": "强制 Clash 拦截 DNS 查询", 27 | "ENHANCED_MODE": "增强模式", 28 | "ENHANCED_MODE_DESC": "增强模式", 29 | "RESTART_CORE": "重启核心", 30 | "RESET_NETWORK": "重置网络", 31 | "REINSTALL_PLUGIN": "重新安装插件", 32 | "UPDATE_TO": "更新到", 33 | "INSTALLED_VERSION": "已安装版本", 34 | "LATEST_VERSION": "最新版本" 35 | } -------------------------------------------------------------------------------- /src/i18n/tchinese.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/i18n/thai.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ButtonItem, 3 | definePlugin, 4 | PanelSection, 5 | PanelSectionRow, 6 | Router, 7 | staticClasses, 8 | ToggleField, 9 | SidebarNavigation, 10 | DropdownOption, 11 | Navigation, 12 | DropdownItem, 13 | SliderField, 14 | NotchLabel, 15 | } from "@decky/ui"; 16 | import { routerHook } from "@decky/api"; 17 | import { FC, useEffect, useState } from "react"; 18 | import { GiEgyptianBird } from "react-icons/gi"; 19 | 20 | import { Subscriptions, About, Debug } from "./pages"; 21 | 22 | import * as backend from "./backend/backend"; 23 | 24 | import { ApiCallBackend, PyBackend, EnhancedMode } from "./backend"; 25 | import { ActionButtonItem, VersionComponent } from "./components"; 26 | import { localizationManager, L } from "./i18n"; 27 | 28 | let enabledGlobal = false; 29 | let enabledSkipProxy = false; 30 | let enabledOverrideDNS = false; 31 | let usdplReady = false; 32 | let subs: any[]; 33 | let subs_option: any[]; 34 | let current_sub = ""; 35 | let enhanced_mode = EnhancedMode.FakeIp; 36 | let dashboard_list: string[]; 37 | let current_dashboard = ""; 38 | let allow_remote_access = false; 39 | let _secret = ""; 40 | 41 | const Content: FC<{}> = ({}) => { 42 | if (!usdplReady) { 43 | return Init...; 44 | } 45 | const [clashState, setClashState] = useState(enabledGlobal); 46 | backend.resolve(backend.getEnabled(), setClashState); 47 | const [options, setOptions] = useState(subs_option); 48 | const [optionDropdownDisabled, setOptionDropdownDisabled] = 49 | useState(enabledGlobal); 50 | const [openDashboardDisabled, setOpenDashboardDisabled] = useState( 51 | !enabledGlobal 52 | ); 53 | const [isSelectionDisabled, setIsSelectionDisabled] = useState(false); 54 | const [SelectionTips, setSelectionTips] = useState( 55 | localizationManager.getString(L.ENABLE_CLASH_DESC) 56 | ); 57 | const [skipProxyState, setSkipProxyState] = useState(enabledSkipProxy); 58 | const [overrideDNSState, setOverrideDNSState] = useState(enabledOverrideDNS); 59 | const [currentSub, setCurrentSub] = useState(current_sub); 60 | const [enhancedMode, setEnhancedMode] = useState(enhanced_mode); 61 | const [dashboardList, setDashboardList] = useState(dashboard_list); 62 | const [currentDashboard, setCurrentDashboard] = 63 | useState(current_dashboard); 64 | const [allowRemoteAccess, setAllowRemoteAccess] = 65 | useState(allow_remote_access); 66 | const [secret, setSecret] = useState(_secret); 67 | 68 | const update_subs = () => { 69 | backend.resolve(backend.getSubList(), (v: String) => { 70 | // console.log(`getSubList: ${v}`); 71 | let x: Array = JSON.parse(v.toString()); 72 | let re = new RegExp("(?<=subs/).+.yaml$"); 73 | let i = 0; 74 | subs = x.map((x) => { 75 | let name = re.exec(x.path); 76 | return { 77 | id: i++, 78 | name: name![0], 79 | url: x.url, 80 | }; 81 | }); 82 | let items = x.map((x) => { 83 | let name = re.exec(x.path); 84 | return { 85 | label: name![0], 86 | data: x.path, 87 | }; 88 | }); 89 | subs_option = items; 90 | setOptions(subs_option); 91 | console.log("Subs ready"); 92 | setIsSelectionDisabled(i == 0); 93 | //console.log(sub); 94 | }); 95 | }; 96 | 97 | const getConfig = async () => { 98 | await ApiCallBackend.getConfig().then((res) => { 99 | console.log( 100 | `~~~~~~~~~~~~~~~~~~~ getConfig: ${JSON.stringify(res.data, null, 2)}` 101 | ); 102 | if (res.data.status_code == 200) { 103 | enabledSkipProxy = res.data.skip_proxy; 104 | enabledOverrideDNS = res.data.override_dns; 105 | enhanced_mode = res.data.enhanced_mode; 106 | allow_remote_access = res.data.allow_remote_access; 107 | _secret = res.data.secret; 108 | 109 | setSkipProxyState(enabledSkipProxy); 110 | setOverrideDNSState(enabledOverrideDNS); 111 | setEnhancedMode(enhanced_mode); 112 | setAllowRemoteAccess(allow_remote_access); 113 | setSecret(_secret); 114 | } 115 | }); 116 | }; 117 | 118 | useEffect(() => { 119 | const getCurrentSub = async () => { 120 | const sub = await backend.getCurrentSub(); 121 | setCurrentSub(sub); 122 | }; 123 | 124 | const getDashboardList = async () => { 125 | // console.log(`>>>>>> getDashboardList`); 126 | const list = await PyBackend.getDashboardList(); 127 | console.log(`>>>>>> getDashboardList: ${list}`); 128 | setDashboardList(list); 129 | }; 130 | 131 | const getCurrentDashboard = async () => { 132 | const dashboard = await PyBackend.getCurrentDashboard(); 133 | setCurrentDashboard(dashboard); 134 | }; 135 | 136 | const loadDate = async () => { 137 | await getConfig(); 138 | 139 | getCurrentSub(); 140 | getDashboardList(); 141 | getCurrentDashboard(); 142 | update_subs(); 143 | }; 144 | 145 | loadDate(); 146 | }, []); 147 | 148 | useEffect(() => { 149 | current_sub = currentSub; 150 | }, [currentSub]); 151 | 152 | useEffect(() => { 153 | dashboard_list = dashboardList; 154 | }, [dashboardList]); 155 | 156 | useEffect(() => { 157 | current_dashboard = currentDashboard; 158 | }, [currentDashboard]); 159 | 160 | const enhancedModeOptions = [ 161 | { mode: EnhancedMode.RedirHost, label: "Redir Host" }, 162 | { mode: EnhancedMode.FakeIp, label: "Fake IP" }, 163 | ]; 164 | 165 | const enhancedModeNotchLabels: NotchLabel[] = enhancedModeOptions.map( 166 | (opt, i) => { 167 | return { 168 | notchIndex: i, 169 | label: opt.label, 170 | value: i, 171 | }; 172 | } 173 | ); 174 | 175 | const convertEnhancedMode = (value: number) => { 176 | return enhancedModeOptions[value].mode; 177 | }; 178 | 179 | const convertEnhancedModeValue = (value: EnhancedMode) => { 180 | return enhancedModeOptions.findIndex((opt) => opt.mode === value); 181 | }; 182 | 183 | return ( 184 |
185 | 186 | 187 | { 192 | setIsSelectionDisabled(true); 193 | setSelectionTips( 194 | localizationManager.getString(L.ENABLE_CLASH_LOADING) 195 | ); 196 | backend.resolve(backend.setEnabled(value), (v: boolean) => { 197 | enabledGlobal = v; 198 | setIsSelectionDisabled(false); 199 | }); 200 | //获取 Clash 启动状态 201 | if (!clashState) { 202 | let check_running_handle = setInterval(() => { 203 | backend.resolve(backend.getRunningStatus(), (v: String) => { 204 | // console.log(v); 205 | switch (v) { 206 | case "Loading": 207 | setSelectionTips( 208 | localizationManager.getString(L.ENABLE_CLASH_LOADING) 209 | ); 210 | break; 211 | case "Failed": 212 | setSelectionTips( 213 | localizationManager.getString(L.ENABLE_CLASH_FAILED) 214 | ); 215 | setClashState(false); 216 | break; 217 | case "Success": 218 | setSelectionTips( 219 | localizationManager.getString( 220 | L.ENABLE_CLASH_IS_RUNNING 221 | ) 222 | ); 223 | getConfig(); 224 | break; 225 | } 226 | if (v != "Loading") { 227 | clearInterval(check_running_handle); 228 | } 229 | }); 230 | }, 500); 231 | } else { 232 | setSelectionTips( 233 | localizationManager.getString(L.ENABLE_CLASH_DESC) 234 | ); 235 | } 236 | setOptionDropdownDisabled(value); 237 | setOpenDashboardDisabled(!value); 238 | }} 239 | disabled={isSelectionDisabled} 240 | /> 241 | 242 | 243 | { 251 | update_subs(); 252 | // setOptions(subs_option); 253 | }} 254 | onChange={(x) => { 255 | const setSub = async () => { 256 | await backend.setSub(x.data); 257 | await ApiCallBackend.reloadClashConfig(); 258 | }; 259 | backend.resolve(setSub(), () => { 260 | setIsSelectionDisabled(false); 261 | }); 262 | }} 263 | /> 264 | 265 | 266 | { 269 | Router.CloseSideMenus(); 270 | Router.Navigate("/tomoon-config"); 271 | }} 272 | > 273 | {localizationManager.getString(L.MANAGE_SUBSCRIPTIONS)} 274 | 275 | 276 | 277 | { 280 | Router.CloseSideMenus(); 281 | let param = ""; 282 | let page = "setup"; 283 | const currentDashboard_name = 284 | currentDashboard.split("/").pop() || "yacd-meta"; 285 | if (currentDashboard_name) { 286 | param = `/${currentDashboard_name}/#`; 287 | if (secret) { 288 | // secret 不为空时,使用完整的参数,但是不同 dashboard 使用不同的 page 289 | switch (currentDashboard_name) { 290 | case "metacubexd": 291 | case "zashboard": 292 | page = "setup"; 293 | break; 294 | default: 295 | page = "proxies"; 296 | break; 297 | } 298 | param += `/${page}?hostname=127.0.0.1&port=9090&secret=${secret}`; 299 | } else if (currentDashboard_name == "metacubexd") { 300 | // 即使没有设置 secret,metacubexd 也会有奇怪的跳转问题,加上host和port 301 | param += `/${page}?hostname=127.0.0.1&port=9090`; 302 | } 303 | } 304 | Navigation.NavigateToExternalWeb( 305 | "http://127.0.0.1:9090/ui" + param 306 | ); 307 | }} 308 | disabled={openDashboardDisabled} 309 | > 310 | {localizationManager.getString(L.OPEN_DASHBOARD)} 311 | 312 | 313 | 314 | { 318 | return { 319 | label: path.split("/").pop(), 320 | data: path, 321 | }; 322 | })} 323 | selectedOption={currentDashboard} 324 | onChange={(val) => { 325 | console.log(`>>>>>>>>>>>>>>>> selected dashboard: ${val.data}`); 326 | current_dashboard = val.data; 327 | PyBackend.setCurrentDashboard(val.data); 328 | ApiCallBackend.setDashboard(val.data.split("/").pop()); 329 | }} 330 | /> 331 | 332 | 333 | { 340 | ApiCallBackend.allowRemoteAccess(value); 341 | setAllowRemoteAccess(value); 342 | }} 343 | > 344 | 345 | 346 | { 351 | ApiCallBackend.skipProxy(value); 352 | setSkipProxyState(value); 353 | }} 354 | > 355 | 356 | 357 | { 362 | ApiCallBackend.overrideDns(value); 363 | setOverrideDNSState(value); 364 | }} 365 | > 366 | 367 | {overrideDNSState && ( 368 | 369 | { 379 | const _enhancedMode = convertEnhancedMode(value); 380 | setEnhancedMode(_enhancedMode); 381 | ApiCallBackend.enhancedMode(_enhancedMode); 382 | }} 383 | /> 384 | 385 | )} 386 | 387 | { 391 | ApiCallBackend.restartClash(); 392 | }} 393 | > 394 | {localizationManager.getString(L.RESTART_CORE)} 395 | 396 | 397 | 398 | 399 | 400 | 401 | { 404 | backend.resolve(backend.resetNetwork(), () => { 405 | Router.CloseSideMenus(); 406 | console.log("reset network"); 407 | }); 408 | }} 409 | > 410 | {localizationManager.getString(L.RESET_NETWORK)} 411 | 412 | 413 | 414 | 415 |
416 | ); 417 | }; 418 | 419 | const DeckyPluginRouterTest: FC = () => { 420 | return ( 421 | , 428 | route: "/tomoon-config/subscriptions", 429 | }, 430 | { 431 | title: localizationManager.getString(L.ABOUT), 432 | content: , 433 | route: "/tomoon-config/about", 434 | }, 435 | { 436 | title: localizationManager.getString(L.DEBUG), 437 | content: , 438 | route: "/tomoon-config/debug", 439 | }, 440 | ]} 441 | /> 442 | ); 443 | }; 444 | 445 | export default definePlugin(() => { 446 | // init USDPL WASM and connection to back-end 447 | (async function () { 448 | await backend.initBackend(); 449 | await backend.PyBackend.init(); 450 | await localizationManager.init(); 451 | usdplReady = true; 452 | backend.resolve(backend.getEnabled(), (v: boolean) => { 453 | enabledGlobal = v; 454 | }); 455 | ApiCallBackend.getConfig().then((res) => { 456 | if (res.data.status_code == 200) { 457 | enabledSkipProxy = res.data.skip_proxy; 458 | enabledOverrideDNS = res.data.override_dns; 459 | enhanced_mode = res.data.enhanced_mode; 460 | allow_remote_access = res.data.allow_remote_access; 461 | } 462 | }); 463 | })(); 464 | 465 | routerHook.addRoute("/tomoon-config", DeckyPluginRouterTest); 466 | 467 | return { 468 | title:
To Moon
, 469 | content: , 470 | icon: , 471 | onDismount() { 472 | routerHook.removeRoute("/tomoon-config"); 473 | }, 474 | }; 475 | }); 476 | -------------------------------------------------------------------------------- /src/pages/About.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { ButtonItem, PanelSectionRow, Navigation } from "@decky/ui"; 3 | import { FiGithub } from "react-icons/fi"; 4 | import { FaSteamSymbol } from "react-icons/fa"; 5 | import { TbBrandTelegram } from "react-icons/tb"; 6 | 7 | export const About: FC = () => { 8 | return ( 9 | // The outermost div is to push the content down into the visible area 10 | <> 11 |

14 | To Moon 15 |

16 | 17 | A network toolbox for SteamOS. 18 |
19 |
20 | 21 | } 23 | label="ToMoon" 24 | onClick={() => { 25 | Navigation.NavigateToExternalWeb( 26 | "https://github.com/YukiCoco/ToMoon" 27 | ); 28 | }} 29 | > 30 | GitHub Repo 31 | 32 | 33 |

36 | Developer 37 |

38 | 39 | } 41 | label="Sayo Kurisu" 42 | onClick={() => { 43 | Navigation.NavigateToExternalWeb( 44 | "https://steamcommunity.com/profiles/76561198217352855/" 45 | ); 46 | }} 47 | > 48 | Steam Profile 49 | 50 | 51 |

54 | Support 55 |

56 | 57 | Join our Telegram group for support. 58 |
59 |
60 | 61 | } 63 | label="@steamdecktalk" 64 | onClick={() => { 65 | Navigation.NavigateToExternalWeb( 66 | "https://github.com/YukiCoco/StaticFilesCDN/blob/main/deck_tg_2.jpg?raw=true" 67 | ); 68 | }} 69 | > 70 | Telegram Group 71 | 72 | 73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/pages/Debug.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { ButtonItem, PanelSectionRow } from "@decky/ui"; 3 | import { VscDebug } from "react-icons/vsc"; 4 | 5 | import * as backend from "../backend/backend"; 6 | 7 | export const Debug: FC = () => { 8 | return ( 9 | // The outermost div is to push the content down into the visible area 10 | <> 11 | 12 | } 14 | label="Debug" 15 | onClick={() => { 16 | backend.resolve(backend.createDebugLog(), () => {}); 17 | }} 18 | description="Debug Log is located at /tmp/tomoon.debug.log , please send it to the developer." 19 | > 20 | Generate Debug Log 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/pages/Subscriptions.tsx: -------------------------------------------------------------------------------- 1 | import { PanelSectionRow, TextField, ButtonItem } from "@decky/ui"; 2 | import { useReducer, useState, FC } from "react"; 3 | import { cleanPadding } from "../style"; 4 | import { SubList } from "../components"; 5 | import { QRCodeCanvas } from "qrcode.react"; 6 | 7 | import * as backend from "../backend/backend"; 8 | import axios from "axios"; 9 | import { localizationManager, L } from "../i18n"; 10 | 11 | interface SubProp { 12 | Subscriptions: Array; 13 | } 14 | 15 | export const Subscriptions: FC = ({ Subscriptions }) => { 16 | const [text, setText] = useState(""); 17 | const [downloadTips, setDownloadTips] = useState(""); 18 | const [subscriptions, updateSubscriptions] = useState(Subscriptions); 19 | const [downlaodBtnDisable, setDownlaodBtnDisable] = useState(false); 20 | const [updateBtnDisable, setUpdateBtnDisable] = useState(false); 21 | const [_, forceUpdate] = useReducer((x) => x + 1, 0); 22 | const [updateTips, setUpdateTips] = useState(""); 23 | const [QRPageUrl, setQRPageUrl] = useState(""); 24 | 25 | let checkStatusHandler: any; 26 | let checkUpdateStatusHandler: any; 27 | 28 | const refreshDownloadStatus = () => { 29 | backend.resolve(backend.getDownloadStatus(), (v: any) => { 30 | let response = v.toString(); 31 | switch (response) { 32 | case "Downloading": 33 | setDownloadTips("Downloading..."); 34 | break; 35 | case "Error": 36 | setDownloadTips("Download Error"); 37 | break; 38 | case "Failed": 39 | setDownloadTips("Download Failed"); 40 | break; 41 | case "Success": 42 | setDownloadTips("Download Succeeded"); 43 | // 刷新 Subs 44 | refreshSubs(); 45 | break; 46 | } 47 | if (response != "Downloading") { 48 | clearInterval(checkStatusHandler); 49 | setDownlaodBtnDisable(false); 50 | } 51 | }); 52 | }; 53 | 54 | const refreshUpdateStatus = () => { 55 | backend.resolve(backend.getUpdateStatus(), (v: any) => { 56 | let response = v.toString(); 57 | switch (response) { 58 | case "Downloading": 59 | setDownloadTips("Downloading... Please wait"); 60 | break; 61 | case "Error": 62 | setDownloadTips("Update Error"); 63 | break; 64 | case "Failed": 65 | setDownloadTips("Update Failed"); 66 | break; 67 | case "Success": 68 | setDownloadTips("Update Succeeded"); 69 | // 刷新 Subs 70 | refreshSubs(); 71 | break; 72 | } 73 | if (response != "Downloading") { 74 | clearInterval(checkUpdateStatusHandler); 75 | setUpdateBtnDisable(false); 76 | } 77 | }); 78 | }; 79 | 80 | const refreshSubs = () => { 81 | backend.resolve(backend.getSubList(), (v: String) => { 82 | let x: Array = JSON.parse(v.toString()); 83 | let re = new RegExp("(?<=subs/).+.yaml$"); 84 | let i = 0; 85 | let subs = x.map((x) => { 86 | let name = re.exec(x.path); 87 | return { 88 | id: i++, 89 | name: name![0], 90 | url: x.url, 91 | }; 92 | }); 93 | console.log("Subs refresh"); 94 | updateSubscriptions(subs); 95 | //console.log(sub); 96 | }); 97 | }; 98 | 99 | //获取 QR Page 100 | axios.get("http://127.0.0.1:55556/get_ip_address").then((r) => { 101 | if (r.data.status_code == 200) { 102 | setQRPageUrl(`http://${r.data.ip}:55556`); 103 | } else { 104 | setQRPageUrl(""); 105 | } 106 | }); 107 | 108 | console.log("load Subs page"); 109 | 110 | return ( 111 | <> 112 | 126 | 127 |
128 | 129 |
130 |
131 | setText(e?.target.value)} 135 | description={downloadTips} 136 | /> 137 |
138 | { 142 | setDownlaodBtnDisable(true); 143 | backend.resolve(backend.downloadSub(text), () => { 144 | console.log("download sub: " + text); 145 | }); 146 | checkStatusHandler = setInterval(refreshDownloadStatus, 500); 147 | }} 148 | > 149 | {localizationManager.getString(L.DOWNLOAD)} 150 | 151 | { 155 | setUpdateBtnDisable(true); 156 | backend.resolve(backend.updateSubs(), () => { 157 | console.log("update subs."); 158 | }); 159 | checkUpdateStatusHandler = setInterval(refreshUpdateStatus, 500); 160 | }} 161 | disabled={updateBtnDisable} 162 | > 163 | {localizationManager.getString(L.UPDATE_ALL)} 164 | 165 |
166 | 167 | {/* { 168 | subscriptions.map(x => { 169 | return ( 170 |
171 | { 173 | //删除订阅 174 | } 175 | }>Delete 176 |
177 | ); 178 | }) 179 | } */} 180 | 185 |
186 | 187 | ); 188 | }; 189 | -------------------------------------------------------------------------------- /src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Subscriptions"; 2 | export * from "./About"; 3 | export * from "./Debug"; -------------------------------------------------------------------------------- /src/style.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | 3 | export const mt8Style: CSSProperties = { 4 | marginTop: "8px" 5 | }; 6 | 7 | export const cleanPadding: CSSProperties = { 8 | padding: "0px !important" 9 | }; -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: string; 3 | export default content; 4 | } 5 | 6 | declare module "*.png" { 7 | const content: string; 8 | export default content; 9 | } 10 | 11 | declare module "*.jpg" { 12 | const content: string; 13 | export default content; 14 | } 15 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | declare global { 4 | namespace JSX { 5 | interface IntrinsicElements { 6 | [elemName: string]: any; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tomoon-web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /tomoon-web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tomoon Web Page 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tomoon-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tomoon-web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "autoprefixer": "^10.4.13", 13 | "axios": "^1.2.2", 14 | "postcss": "^8.4.21", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "react-scripts": "^5.0.1", 18 | "sweetalert2": "^11.7.0", 19 | "tailwindcss": "^3.2.4" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.0.26", 23 | "@types/react-dom": "^18.0.9", 24 | "@vitejs/plugin-react": "^3.0.0", 25 | "vite": "^4.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tomoon-web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /tomoon-web/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tomoon-web/src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YukiCoco/ToMoon/697d7da6e6b95f5b50306a4a6e76a7a1d196b326/tomoon-web/src/App.css -------------------------------------------------------------------------------- /tomoon-web/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import reactLogo from './assets/react.svg' 3 | import './App.css' 4 | import Swal from 'sweetalert2' 5 | import axios from 'axios' 6 | 7 | function App() { 8 | const [url, setUrl] = useState(""); 9 | const [isSubscribed, setIsSubscribed] = useState(true); 10 | 11 | const handleUrlChange = (event) => { 12 | setUrl(event.target.value); 13 | }; 14 | 15 | const handleToggleChange = () => { 16 | setIsSubscribed(!isSubscribed); 17 | }; 18 | 19 | return ( 20 |
21 |
22 |
23 |
24 |

25 | Tomoon 26 |

27 |
28 | 32 | 订阅转换 33 |
34 |
35 | 36 | 41 |
42 |
43 |
44 |
45 |
46 | ); 47 | } 48 | 49 | const on_download_btn_click = (url, isSubscribed) => { 50 | let baseHost = '/'; 51 | if (import.meta.env.DEV) { 52 | baseHost = 'http://127.0.0.1:55556/'; 53 | } 54 | Swal.fire({ 55 | iconColor: '#5E5F55', 56 | confirmButtonColor: '#5A6242', 57 | background: '#DEE7BF', 58 | title: "下载中", 59 | text: "正在下载订阅配置,请稍等......", 60 | icon: "info" 61 | }); 62 | Swal.showLoading(null); 63 | axios.post(baseHost + "download_sub", { 64 | link: url.trim(), 65 | subconv: isSubscribed 66 | }, { 67 | headers: { 'content-type': 'application/x-www-form-urlencoded' }, 68 | }).then((response) => { 69 | if (response.status === 200) { 70 | Swal.fire({ 71 | icon: 'success', 72 | iconColor: '#5E5F55', 73 | title: '完成', 74 | text: '已添加订阅', 75 | confirmButtonColor: '#5A6242', 76 | background: '#DEE7BF' 77 | }); 78 | } 79 | }).catch(error => { 80 | if (error.response) { 81 | Swal.fire({ 82 | icon: 'error', 83 | iconColor: '#5E5F55', 84 | title: '失败', 85 | text: error.response.data?.error?.message, 86 | confirmButtonColor: '#5A6242', 87 | background: '#DEE7BF' 88 | }); 89 | } 90 | }); 91 | 92 | } 93 | 94 | export default App 95 | -------------------------------------------------------------------------------- /tomoon-web/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tomoon-web/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .tomoon-input { 6 | height: 4rem; 7 | background: linear-gradient(0deg, rgba(90, 98, 66, 0.08), rgba(90, 98, 66, 0.08)), #FAFFE3; 8 | color: #474743; 9 | } 10 | 11 | .tomoon-button { 12 | height: 4rem; 13 | background: #5A6242; 14 | color: #FFFFFF; 15 | width: 4.25rem; 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | } 20 | 21 | .tomoon-svg { 22 | width: 2rem; /* You can adjust the size here to be responsive */ 23 | height: 2rem; /* You can adjust the size here to be responsive */ 24 | } 25 | 26 | .tomoon-title { 27 | color: #5A6242; 28 | } 29 | 30 | /* Toggle Switch */ 31 | .switch { 32 | position: relative; 33 | display: inline-block; 34 | width: 60px; 35 | height: 34px; 36 | } 37 | 38 | .switch input { 39 | opacity: 0; 40 | width: 0; 41 | height: 0; 42 | } 43 | 44 | .slider { 45 | position: absolute; 46 | cursor: pointer; 47 | top: 0; 48 | left: 0; 49 | right: 0; 50 | bottom: 0; 51 | background-color: #ccc; 52 | transition: 0.4s; 53 | } 54 | 55 | .slider:before { 56 | position: absolute; 57 | content: ""; 58 | height: 26px; 59 | width: 26px; 60 | left: 4px; 61 | bottom: 4px; 62 | background-color: white; 63 | transition: 0.4s; 64 | } 65 | 66 | input:checked + .slider { 67 | background-color: #5A6242; 68 | } 69 | 70 | input:checked + .slider:before { 71 | transform: translateX(26px); 72 | } 73 | 74 | /* Rounded sliders */ 75 | .slider.round { 76 | border-radius: 34px; 77 | } 78 | 79 | .slider.round:before { 80 | border-radius: 50%; 81 | } 82 | 83 | .text-xl.tomoon-title { 84 | color: #5A6242; 85 | } 86 | -------------------------------------------------------------------------------- /tomoon-web/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /tomoon-web/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | -------------------------------------------------------------------------------- /tomoon-web/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "ESNext", 5 | "target": "ES2020", 6 | "jsx": "react", 7 | "jsxFactory": "window.SP_REACT.createElement", 8 | "jsxFragmentFactory": "window.SP_REACT.Fragment", 9 | "declaration": false, 10 | "moduleResolution": "node", 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "esModuleInterop": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strict": true, 18 | "allowSyntheticDefaultImports": true, 19 | "skipLibCheck": true, 20 | "resolveJsonModule":true, 21 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 22 | "types": ["react/jsx-runtime", "react-dom"] 23 | }, 24 | "include": ["src"], 25 | "exclude": ["node_modules"] 26 | } -------------------------------------------------------------------------------- /usdpl/README.md: -------------------------------------------------------------------------------- 1 | [![Crates.io](https://img.shields.io/crates/v/usdpl-front?style=flat-square)](https://crates.io/crates/usdpl-front) 2 | 3 | # usdpl-front-front 4 | 5 | Front-end library to be called from Javascript. 6 | Targets WASM. 7 | 8 | In true Javascript tradition, this part of the library does not support error handling. 9 | 10 | -------------------------------------------------------------------------------- /usdpl/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "usdpl-front", 3 | "collaborators": [ 4 | "NGnius (Graham) " 5 | ], 6 | "description": "Universal Steam Deck Plugin Library front-end designed for WASM", 7 | "version": "0.7.0", 8 | "license": "GPL-3.0-only", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/NGnius/usdpl-rs" 12 | }, 13 | "files": [ 14 | "usdpl_front_bg.wasm", 15 | "usdpl_front.js", 16 | "usdpl_front.d.ts" 17 | ], 18 | "module": "usdpl_front.js", 19 | "types": "usdpl_front.d.ts", 20 | "sideEffects": false 21 | } -------------------------------------------------------------------------------- /usdpl/usdpl_front.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Initialize the front-end library 5 | * @param {number} port 6 | */ 7 | export function init_usdpl(port: number): void; 8 | /** 9 | * Get the targeted plugin framework, or "any" if unknown 10 | * @returns {string} 11 | */ 12 | export function target_usdpl(): string; 13 | /** 14 | * Get the UDSPL front-end version 15 | * @returns {string} 16 | */ 17 | export function version_usdpl(): string; 18 | /** 19 | * Get the targeted plugin framework, or "any" if unknown 20 | * @param {string} key 21 | * @param {any} value 22 | * @returns {any} 23 | */ 24 | export function set_value(key: string, value: any): any; 25 | /** 26 | * Get the targeted plugin framework, or "any" if unknown 27 | * @param {string} key 28 | * @returns {any} 29 | */ 30 | export function get_value(key: string): any; 31 | /** 32 | * Call a function on the back-end. 33 | * Returns null (None) if this fails for any reason. 34 | * @param {string} name 35 | * @param {any[]} parameters 36 | * @returns {Promise} 37 | */ 38 | export function call_backend(name: string, parameters: any[]): Promise; 39 | 40 | export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; 41 | 42 | export interface InitOutput { 43 | readonly memory: WebAssembly.Memory; 44 | readonly init_usdpl: (a: number) => void; 45 | readonly target_usdpl: (a: number) => void; 46 | readonly version_usdpl: (a: number) => void; 47 | readonly set_value: (a: number, b: number, c: number) => number; 48 | readonly get_value: (a: number, b: number) => number; 49 | readonly call_backend: (a: number, b: number, c: number, d: number) => number; 50 | readonly __wbindgen_export_0: (a: number) => number; 51 | readonly __wbindgen_export_1: (a: number, b: number, c: number) => number; 52 | readonly __wbindgen_export_2: WebAssembly.Table; 53 | readonly __wbindgen_export_3: (a: number, b: number, c: number) => void; 54 | readonly __wbindgen_add_to_stack_pointer: (a: number) => number; 55 | readonly __wbindgen_export_4: (a: number, b: number) => void; 56 | readonly __wbindgen_export_5: (a: number) => void; 57 | readonly __wbindgen_export_6: (a: number, b: number, c: number, d: number) => void; 58 | } 59 | 60 | /** 61 | * Synchronously compiles the given `bytes` and instantiates the WebAssembly module. 62 | * 63 | * @param {BufferSource} bytes 64 | * 65 | * @returns {InitOutput} 66 | */ 67 | export function initSync(bytes: BufferSource): InitOutput; 68 | 69 | /** 70 | * If `module_or_path` is {RequestInfo} or {URL}, makes a request and 71 | * for everything else, calls `WebAssembly.instantiate` directly. 72 | * 73 | * @param {InitInput | Promise} module_or_path 74 | * 75 | * @returns {Promise} 76 | */ 77 | export default function init (module_or_path?: InitInput | Promise): Promise; 78 | 79 | 80 | // USDPL customization 81 | export function init_embedded(); 82 | -------------------------------------------------------------------------------- /usdpl/usdpl_front_bg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YukiCoco/ToMoon/697d7da6e6b95f5b50306a4a6e76a7a1d196b326/usdpl/usdpl_front_bg.wasm -------------------------------------------------------------------------------- /usdpl/usdpl_front_bg.wasm.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | export const memory: WebAssembly.Memory; 4 | export function init_usdpl(a: number): void; 5 | export function target_usdpl(a: number): void; 6 | export function version_usdpl(a: number): void; 7 | export function set_value(a: number, b: number, c: number): number; 8 | export function get_value(a: number, b: number): number; 9 | export function call_backend(a: number, b: number, c: number, d: number): number; 10 | export function __wbindgen_export_0(a: number): number; 11 | export function __wbindgen_export_1(a: number, b: number, c: number): number; 12 | export const __wbindgen_export_2: WebAssembly.Table; 13 | export function __wbindgen_export_3(a: number, b: number, c: number): void; 14 | export function __wbindgen_add_to_stack_pointer(a: number): number; 15 | export function __wbindgen_export_4(a: number, b: number): void; 16 | export function __wbindgen_export_5(a: number): void; 17 | export function __wbindgen_export_6(a: number, b: number, c: number, d: number): void; 18 | --------------------------------------------------------------------------------