├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── ci-build.yaml │ ├── codeql.yml │ ├── manual-build-console.yml │ ├── manual-build.yml │ ├── release-notify.yml │ └── update-game-data.yml ├── .gitignore ├── LICENSE ├── api ├── __init__.py ├── cheats_api.py ├── common_api.py ├── common_response.py ├── ryujinx_api.py ├── save_manager_api.py ├── suyu_api.py ├── updater_api.py └── yuzu_api.py ├── build_tools ├── upx │ ├── COPYING │ ├── LICENSE │ ├── NEWS │ ├── README │ ├── THANKS.txt │ ├── upx-doc.html │ ├── upx-doc.txt │ ├── upx.1 │ └── upx.exe └── zip_files.py ├── changelog.md ├── config.py ├── doc ├── assets │ └── yuzu_user_id.jpg └── dev.md ├── exception ├── common_exception.py ├── download_exception.py └── install_exception.py ├── frontend ├── .browserslistrc ├── .editorconfig ├── .env.development ├── .env.production ├── .eslintrc-auto-import.json ├── .gitignore ├── README.md ├── bun.lock ├── env.d.ts ├── eslint.config.js ├── index.html ├── package.json ├── public │ └── favicon.ico ├── src │ ├── App.vue │ ├── assets │ │ ├── icon.png │ │ ├── ryujinx.webp │ │ ├── suyu.png │ │ ├── telegram.webp │ │ ├── telegram_black.webp │ │ └── yuzu.webp │ ├── auto-imports.d.ts │ ├── components.d.ts │ ├── components │ │ ├── ChangeLogDialog.vue │ │ ├── ConsoleDialog.vue │ │ ├── DialogTitle.vue │ │ ├── FaqGroup.vue │ │ ├── MarkdownContentBox.vue │ │ ├── NewVersionDialog.vue │ │ ├── OtherLinkItem.vue │ │ ├── README.md │ │ ├── SimplePage.vue │ │ ├── SpeedDial.vue │ │ ├── YuzuSaveCommonPart.vue │ │ └── YuzuSaveRestoreTab.vue │ ├── layouts │ │ ├── AppBar.vue │ │ ├── AppDrawer.vue │ │ ├── README.md │ │ ├── View.vue │ │ └── default.vue │ ├── main.ts │ ├── pages │ │ ├── about.vue │ │ ├── faq.vue │ │ ├── index.vue │ │ ├── keys.vue │ │ ├── otherLinks.vue │ │ ├── ryujinx.vue │ │ ├── settings.vue │ │ ├── suyu.vue │ │ ├── yuzu.vue │ │ ├── yuzuCheatsManagement.vue │ │ └── yuzuSaveManagement.vue │ ├── plugins │ │ ├── README.md │ │ ├── index.ts │ │ ├── mitt.ts │ │ └── vuetify.ts │ ├── router │ │ └── index.ts │ ├── stores │ │ ├── ConfigStore.ts │ │ ├── ConsoleDialogStore.ts │ │ ├── README.md │ │ ├── YuzuSaveStore.ts │ │ ├── app.ts │ │ └── index.ts │ ├── styles │ │ ├── README.md │ │ └── settings.scss │ ├── typed-router.d.ts │ ├── types │ │ ├── DefaultConfig.ts │ │ └── index.ts │ └── utils │ │ ├── common.ts │ │ └── markdown.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.mts ├── game_data.json ├── hooks └── hook-api.py ├── main.py ├── main_devnull.py ├── module ├── aria2c.exe ├── cheats.py ├── common.py ├── dialogs.py ├── downloader.py ├── external │ └── bat_scripts.py ├── firmware.py ├── hosts.py ├── msg_notifier.py ├── network.py ├── nsz_wrapper.py ├── ryujinx.py ├── save_manager.py ├── sentry.py ├── suyu.py ├── updater.py └── yuzu.py ├── package.bat ├── package_all.bat ├── pyproject.toml ├── readme.md ├── repository ├── domain │ └── release_info.py ├── my_info.py ├── ryujinx.py ├── suyu.py └── yuzu.py ├── requirements.txt ├── send_release_notify.py ├── storage.py ├── ui.py ├── ui_webview.py ├── update_game_data.py ├── update_requirements.bat ├── utils ├── admin.py ├── common.py ├── doh.py ├── hardware.py ├── package.py ├── string_util.py └── webview2.py └── uv.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 请在描述问题的同时上传 ns-emu-tools.log 这个文件。 11 | 如果问题与 aria2 下载有关,请将 aria2.log 这个文件也带上。 12 | (将文件拖拽至输入框内即可) 13 | -------------------------------------------------------------------------------- /.github/workflows/ci-build.yaml: -------------------------------------------------------------------------------- 1 | name: CI build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: ['windows-latest'] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.12' 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | - uses: oven-sh/setup-bun@v2 28 | - run: | 29 | cd .\frontend 30 | bun install 31 | bun run build 32 | - run: | 33 | pip install uv 34 | uv sync 35 | .\package_all.bat 36 | # pip install pywebview 37 | # pyinstaller --noconfirm --onefile --console --icon "./web/favicon.ico" --add-data "./module/*.exe;./module/" --add-data "./web;web/" --additional-hooks-dir=".\\hooks" "./ui_webview.py" --name "NsEmuTools-webview" 38 | - uses: actions/upload-artifact@v4 39 | with: 40 | path: dist/NsEmuTools.exe 41 | name: NsEmuTools 42 | - uses: actions/upload-artifact@v4 43 | with: 44 | path: dist/NsEmuTools-console.exe 45 | name: NsEmuTools-console 46 | - uses: actions/upload-artifact@v4 47 | with: 48 | path: dist/NsEmuTools 49 | name: NsEmuTools-dir 50 | - name: Release 51 | uses: softprops/action-gh-release@v2.2.2 52 | if: startsWith(github.ref, 'refs/tags/') 53 | with: 54 | draft: true 55 | files: | 56 | dist/*.exe 57 | dist/*.7z 58 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '29 21 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript', 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /.github/workflows/manual-build-console.yml: -------------------------------------------------------------------------------- 1 | name: Manual build console 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | os: ['windows-latest'] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.12' 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | - uses: oven-sh/setup-bun@v2 23 | - run: | 24 | cd .\frontend 25 | bun install 26 | bun run build 27 | - run: | 28 | pip install uv 29 | uv sync 30 | uv run pyinstaller --noconfirm --onefile --console --icon "./web/favicon.ico" --add-data "./module/*.exe;./module/" --add-data "./web;web/" "./main.py" --additional-hooks-dir=".\\hooks" --name "NsEmuTools-console" 31 | - uses: actions/upload-artifact@v4 32 | with: 33 | path: dist/* 34 | name: NsEmuTools-console 35 | -------------------------------------------------------------------------------- /.github/workflows/manual-build.yml: -------------------------------------------------------------------------------- 1 | name: Manual build 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | os: ['windows-latest'] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.12' 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | - uses: oven-sh/setup-bun@v2 23 | - run: | 24 | cd .\frontend 25 | bun install 26 | bun run build 27 | - run: | 28 | pip install uv 29 | uv sync 30 | uv run pyinstaller --noconfirm --onefile --windowed --icon "./web/favicon.ico" --add-data "./module/*.exe;./module/" --add-data "./web;web/" --additional-hooks-dir=".\\hooks" "./main_devnull.py" --name "NsEmuTools" 31 | - uses: actions/upload-artifact@v4 32 | with: 33 | path: dist/* 34 | name: NsEmuTools 35 | -------------------------------------------------------------------------------- /.github/workflows/release-notify.yml: -------------------------------------------------------------------------------- 1 | name: Release notify 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [released] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.10' 18 | - name: Install dependencies 19 | run: pip install requests 20 | - name: run send_release_notify.py 21 | env: 22 | TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} 23 | TG_SEND_TO: ${{ secrets.TELEGRAM_TO }} 24 | run: | 25 | python send_release_notify.py -------------------------------------------------------------------------------- /.github/workflows/update-game-data.yml: -------------------------------------------------------------------------------- 1 | name: Update game data 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 0 * * 0,3' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Setup Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: '3.10' 17 | architecture: x64 18 | - name: Install dependencies 19 | run: pip install requests requests_cache 20 | - name: run update_game_data.py 21 | run: | 22 | python update_game_data.py 23 | - name: Commit changes 24 | uses: EndBug/add-and-commit@v9 25 | with: 26 | author_name: github-actions 27 | author_email: 41898282+github-actions[bot]@users.noreply.github.com 28 | message: 'game data auto update' 29 | add: '.' 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### JetBrains template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | .idea/ 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # Generated files 13 | .idea/**/contentModel.xml 14 | 15 | # Sensitive or high-churn files 16 | .idea/**/dataSources/ 17 | .idea/**/dataSources.ids 18 | .idea/**/dataSources.local.xml 19 | .idea/**/sqlDataSources.xml 20 | .idea/**/dynamic.xml 21 | .idea/**/uiDesigner.xml 22 | .idea/**/dbnavigator.xml 23 | 24 | # Gradle 25 | .idea/**/gradle.xml 26 | .idea/**/libraries 27 | 28 | # Gradle and Maven with auto-import 29 | # When using Gradle or Maven with auto-import, you should exclude module files, 30 | # since they will be recreated, and may cause churn. Uncomment if using 31 | # auto-import. 32 | # .idea/artifacts 33 | # .idea/compiler.xml 34 | # .idea/jarRepositories.xml 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | 74 | ### Python template 75 | # Byte-compiled / optimized / DLL files 76 | __pycache__/ 77 | *.py[cod] 78 | *$py.class 79 | 80 | # C extensions 81 | *.so 82 | 83 | # Distribution / packaging 84 | .Python 85 | build/ 86 | develop-eggs/ 87 | dist/ 88 | downloads/ 89 | eggs/ 90 | .eggs/ 91 | lib/ 92 | lib64/ 93 | parts/ 94 | sdist/ 95 | var/ 96 | wheels/ 97 | share/python-wheels/ 98 | *.egg-info/ 99 | .installed.cfg 100 | *.egg 101 | MANIFEST 102 | 103 | # PyInstaller 104 | # Usually these files are written by a python script from a template 105 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 106 | *.manifest 107 | *.spec 108 | 109 | # Installer logs 110 | pip-log.txt 111 | pip-delete-this-directory.txt 112 | 113 | # Unit test / coverage reports 114 | htmlcov/ 115 | .tox/ 116 | .nox/ 117 | .coverage 118 | .coverage.* 119 | .cache 120 | nosetests.xml 121 | coverage.xml 122 | *.cover 123 | *.py,cover 124 | .hypothesis/ 125 | .pytest_cache/ 126 | cover/ 127 | 128 | # Translations 129 | *.mo 130 | *.pot 131 | 132 | # Django stuff: 133 | *.log 134 | local_settings.py 135 | db.sqlite3 136 | db.sqlite3-journal 137 | 138 | # Flask stuff: 139 | instance/ 140 | .webassets-cache 141 | 142 | # Scrapy stuff: 143 | .scrapy 144 | 145 | # Sphinx documentation 146 | docs/_build/ 147 | 148 | # PyBuilder 149 | .pybuilder/ 150 | target/ 151 | 152 | # Jupyter Notebook 153 | .ipynb_checkpoints 154 | 155 | # IPython 156 | profile_default/ 157 | ipython_config.py 158 | 159 | # pyenv 160 | # For a library or package, you might want to ignore these files since the code is 161 | # intended to run in multiple environments; otherwise, check them in: 162 | # .python-version 163 | 164 | # pipenv 165 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 166 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 167 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 168 | # install all needed dependencies. 169 | #Pipfile.lock 170 | 171 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 172 | __pypackages__/ 173 | 174 | # Celery stuff 175 | celerybeat-schedule 176 | celerybeat.pid 177 | 178 | # SageMath parsed files 179 | *.sage.py 180 | 181 | # Environments 182 | .env 183 | .venv 184 | env/ 185 | venv/ 186 | ENV/ 187 | env.bak/ 188 | venv.bak/ 189 | 190 | # Spyder project settings 191 | .spyderproject 192 | .spyproject 193 | 194 | # Rope project settings 195 | .ropeproject 196 | 197 | # mkdocs documentation 198 | /site 199 | 200 | # mypy 201 | .mypy_cache/ 202 | .dmypy.json 203 | dmypy.json 204 | 205 | # Pyre type checker 206 | .pyre/ 207 | 208 | # pytype static type analyzer 209 | .pytype/ 210 | 211 | # Cython debug symbols 212 | cython_debug/ 213 | 214 | config.json 215 | yuzu-config.json 216 | download/ 217 | output/ 218 | web/ 219 | http_cache.sqlite 220 | **/CloudflareSpeedTest/ 221 | storage.json 222 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pkgutil 3 | from importlib import import_module 4 | 5 | __all__ = list(module for _, module, _ in pkgutil.iter_modules([os.path.dirname(__file__)])) 6 | 7 | for m in __all__: 8 | import_module(f'.{m}', 'api') 9 | -------------------------------------------------------------------------------- /api/cheats_api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from config import config 3 | from api.common_response import * 4 | 5 | import eel 6 | 7 | 8 | @eel.expose 9 | def scan_all_cheats_folder(): 10 | from module.cheats import scan_all_cheats_folder 11 | from module.yuzu import get_yuzu_load_path 12 | try: 13 | return success_response(scan_all_cheats_folder(get_yuzu_load_path())) 14 | except Exception as e: 15 | return exception_response(e) 16 | 17 | 18 | @eel.expose 19 | def list_all_cheat_files_from_folder(folder_path: str): 20 | from module.cheats import list_all_cheat_files_from_folder 21 | try: 22 | return success_response(list_all_cheat_files_from_folder(folder_path)) 23 | except Exception as e: 24 | return exception_response(e) 25 | 26 | 27 | @eel.expose 28 | def load_cheat_chunk_info(cheat_file_path: str): 29 | from module.cheats import load_cheat_chunk_info 30 | try: 31 | return success_response(load_cheat_chunk_info(cheat_file_path)) 32 | except Exception as e: 33 | return exception_response(e) 34 | 35 | 36 | @eel.expose 37 | def update_current_cheats(enable_titles: List[str], cheat_file_path: str): 38 | from module.cheats import update_current_cheats 39 | try: 40 | return success_response(update_current_cheats(enable_titles, cheat_file_path)) 41 | except Exception as e: 42 | return exception_response(e) 43 | 44 | 45 | @eel.expose 46 | def open_cheat_mod_folder(folder_path: str): 47 | from module.cheats import open_cheat_mod_folder 48 | try: 49 | return success_response(open_cheat_mod_folder(folder_path)) 50 | except Exception as e: 51 | return exception_response(e) 52 | 53 | 54 | @eel.expose 55 | def get_game_data(): 56 | from module.cheats import get_game_data 57 | try: 58 | return success_response(get_game_data()) 59 | except Exception as e: 60 | return exception_response(e) 61 | -------------------------------------------------------------------------------- /api/common_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Dict 3 | 4 | import eel 5 | from api.common_response import * 6 | from config import current_version, shared 7 | import logging 8 | import time 9 | from module.firmware import get_firmware_infos 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | @eel.expose 15 | def get_available_firmware_infos(): 16 | try: 17 | return success_response(get_firmware_infos()) 18 | except Exception as e: 19 | return exception_response(e) 20 | 21 | 22 | @generic_api 23 | def get_available_firmware_sources(): 24 | from module.firmware import get_available_firmware_sources 25 | return get_available_firmware_sources() 26 | 27 | 28 | @eel.expose 29 | def get_current_version(): 30 | shared['ui_init_time'] = time.time() 31 | return success_response(current_version) 32 | 33 | 34 | @eel.expose 35 | def update_last_open_emu_page(page): 36 | from config import update_last_open_emu_page 37 | update_last_open_emu_page(page) 38 | 39 | 40 | @eel.expose 41 | def update_dark_state(dark): 42 | from config import update_dark_state 43 | update_dark_state(dark) 44 | 45 | 46 | @eel.expose 47 | def detect_firmware_version(emu_type: str): 48 | from module.firmware import detect_firmware_version 49 | try: 50 | detect_firmware_version(emu_type) 51 | return success_response() 52 | except Exception as e: 53 | return exception_response(e) 54 | 55 | 56 | @eel.expose 57 | def get_config(): 58 | from config import config 59 | return success_response(config.to_dict()) 60 | 61 | 62 | @eel.expose 63 | def open_url_in_default_browser(url): 64 | import webbrowser 65 | webbrowser.open(url, new=0, autoraise=True) 66 | 67 | 68 | @eel.expose 69 | def update_setting(setting: Dict[str, object]): 70 | from config import config, update_setting 71 | update_setting(setting) 72 | from module.network import session, get_durable_cache_session, get_proxies 73 | session.proxies.update(get_proxies()) 74 | get_durable_cache_session().proxies.update(get_proxies()) 75 | return success_response(config.to_dict()) 76 | 77 | 78 | @eel.expose 79 | def get_net_release_info_by_tag(tag: str): 80 | from repository.my_info import get_release_info_by_tag 81 | try: 82 | return success_response(get_release_info_by_tag(tag)) 83 | except Exception as e: 84 | return exception_response(e) 85 | 86 | 87 | @eel.expose 88 | def stop_download(): 89 | from module.downloader import stop_download 90 | try: 91 | return success_response(stop_download()) 92 | except Exception as e: 93 | return exception_response(e) 94 | 95 | 96 | @eel.expose 97 | def pause_download(): 98 | from module.downloader import pause_download 99 | try: 100 | return success_response(pause_download()) 101 | except Exception as e: 102 | return exception_response(e) 103 | 104 | 105 | @eel.expose 106 | def load_history_path(emu_type: str): 107 | from storage import storage 108 | from config import config 109 | emu_type = emu_type.lower() 110 | if emu_type == 'yuzu': 111 | return success_response(list(_merge_to_set(storage.yuzu_history.keys(), config.yuzu.yuzu_path))) 112 | elif emu_type == 'suyu': 113 | return success_response(list(_merge_to_set(storage.suyu_history.keys(), config.suyu.path))) 114 | else: 115 | return success_response(list(_merge_to_set(storage.ryujinx_history.keys(), config.ryujinx.path))) 116 | 117 | 118 | @eel.expose 119 | def delete_history_path(emu_type: str, path_to_delete: str): 120 | from storage import delete_history_path 121 | delete_history_path(emu_type, path_to_delete) 122 | return success_response() 123 | 124 | 125 | @eel.expose 126 | def get_github_mirrors(): 127 | from module.network import get_github_mirrors 128 | try: 129 | return success_response(get_github_mirrors()) 130 | except Exception as e: 131 | return exception_response(e) 132 | 133 | 134 | @eel.expose 135 | def update_window_size(width: int, height: int): 136 | from config import dump_config, config, shared 137 | if shared['mode'] == 'webview': 138 | from ui_webview import get_window_size 139 | width, height = get_window_size() 140 | if width == config.setting.ui.width and height == config.setting.ui.height: 141 | return success_response() 142 | config.setting.ui.width = width 143 | config.setting.ui.height = height 144 | logger.info(f'saving window size: {(config.setting.ui.width, config.setting.ui.height)}') 145 | dump_config() 146 | return success_response() 147 | 148 | 149 | @generic_api 150 | def get_storage(): 151 | from storage import storage 152 | return storage.to_dict() 153 | 154 | 155 | @generic_api 156 | def delete_path(path: str): 157 | from module.common import delete_path 158 | return delete_path(path) 159 | 160 | 161 | def _merge_to_set(*cols): 162 | from collections.abc import Iterable 163 | s = set() 164 | for c in cols: 165 | if isinstance(c, Iterable) and not isinstance(c, str): 166 | for i in c: 167 | s.add(i) 168 | else: 169 | s.add(c) 170 | return s 171 | -------------------------------------------------------------------------------- /api/common_response.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from module.msg_notifier import send_notify 3 | from exception.common_exception import * 4 | from exception.download_exception import * 5 | from exception.install_exception import * 6 | from requests.exceptions import ConnectionError 7 | import eel 8 | import orjson 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def success_response(data=None, msg=None): 15 | if data: 16 | data = orjson.loads(orjson.dumps(data)) 17 | return {'code': 0, 'data': data, 'msg': msg} 18 | 19 | 20 | def exception_response(ex): 21 | import traceback 22 | if type(ex) in exception_handler_map: 23 | return exception_handler_map[type(ex)](ex) 24 | logger.error(ex, exc_info=True) 25 | traceback_str = "".join(traceback.format_exception(ex)) 26 | send_notify(f'出现异常, {traceback_str}') 27 | return error_response(999, str(ex)) 28 | 29 | 30 | def version_not_found_handler(ex: VersionNotFoundException): 31 | logger.info(f'{str(ex)}') 32 | send_notify(f'无法获取 {ex.branch} 分支的 [{ex.target_version}] 版本信息') 33 | return error_response(404, str(ex)) 34 | 35 | 36 | def md5_not_found_handler(ex: Md5NotMatchException): 37 | logger.info(f'{str(ex)}') 38 | send_notify(f'固件文件 md5 不匹配, 请重新下载') 39 | return error_response(501, str(ex)) 40 | 41 | 42 | def download_interrupted_handler(ex: DownloadInterrupted): 43 | logger.info(f'{str(ex)}') 44 | send_notify(f'下载任务被终止') 45 | return error_response(601, str(ex)) 46 | 47 | 48 | def download_paused_handler(ex: DownloadPaused): 49 | logger.info(f'{str(ex)}') 50 | send_notify(f'下载任务被暂停') 51 | return error_response(602, str(ex)) 52 | 53 | 54 | def download_not_completed_handler(ex: DownloadNotCompleted): 55 | logger.info(f'{str(ex)}') 56 | send_notify(f'下载任务 [{ex.name}] 未完成, 状态: {ex.status}') 57 | return error_response(603, str(ex)) 58 | 59 | 60 | def fail_to_copy_files_handler(ex: FailToCopyFiles): 61 | logger.exception(ex.raw_exception) 62 | send_notify(f'{ex.msg}, 这可能是由于相关文件被占用或者没有相关目录的写入权限造成的') 63 | send_notify(f'请检查相关程序是否已经关闭, 或者重启一下系统试试') 64 | return error_response(701, str(ex)) 65 | 66 | 67 | def ignored_exception_handler(ex): 68 | logger.info(f'{str(ex)}') 69 | return error_response(801, str(ex)) 70 | 71 | 72 | def connection_error_handler(ex): 73 | import traceback 74 | traceback_str = "".join([s for s in traceback.format_exception(ex) if s.strip() != '']) 75 | logger.info(f'{str(ex)}\n{traceback_str}') 76 | send_notify(f'出现异常, {traceback_str}') 77 | return error_response(999, str(ex)) 78 | 79 | 80 | exception_handler_map = { 81 | VersionNotFoundException: version_not_found_handler, 82 | Md5NotMatchException: md5_not_found_handler, 83 | DownloadInterrupted: download_interrupted_handler, 84 | DownloadPaused: download_paused_handler, 85 | DownloadNotCompleted: download_not_completed_handler, 86 | FailToCopyFiles: fail_to_copy_files_handler, 87 | IgnoredException: ignored_exception_handler, 88 | ConnectionError: connection_error_handler, 89 | } 90 | 91 | 92 | def generic_api(func): 93 | def wrapper(*args, **kw): 94 | try: 95 | return success_response(func(*args, **kw)) 96 | except Exception as e: 97 | return exception_response(e) 98 | eel._expose(func.__name__, wrapper) 99 | return wrapper 100 | 101 | 102 | def error_response(code, msg): 103 | return {'code': code, 'msg': msg} 104 | 105 | 106 | __all__ = ['success_response', 'exception_response', 'error_response', 'generic_api'] 107 | -------------------------------------------------------------------------------- /api/ryujinx_api.py: -------------------------------------------------------------------------------- 1 | import eel 2 | from api.common_response import success_response, exception_response, error_response 3 | from repository.ryujinx import get_all_ryujinx_release_infos 4 | from config import config 5 | import logging 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @eel.expose 12 | def open_ryujinx_keys_folder(): 13 | from module.ryujinx import open_ryujinx_keys_folder 14 | open_ryujinx_keys_folder() 15 | return success_response() 16 | 17 | 18 | @eel.expose 19 | def get_ryujinx_config(): 20 | return config.ryujinx.to_dict() 21 | 22 | 23 | @eel.expose 24 | def ask_and_update_ryujinx_path(): 25 | from module.dialogs import ask_folder 26 | folder = ask_folder() 27 | logger.info(f'select folder: {folder}') 28 | if folder: 29 | from module.ryujinx import update_ryujinx_path 30 | update_ryujinx_path(folder) 31 | return success_response(msg=f'修改 ryujinx 目录至 {folder}') 32 | else: 33 | return error_response(100, '修改已取消') 34 | 35 | 36 | @eel.expose 37 | def update_ryujinx_path(folder: str): 38 | from module.ryujinx import update_ryujinx_path 39 | update_ryujinx_path(folder) 40 | return success_response(msg=f'修改 ryujinx 目录至 {folder}') 41 | 42 | 43 | @eel.expose 44 | def get_ryujinx_release_infos(): 45 | try: 46 | return success_response(get_all_ryujinx_release_infos(config.ryujinx.branch)) 47 | except Exception as e: 48 | return exception_response(e) 49 | 50 | 51 | @eel.expose 52 | def detect_ryujinx_version(): 53 | try: 54 | from module.ryujinx import detect_ryujinx_version 55 | return success_response(detect_ryujinx_version()) 56 | except Exception as e: 57 | return exception_response(e) 58 | 59 | 60 | @eel.expose 61 | def start_ryujinx(): 62 | from module.ryujinx import start_ryujinx 63 | try: 64 | start_ryujinx() 65 | return success_response() 66 | except Exception as e: 67 | return exception_response(e) 68 | 69 | 70 | @eel.expose 71 | def install_ryujinx(version, branch): 72 | if not version or version == '': 73 | return {'msg': f'无效的版本 {version}'} 74 | from module.ryujinx import install_ryujinx_by_version 75 | try: 76 | return success_response(msg=install_ryujinx_by_version(version, branch)) 77 | except Exception as e: 78 | return exception_response(e) 79 | 80 | 81 | @eel.expose 82 | def install_ryujinx_firmware(version): 83 | if not version or version == '': 84 | return {'msg': f'无效的版本 {version}'} 85 | from module.ryujinx import install_firmware_to_ryujinx 86 | try: 87 | return success_response(msg=install_firmware_to_ryujinx(version)) 88 | except Exception as e: 89 | return exception_response(e) 90 | 91 | 92 | @eel.expose 93 | def switch_ryujinx_branch(branch: str): 94 | from config import dump_config 95 | if branch not in {'mainline', 'canary'}: 96 | return error_response(-1, f'Invalidate branch: {branch}') 97 | target_branch = branch 98 | logger.info(f'switch ryujinx branch to {target_branch}') 99 | config.ryujinx.branch = target_branch 100 | dump_config() 101 | return success_response(config.ryujinx.to_dict()) 102 | 103 | 104 | @eel.expose 105 | def load_ryujinx_change_log(): 106 | from repository.ryujinx import load_ryujinx_change_log 107 | try: 108 | return success_response(load_ryujinx_change_log(config.ryujinx.branch)) 109 | except Exception as e: 110 | return exception_response(e) 111 | 112 | -------------------------------------------------------------------------------- /api/save_manager_api.py: -------------------------------------------------------------------------------- 1 | from config import config 2 | from api.common_response import * 3 | 4 | 5 | @generic_api 6 | def get_users_in_save(): 7 | from module.save_manager import get_users_in_save 8 | return get_users_in_save() 9 | 10 | 11 | @generic_api 12 | def list_all_games_by_user_folder(folder: str): 13 | from module.save_manager import list_all_games_by_user_folder 14 | return list_all_games_by_user_folder(folder) 15 | 16 | 17 | @generic_api 18 | def ask_and_update_yuzu_save_backup_folder(): 19 | from module.save_manager import ask_and_update_yuzu_save_backup_folder 20 | return ask_and_update_yuzu_save_backup_folder() 21 | 22 | 23 | @generic_api 24 | def backup_yuzu_save_folder(folder: str): 25 | from module.save_manager import backup_folder 26 | return backup_folder(folder) 27 | 28 | 29 | @generic_api 30 | def open_yuzu_save_backup_folder(): 31 | from module.save_manager import open_yuzu_save_backup_folder 32 | return open_yuzu_save_backup_folder() 33 | 34 | 35 | @generic_api 36 | def list_all_yuzu_backups(): 37 | from module.save_manager import list_all_yuzu_backups 38 | return list_all_yuzu_backups() 39 | 40 | 41 | @generic_api 42 | def restore_yuzu_save_from_backup(user_folder_name: str, backup_path: str): 43 | from module.save_manager import restore_yuzu_save_from_backup 44 | return restore_yuzu_save_from_backup(user_folder_name, backup_path) 45 | -------------------------------------------------------------------------------- /api/suyu_api.py: -------------------------------------------------------------------------------- 1 | import eel 2 | from api.common_response import success_response, exception_response, error_response 3 | from config import config, dump_config 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | @eel.expose 10 | def open_suyu_keys_folder(): 11 | from module.suyu import open_suyu_keys_folder 12 | open_suyu_keys_folder() 13 | return success_response() 14 | 15 | 16 | @eel.expose 17 | def get_suyu_config(): 18 | return config.suyu.to_dict() 19 | 20 | 21 | @eel.expose 22 | def ask_and_update_suyu_path(): 23 | from module.dialogs import ask_folder 24 | folder = ask_folder() 25 | logger.info(f'select folder: {folder}') 26 | if folder: 27 | from module.suyu import update_suyu_path 28 | update_suyu_path(folder) 29 | return success_response(msg=f'修改 suyu 目录至 {folder}') 30 | else: 31 | return error_response(100, '修改已取消') 32 | 33 | 34 | @eel.expose 35 | def update_suyu_path(folder: str): 36 | from module.suyu import update_suyu_path 37 | update_suyu_path(folder) 38 | return success_response(msg=f'修改 suyu 目录至 {folder}') 39 | 40 | 41 | # @eel.expose 42 | # def detect_suyu_version(): 43 | # try: 44 | # from module.suyu import detect_suyu_version 45 | # return success_response(detect_suyu_version()) 46 | # except Exception as e: 47 | # return exception_response(e) 48 | 49 | 50 | @eel.expose 51 | def start_suyu(): 52 | from module.suyu import start_suyu 53 | try: 54 | start_suyu() 55 | return success_response() 56 | except Exception as e: 57 | return exception_response(e) 58 | 59 | 60 | @eel.expose 61 | def install_suyu(version, branch): 62 | if not version or version == '': 63 | return error_response(404, f'无效的版本 {version}') 64 | from module.suyu import install_suyu 65 | try: 66 | return success_response(msg=install_suyu(version)) 67 | except Exception as e: 68 | return exception_response(e) 69 | 70 | 71 | @eel.expose 72 | def install_suyu_firmware(version): 73 | if not version or version == '': 74 | return error_response(404, f'无效的版本 {version}') 75 | from module.suyu import install_firmware_to_suyu 76 | try: 77 | return success_response(msg=install_firmware_to_suyu(version)) 78 | except Exception as e: 79 | return exception_response(e) 80 | 81 | 82 | @eel.expose 83 | def get_all_suyu_release_versions(): 84 | from repository.suyu import get_all_suyu_release_versions 85 | try: 86 | return success_response(get_all_suyu_release_versions()) 87 | except Exception as e: 88 | return exception_response(e) 89 | 90 | 91 | # @eel.expose 92 | # def get_suyu_commit_logs(): 93 | # from module.suyu import get_suyu_commit_logs 94 | # try: 95 | # return success_response(get_suyu_commit_logs()) 96 | # except Exception as e: 97 | # return exception_response(e) 98 | -------------------------------------------------------------------------------- /api/updater_api.py: -------------------------------------------------------------------------------- 1 | from config import config 2 | from api.common_response import * 3 | 4 | import eel 5 | 6 | 7 | @eel.expose 8 | def check_update(): 9 | from module.updater import check_update 10 | has_update, latest_version = check_update() 11 | return success_response(has_update, latest_version) 12 | 13 | 14 | @eel.expose 15 | def download_net_by_tag(tag: str): 16 | from module.updater import download_net_by_tag 17 | try: 18 | return success_response(download_net_by_tag(tag)) 19 | except Exception as e: 20 | return exception_response(e) 21 | 22 | 23 | @eel.expose 24 | def update_net_by_tag(tag: str): 25 | from module.updater import update_self_by_tag 26 | try: 27 | return success_response(update_self_by_tag(tag)) 28 | except Exception as e: 29 | return exception_response(e) 30 | 31 | 32 | @eel.expose 33 | def load_change_log(): 34 | from repository.my_info import load_change_log 35 | try: 36 | return success_response(load_change_log()) 37 | except Exception as e: 38 | exception_response(e) 39 | -------------------------------------------------------------------------------- /api/yuzu_api.py: -------------------------------------------------------------------------------- 1 | import eel 2 | from api.common_response import success_response, exception_response, error_response 3 | from repository.yuzu import get_all_yuzu_release_infos 4 | from config import config, dump_config 5 | import logging 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | @eel.expose 11 | def open_yuzu_keys_folder(): 12 | from module.yuzu import open_yuzu_keys_folder 13 | open_yuzu_keys_folder() 14 | return success_response() 15 | 16 | 17 | @eel.expose 18 | def get_yuzu_config(): 19 | return config.yuzu.to_dict() 20 | 21 | 22 | @eel.expose 23 | def ask_and_update_yuzu_path(): 24 | from module.dialogs import ask_folder 25 | folder = ask_folder() 26 | logger.info(f'select folder: {folder}') 27 | if folder: 28 | from module.yuzu import update_yuzu_path 29 | update_yuzu_path(folder) 30 | return success_response(msg=f'修改 yuzu 目录至 {folder}') 31 | else: 32 | return error_response(100, '修改已取消') 33 | 34 | 35 | @eel.expose 36 | def update_yuzu_path(folder: str): 37 | from module.yuzu import update_yuzu_path 38 | update_yuzu_path(folder) 39 | return success_response(msg=f'修改 yuzu 目录至 {folder}') 40 | 41 | 42 | @eel.expose 43 | def detect_yuzu_version(): 44 | try: 45 | from module.yuzu import detect_yuzu_version 46 | return success_response(detect_yuzu_version()) 47 | except Exception as e: 48 | return exception_response(e) 49 | 50 | 51 | @eel.expose 52 | def start_yuzu(): 53 | from module.yuzu import start_yuzu 54 | try: 55 | start_yuzu() 56 | return success_response() 57 | except Exception as e: 58 | return exception_response(e) 59 | 60 | 61 | @eel.expose 62 | def install_yuzu(version, branch): 63 | if not version or version == '': 64 | return error_response(404, f'无效的版本 {version}') 65 | from module.yuzu import install_yuzu 66 | try: 67 | return success_response(msg=install_yuzu(version, branch)) 68 | except Exception as e: 69 | return exception_response(e) 70 | 71 | 72 | @eel.expose 73 | def install_yuzu_firmware(version): 74 | if not version or version == '': 75 | return error_response(404, f'无效的版本 {version}') 76 | from module.yuzu import install_firmware_to_yuzu 77 | try: 78 | return success_response(msg=install_firmware_to_yuzu(version)) 79 | except Exception as e: 80 | return exception_response(e) 81 | 82 | 83 | @eel.expose 84 | def switch_yuzu_branch(): 85 | if config.yuzu.branch == 'ea': 86 | target_branch = 'mainline' 87 | else: 88 | target_branch = 'ea' 89 | logger.info(f'switch yuzu branch to {target_branch}') 90 | config.yuzu.branch = target_branch 91 | dump_config() 92 | return config.yuzu.to_dict() 93 | 94 | 95 | @eel.expose 96 | def get_all_yuzu_release_versions(): 97 | from repository.yuzu import get_all_yuzu_release_versions 98 | try: 99 | return success_response(get_all_yuzu_release_versions(config.yuzu.branch)) 100 | except Exception as e: 101 | return exception_response(e) 102 | 103 | 104 | @eel.expose 105 | def get_yuzu_commit_logs(): 106 | from module.yuzu import get_yuzu_commit_logs 107 | try: 108 | return success_response(get_yuzu_commit_logs()) 109 | except Exception as e: 110 | return exception_response(e) 111 | -------------------------------------------------------------------------------- /build_tools/upx/LICENSE: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP SIGNED MESSAGE----- 2 | 3 | 4 | ooooo ooo ooooooooo. ooooooo ooooo 5 | `888' `8' `888 `Y88. `8888 d8' 6 | 888 8 888 .d88' Y888..8P 7 | 888 8 888ooo88P' `8888' 8 | 888 8 888 .8PY888. 9 | `88. .8' 888 d8' `888b 10 | `YbodP' o888o o888o o88888o 11 | 12 | 13 | The Ultimate Packer for eXecutables 14 | Copyright (c) 1996-2000 Markus Oberhumer & Laszlo Molnar 15 | http://wildsau.idv.uni-linz.ac.at/mfx/upx.html 16 | http://www.nexus.hu/upx 17 | http://upx.tsx.org 18 | 19 | 20 | PLEASE CAREFULLY READ THIS LICENSE AGREEMENT, ESPECIALLY IF YOU PLAN 21 | TO MODIFY THE UPX SOURCE CODE OR USE A MODIFIED UPX VERSION. 22 | 23 | 24 | ABSTRACT 25 | ======== 26 | 27 | UPX and UCL are copyrighted software distributed under the terms 28 | of the GNU General Public License (hereinafter the "GPL"). 29 | 30 | The stub which is imbedded in each UPX compressed program is part 31 | of UPX and UCL, and contains code that is under our copyright. The 32 | terms of the GNU General Public License still apply as compressing 33 | a program is a special form of linking with our stub. 34 | 35 | As a special exception we grant the free usage of UPX for all 36 | executables, including commercial programs. 37 | See below for details and restrictions. 38 | 39 | 40 | COPYRIGHT 41 | ========= 42 | 43 | UPX and UCL are copyrighted software. All rights remain with the authors. 44 | 45 | UPX is Copyright (C) 1996-2000 Markus Franz Xaver Johannes Oberhumer 46 | UPX is Copyright (C) 1996-2000 Laszlo Molnar 47 | 48 | UCL is Copyright (C) 1996-2000 Markus Franz Xaver Johannes Oberhumer 49 | 50 | 51 | GNU GENERAL PUBLIC LICENSE 52 | ========================== 53 | 54 | UPX and the UCL library are free software; you can redistribute them 55 | and/or modify them under the terms of the GNU General Public License as 56 | published by the Free Software Foundation; either version 2 of 57 | the License, or (at your option) any later version. 58 | 59 | UPX and UCL are distributed in the hope that they will be useful, 60 | but WITHOUT ANY WARRANTY; without even the implied warranty of 61 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 62 | GNU General Public License for more details. 63 | 64 | You should have received a copy of the GNU General Public License 65 | along with this program; see the file COPYING. 66 | 67 | 68 | SPECIAL EXCEPTION FOR COMPRESSED EXECUTABLES 69 | ============================================ 70 | 71 | The stub which is imbedded in each UPX compressed program is part 72 | of UPX and UCL, and contains code that is under our copyright. The 73 | terms of the GNU General Public License still apply as compressing 74 | a program is a special form of linking with our stub. 75 | 76 | Hereby Markus F.X.J. Oberhumer and Laszlo Molnar grant you special 77 | permission to freely use and distribute all UPX compressed programs 78 | (including commercial ones), subject to the following restrictions: 79 | 80 | 1. You must compress your program with a completely unmodified UPX 81 | version; either with our precompiled version, or (at your option) 82 | with a self compiled version of the unmodified UPX sources as 83 | distributed by us. 84 | 2. This also implies that the UPX stub must be completely unmodfied, i.e. 85 | the stub imbedded in your compressed program must be byte-identical 86 | to the stub that is produced by the official unmodified UPX version. 87 | 3. The decompressor and any other code from the stub must exclusively get 88 | used by the unmodified UPX stub for decompressing your program at 89 | program startup. No portion of the stub may get read, copied, 90 | called or otherwise get used or accessed by your program. 91 | 92 | 93 | ANNOTATIONS 94 | =========== 95 | 96 | - You can use a modified UPX version or modified UPX stub only for 97 | programs that are compatible with the GNU General Public License. 98 | 99 | - We grant you special permission to freely use and distribute all UPX 100 | compressed programs. But any modification of the UPX stub (such as, 101 | but not limited to, removing our copyright string or making your 102 | program non-decompressible) will immediately revoke your right to 103 | use and distribute a UPX compressed program. 104 | 105 | - UPX is not a software protection tool; by requiring that you use 106 | the unmodified UPX version for your proprietary programs we 107 | make sure that any user can decompress your program. This protects 108 | both you and your users as nobody can hide malicious code - 109 | any program that cannot be decompressed is highly suspicious 110 | by definition. 111 | 112 | - You can integrate all or part of UPX and UCL into projects that 113 | are compatible with the GNU GPL, but obviously you cannot grant 114 | any special exceptions beyond the GPL for our code in your project. 115 | 116 | - We want to actively support manufacturers of virus scanners and 117 | similar security software. Please contact us if you would like to 118 | incorporate parts of UPX or UCL into such a product. 119 | 120 | 121 | 122 | Markus F.X.J. Oberhumer Laszlo Molnar 123 | markus.oberhumer@jk.uni-linz.ac.at ml1050@cdata.tvnet.hu 124 | 125 | Linz, Austria, 25 Feb 2000 126 | 127 | 128 | 129 | -----BEGIN PGP SIGNATURE----- 130 | Version: 2.6.3ia 131 | Charset: noconv 132 | 133 | iQCVAwUBOLaLS210fyLu8beJAQFYVAP/ShzENWKLTvedLCjZbDcwaBEHfUVcrGMI 134 | wE7frMkbWT2zmkdv9hW90WmjMhOBu7yhUplvN8BKOtLiolEnZmLCYu8AGCwr5wBf 135 | dfLoClxnzfTtgQv5axF1awp4RwCUH3hf4cDrOVqmAsWXKPHtm4hx96jF6L4oHhjx 136 | OO03+ojZdO8= 137 | =CS52 138 | -----END PGP SIGNATURE----- 139 | -------------------------------------------------------------------------------- /build_tools/upx/README: -------------------------------------------------------------------------------- 1 | ooooo ooo ooooooooo. ooooooo ooooo 2 | `888' `8' `888 `Y88. `8888 d8' 3 | 888 8 888 .d88' Y888..8P 4 | 888 8 888ooo88P' `8888' 5 | 888 8 888 .8PY888. 6 | `88. .8' 888 d8' `888b 7 | `YbodP' o888o o888o o88888o 8 | 9 | 10 | The Ultimate Packer for eXecutables 11 | Copyright (c) 1996-2024 Markus Oberhumer, Laszlo Molnar & John Reiser 12 | https://upx.github.io 13 | 14 | 15 | 16 | WELCOME 17 | ======= 18 | 19 | Welcome to UPX ! 20 | 21 | UPX is a free, secure, portable, extendable, high-performance 22 | executable packer for several executable formats. 23 | 24 | 25 | INTRODUCTION 26 | ============ 27 | 28 | UPX is an advanced executable file compressor. UPX will typically 29 | reduce the file size of programs and DLLs by around 50%-70%, thus 30 | reducing disk space, network load times, download times and 31 | other distribution and storage costs. 32 | 33 | Programs and libraries compressed by UPX are completely self-contained 34 | and run exactly as before, with no runtime or memory penalty for most 35 | of the supported formats. 36 | 37 | UPX supports a number of different executable formats, including 38 | Windows programs and DLLs, and Linux executables. 39 | 40 | UPX is free software distributed under the term of the GNU General 41 | Public License. Full source code is available. 42 | 43 | UPX may be distributed and used freely, even with commercial applications. 44 | See the UPX License Agreements for details. 45 | 46 | 47 | SECURITY CONTEXT 48 | ================ 49 | 50 | IMPORTANT NOTE: UPX inherits the security context of any files it handles. 51 | 52 | This means that packing, unpacking, or even testing or listing a file requires 53 | the same security considerations as actually executing the file. 54 | 55 | Use UPX on trusted files only! 56 | 57 | 58 | SHORT DOCUMENTATION 59 | =================== 60 | 61 | 'upx program.exe' will compress a program or DLL. For best compression 62 | results try 'upx --best program.exe' or 'upx --brute program.exe'. 63 | 64 | Please see the file UPX.DOC for the full documentation. The files 65 | NEWS and BUGS also contain various tidbits of information. 66 | 67 | 68 | THE FUTURE 69 | ========== 70 | 71 | - Stay up-to-date with ongoing OS and executable format changes 72 | 73 | - RISC-V 64 for Linux 74 | 75 | - ARM64 for Windows (help wanted) 76 | 77 | - We will *NOT* add any sort of protection and/or encryption. 78 | This only gives people a false feeling of security because 79 | all "protectors" can be broken by definition. 80 | 81 | - Fix all remaining bugs - please report any issues 82 | https://github.com/upx/upx/issues 83 | 84 | 85 | COPYRIGHT 86 | ========= 87 | 88 | Copyright (C) 1996-2024 Markus Franz Xaver Johannes Oberhumer 89 | Copyright (C) 1996-2024 Laszlo Molnar 90 | Copyright (C) 2000-2024 John F. Reiser 91 | 92 | UPX is distributed with full source code under the terms of the 93 | GNU General Public License v2+; either under the pure GPLv2+ (see 94 | the file COPYING), or (at your option) under the GPLv+2 with special 95 | exceptions and restrictions granting the free usage for all binaries 96 | including commercial programs (see the file LICENSE). 97 | 98 | This program is distributed in the hope that it will be useful, 99 | but WITHOUT ANY WARRANTY; without even the implied warranty of 100 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 101 | 102 | You should have received a copy of the UPX License Agreements along 103 | with this program; see the files COPYING and LICENSE. If not, 104 | visit the UPX home page. 105 | 106 | 107 | Share and enjoy, 108 | Markus & Laszlo & John 109 | 110 | 111 | Markus F.X.J. Oberhumer Laszlo Molnar 112 | <markus@oberhumer.com> <ezerotven+github@gmail.com> 113 | 114 | John F. Reiser 115 | <jreiser@BitWagon.com> 116 | 117 | 118 | [ The term UPX is a shorthand for the Ultimate Packer for eXecutables 119 | and holds no connection with potential owners of registered trademarks 120 | or other rights. ] 121 | -------------------------------------------------------------------------------- /build_tools/upx/THANKS.txt: -------------------------------------------------------------------------------- 1 | ooooo ooo ooooooooo. ooooooo ooooo 2 | `888' `8' `888 `Y88. `8888 d8' 3 | 888 8 888 .d88' Y888..8P 4 | 888 8 888ooo88P' `8888' 5 | 888 8 888 .8PY888. 6 | `88. .8' 888 d8' `888b 7 | `YbodP' o888o o888o o88888o 8 | 9 | 10 | The Ultimate Packer for eXecutables 11 | Copyright (c) 1996-2024 Markus Oberhumer, Laszlo Molnar & John Reiser 12 | https://upx.github.io 13 | 14 | 15 | .___.. . 16 | | |_ _.._ ;_/ __ 17 | | [ )(_][ )| \_) 18 | -------------------- 19 | 20 | UPX would not be what it is today without the invaluable help of 21 | everybody who was kind enough to spend time testing it, using it 22 | in applications and reporting bugs. 23 | 24 | The following people made especially gracious contributions of their 25 | time and energy in helping to track down bugs, add new features, and 26 | generally assist in the UPX maintainership process: 27 | 28 | Adam Ierymenko <api@one.net> 29 | for severals ideas for the Linux version 30 | Andi Kleen <ak@muc.de> and Jamie Lokier <nospam@cern.ch> 31 | for the /proc/self/fd/X and other Linux suggestions 32 | Andreas Muegge <andreas.muegge@gmx.de> 33 | for the Win32 GUI 34 | Atli Mar Gudmundsson <agudmundsson@symantec.com> 35 | for several comments on the win32/pe stub 36 | Charles W. Sandmann <sandmann@clio.rice.edu> 37 | for the idea with the stubless decompressor in djgpp2/coff 38 | Ice 39 | for debugging the PE headersize problem down 40 | Joergen Ibsen <jibz@hotmail.com> and d'b 41 | for the relocation & address optimization ideas 42 | John S. Fine <johnfine@erols.com> 43 | for the new version of the dos/exe decompressor 44 | Kornel Pal 45 | for the EFI support 46 | Lukundoo <Lukundoo@softhome.net> 47 | for beta testing 48 | Michael Devore 49 | for initial dos/exe device driver support 50 | Oleg V. Volkov <rover@lglobus.ru> 51 | for various FreeBSD specific information 52 | The Owl & G-RoM 53 | for the --compress-icons fix 54 | Ralph Roth <RalphRoth@gmx.net> 55 | for reporting several bugs 56 | Salvador Eduardo Tropea 57 | for beta testing 58 | Stefan Widmann 59 | for the win32/pe TLS callback support 60 | The WINE project (https://www.winehq.com/) 61 | for lots of useful information found in their PE loader sources 62 | Natascha 63 | -------------------------------------------------------------------------------- /build_tools/upx/upx.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triwinds/ns-emu-tools/ab6a1a301ad524e85b1b5f2796f382bba384e915/build_tools/upx/upx.exe -------------------------------------------------------------------------------- /build_tools/zip_files.py: -------------------------------------------------------------------------------- 1 | from utils.package import compress_folder 2 | from pathlib import Path 3 | 4 | 5 | DIST_FOLDER = Path(__file__).parent.parent / 'dist' 6 | 7 | 8 | if __name__ == '__main__': 9 | compress_folder(DIST_FOLDER.joinpath('NsEmuTools'), DIST_FOLDER.joinpath('NsEmuTools-dir.7z')) 10 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from dataclasses import dataclass, field 4 | from typing import Optional, Dict 5 | from pathlib import Path 6 | from dataclasses_json import dataclass_json, Undefined 7 | import logging 8 | from logging.handlers import RotatingFileHandler 9 | 10 | 11 | current_version = '0.5.5' 12 | user_agent = f'ns-emu-tools/{current_version}' 13 | 14 | 15 | console = logging.StreamHandler() 16 | console.setLevel(logging.DEBUG) 17 | # logging.getLogger("requests").setLevel(logging.WARNING) 18 | logging.getLogger("urllib3").setLevel(logging.WARNING) 19 | logging.getLogger("httpx").setLevel(logging.WARNING) 20 | logging.getLogger("httpcore").setLevel(logging.WARNING) 21 | # logging.getLogger("geventwebsocket.handler").setLevel(logging.WARNING) 22 | logging.basicConfig( 23 | level=logging.DEBUG, 24 | format='%(asctime)s.%(msecs)03d|%(levelname)s|%(name)s|%(filename)s:%(lineno)s|%(funcName)s|%(message)s', 25 | datefmt='%Y-%m-%d %H:%M:%S', 26 | handlers=[RotatingFileHandler('ns-emu-tools.log', encoding='utf-8', maxBytes=10 * 1024 * 1024, backupCount=10), 27 | console] 28 | ) 29 | logger = logging.getLogger(__name__) 30 | config_path = Path('config.json') 31 | config = None 32 | shared = {} 33 | 34 | 35 | def log_versions(): 36 | import platform 37 | logger.info(f'system version: {platform.platform()}') 38 | logger.info(f'current version: {current_version}') 39 | 40 | 41 | log_versions() 42 | 43 | 44 | @dataclass_json 45 | @dataclass 46 | class YuzuConfig: 47 | yuzu_path: Optional[str] = 'D:\\Yuzu' 48 | yuzu_version: Optional[str] = None 49 | yuzu_firmware: Optional[str] = None 50 | branch: Optional[str] = 'ea' 51 | 52 | 53 | @dataclass_json 54 | @dataclass 55 | class RyujinxConfig: 56 | path: Optional[str] = 'D:\\Ryujinx' 57 | version: Optional[str] = None 58 | firmware: Optional[str] = None 59 | branch: Optional[str] = 'mainline' 60 | 61 | 62 | @dataclass_json 63 | @dataclass 64 | class SuyuConfig: 65 | path: Optional[str] = 'D:\\Suyu' 66 | version: Optional[str] = None 67 | firmware: Optional[str] = None 68 | branch: Optional[str] = 'dev' 69 | 70 | 71 | @dataclass_json 72 | @dataclass 73 | class NetworkSetting: 74 | firmwareDownloadSource: Optional[str] = 'github' 75 | githubApiMode: Optional[str] = 'direct' 76 | githubDownloadMirror: Optional[str] = 'cloudflare_load_balance' 77 | useDoh: Optional[bool] = True 78 | proxy: Optional[str] = 'system' 79 | 80 | 81 | @dataclass_json 82 | @dataclass 83 | class DownloadSetting: 84 | autoDeleteAfterInstall: Optional[bool] = True 85 | disableAria2Ipv6: Optional[bool] = True 86 | removeOldAria2LogFile: Optional[bool] = True 87 | verifyFirmwareMd5: Optional[bool] = True 88 | 89 | 90 | @dataclass_json 91 | @dataclass 92 | class UiSetting: 93 | lastOpenEmuPage: Optional[str] = 'ryujinx' 94 | dark: Optional[bool] = True 95 | mode: Optional[str] = 'auto' 96 | width: int = 1300 97 | height: int = 850 98 | 99 | 100 | @dataclass_json 101 | @dataclass 102 | class OtherSetting: 103 | rename_yuzu_to_cemu: Optional[bool] = False 104 | 105 | 106 | @dataclass_json(undefined=Undefined.EXCLUDE) 107 | @dataclass 108 | class CommonSetting: 109 | ui: UiSetting = field(default_factory=UiSetting) 110 | network: NetworkSetting = field(default_factory=NetworkSetting) 111 | download: DownloadSetting = field(default_factory=DownloadSetting) 112 | other: OtherSetting = field(default_factory=OtherSetting) 113 | 114 | 115 | @dataclass_json(undefined=Undefined.EXCLUDE) 116 | @dataclass 117 | class Config: 118 | yuzu: YuzuConfig = field(default_factory=YuzuConfig) 119 | ryujinx: RyujinxConfig = field(default_factory=RyujinxConfig) 120 | suyu: SuyuConfig = field(default_factory=SuyuConfig) 121 | setting: CommonSetting = field(default_factory=CommonSetting) 122 | 123 | 124 | if os.path.exists(config_path): 125 | with open(config_path, 'r', encoding='utf-8') as f: 126 | config = Config.from_dict(json.load(f)) 127 | config.yuzu.yuzu_path = str(Path(config.yuzu.yuzu_path).absolute()) 128 | config.ryujinx.path = str(Path(config.ryujinx.path).absolute()) 129 | if not config: 130 | config = Config() 131 | config.yuzu.branch = 'ea' 132 | 133 | 134 | def dump_config(): 135 | logger.info(f'saving config to {config_path.absolute()}') 136 | with open(config_path, 'w', encoding='utf-8') as f: 137 | f.write(config.to_json(ensure_ascii=False, indent=2)) 138 | 139 | 140 | def update_last_open_emu_page(page: str): 141 | if page == 'ryujinx': 142 | config.setting.ui.lastOpenEmuPage = 'ryujinx' 143 | elif page == 'suyu': 144 | config.setting.ui.lastOpenEmuPage = 'suyu' 145 | else: 146 | config.setting.ui.lastOpenEmuPage = 'yuzu' 147 | logger.info(f'update lastOpenEmuPage to {config.setting.ui.lastOpenEmuPage}') 148 | dump_config() 149 | 150 | 151 | def update_dark_state(dark: bool): 152 | if dark is None: 153 | dark = True 154 | config.setting.ui.dark = dark 155 | logger.info(f'update dark to {config.setting.ui.dark}') 156 | dump_config() 157 | 158 | 159 | def update_setting(setting: Dict[str, object]): 160 | logger.info(f'updating settings: {setting}') 161 | config.setting = CommonSetting.from_dict(setting) 162 | dump_config() 163 | 164 | 165 | __all__ = ['config', 'dump_config', 'YuzuConfig', 'current_version', 'RyujinxConfig', 'update_dark_state', 166 | 'update_last_open_emu_page', 'update_setting', 'user_agent', 'shared'] 167 | -------------------------------------------------------------------------------- /doc/assets/yuzu_user_id.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triwinds/ns-emu-tools/ab6a1a301ad524e85b1b5f2796f382bba384e915/doc/assets/yuzu_user_id.jpg -------------------------------------------------------------------------------- /doc/dev.md: -------------------------------------------------------------------------------- 1 | # Dev 2 | 3 | ## 开发环境需求 4 | 5 | - Python 3.12 6 | - Node.js 20 7 | 8 | ## 运行环境准备 9 | 10 | ### Step 1 构建前端 package 11 | 12 | ```shell 13 | cd frontend 14 | bun install 15 | bun build 16 | ``` 17 | 18 | ### Step 2 安装 Python 依赖 19 | 20 | ```shell 21 | # 通过 uv 安装 (推荐) 22 | uv sync 23 | 24 | # 通过 pip 安装 25 | python -m venv venv 26 | venv\Scripts\activate 27 | pip install -r requirements.txt 28 | ``` 29 | 30 | ## 本地运行 31 | 32 | ```shell 33 | poetry run python main.py 34 | ``` 35 | 36 | ## 开发与调试 37 | 38 | ### 调试前端页面 39 | 40 | 先启动后端服务 41 | ```shell 42 | uv run python ui.py 43 | ``` 44 | 45 | 然后另起一个终端启动 dev server 46 | ```shell 47 | cd frontend 48 | bun dev 49 | ``` 50 | 51 | ### 调试 Python 代码 52 | 53 | 由于 gevent 会与 pydebugger 冲突,因此不建议在 eel 启动时调试 Python 代码。 54 | 55 | 可以直接使用 pycharm 或 vscode 等 IDE 在 py 文件中通过 main 方法进行调试。 56 | 57 | -------------------------------------------------------------------------------- /exception/common_exception.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class VersionNotFoundException(Exception): 4 | msg: str = '' 5 | target_version: str = '' 6 | branch: str = '' 7 | emu_type: str = '' 8 | 9 | def __init__(self, target_version, branch, emu_type): 10 | self.target_version = target_version 11 | self.branch = branch 12 | self.emu_type = emu_type 13 | self.msg = f'Fail to get release info of version [{target_version}] on branch [{branch}]' 14 | super().__init__(self.msg) 15 | 16 | 17 | class Md5NotMatchException(Exception): 18 | def __init__(self): 19 | super().__init__('MD5 not match') 20 | 21 | 22 | class IgnoredException(RuntimeError): 23 | def __init__(self, msg=''): 24 | super().__init__(msg) 25 | -------------------------------------------------------------------------------- /exception/download_exception.py: -------------------------------------------------------------------------------- 1 | 2 | class DownloadInterrupted(Exception): 3 | def __init__(self): 4 | super().__init__('Download has been interrupted') 5 | 6 | 7 | class DownloadPaused(Exception): 8 | def __init__(self): 9 | super().__init__('Download has been paused') 10 | 11 | 12 | class DownloadNotCompleted(Exception): 13 | status: str 14 | name: str 15 | 16 | def __init__(self, name, status): 17 | self.name = name 18 | self.status = status 19 | super().__init__(f'Download task [{name}] is not completed, status: {status}') 20 | -------------------------------------------------------------------------------- /exception/install_exception.py: -------------------------------------------------------------------------------- 1 | class FailToCopyFiles(Exception): 2 | raw_exception: Exception 3 | msg: str 4 | 5 | def __init__(self, raw_exception: Exception, msg): 6 | self.raw_exception = raw_exception 7 | self.msg = msg 8 | super().__init__(msg) 9 | -------------------------------------------------------------------------------- /frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | not ie 11 5 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | VITE_EEL_BASE_URI=http://127.0.0.1:8888 2 | -------------------------------------------------------------------------------- /frontend/.env.production: -------------------------------------------------------------------------------- 1 | VITE_EEL_BASE_URI=. -------------------------------------------------------------------------------- /frontend/.eslintrc-auto-import.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "Component": true, 4 | "ComponentPublicInstance": true, 5 | "ComputedRef": true, 6 | "EffectScope": true, 7 | "ExtractDefaultPropTypes": true, 8 | "ExtractPropTypes": true, 9 | "ExtractPublicPropTypes": true, 10 | "InjectionKey": true, 11 | "PropType": true, 12 | "Ref": true, 13 | "VNode": true, 14 | "WritableComputedRef": true, 15 | "computed": true, 16 | "createApp": true, 17 | "customRef": true, 18 | "defineAsyncComponent": true, 19 | "defineComponent": true, 20 | "effectScope": true, 21 | "getCurrentInstance": true, 22 | "getCurrentScope": true, 23 | "h": true, 24 | "inject": true, 25 | "isProxy": true, 26 | "isReactive": true, 27 | "isReadonly": true, 28 | "isRef": true, 29 | "markRaw": true, 30 | "nextTick": true, 31 | "onActivated": true, 32 | "onBeforeMount": true, 33 | "onBeforeUnmount": true, 34 | "onBeforeUpdate": true, 35 | "onDeactivated": true, 36 | "onErrorCaptured": true, 37 | "onMounted": true, 38 | "onRenderTracked": true, 39 | "onRenderTriggered": true, 40 | "onScopeDispose": true, 41 | "onServerPrefetch": true, 42 | "onUnmounted": true, 43 | "onUpdated": true, 44 | "provide": true, 45 | "reactive": true, 46 | "readonly": true, 47 | "ref": true, 48 | "resolveComponent": true, 49 | "shallowReactive": true, 50 | "shallowReadonly": true, 51 | "shallowRef": true, 52 | "toRaw": true, 53 | "toRef": true, 54 | "toRefs": true, 55 | "toValue": true, 56 | "triggerRef": true, 57 | "unref": true, 58 | "useAttrs": true, 59 | "useCssModule": true, 60 | "useCssVars": true, 61 | "useRoute": true, 62 | "useRouter": true, 63 | "useSlots": true, 64 | "watch": true, 65 | "watchEffect": true, 66 | "watchPostEffect": true, 67 | "watchSyncEffect": true, 68 | "DirectiveBinding": true, 69 | "MaybeRef": true, 70 | "MaybeRefOrGetter": true, 71 | "onWatcherCleanup": true, 72 | "useId": true, 73 | "useModel": true, 74 | "useTemplateRef": true 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Vuetify (Default) 2 | 3 | This is the official scaffolding tool for Vuetify, designed to give you a head start in building your new Vuetify application. It sets up a base template with all the necessary configurations and standard directory structure, enabling you to begin development without the hassle of setting up the project from scratch. 4 | 5 | ## ❗️ Important Links 6 | 7 | - 📄 [Docs](https://vuetifyjs.com/) 8 | - 🚨 [Issues](https://issues.vuetifyjs.com/) 9 | - 🏬 [Store](https://store.vuetifyjs.com/) 10 | - 🎮 [Playground](https://play.vuetifyjs.com/) 11 | - 💬 [Discord](https://community.vuetifyjs.com) 12 | 13 | ## 💿 Install 14 | 15 | Set up your project using your preferred package manager. Use the corresponding command to install the dependencies: 16 | 17 | | Package Manager | Command | 18 | |---------------------------------------------------------------|----------------| 19 | | [yarn](https://yarnpkg.com/getting-started) | `yarn install` | 20 | | [npm](https://docs.npmjs.com/cli/v7/commands/npm-install) | `npm install` | 21 | | [pnpm](https://pnpm.io/installation) | `pnpm install` | 22 | | [bun](https://bun.sh/#getting-started) | `bun install` | 23 | 24 | After completing the installation, your environment is ready for Vuetify development. 25 | 26 | ## ✨ Features 27 | 28 | - 🖼️ **Optimized Front-End Stack**: Leverage the latest Vue 3 and Vuetify 3 for a modern, reactive UI development experience. [Vue 3](https://v3.vuejs.org/) | [Vuetify 3](https://vuetifyjs.com/en/) 29 | - 🗃️ **State Management**: Integrated with [Pinia](https://pinia.vuejs.org/), the intuitive, modular state management solution for Vue. 30 | - 🚦 **Routing and Layouts**: Utilizes Vue Router for SPA navigation and vite-plugin-vue-layouts for organizing Vue file layouts. [Vue Router](https://router.vuejs.org/) | [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts) 31 | - 💻 **Enhanced Development Experience**: Benefit from TypeScript's static type checking and the ESLint plugin suite for Vue, ensuring code quality and consistency. [TypeScript](https://www.typescriptlang.org/) | [ESLint Plugin Vue](https://eslint.vuejs.org/) 32 | - ⚡ **Next-Gen Tooling**: Powered by Vite, experience fast cold starts and instant HMR (Hot Module Replacement). [Vite](https://vitejs.dev/) 33 | - 🧩 **Automated Component Importing**: Streamline your workflow with unplugin-vue-components, automatically importing components as you use them. [unplugin-vue-components](https://github.com/antfu/unplugin-vue-components) 34 | - 🛠️ **Strongly-Typed Vue**: Use vue-tsc for type-checking your Vue components, and enjoy a robust development experience. [vue-tsc](https://github.com/johnsoncodehk/volar/tree/master/packages/vue-tsc) 35 | 36 | These features are curated to provide a seamless development experience from setup to deployment, ensuring that your Vuetify application is both powerful and maintainable. 37 | 38 | ## 💡 Usage 39 | 40 | This section covers how to start the development server and build your project for production. 41 | 42 | ### Starting the Development Server 43 | 44 | To start the development server with hot-reload, run the following command. The server will be accessible at [http://localhost:3000](http://localhost:3000): 45 | 46 | ```bash 47 | yarn dev 48 | ``` 49 | 50 | (Repeat for npm, pnpm, and bun with respective commands.) 51 | 52 | > Add NODE_OPTIONS='--no-warnings' to suppress the JSON import warnings that happen as part of the Vuetify import mapping. If you are on Node [v21.3.0](https://nodejs.org/en/blog/release/v21.3.0) or higher, you can change this to NODE_OPTIONS='--disable-warning=5401'. If you don't mind the warning, you can remove this from your package.json dev script. 53 | 54 | ### Building for Production 55 | 56 | To build your project for production, use: 57 | 58 | ```bash 59 | yarn build 60 | ``` 61 | 62 | (Repeat for npm, pnpm, and bun with respective commands.) 63 | 64 | Once the build process is completed, your application will be ready for deployment in a production environment. 65 | 66 | ## 💪 Support Vuetify Development 67 | 68 | This project is built with [Vuetify](https://vuetifyjs.com/en/), a UI Library with a comprehensive collection of Vue components. Vuetify is an MIT licensed Open Source project that has been made possible due to the generous contributions by our [sponsors and backers](https://vuetifyjs.com/introduction/sponsors-and-backers/). If you are interested in supporting this project, please consider: 69 | 70 | - [Requesting Enterprise Support](https://support.vuetifyjs.com/) 71 | - [Sponsoring John on Github](https://github.com/users/johnleider/sponsorship) 72 | - [Sponsoring Kael on Github](https://github.com/users/kaelwd/sponsorship) 73 | - [Supporting the team on Open Collective](https://opencollective.com/vuetify) 74 | - [Becoming a sponsor on Patreon](https://www.patreon.com/vuetify) 75 | - [Becoming a subscriber on Tidelift](https://tidelift.com/subscription/npm/vuetify) 76 | - [Making a one-time donation with Paypal](https://paypal.me/vuetify) 77 | 78 | ## 📑 License 79 | [MIT](http://opensource.org/licenses/MIT) 80 | 81 | Copyright (c) 2016-present Vuetify, LLC 82 | -------------------------------------------------------------------------------- /frontend/env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="vite/client" /> 2 | /// <reference types="unplugin-vue-router/client" /> 3 | /// <reference types="vite-plugin-vue-layouts/client" /> 4 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * .eslint.js 3 | * 4 | * ESLint configuration file. 5 | */ 6 | 7 | import pluginVue from 'eslint-plugin-vue' 8 | import vueTsEslintConfig from '@vue/eslint-config-typescript' 9 | 10 | export default [ 11 | { 12 | name: 'app/files-to-lint', 13 | files: ['**/*.{ts,mts,tsx,vue}'], 14 | }, 15 | 16 | { 17 | name: 'app/files-to-ignore', 18 | ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'], 19 | }, 20 | 21 | ...pluginVue.configs['flat/recommended'], 22 | ...vueTsEslintConfig(), 23 | 24 | { 25 | rules: { 26 | '@typescript-eslint/no-unused-expressions': [ 27 | 'error', 28 | { 29 | allowShortCircuit: true, 30 | allowTernary: true, 31 | }, 32 | ], 33 | 'vue/multi-word-component-names': 'off', 34 | } 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html style="height: 100%; width: 100%; overflow: hidden;"> 3 | 4 | <head> 5 | <meta charset="UTF-8" /> 6 | <link rel="icon" href="/favicon.ico" /> 7 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 | <script type=text/javascript src="%VITE_EEL_BASE_URI%/eel.js"></script> 9 | <script> 10 | if ('%VITE_EEL_BASE_URI%' !== '.') { 11 | window.eel.set_host('%VITE_EEL_BASE_URI%') 12 | } 13 | window.eel.expose(appendConsoleMessage) 14 | function appendConsoleMessage(msg) { 15 | window.$bus.emit('APPEND_CONSOLE_MESSAGE', msg) 16 | } 17 | </script> 18 | <title>NS EMU TOOLS</title> 19 | </head> 20 | 21 | <body> 22 | <div id="app"></div> 23 | <script type="module" src="/src/main.ts"></script> 24 | </body> 25 | 26 | </html> 27 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.0", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "run-p type-check \"build-only {@}\" --", 9 | "preview": "vite preview", 10 | "build-only": "vite build", 11 | "type-check": "vue-tsc --build --force", 12 | "lint": "eslint . --fix" 13 | }, 14 | "dependencies": { 15 | "@fontsource-variable/jetbrains-mono": "^5.2.5", 16 | "@fontsource/roboto": "^5.2.5", 17 | "core-js": "^3.37.1", 18 | "isomorphic-dompurify": "^2.22.0", 19 | "marked": "^15.0.7", 20 | "mitt": "^3.0.1", 21 | "roboto-fontface": "*", 22 | "vue": "^3.4.31", 23 | "vuetify": "^3.6.14" 24 | }, 25 | "devDependencies": { 26 | "@eslint/js": "^9.14.0", 27 | "@mdi/js": "^7.4.47", 28 | "@tsconfig/node22": "^22.0.0", 29 | "@types/node": "^22.9.0", 30 | "@vitejs/plugin-vue": "^5.1.4", 31 | "@vue/eslint-config-typescript": "^14.1.3", 32 | "@vue/tsconfig": "^0.5.1", 33 | "eslint": "^9.14.0", 34 | "eslint-plugin-vue": "^9.30.0", 35 | "npm-run-all2": "^7.0.1", 36 | "pinia": "^2.1.7", 37 | "sass": "1.77.8", 38 | "sass-embedded": "^1.77.8", 39 | "typescript": "~5.6.3", 40 | "unplugin-auto-import": "^0.17.6", 41 | "unplugin-fonts": "^1.1.1", 42 | "unplugin-vue-components": "^0.27.2", 43 | "unplugin-vue-router": "^0.10.0", 44 | "vite": "^5.4.10", 45 | "vite-plugin-vue-layouts": "^0.11.0", 46 | "vite-plugin-vuetify": "^2.0.3", 47 | "vue-router": "^4.4.0", 48 | "vue-tsc": "^2.1.10" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triwinds/ns-emu-tools/ab6a1a301ad524e85b1b5f2796f382bba384e915/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <router-view /> 3 | </template> 4 | 5 | <script lang="ts" setup> 6 | import {onMounted} from "vue"; 7 | import {useConsoleDialogStore} from "@/stores/ConsoleDialogStore"; 8 | 9 | const cds = useConsoleDialogStore() 10 | let pendingWriteSize = false 11 | 12 | onMounted(() => { 13 | if (import.meta.env.NODE_ENV !== 'development') { 14 | console.log('setupWebsocketConnectivityCheck') 15 | setupWebsocketConnectivityCheck() 16 | } 17 | window.addEventListener('resize', rememberWindowSize); 18 | }) 19 | 20 | function rememberWindowSize() { 21 | if (!pendingWriteSize) { 22 | pendingWriteSize = true 23 | setTimeout(() => { 24 | pendingWriteSize = false 25 | window.eel.update_window_size(window.outerWidth, window.outerHeight)() 26 | }, 1000) 27 | } 28 | } 29 | 30 | function setupWebsocketConnectivityCheck() { 31 | setInterval(() => { 32 | try { 33 | let ws = window.eel._websocket 34 | if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { 35 | cds.cleanAndShowConsoleDialog() 36 | cds.appendConsoleMessage('程序后端连接出错, 请关闭当前页面并重启程序以解决这个问题。') 37 | } 38 | } catch (e) { 39 | console.log(e) 40 | cds.cleanAndShowConsoleDialog() 41 | cds.appendConsoleMessage('程序后端连接出错, 请关闭当前页面并重启程序以解决这个问题。') 42 | } 43 | }, 5000) 44 | } 45 | </script> 46 | 47 | <style> 48 | html ::-webkit-scrollbar { 49 | width: 0 ; 50 | height: 0 ; 51 | } 52 | div::-webkit-resizer, div::-webkit-scrollbar-thumb { 53 | background: #aaa; 54 | border-radius: 3px; 55 | } 56 | 57 | div::-webkit-scrollbar { 58 | width: 5px !important; 59 | height: 5px !important; 60 | } 61 | 62 | div::-webkit-scrollbar-corner, div ::-webkit-scrollbar-track { 63 | background: transparent !important; 64 | } 65 | 66 | div::-webkit-resizer, div ::-webkit-scrollbar-thumb { 67 | background: #aaa; 68 | border-radius: 3px; 69 | } 70 | 71 | div::-webkit-scrollbar-corner, div ::-webkit-scrollbar-track { 72 | background: transparent !important; 73 | } 74 | 75 | a { 76 | cursor: pointer; 77 | } 78 | </style> 79 | -------------------------------------------------------------------------------- /frontend/src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triwinds/ns-emu-tools/ab6a1a301ad524e85b1b5f2796f382bba384e915/frontend/src/assets/icon.png -------------------------------------------------------------------------------- /frontend/src/assets/ryujinx.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triwinds/ns-emu-tools/ab6a1a301ad524e85b1b5f2796f382bba384e915/frontend/src/assets/ryujinx.webp -------------------------------------------------------------------------------- /frontend/src/assets/suyu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triwinds/ns-emu-tools/ab6a1a301ad524e85b1b5f2796f382bba384e915/frontend/src/assets/suyu.png -------------------------------------------------------------------------------- /frontend/src/assets/telegram.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triwinds/ns-emu-tools/ab6a1a301ad524e85b1b5f2796f382bba384e915/frontend/src/assets/telegram.webp -------------------------------------------------------------------------------- /frontend/src/assets/telegram_black.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triwinds/ns-emu-tools/ab6a1a301ad524e85b1b5f2796f382bba384e915/frontend/src/assets/telegram_black.webp -------------------------------------------------------------------------------- /frontend/src/assets/yuzu.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triwinds/ns-emu-tools/ab6a1a301ad524e85b1b5f2796f382bba384e915/frontend/src/assets/yuzu.webp -------------------------------------------------------------------------------- /frontend/src/components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // Generated by unplugin-vue-components 4 | // Read more: https://github.com/vuejs/core/pull/3399 5 | export {} 6 | 7 | /* prettier-ignore */ 8 | declare module 'vue' { 9 | export interface GlobalComponents { 10 | ChangeLogDialog: typeof import('./components/ChangeLogDialog.vue')['default'] 11 | ConsoleDialog: typeof import('./components/ConsoleDialog.vue')['default'] 12 | DialogTitle: typeof import('./components/DialogTitle.vue')['default'] 13 | FaqGroup: typeof import('./components/FaqGroup.vue')['default'] 14 | MarkdownContentBox: typeof import('./components/MarkdownContentBox.vue')['default'] 15 | NewVersionDialog: typeof import('./components/NewVersionDialog.vue')['default'] 16 | OtherLinkItem: typeof import('./components/OtherLinkItem.vue')['default'] 17 | RouterLink: typeof import('vue-router')['RouterLink'] 18 | RouterView: typeof import('vue-router')['RouterView'] 19 | SimplePage: typeof import('./components/SimplePage.vue')['default'] 20 | SpeedDial: typeof import('./components/SpeedDial.vue')['default'] 21 | YuzuSaveCommonPart: typeof import('./components/YuzuSaveCommonPart.vue')['default'] 22 | YuzuSaveRestoreTab: typeof import('./components/YuzuSaveRestoreTab.vue')['default'] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/ChangeLogDialog.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <v-dialog 3 | v-model="dialog" 4 | max-width="900" 5 | > 6 | <template v-slot:activator="{ props }"> 7 | <slot name="activator" v-bind:props="props"> 8 | 9 | </slot> 10 | </template> 11 | 12 | <v-card> 13 | <dialog-title> 14 | 更新日志 15 | </dialog-title> 16 | 17 | <div class="change-log-content" style=" padding: 15px; overflow-y: auto; max-height: 50vh"> 18 | <slot name="content"></slot> 19 | </div> 20 | 21 | <v-divider></v-divider> 22 | 23 | <v-card-actions> 24 | <v-spacer></v-spacer> 25 | <v-btn 26 | color="primary" 27 | variant="text" 28 | @click="dialog = false" 29 | > 30 | 关闭 31 | </v-btn> 32 | </v-card-actions> 33 | </v-card> 34 | </v-dialog> 35 | </template> 36 | 37 | <script setup lang="ts"> 38 | import {ref} from "vue"; 39 | import DialogTitle from "@/components/DialogTitle.vue"; 40 | 41 | let dialog = ref(false) 42 | </script> 43 | 44 | <style> 45 | .change-log-content li { 46 | margin-left: 30px !important; 47 | } 48 | </style> 49 | -------------------------------------------------------------------------------- /frontend/src/components/ConsoleDialog.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="text-center"> 3 | <v-dialog 4 | v-model="consoleDialogStore.dialogFlag" 5 | max-width="900" 6 | :persistent="consoleDialogStore.persistentConsoleDialog" 7 | > 8 | 9 | <v-card> 10 | <dialog-title> 11 | 控制台日志 12 | </dialog-title> 13 | 14 | <div style="padding-left: 10px; padding-right: 10px; padding-top: 10px;" class="flex-grow-0"> 15 | <textarea id="consoleBox" :value="logText" readonly rows="12"></textarea> 16 | </div> 17 | 18 | <v-divider></v-divider> 19 | 20 | <v-card-actions> 21 | <v-spacer></v-spacer> 22 | <v-btn 23 | color="primary" 24 | variant="text" 25 | @click="pauseDownload" 26 | v-if="consoleDialogStore.persistentConsoleDialog" 27 | > 28 | 暂停下载任务 29 | </v-btn> 30 | <v-btn 31 | color="primary" 32 | variant="text" 33 | @click="stopDownload" 34 | v-if="consoleDialogStore.persistentConsoleDialog" 35 | > 36 | 中断并删除下载任务 37 | </v-btn> 38 | <v-btn 39 | color="primary" 40 | variant="text" 41 | @click="closeDialog" 42 | :disabled="consoleDialogStore.persistentConsoleDialog" 43 | > 44 | 关闭 45 | </v-btn> 46 | </v-card-actions> 47 | </v-card> 48 | </v-dialog> 49 | </div> 50 | </template> 51 | 52 | <script lang="ts" setup> 53 | import {useConsoleDialogStore} from "@/stores/ConsoleDialogStore"; 54 | import type {CommonResponse} from "@/types"; 55 | import {computed, nextTick, onUpdated} from "vue"; 56 | import DialogTitle from "@/components/DialogTitle.vue"; 57 | 58 | const consoleDialogStore = useConsoleDialogStore() 59 | 60 | function closeDialog() { 61 | consoleDialogStore.dialogFlag = false 62 | } 63 | function stopDownload() { 64 | window.eel.stop_download()((resp: CommonResponse) => { 65 | console.log(resp) 66 | }) 67 | } 68 | function pauseDownload() { 69 | window.eel.pause_download()((resp: CommonResponse) => { 70 | console.log(resp) 71 | }) 72 | } 73 | 74 | let logText = computed(() => { 75 | let text = '' 76 | for (let line of consoleDialogStore.consoleMessages) { 77 | text += line + '\n' 78 | } 79 | return text 80 | }) 81 | 82 | onUpdated(() => { 83 | nextTick(() => { 84 | let consoleBox = document.getElementById("consoleBox") 85 | if (consoleBox) { 86 | consoleBox.scrollTop = consoleBox.scrollHeight 87 | } 88 | }) 89 | }) 90 | </script> 91 | 92 | <style scoped> 93 | #consoleBox { 94 | background-color: #000; 95 | width: 100%; 96 | color: white; 97 | overflow-x: scroll; 98 | overflow-y: scroll; 99 | resize: none; 100 | padding: 10px; 101 | font-family: 'JetBrains Mono Variable',sans-serif !important; 102 | } 103 | 104 | #consoleBox::-webkit-resizer, #consoleBox::-webkit-scrollbar-thumb { 105 | background: #aaa; 106 | border-radius: 3px; 107 | } 108 | 109 | #consoleBox::-webkit-scrollbar { 110 | width: 5px !important; 111 | height: 5px !important; 112 | } 113 | 114 | #consoleBox::-webkit-scrollbar-corner, #consoleBox ::-webkit-scrollbar-track { 115 | background: transparent !important; 116 | } 117 | 118 | #consoleBox::-webkit-resizer, #consoleBox ::-webkit-scrollbar-thumb { 119 | background: #aaa; 120 | border-radius: 3px; 121 | } 122 | 123 | #consoleBox::-webkit-scrollbar-corner, #consoleBox ::-webkit-scrollbar-track { 124 | background: transparent !important; 125 | } 126 | </style> 127 | -------------------------------------------------------------------------------- /frontend/src/components/DialogTitle.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | 3 | </script> 4 | 5 | <template> 6 | <v-card-title class="text-h5 bg-primary text-white" style="height: 64px; padding-top: 18px"> 7 | <slot></slot> 8 | </v-card-title> 9 | </template> 10 | 11 | <style scoped> 12 | 13 | </style> 14 | -------------------------------------------------------------------------------- /frontend/src/components/FaqGroup.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <v-list-group> 3 | <template v-slot:activator="{props}"> 4 | <v-list-item v-bind="props" title=""> 5 | <span class="text-info" style="font-size: 22px"> 6 | <slot name="title"></slot> 7 | </span> 8 | </v-list-item> 9 | </template> 10 | <v-list-item title=""> 11 | <slot name="content"></slot> 12 | </v-list-item> 13 | 14 | </v-list-group> 15 | </template> 16 | 17 | <script setup lang="ts"> 18 | 19 | </script> 20 | 21 | <style scoped> 22 | 23 | </style> 24 | -------------------------------------------------------------------------------- /frontend/src/components/MarkdownContentBox.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div style="padding: 15px;"> 3 | <div id="text-box" v-html="mdHtml"> 4 | 5 | </div> 6 | </div> 7 | </template> 8 | 9 | <script setup lang="ts"> 10 | import {nextTick, onMounted, onUpdated, ref} from "vue"; 11 | import md from "@/utils/markdown"; 12 | import {openUrlWithDefaultBrowser} from "@/utils/common"; 13 | 14 | let mdHtml = ref('') 15 | const props = defineProps(['content']) 16 | 17 | onMounted(() => { 18 | mdHtml.value = md.parse(props.content) 19 | }) 20 | 21 | onUpdated(() => { 22 | nextTick(() => { 23 | let aTags = document.getElementById("text-box")!.getElementsByTagName('a') 24 | for (let aTag of aTags) { 25 | let url = aTag.href 26 | aTag.href = 'javascript:;' 27 | aTag.onclick = () => { 28 | openUrlWithDefaultBrowser(url) 29 | } 30 | } 31 | let infoTags = [] as HTMLElement[] 32 | infoTags.push(...document.getElementById("text-box")!.getElementsByTagName('strong')) 33 | infoTags.push(...document.getElementById("text-box")!.getElementsByTagName('code')) 34 | for (let infoTag of infoTags) { 35 | infoTag.classList.add("text-info") 36 | infoTag.style.fontFamily = "'JetBrains Mono Variable', sans-serif" 37 | } 38 | }) 39 | }) 40 | </script> 41 | 42 | <style > 43 | #text-box details > p { 44 | margin-left: 30px; 45 | } 46 | </style> 47 | -------------------------------------------------------------------------------- /frontend/src/components/NewVersionDialog.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="text-center"> 3 | <v-dialog 4 | v-model="dialog" 5 | width="850" 6 | > 7 | <v-card> 8 | <v-card-title class="text-h5 bg-primary text-white"> 9 | {{ configStore.hasNewVersion ? '更新日志' : '版本检测' }} 10 | </v-card-title> 11 | 12 | <div style="padding: 15px;"> 13 | <p class="text-h6 text--primary" v-show="!configStore.hasNewVersion">当前版本已经是最新版本</p> 14 | <div v-show="configStore.hasNewVersion" > 15 | <!-- <p class="text-h6 text--primary">[{{newVersion}}] 更新内容:</p>--> 16 | <div v-html="releaseDescriptionHtml" class="text--primary" 17 | style="max-height: 300px; overflow-y: auto"></div> 18 | </div> 19 | </div> 20 | 21 | <v-divider></v-divider> 22 | 23 | <v-card-actions v-show="!configStore.hasNewVersion"> 24 | <v-spacer></v-spacer> 25 | <v-btn 26 | color="primary" 27 | variant="text" 28 | @click="dialog = false" 29 | > 30 | OK 31 | </v-btn> 32 | </v-card-actions> 33 | <v-card-actions v-show="configStore.hasNewVersion"> 34 | <v-spacer></v-spacer> 35 | <v-btn 36 | color="primary" 37 | variant="text" 38 | @click="updateNET" 39 | > 40 | 自动更新 41 | </v-btn> 42 | <v-btn 43 | color="primary" 44 | variant="text" 45 | @click="downloadNET" 46 | > 47 | 下载最新版本 48 | </v-btn> 49 | <v-btn 50 | color="primary" 51 | variant="text" 52 | @click="openReleasePage" 53 | > 54 | 前往发布页 55 | </v-btn> 56 | <v-btn 57 | color="primary" 58 | variant="text" 59 | @click="dialog = false" 60 | > 61 | 取消 62 | </v-btn> 63 | </v-card-actions> 64 | </v-card> 65 | </v-dialog> 66 | </div> 67 | </template> 68 | 69 | <script setup lang="ts"> 70 | import {onMounted, ref} from "vue"; 71 | import {useConfigStore} from "@/stores/ConfigStore"; 72 | import {openUrlWithDefaultBrowser} from "@/utils/common"; 73 | import md from "@/utils/markdown"; 74 | import {useConsoleDialogStore} from "@/stores/ConsoleDialogStore"; 75 | import type {CommonResponse} from "@/types"; 76 | import {useEmitter} from "@/plugins/mitt"; 77 | 78 | let dialog = ref(false) 79 | const configStore = useConfigStore() 80 | let newVersion = ref('') 81 | let releaseDescriptionHtml = ref('') 82 | const cds = useConsoleDialogStore() 83 | const emitter = useEmitter() 84 | 85 | onMounted(() => { 86 | emitter.on('showNewVersionDialog', showNewVersionDialog) 87 | }) 88 | 89 | function showNewVersionDialog(info: any) { 90 | dialog.value = true 91 | newVersion.value = info.latestVersion 92 | if (configStore.hasNewVersion) { 93 | loadReleaseDescription() 94 | } 95 | } 96 | 97 | function openReleasePage() { 98 | dialog.value = false 99 | if (configStore.hasNewVersion) { 100 | openUrlWithDefaultBrowser('https://github.com/triwinds/ns-emu-tools/releases'); 101 | } 102 | } 103 | 104 | function loadReleaseDescription() { 105 | window.eel.load_change_log()((resp: CommonResponse) => { 106 | if (resp.code === 0) { 107 | let rawMd = resp.data.replace('# Change Log\n\n', '') 108 | releaseDescriptionHtml.value = md.parse(rawMd) 109 | } else { 110 | releaseDescriptionHtml.value = '<p>加载失败</p>' 111 | } 112 | }) 113 | } 114 | 115 | function downloadNET() { 116 | cds.cleanAndShowConsoleDialog() 117 | window.eel.download_net_by_tag(newVersion.value)((resp: CommonResponse) => { 118 | if (resp.code === 0) { 119 | cds.appendConsoleMessage('NET 下载完成') 120 | } else { 121 | cds.appendConsoleMessage(resp.msg) 122 | cds.appendConsoleMessage('NET 下载失败') 123 | } 124 | }) 125 | } 126 | 127 | async function updateNET() { 128 | cds.cleanAndShowConsoleDialog() 129 | window.eel.update_net_by_tag(newVersion.value) 130 | } 131 | </script> 132 | 133 | <style scoped> 134 | 135 | </style> 136 | -------------------------------------------------------------------------------- /frontend/src/components/OtherLinkItem.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div> 3 | <v-card 4 | elevation="4" 5 | rounded="shaped" 6 | > 7 | <v-card-title> 8 | <slot name="title"></slot> 9 | </v-card-title> 10 | <v-card-subtitle> 11 | <slot name="subtitle"></slot> 12 | </v-card-subtitle> 13 | <v-card-text class="text-body-1"> 14 | <slot name="description"></slot> 15 | </v-card-text> 16 | <v-card-actions> 17 | <slot name="actions"></slot> 18 | </v-card-actions> 19 | </v-card> 20 | </div> 21 | </template> 22 | 23 | <script setup lang="ts"> 24 | 25 | </script> 26 | 27 | <style scoped> 28 | 29 | </style> 30 | -------------------------------------------------------------------------------- /frontend/src/components/README.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | Vue template files in this folder are automatically imported. 4 | 5 | ## 🚀 Usage 6 | 7 | Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it. 8 | 9 | The following example assumes a component located at `src/components/MyComponent.vue`: 10 | 11 | ```vue 12 | <template> 13 | <div> 14 | <MyComponent /> 15 | </div> 16 | </template> 17 | 18 | <script lang="ts" setup> 19 | // 20 | </script> 21 | ``` 22 | 23 | When your template is rendered, the component's import will automatically be inlined, which renders to this: 24 | 25 | ```vue 26 | <template> 27 | <div> 28 | <MyComponent /> 29 | </div> 30 | </template> 31 | 32 | <script lang="ts" setup> 33 | import MyComponent from '@/components/MyComponent.vue' 34 | </script> 35 | ``` 36 | -------------------------------------------------------------------------------- /frontend/src/components/SimplePage.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <v-container> 3 | <v-row> 4 | <v-spacer v-show="display.mdAndUp.value"></v-spacer> 5 | <v-col md="11" lg="9" xl="8" style="padding-bottom: 0"> 6 | <slot></slot> 7 | </v-col> 8 | <v-spacer v-show="display.mdAndUp.value"></v-spacer> 9 | </v-row> 10 | </v-container> 11 | </template> 12 | 13 | <script lang="ts" setup> 14 | import type { DisplayInstance } from 'vuetify' 15 | import {useDisplay} from "vuetify"; 16 | 17 | const display = useDisplay() as DisplayInstance 18 | </script> 19 | 20 | <style scoped> 21 | 22 | </style> 23 | -------------------------------------------------------------------------------- /frontend/src/components/SpeedDial.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div id="speed-dial" class="text-center"> 3 | <v-menu open-delay="0" close-delay="100" 4 | open-on-hover 5 | transition="slide-y-reverse-transition"> 6 | <template v-slot:activator="{ props }"> 7 | <v-btn 8 | color="primary" 9 | v-bind="props" 10 | :icon="mdiCreation" 11 | /> 12 | </template> 13 | <div class="btn-item"> 14 | <v-btn 15 | color="grey-darken-3" 16 | :icon="mdiGithub" 17 | @click="openUrlWithDefaultBrowser('https://github.com/triwinds/ns-emu-tools')" 18 | /> 19 | </div> 20 | <div class="btn-item"> 21 | <v-btn 22 | color="blue-darken-1" 23 | :icon="mdiGithub" 24 | @click="openUrlWithDefaultBrowser('https://t.me/+mxI34BRClLUwZDcx')" 25 | > 26 | <v-img src="@/assets/telegram.webp" height="22" width="22"></v-img> 27 | </v-btn> 28 | </div> 29 | 30 | </v-menu> 31 | </div> 32 | </template> 33 | 34 | <script setup lang="ts"> 35 | import {mdiCreation, mdiGithub} from '@mdi/js' 36 | import {openUrlWithDefaultBrowser} from "@/utils/common"; 37 | </script> 38 | 39 | <style scoped> 40 | #speed-dial { 41 | position: absolute; 42 | right: 24px; 43 | bottom: 24px; 44 | } 45 | 46 | .btn-item { 47 | padding-bottom: 12px; 48 | } 49 | </style> 50 | -------------------------------------------------------------------------------- /frontend/src/components/YuzuSaveCommonPart.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <v-container> 3 | <v-row> 4 | <v-col cols="7"> 5 | <v-text-field label="备份文件存放的文件夹" variant="underlined" hide-details readonly v-model="yuzuSaveStore.yuzuSaveBackupPath"></v-text-field> 6 | </v-col> 7 | <v-col cols="2"> 8 | <v-btn color="info" min-width="110" size="large" variant="outlined" @click="askAndUpdateYuzuBackupPath">选择文件夹</v-btn> 9 | </v-col> 10 | <v-col cols="2"> 11 | <v-btn color="success" min-width="110" size="large" variant="outlined" @click="openYuzuBackupFolder">打开文件夹</v-btn> 12 | </v-col> 13 | </v-row> 14 | <v-row> 15 | <v-col> 16 | <v-autocomplete v-model="yuzuSaveStore.selectedUser" :items="yuzuSaveStore.userList" label="选择模拟器用户 ID" 17 | hint="模拟器的用户 ID 可以在菜单 模拟->设置->系统->配置 中查看" persistent-hint 18 | item-title="user_id" item-value="folder" variant="underlined" 19 | style="margin-bottom: 20px"></v-autocomplete> 20 | </v-col> 21 | </v-row> 22 | 23 | </v-container> 24 | </template> 25 | 26 | <script lang="ts" setup> 27 | import {onMounted} from "vue"; 28 | import type {CommonResponse} from "@/types"; 29 | import {useYuzuSaveStore} from "@/stores/YuzuSaveStore"; 30 | 31 | const yuzuSaveStore = useYuzuSaveStore() 32 | onMounted(() => { 33 | window.eel.get_users_in_save()((resp: CommonResponse) => { 34 | if (resp.code === 0) { 35 | yuzuSaveStore.userList = resp.data 36 | } 37 | }) 38 | loadYuzuSaveBackupPath() 39 | }) 40 | async function askAndUpdateYuzuBackupPath() { 41 | await window.eel.ask_and_update_yuzu_save_backup_folder()() 42 | await loadYuzuSaveBackupPath() 43 | } 44 | 45 | async function loadYuzuSaveBackupPath() { 46 | let resp = await window.eel.get_storage()() 47 | yuzuSaveStore.yuzuSaveBackupPath = resp.data.yuzu_save_backup_path 48 | } 49 | 50 | function openYuzuBackupFolder() { 51 | window.eel.open_yuzu_save_backup_folder()() 52 | } 53 | </script> 54 | 55 | <style scoped> 56 | 57 | </style> 58 | -------------------------------------------------------------------------------- /frontend/src/components/YuzuSaveRestoreTab.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <v-card variant="flat" style="min-height: 400px"> 3 | <YuzuSaveCommonPart/> 4 | <v-card-text v-if="backupList.length === 0" class="text-center text-h2">无备份存档</v-card-text> 5 | <div v-else> 6 | <span class="text-h5 text-primary" style="padding-left: 15px">备份存档</span> 7 | <v-virtual-scroll :items="backupList" :height="backupItemBoxHeight" item-height="106"> 8 | <template v-slot:default="{ item }"> 9 | <v-list-item three-line> 10 | <v-list-item-title class="text-info">{{ concatGameName(item) }}</v-list-item-title> 11 | <v-list-item-subtitle> 12 | <v-icon size="20">{{ mdiClockTimeNineOutline }}</v-icon> 13 | 备份时间: {{ new Date(item.bak_time).toLocaleString() }} 14 | </v-list-item-subtitle> 15 | <v-list-item-subtitle> 16 | <v-icon size="20">{{ mdiFileDocumentOutline }}</v-icon> 17 | 文件名: {{ item.filename }} 18 | </v-list-item-subtitle> 19 | <template v-slot:append> 20 | <div> 21 | <v-btn variant="outlined" color="warning" @click="restoreBackup(item.path)">还原备份</v-btn> 22 | <br/> 23 | <v-btn variant="outlined" color="error" @click="deletePath(item.path)">删除备份</v-btn> 24 | </div> 25 | </template> 26 | </v-list-item> 27 | <v-divider/> 28 | </template> 29 | </v-virtual-scroll> 30 | </div> 31 | <v-dialog v-model="restoreWaringDialog" max-width="600"> 32 | <v-card> 33 | <v-card-title class="primary text--white text-h5">提示</v-card-title> 34 | <v-divider/> 35 | <v-card-text class="text--primary text-body-1" style="padding-top: 15px"> 36 | 还原时会先清空相应游戏的存档文件夹,然后再将备份的文件放入。<br/>是否继续还原存档? 37 | </v-card-text> 38 | <v-divider></v-divider> 39 | 40 | <v-card-actions> 41 | <v-spacer></v-spacer> 42 | <v-btn 43 | color="primary" 44 | variant="text" 45 | @click="restoreWaringDialog = false" 46 | > 47 | 取消还原 48 | </v-btn> 49 | <v-btn 50 | color="primary" 51 | variant="text" 52 | @click="summitRestoreRequest" 53 | > 54 | 执行还原 55 | </v-btn> 56 | </v-card-actions> 57 | </v-card> 58 | </v-dialog> 59 | </v-card> 60 | </template> 61 | 62 | <script setup lang="ts"> 63 | import {mdiClockTimeNineOutline, mdiFileDocumentOutline} from '@mdi/js'; 64 | import {onMounted, onUnmounted, ref} from "vue"; 65 | import {useAppStore} from "@/stores/app"; 66 | import {useYuzuSaveStore} from "@/stores/YuzuSaveStore"; 67 | import {useConsoleDialogStore} from "@/stores/ConsoleDialogStore"; 68 | import type {CommonResponse, YuzuSaveBackupListItem} from "@/types"; 69 | import {useEmitter} from "@/plugins/mitt"; 70 | import YuzuSaveCommonPart from "@/components/YuzuSaveCommonPart.vue"; 71 | 72 | let backupList = ref<YuzuSaveBackupListItem[]>([]) 73 | let backupItemBoxHeight = ref(window.innerHeight - 420) 74 | let restoreWaringDialog = ref(false) 75 | let restoreBackupFile = ref('') 76 | const appStore = useAppStore() 77 | const yuzuSaveStore = useYuzuSaveStore() 78 | const cds = useConsoleDialogStore() 79 | const emitter = useEmitter() 80 | 81 | onMounted(() => { 82 | loadAllYuzuBackups() 83 | window.addEventListener('resize', updateBackupItemBoxHeight); 84 | emitter.on('yuzuSave:tabChange', (tab) => { 85 | loadAllYuzuBackups() 86 | }) 87 | }) 88 | 89 | onUnmounted(() => { 90 | window.removeEventListener('resize', updateBackupItemBoxHeight) 91 | emitter.off('yuzuSave:tabChange') 92 | }) 93 | 94 | function loadAllYuzuBackups() { 95 | window.eel.list_all_yuzu_backups()((resp: CommonResponse) => { 96 | backupList.value = resp.data 97 | updateBackupItemBoxHeight() 98 | appStore.loadGameData().then(gameData => { 99 | let nl = [] 100 | for (let item of backupList.value) { 101 | item.game_name = gameData[item.title_id] 102 | nl.push(item) 103 | } 104 | backupList.value = nl; 105 | }) 106 | }) 107 | } 108 | 109 | function updateBackupItemBoxHeight() { 110 | let maxHeight = window.innerHeight - 420 111 | backupItemBoxHeight.value = Math.min(backupList.value.length * 106, maxHeight) 112 | } 113 | 114 | function restoreBackup(filepath: string) { 115 | if (yuzuSaveStore.selectedUser === '') { 116 | cds.cleanAndShowConsoleDialog() 117 | cds.appendConsoleMessage('请先选择一个模拟器用户') 118 | return 119 | } 120 | restoreBackupFile.value = filepath 121 | restoreWaringDialog.value = true 122 | } 123 | 124 | function summitRestoreRequest() { 125 | restoreWaringDialog.value = false 126 | cds.cleanAndShowConsoleDialog() 127 | window.eel.restore_yuzu_save_from_backup(yuzuSaveStore.selectedUser, restoreBackupFile.value)() 128 | } 129 | 130 | function deletePath(path: string) { 131 | window.eel.delete_path(path)((resp: CommonResponse) => { 132 | if (resp.code === 0) { 133 | loadAllYuzuBackups() 134 | } 135 | }) 136 | } 137 | 138 | function concatGameName(item: YuzuSaveBackupListItem) { 139 | let gameName = item.game_name ? item.game_name : appStore.gameDataInited ? 140 | '未知游戏 - ' + item.title_id : '游戏信息加载中...' 141 | return gameName 142 | } 143 | </script> 144 | 145 | <style scoped> 146 | 147 | </style> 148 | -------------------------------------------------------------------------------- /frontend/src/layouts/AppBar.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <v-app-bar flat color="primary"> 3 | <v-app-bar-nav-icon class="white--text" @click="triggerDrawer"></v-app-bar-nav-icon> 4 | <v-app-bar-title> 5 | NS EMU TOOLS 6 | </v-app-bar-title> 7 | <v-spacer/> 8 | <v-btn class="float-right" icon @click="showConsoleDialog"> 9 | <v-icon color="white" :icon="mdiConsole"></v-icon> 10 | </v-btn> 11 | <v-btn class="float-right" icon @click="switchDarkLight"> 12 | <v-icon color="white" :icon="mdiBrightness6"></v-icon> 13 | </v-btn> 14 | </v-app-bar> 15 | </template> 16 | 17 | <script lang="ts" setup> 18 | import {useEmitter} from "@/plugins/mitt"; 19 | import {mdiBrightness6, mdiConsole} from '@mdi/js' 20 | import {useTheme} from "vuetify"; 21 | import {useConsoleDialogStore} from "@/stores/ConsoleDialogStore"; 22 | 23 | const emitter = useEmitter() 24 | const theme = useTheme() 25 | const cds = useConsoleDialogStore() 26 | 27 | function triggerDrawer() { 28 | emitter.emit('triggerDrawer') 29 | } 30 | function showConsoleDialog() { 31 | cds.showConsoleDialog() 32 | } 33 | 34 | function switchDarkLight() { 35 | theme.global.name.value = theme.global.name.value === 'dark' ? 'light' : 'dark' 36 | window.eel.update_dark_state(theme.global.name.value === 'dark')() 37 | } 38 | </script> 39 | -------------------------------------------------------------------------------- /frontend/src/layouts/README.md: -------------------------------------------------------------------------------- 1 | # Layouts 2 | 3 | Layouts are reusable components that wrap around pages. They are used to provide a consistent look and feel across multiple pages. 4 | 5 | Full documentation for this feature can be found in the Official [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts) repository. 6 | -------------------------------------------------------------------------------- /frontend/src/layouts/View.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <v-main> 3 | <div style="max-height: calc(100vh - 64px); width: 100%; overflow: auto;"> 4 | <router-view /> 5 | </div> 6 | </v-main> 7 | </template> 8 | 9 | <script lang="ts" setup> 10 | // 11 | </script> 12 | -------------------------------------------------------------------------------- /frontend/src/layouts/default.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <v-app> 3 | <AppDrawer/> 4 | <default-bar/> 5 | 6 | <default-view/> 7 | <ConsoleDialog/> 8 | <NewVersionDialog/> 9 | <SpeedDial v-if="display.mdAndUp.value"/> 10 | </v-app> 11 | </template> 12 | 13 | <script lang="ts" setup> 14 | import DefaultBar from './AppBar.vue' 15 | import DefaultView from './View.vue' 16 | import AppDrawer from "./AppDrawer.vue"; 17 | import ConsoleDialog from "@/components/ConsoleDialog.vue"; 18 | import NewVersionDialog from "@/components/NewVersionDialog.vue"; 19 | import SpeedDial from "@/components/SpeedDial.vue"; 20 | import {useDisplay} from "vuetify"; 21 | 22 | const display = useDisplay() 23 | 24 | </script> 25 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * main.ts 3 | * 4 | * Bootstraps Vuetify and other plugins then mounts the App` 5 | */ 6 | 7 | // Components 8 | import App from './App.vue' 9 | 10 | // Composables 11 | import { createApp } from 'vue' 12 | 13 | // Plugins 14 | import { registerPlugins } from '@/plugins' 15 | import {emitter, useEmitter} from "@/plugins/mitt"; 16 | import {useConsoleDialogStore} from "@/stores/ConsoleDialogStore"; 17 | 18 | const app = createApp(App) 19 | 20 | registerPlugins(app) 21 | 22 | app.mount('#app') 23 | 24 | declare global { 25 | interface Window { 26 | $vm: typeof app; 27 | eel: any; 28 | $bus: typeof emitter; 29 | } 30 | } 31 | 32 | window.$vm = app 33 | window.$bus = useEmitter() 34 | const cds = useConsoleDialogStore() 35 | window.$bus.on('APPEND_CONSOLE_MESSAGE', (msg) => { 36 | cds.appendConsoleMessage(msg as string) 37 | }) 38 | cds.appendConsoleMessage('启动时间: ' + new Date()) 39 | -------------------------------------------------------------------------------- /frontend/src/pages/about.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <SimplePage> 3 | <v-card> 4 | <v-card-title class="text-h4 text-primary">Ns Emu Tools</v-card-title> 5 | <v-divider></v-divider> 6 | <v-spacer></v-spacer> 7 | <v-card-text class="text-h6"> 8 | 一个用于安装/更新 NS 模拟器的工具 9 | </v-card-text> 10 | <v-card-text class="text-h6"> 11 | 当前版本:v{{ configStore.currentVersion }} 12 | <v-btn color="primary" variant="outlined" @click="configStore.checkUpdate(true)"> 13 | 检测新版本 14 | </v-btn> 15 | <ChangeLogDialog> 16 | <template v-slot:activator="{props}"> 17 | <v-btn color="info" v-bind="props" @click="loadChangeLog" 18 | variant="outlined" style="margin-left: 10px"> 19 | 更新日志 20 | </v-btn> 21 | </template> 22 | <template v-slot:content> 23 | <div v-html="changeLogHtml"></div> 24 | </template> 25 | </ChangeLogDialog> 26 | <v-btn color="success" variant="outlined" style="margin-left: 10px" 27 | @click="openUrlWithDefaultBrowser('https://github.com/triwinds/ns-emu-tools/blob/main/LICENSE')"> 28 | License 29 | </v-btn> 30 | </v-card-text> 31 | <v-card-text class="text-h6 text--primary"> 32 | <div class="info-block"> 33 | <p class="text-h5 text-accent" style="padding-bottom: 5px">项目地址</p> 34 | <div class="line-group"> 35 | <div class="line-item-icon"><v-icon size="24">{{ mdiGithub }}</v-icon></div> 36 | <div class="line-item">GitHub:<a class="text-error" 37 | @click="openUrlWithDefaultBrowser('https://github.com/triwinds/ns-emu-tools')"> 38 | 39 | triwinds/ns-emu-tools</a></div> 40 | </div> 41 | <span>如果您觉得这个软件好用, 可以在 GitHub 上点个 star</span><br> 42 | <span>这是对我最大的鼓励。</span> 43 | </div> 44 | <div class="info-block"> 45 | <p class="text-h5 text-success">讨论组</p> 46 | <div class="line-group"> 47 | <div class="line-item" style=""> 48 | <v-img src="@/assets/telegram.webp" height="20" width="20" 49 | v-show="theme.global.name.value === 'dark'"></v-img> 50 | <v-img src="@/assets/telegram_black.webp" height="20" width="20" 51 | v-show="theme.global.name.value !== 'dark'"></v-img> 52 | </div> 53 | <div class="line-item">Telegram: 54 | <a class="text-secondary" 55 | @click="openUrlWithDefaultBrowser('https://t.me/+mxI34BRClLUwZDcx')">Telegram 讨论组</a></div> 56 | </div> 57 | </div> 58 | <div class="info-block"> 59 | <p class="text-h5 text-warning">Credits</p> 60 | <div class="line-group" v-for="(item, index) in credits" :key="index"> 61 | <div class="line-item-icon"> 62 | <v-icon size="24">{{ mdiGithub }}</v-icon> 63 | </div> 64 | <div class="line-item"> 65 | <a class="text-secondary" 66 | @click="openUrlWithDefaultBrowser(item.link)">{{ item.name }}</a> 67 | - {{ item.description }} 68 | </div> 69 | </div> 70 | </div> 71 | 72 | </v-card-text> 73 | </v-card> 74 | </SimplePage> 75 | </template> 76 | 77 | <script setup lang="ts"> 78 | import {mdiGithub} from "@mdi/js"; 79 | import SimplePage from "@/components/SimplePage.vue"; 80 | import ChangeLogDialog from "@/components/ChangeLogDialog.vue"; 81 | import {openUrlWithDefaultBrowser} from "@/utils/common"; 82 | import {useTheme} from "vuetify"; 83 | import {ref} from "vue"; 84 | import type {CommonResponse} from "@/types"; 85 | import md from "@/utils/markdown"; 86 | import {useConfigStore} from "@/stores/ConfigStore"; 87 | 88 | const theme = useTheme() 89 | const configStore = useConfigStore() 90 | let credits = [ 91 | {name: 'Yuzu', link: 'https://github.com/yuzu-emu/yuzu', description: 'Yuzu 模拟器'}, 92 | {name: 'Ryujinx', link: 'https://ryujinx.app/', description: 'Ryujinx 模拟器'}, 93 | {name: 'Suyu', link: 'https://git.suyu.dev/suyu/suyu', description: 'Suyu 模拟器'}, 94 | {name: 'hactool', link: 'https://github.com/SciresM/hactool', description: 'NS 固件解析'}, 95 | {name: 'nsz', link: 'https://github.com/nicoboss/nsz', description: 'NS 固件解析'}, 96 | {name: 'aria2', link: 'https://github.com/aria2/aria2', description: 'aria2 下载器'}, 97 | {name: 'Github 镜像源', link: 'https://github.com/XIU2/UserScript/blob/master/GithubEnhanced-High-Speed-Download.user.js', description: '来自 X.I.U 大佬的 Github 增强脚本'}, 98 | {name: 'pineappleEA', link: 'https://github.com/pineappleEA/pineapple-src', description: 'Yuzu EA 版本来源'}, 99 | {name: 'THZoria/NX_Firmware', link: 'https://github.com/THZoria/NX_Firmware', description: 'NS 固件来源'}, 100 | {name: 'darthsternie.net', link: 'https://darthsternie.net/switch-firmwares/', description: 'NS 固件来源'}, 101 | ] 102 | let changeLogHtml = ref('<p>加载中...</p>') 103 | function loadChangeLog() { 104 | window.eel.load_change_log()((resp: CommonResponse) => { 105 | if (resp.code === 0) { 106 | changeLogHtml.value = md.parse(resp.data) 107 | } else { 108 | changeLogHtml.value = '<p>加载失败。</p>' 109 | } 110 | }) 111 | } 112 | </script> 113 | 114 | <style scoped> 115 | .info-block { 116 | margin-bottom: 20px; 117 | } 118 | 119 | .line-group { 120 | width: 100%; 121 | /*overflow-x: auto;*/ 122 | overflow: hidden; 123 | margin-top: 5px; 124 | } 125 | 126 | .line-item { 127 | float: left; 128 | margin-right: 10px; 129 | margin-top: 2px; 130 | height: 24px; 131 | } 132 | 133 | .line-item-icon { 134 | float: left; 135 | margin-right: 10px; 136 | } 137 | span { 138 | line-height: 30px; 139 | } 140 | </style> 141 | -------------------------------------------------------------------------------- /frontend/src/pages/faq.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <SimplePage> 3 | <v-card id="faq-card" style="padding-bottom: 10px"> 4 | <v-card-title> 5 | <span class="text-h4 text-primary">常见问题</span> 6 | </v-card-title> 7 | <v-divider></v-divider> 8 | <v-list> 9 | <FaqGroup> 10 | <template v-slot:title>第一次使用应该安装哪些组件</template> 11 | <template v-slot:content> 12 | <p>1. 安装最新的显卡驱动,这能减少模拟器运行时发生的很多问题。</p> 13 | <p>2. 安装 Yuzu/Ryujinx 模拟器(如果检测到缺少 msvc 运行库的话会自动运行 msvc 的安装程序,msvc 14 | 装完后记得重启一下)</p> 15 | <p>3. 安装固件并配置相应的密钥文件</p> 16 | <p>这些东西装完之后模拟器就安装好了。</p> 17 | </template> 18 | </FaqGroup> 19 | 20 | <FaqGroup> 21 | <template v-slot:title>Yuzu/Ryujinx 最新版本信息加载失败</template> 22 | <template v-slot:content> 23 | <p>yuzu/ryujinx 的 release 数据需要从 GitHub api 中获取,而接口调用失败时就会出现这个问题。</p> 24 | <p><b>这个问题可能由以下两种原因产生:</b></p> 25 | <p>1.某些运营商的屏蔽了 api.github.com 这个地址,因此无法连接至 GitHub.</p> 26 | <p>2.如果你使用的是共享的 ip (比如用了某些公用的梯子或某些运营商的公共出口),可能是当前 ip 使用的频率达到 27 | GitHub 的使用上限(大概每小时 60 次).</p> 28 | <p><b>解决方法:</b></p> 29 | <p>1. 在设置中将 GitHub api 改为 "使用 CDN";如果还是不行,那说明 CDN 也被屏蔽了,挂个梯子直连吧。</p> 30 | <p>2. 如果是用梯子的话可以尝试换个节点, 或者等一段时间, GitHub 会自动解除封禁(最多只封 1h)</p> 31 | </template> 32 | </FaqGroup> 33 | 34 | <FaqGroup> 35 | <template v-slot:title>下载时出错,download 文件夹下没有任何东西</template> 36 | <template v-slot:content> 37 | <p>特殊地区/时期 Cloudflare 38 | 服务器会因为某些原因被屏蔽,可以在设置中切换下载源,如果还是不行就需要使用代理软件了。</p> 39 | <p>或者前往下一小节中提到的 整合贴 中下载整合包。</p> 40 | </template> 41 | </FaqGroup> 42 | 43 | <FaqGroup> 44 | <template v-slot:title>下载速度很慢</template> 45 | <template v-slot:content> 46 | <p><b>如果设置中的下载源用的是 "直连":</b></p> 47 | <p>请直接和你的宽带运营商或者梯子提供者反馈下载速度不达标的问题。</p> 48 | <p><b>如果设置中的下载源用的是 CDN:</b></p> 49 | <p> 50 | 由于 Cloudflare CDN 服务器不在国内,因此下载速度取决于你的宽带运营商所能提供的国际带宽, 51 | 某些运营商为了节约运营成本,提供的国际线路不太好,带宽容量也比较少,所以在高峰期会出现丢包和卡顿。 52 | </p> 53 | <p>一劳永逸的解决办法当然是用好点的运营商以及一个好点的梯子 54 | <del>(加钱,世界触手可及.jpg)</del> 55 | </p> 56 | <p>如果你的动手能力比较强,可以看看这个由 XIU2 大佬分享的 <a 57 | @click="openUrlWithDefaultBrowser('https://github.com/XIU2/CloudflareSpeedTest/discussions/71')"> 58 | 加速 Cloudflare CDN 访问</a> 的办法。</p> 59 | <p>或者直接在贴吧的 60 | <a @click="openUrlWithDefaultBrowser('https://tieba.baidu.com/p/7665223775')">整合贴 1</a> 或 61 | <a @click="openUrlWithDefaultBrowser('https://tieba.baidu.com/p/7799545671')">整合贴 2</a> 62 | 中下载整合包。 63 | </p> 64 | </template> 65 | </FaqGroup> 66 | 67 | <FaqGroup> 68 | <template v-slot:title>如何为 aria2 配置代理</template> 69 | <template v-slot:content> 70 | <p>目前程序可以检测系统代理,并在进行下载时自动为 aria2 配置代理。</p> 71 | <p>当前使用的系统代理可以在右键 开始菜单图标 - 设置 - 网络和 Internet - 代理 - 代理服务器 中查看。</p> 72 | <p>v2rayN 的 "自动配置系统代理" 以及 Clash for windows 的 "System Proxy" 都可以正确配置系统代理。</p> 73 | <p>其它代理工具请自行摸索。</p> 74 | </template> 75 | </FaqGroup> 76 | 77 | <FaqGroup> 78 | <template v-slot:title>点击安装后出现 “当前的 xxx 就是 [yyy], 跳过安装.”</template> 79 | <template v-slot:content> 80 | <p>这是由于记录中的版本和你选择的版本一致,为了避免重复下载/安装,这里会跳过安装过程。</p> 81 | <p>如果你确认你选择的版本没有安装过,那可能是因为你用的是别人的配置文件。</p> 82 | <p>可以删除目录下的 config.json 文件,然后重启程序(这会重置你的设置及记录).</p> 83 | <p>这个时候点安装就应该正常了。</p> 84 | </template> 85 | </FaqGroup> 86 | 87 | <FaqGroup> 88 | <template v-slot:title>关于模拟器版本检测</template> 89 | <template v-slot:content> 90 | <p> 91 | 模拟器版本检测的原理是启动模拟器,然后根据窗口标题确定正在使用的模拟器是什么版本,再将检测到的版本和分支信息保存到程序的记录中。</p> 92 | <p>ps.这个功能仅用于更新记录的版本号,不影响模拟器及程序的使用。</p> 93 | </template> 94 | </FaqGroup> 95 | 96 | <FaqGroup> 97 | <template v-slot:title>关于固件版本检测</template> 98 | <template v-slot:content> 99 | <p> 100 | 固件版本检测的原理是使用配置的密钥去解密固件文件,从而获取版本号。因此,这个功能需要你正确的配置了固件和相应的密钥后才能使用</p> 101 | <p>ps.这个功能仅用于更新记录的版本号,不影响程序的使用。</p> 102 | </template> 103 | </FaqGroup> 104 | 105 | <FaqGroup> 106 | <template v-slot:title>游戏与模拟器的兼容问题</template> 107 | <template v-slot:content> 108 | <p> 109 | 不同游戏对不同版本的模拟器兼容程度不一样,新版本模拟器对老游戏的支持不一定好,可以在贴吧里面找找别人是用什么版本 110 | 的模拟器和固件通关的,依此来决定你应该使用什么版本。 111 | </p> 112 | <p> 113 | 对于新游戏,模拟器会渐渐完善相关的支持,可以等一段时间后更新试试。 114 | </p> 115 | </template> 116 | </FaqGroup> 117 | 118 | <FaqGroup> 119 | <template v-slot:title>其它问题反馈</template> 120 | <template v-slot:content> 121 | <p>如果你遇到的问题不属于上面的任何一个,可以在 122 | <a 123 | @click="openUrlWithDefaultBrowser('https://github.com/triwinds/ns-emu-tools/issues')">GitHub Issues</a> 124 | 中提交问题反馈,记得带上程序目录下的两个 log 文件,这将有助于排查你遇到的问题。 125 | </p> 126 | </template> 127 | </FaqGroup> 128 | </v-list> 129 | </v-card> 130 | </SimplePage> 131 | </template> 132 | 133 | <script setup lang="ts"> 134 | import SimplePage from "@/components/SimplePage.vue"; 135 | import FaqGroup from "@/components/FaqGroup.vue"; 136 | import {openUrlWithDefaultBrowser} from "@/utils/common"; 137 | import {onMounted} from "vue"; 138 | 139 | onMounted(() => { 140 | let aLinks = document.getElementById('faq-card')?.getElementsByTagName('a') 141 | if (aLinks) { 142 | for (let aLink of aLinks) { 143 | aLink.classList.add('text-primary') 144 | aLink.style.cursor = 'pointer' 145 | } 146 | } 147 | }) 148 | 149 | </script> 150 | 151 | <style scoped> 152 | p { 153 | font-size: 18px; 154 | line-height: 30px !important; 155 | margin-bottom: 16px !important; 156 | } 157 | </style> 158 | -------------------------------------------------------------------------------- /frontend/src/pages/index.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div></div> 3 | </template> 4 | 5 | <script lang="ts"> 6 | </script> 7 | -------------------------------------------------------------------------------- /frontend/src/pages/keys.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <SimplePage> 3 | <v-card> 4 | <v-container id="main-container"> 5 | <v-row> 6 | <v-col> 7 | <p class="text-h3 text-primary"> 8 | 密钥管理 9 | </p> 10 | </v-col> 11 | </v-row> 12 | <v-divider style="margin-bottom: 15px"></v-divider> 13 | 14 | <div class="text-h6"> 15 | <p>由于版权原因, 本项目不再提供 keys 自动安装功能。</p> 16 | <p>请用户自行放置 keys 文件至相应文件夹。</p> 17 | <div class="text-h4 text-accent">获取密钥</div> 18 | <v-divider style="margin-bottom: 15px"></v-divider> 19 | <p>请善用搜索引擎...</p> 20 | <div id="root"> 21 | <div class="text-h4 text-warning">添加 keys 到模拟器</div> 22 | <v-divider style="margin-bottom: 15px"></v-divider> 23 | <p>请将上面获取到的 <b>prod.keys</b> 放进相应模拟器的文件夹 24 | </p> 25 | <p><b>注意:</b> 你使用的 keys 版本号应该大于等于当前使用的固件版本号</p> 26 | <v-row style="padding-top: 25px"> 27 | <v-col> 28 | <v-btn color="info" variant="outlined" @click="openYuzuKeyFolder" block>打开 Yuzu keys 文件夹</v-btn> 29 | </v-col> 30 | <v-col> 31 | <v-btn color="info" variant="outlined" @click="openSuyuKeyFolder" block>打开 Suzu keys 文件夹</v-btn> 32 | </v-col> 33 | <v-col> 34 | <v-btn color="info" variant="outlined" @click="openRyujinxKeyFolder" block>打开 Ryujinx keys 文件夹 35 | </v-btn> 36 | </v-col> 37 | </v-row> 38 | </div> 39 | </div> 40 | </v-container> 41 | </v-card> 42 | </SimplePage> 43 | </template> 44 | 45 | <script setup lang="ts"> 46 | import SimplePage from "@/components/SimplePage.vue"; 47 | import {openUrlWithDefaultBrowser} from "@/utils/common"; 48 | 49 | function openYuzuKeyFolder() { 50 | window.eel.open_yuzu_keys_folder()(() => { 51 | }) 52 | } 53 | 54 | function openSuyuKeyFolder() { 55 | window.eel.open_suyu_keys_folder()(() => { 56 | }) 57 | } 58 | 59 | function openRyujinxKeyFolder() { 60 | window.eel.open_ryujinx_keys_folder()(() => { 61 | }) 62 | } 63 | </script> 64 | 65 | <style scoped> 66 | .text-h3 { 67 | padding-bottom: 10px; 68 | } 69 | 70 | .text-h4 { 71 | padding-top: 25px; 72 | padding-bottom: 10px; 73 | } 74 | </style> 75 | -------------------------------------------------------------------------------- /frontend/src/pages/otherLinks.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <SimplePage> 3 | <OtherLinkItem v-for="item in linkItems" :key="item.link" style="margin-bottom: 10px"> 4 | <template v-slot:title> 5 | <v-icon style="margin-right: 5px">{{ 6 | item.link.startsWith('https://github.com') ? mdiGithub : mdiWeb 7 | }} 8 | </v-icon> 9 | <span class="text-warning">{{ item.title }}</span> 10 | </template> 11 | <template v-slot:description>{{ item.description }}</template> 12 | <template v-slot:actions> 13 | <v-btn variant="text" color="info" @click="openUrlWithDefaultBrowser(item.link)"> 14 | 打开链接 15 | </v-btn> 16 | <div v-if="item.subLinks"> 17 | <v-btn v-for="subLink in item.subLinks" variant="text" color="info" 18 | :key="subLink.link" @click="openUrlWithDefaultBrowser(subLink.link)"> 19 | {{ subLink.name }} 20 | </v-btn> 21 | </div> 22 | </template> 23 | </OtherLinkItem> 24 | </SimplePage> 25 | </template> 26 | 27 | <script setup lang="ts"> 28 | 29 | import {mdiGithub, mdiWeb} from '@mdi/js'; 30 | import {openUrlWithDefaultBrowser} from "@/utils/common"; 31 | import SimplePage from "@/components/SimplePage.vue"; 32 | import OtherLinkItem from "@/components/OtherLinkItem.vue"; 33 | 34 | let linkItems = [ 35 | { 36 | link: 'https://github.com/dezem/SAK', 37 | title: 'Switch Army Knife (SAK)', 38 | description: '一个用于转换 XCI / NSP / NSZ 格式的工具', 39 | }, { 40 | link: 'https://github.com/BeyondDimension/SteamTools', 41 | title: 'Watt Toolkit (原 steam++)', 42 | description: '一个开源跨平台的多功能 Steam 工具箱, 可以本地代理 Steam 社区、 Github 、谷歌验证码等国内难以访问的网页', 43 | subLinks: [ 44 | { 45 | name: '官网', 46 | link: 'https://steampp.net/' 47 | } 48 | ] 49 | }, { 50 | link: 'https://github.com/theboy181/switch-ptchtxt-mods', 51 | title: 'theboy181 的 MOD 合集', 52 | description: '由 theboy181 整理的 MOD 合集', 53 | subLinks: [ 54 | { 55 | name: 'Discord', 56 | link: 'https://discord.gg/B56sXVVRmC' 57 | } 58 | ] 59 | }, { 60 | link: 'https://github.com/ChanseyIsTheBest/NX-60FPS-RES-GFX-Cheats/blob/main/GAMES.md', 61 | title: '图形类金手指合集', 62 | description: '由 ChanseyIsTheBest 整理的金手指合集', 63 | }, { 64 | link: 'https://www.cheatslips.com/', 65 | title: '作弊类金手指合集', 66 | description: '由 cheatslips 整理的金手指合集', 67 | }, { 68 | link: 'http://www.ffhome.com/works/1814.html', 69 | title: 'FFHOME Nintendo Switch Game Manager', 70 | description: '软件全称 FFHOME Nintendo Switch Game Manager,它是一款整理(处理)你拥有的NSP格式、XCI格式游戏文件的一款小工具,包括文件信息查看、批量更名和文件处理等功能。', 71 | }, 72 | ] 73 | 74 | </script> 75 | 76 | <style scoped> 77 | 78 | </style> 79 | -------------------------------------------------------------------------------- /frontend/src/pages/yuzuSaveManagement.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <SimplePage> 3 | <v-card> 4 | <v-card-title class="text-primary text-h4" style="margin-bottom: 10px; margin-top: 10px;">Yuzu 存档管理</v-card-title> 5 | <v-divider></v-divider> 6 | <div class="d-flex flex-row"> 7 | <v-tabs v-model="tab" direction="vertical" color="primary"> 8 | <v-tab value="backup"> 9 | <v-icon>{{ mdiContentSaveAll }}</v-icon> 10 | <span style="padding-left: 15px">备份</span> 11 | </v-tab> 12 | <v-tab value="restore"> 13 | <v-icon>{{ mdiBackupRestore }}</v-icon> 14 | <span style="padding-left: 15px">还原</span> 15 | </v-tab> 16 | </v-tabs> 17 | 18 | <v-window v-model="tab" style="width: 100%; height: 100%;"> 19 | <v-window-item key="backup" value="backup"> 20 | <v-card variant="flat"> 21 | <YuzuSaveCommonPart/> 22 | <MarkdownContentBox v-if="!yuzuSaveStore.selectedUser || yuzuSaveStore.selectedUser === ''" :content="guide"/> 23 | <v-container v-else> 24 | <v-row> 25 | <v-col> 26 | <v-autocomplete v-model="selectedGameFolder" hide-details variant="underlined" 27 | :items="gameList" label="选择需要进行备份的游戏" 28 | :item-title="concatGameName" item-value="folder"/> 29 | </v-col> 30 | </v-row> 31 | <v-row> 32 | <v-col> 33 | <v-btn color="success" variant="outlined" block @click="doBackup" 34 | :disabled="selectedGameFolder === ''">创建备份</v-btn> 35 | </v-col> 36 | </v-row> 37 | </v-container> 38 | </v-card> 39 | </v-window-item> 40 | 41 | <v-window-item key="restore" value="restore"> 42 | <YuzuSaveRestoreTab/> 43 | </v-window-item> 44 | </v-window> 45 | </div> 46 | </v-card> 47 | </SimplePage> 48 | </template> 49 | 50 | <script setup lang="ts"> 51 | import SimplePage from "@/components/SimplePage.vue"; 52 | import {ref, watch} from "vue"; 53 | import {mdiContentSaveAll, mdiBackupRestore} from '@mdi/js'; 54 | import {useYuzuSaveStore} from "@/stores/YuzuSaveStore"; 55 | import MarkdownContentBox from "@/components/MarkdownContentBox.vue"; 56 | import YuzuSaveCommonPart from "@/components/YuzuSaveCommonPart.vue"; 57 | import type {CommonResponse, SaveGameInfo} from "@/types"; 58 | import {useAppStore} from "@/stores/app"; 59 | import {useConsoleDialogStore} from "@/stores/ConsoleDialogStore"; 60 | import YuzuSaveRestoreTab from "@/components/YuzuSaveRestoreTab.vue"; 61 | import {useEmitter} from "@/plugins/mitt"; 62 | 63 | let tab = ref('') 64 | const guide = ` 65 | ## 使用说明 66 | 67 | Yuzu 模拟器在保存存档时会根据用户 id 选择不同的文件夹,因此需要先确认你正在使用的用户 id. 68 | 69 | 模拟器的用户 ID 可以在菜单 模拟->设置->系统->配置 中查看 70 | [参考截图](https://cdn.jsdelivr.net/gh/triwinds/ns-emu-tools@main/doc/assets/yuzu_user_id.jpg). 71 | 72 | 点击备份会将选择的存档文件夹打包成一个 7z 压缩包放到指定的目录, 你也可以选择手动解压还原。 73 | ` 74 | let selectedGameFolder = ref('') 75 | let gameList = ref<SaveGameInfo[]>([]) 76 | const yuzuSaveStore = useYuzuSaveStore() 77 | const appStore = useAppStore() 78 | const cds = useConsoleDialogStore() 79 | const emitter = useEmitter() 80 | let lastUser: string = '' 81 | 82 | yuzuSaveStore.$subscribe((mutation, state) => { 83 | if (state.selectedUser && lastUser != state.selectedUser) { 84 | reloadGameList() 85 | lastUser = state.selectedUser 86 | } 87 | }) 88 | 89 | watch(tab, () => { 90 | emitter.emit('yuzuSave:tabChange', tab) 91 | }) 92 | 93 | function reloadGameList() { 94 | window.eel.list_all_games_by_user_folder(yuzuSaveStore.selectedUser)((resp: CommonResponse) => { 95 | if (resp.code === 0) { 96 | gameList.value = resp.data 97 | if (appStore.gameDataInited) { 98 | enrichGameList() 99 | } else { 100 | appStore.loadGameData().then(() => { 101 | enrichGameList() 102 | }) 103 | } 104 | if (gameList.value.length > 0) { 105 | selectedGameFolder.value = gameList.value[0].folder 106 | } 107 | } 108 | }) 109 | } 110 | 111 | function enrichGameList() { 112 | let nl = [] 113 | for (let item of gameList.value) { 114 | item.game_name = appStore.gameData[item.title_id] 115 | nl.push(item) 116 | } 117 | gameList.value = nl; 118 | } 119 | 120 | function concatGameName(item: SaveGameInfo) { 121 | let gameName = item.game_name ? item.game_name : appStore.gameDataInited ? '未知游戏' : '游戏信息加载中...' 122 | return `[${item.title_id}] ${gameName}` 123 | } 124 | 125 | function doBackup() { 126 | cds.cleanAndShowConsoleDialog() 127 | window.eel.backup_yuzu_save_folder(selectedGameFolder.value)() 128 | } 129 | 130 | </script> 131 | 132 | <style scoped> 133 | 134 | </style> 135 | -------------------------------------------------------------------------------- /frontend/src/plugins/README.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally. 4 | -------------------------------------------------------------------------------- /frontend/src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/index.ts 3 | * 4 | * Automatically included in `./src/main.ts` 5 | */ 6 | 7 | // Plugins 8 | import vuetify from './vuetify' 9 | import pinia from '../stores' 10 | import router from '../router' 11 | 12 | // Types 13 | import type { App } from 'vue' 14 | 15 | export function registerPlugins (app: App) { 16 | app 17 | .use(vuetify) 18 | .use(router) 19 | .use(pinia) 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/plugins/mitt.ts: -------------------------------------------------------------------------------- 1 | // Composables 2 | import mitt from 'mitt' 3 | 4 | export const emitter = mitt() 5 | 6 | export function useEmitter() { 7 | return emitter 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/vuetify.ts 3 | * 4 | * Framework documentation: https://vuetifyjs.com` 5 | */ 6 | 7 | // Styles 8 | import 'vuetify/styles' 9 | import '@fontsource-variable/jetbrains-mono'; 10 | 11 | // Composables 12 | import { createVuetify } from 'vuetify' 13 | import { aliases, mdi } from 'vuetify/iconsets/mdi-svg' 14 | 15 | // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides 16 | export default createVuetify({ 17 | icons: { 18 | defaultSet: 'mdi', 19 | aliases, 20 | sets: { 21 | mdi, 22 | }, 23 | }, 24 | defaults: { 25 | VCardTitle: { 26 | style: 'padding-top: 16px; padding-bottom: 16px;' 27 | } 28 | }, 29 | theme: { 30 | defaultTheme: 'dark', 31 | themes: { 32 | dark: { 33 | colors: { 34 | primary: '#009688', 35 | secondary: '#89ddff', 36 | accent: '#c792ea', 37 | error: '#ff5370', 38 | warning: '#ffcb6b', 39 | info: '#89ddff', 40 | success: '#c3e88d', 41 | background: '#263238' 42 | } 43 | }, 44 | light: { 45 | colors: { 46 | primary: '#3A66D1', 47 | secondary: '#2AA298', 48 | accent: '#6F42C1', 49 | error: '#d25252', 50 | warning: '#E36209', 51 | info: '#2AA298', 52 | success: '#22863A', 53 | background: '#F7F8FA' 54 | } 55 | }, 56 | }, 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /frontend/src/router/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * router/index.ts 3 | * 4 | * Automatic routes for `./src/pages/*.vue` 5 | */ 6 | 7 | // Composables 8 | import { createRouter, createWebHashHistory } from 'vue-router/auto' 9 | import { setupLayouts } from 'virtual:generated-layouts' 10 | import { routes } from 'vue-router/auto-routes' 11 | 12 | const router = createRouter({ 13 | history: createWebHashHistory(import.meta.env.BASE_URL), 14 | routes: setupLayouts(routes), 15 | }) 16 | 17 | // Workaround for https://github.com/vitejs/vite/issues/11804 18 | router.onError((err, to) => { 19 | if (err?.message?.includes?.('Failed to fetch dynamically imported module')) { 20 | if (!localStorage.getItem('vuetify:dynamic-reload')) { 21 | console.log('Reloading page to fix dynamic import error') 22 | localStorage.setItem('vuetify:dynamic-reload', 'true') 23 | location.assign(to.fullPath) 24 | } else { 25 | console.error('Dynamic import error, reloading page did not fix it', err) 26 | } 27 | } else { 28 | console.error(err) 29 | } 30 | }) 31 | 32 | router.isReady().then(() => { 33 | localStorage.removeItem('vuetify:dynamic-reload') 34 | }) 35 | 36 | export default router 37 | -------------------------------------------------------------------------------- /frontend/src/stores/ConfigStore.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import { defineStore } from 'pinia' 3 | import type {AppConfig, CommonResponse} from "@/types"; 4 | 5 | export const useConfigStore = defineStore('config', { 6 | state: () => ({ 7 | config: { 8 | yuzu: {}, 9 | suyu: {}, 10 | ryujinx: {}, 11 | setting: {} 12 | } as AppConfig, 13 | currentVersion: '', 14 | hasNewVersion: false, 15 | }), 16 | actions: { 17 | async reloadConfig() { 18 | const resp = await window.eel.get_config()() 19 | if (resp.code === 0) { 20 | this.config = resp.data 21 | } 22 | }, 23 | initCurrentVersion() { 24 | window.eel.get_current_version()((resp: CommonResponse) => { 25 | if (resp['code'] === 0) { 26 | this.currentVersion = resp.data 27 | } else { 28 | this.currentVersion = '未知' 29 | } 30 | }) 31 | }, 32 | checkUpdate(forceShowDialog: boolean) { 33 | window.eel.check_update()((data: CommonResponse) => { 34 | if (data['code'] === 0 && data['data']) { 35 | this.hasNewVersion = true 36 | } 37 | if (forceShowDialog || this.hasNewVersion) { 38 | window.$bus.emit('showNewVersionDialog', 39 | {hasNewVersion: this.hasNewVersion, latestVersion: data['msg']}) 40 | } 41 | }) 42 | }, 43 | }, 44 | getters: { 45 | yuzuConfig(state) { 46 | return state.config.yuzu 47 | } 48 | } 49 | }) 50 | -------------------------------------------------------------------------------- /frontend/src/stores/ConsoleDialogStore.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import {defineStore} from 'pinia' 3 | 4 | export const useConsoleDialogStore = defineStore('consoleDialog', { 5 | state: () => ({ 6 | dialogFlag: false, 7 | persistentConsoleDialog: false, 8 | consoleMessages: [] as string[], 9 | newLine: true 10 | }), 11 | actions: { 12 | cleanAndShowConsoleDialog() { 13 | this.dialogFlag = true 14 | this.consoleMessages = [] 15 | }, 16 | showConsoleDialog() { 17 | this.dialogFlag = true 18 | }, 19 | appendConsoleMessage(message: string) { 20 | if (!message) { 21 | return 22 | } 23 | const splits = message.split('\n') 24 | for (const value of splits) { 25 | if (value.length < 1) { 26 | continue 27 | } 28 | if (value && value.startsWith('^')) { 29 | if (this.newLine) { 30 | this.consoleMessages.push(value) 31 | this.newLine = false 32 | } else { 33 | this.consoleMessages[this.consoleMessages.length - 1] = value.substring(1) 34 | } 35 | } else { 36 | this.consoleMessages.push(value) 37 | if (!this.newLine) { 38 | this.newLine = true 39 | } 40 | } 41 | } 42 | }, 43 | cleanMessages() { 44 | this.consoleMessages = [] 45 | } 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /frontend/src/stores/README.md: -------------------------------------------------------------------------------- 1 | # Store 2 | 3 | Pinia stores are used to store reactive state and expose actions to mutate it. 4 | 5 | Full documentation for this feature can be found in the Official [Pinia](https://pinia.esm.dev/) repository. 6 | -------------------------------------------------------------------------------- /frontend/src/stores/YuzuSaveStore.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import { defineStore } from 'pinia' 3 | import type {CommonResponse, YuzuSaveUserListItem} from "@/types"; 4 | 5 | 6 | 7 | export const useYuzuSaveStore = defineStore('yuzuSave', { 8 | state: () => ({ 9 | userList: [] as YuzuSaveUserListItem[], 10 | selectedUser: '', 11 | yuzuSaveBackupPath: '' 12 | }), 13 | actions: { 14 | 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /frontend/src/stores/app.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import { defineStore } from 'pinia' 3 | import type {CheatGameInfo, CommonResponse} from "@/types"; 4 | import {useConsoleDialogStore} from "@/stores/ConsoleDialogStore"; 5 | 6 | const cds = useConsoleDialogStore() 7 | 8 | export const useAppStore = defineStore('app', { 9 | state: () => ({ 10 | targetFirmwareVersion: null, 11 | availableFirmwareInfos: [], 12 | gameData: {} as {[key: string]: string} 13 | }), 14 | getters: { 15 | gameDataInited(state) { 16 | return Object.keys(state.gameData).length !== 0 17 | } 18 | }, 19 | actions: { 20 | updateAvailableFirmwareInfos() { 21 | this.targetFirmwareVersion = null 22 | window.eel.get_available_firmware_infos()((data: CommonResponse) => { 23 | if (data['code'] === 0) { 24 | const infos = data['data'] 25 | this.availableFirmwareInfos = infos 26 | this.targetFirmwareVersion = infos[0]['version'] 27 | } else { 28 | cds.showConsoleDialog() 29 | cds.appendConsoleMessage('固件信息加载异常.') 30 | } 31 | }) 32 | }, 33 | async loadGameData() { 34 | if (this.gameDataInited && !('unknown' in this.gameData)) { 35 | return this.gameData 36 | } 37 | const resp = await window.eel.get_game_data()() 38 | const gameData = resp.code === 0 ? resp.data : {'unknown': 'unknown'} 39 | this.gameData = gameData 40 | return gameData 41 | } 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /frontend/src/stores/index.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import { createPinia } from 'pinia' 3 | 4 | export default createPinia() 5 | -------------------------------------------------------------------------------- /frontend/src/styles/README.md: -------------------------------------------------------------------------------- 1 | # Styles 2 | 3 | This directory is for configuring the styles of the application. 4 | -------------------------------------------------------------------------------- /frontend/src/styles/settings.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * src/styles/settings.scss 3 | * 4 | * Configures SASS variables and Vuetify overwrites 5 | */ 6 | 7 | // https://vuetifyjs.com/features/sass-variables/` 8 | // @use 'vuetify/settings' with ( 9 | // $color-pack: false 10 | // ); 11 | -------------------------------------------------------------------------------- /frontend/src/typed-router.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️ 5 | // It's recommended to commit this file. 6 | // Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry. 7 | 8 | declare module 'vue-router/auto-routes' { 9 | import type { 10 | RouteRecordInfo, 11 | ParamValue, 12 | ParamValueOneOrMore, 13 | ParamValueZeroOrMore, 14 | ParamValueZeroOrOne, 15 | } from 'vue-router' 16 | 17 | /** 18 | * Route name map generated by unplugin-vue-router 19 | */ 20 | export interface RouteNamedMap { 21 | '/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>, 22 | '/about': RouteRecordInfo<'/about', '/about', Record<never, never>, Record<never, never>>, 23 | '/faq': RouteRecordInfo<'/faq', '/faq', Record<never, never>, Record<never, never>>, 24 | '/keys': RouteRecordInfo<'/keys', '/keys', Record<never, never>, Record<never, never>>, 25 | '/otherLinks': RouteRecordInfo<'/otherLinks', '/otherLinks', Record<never, never>, Record<never, never>>, 26 | '/ryujinx': RouteRecordInfo<'/ryujinx', '/ryujinx', Record<never, never>, Record<never, never>>, 27 | '/settings': RouteRecordInfo<'/settings', '/settings', Record<never, never>, Record<never, never>>, 28 | '/suyu': RouteRecordInfo<'/suyu', '/suyu', Record<never, never>, Record<never, never>>, 29 | '/yuzu': RouteRecordInfo<'/yuzu', '/yuzu', Record<never, never>, Record<never, never>>, 30 | '/yuzuCheatsManagement': RouteRecordInfo<'/yuzuCheatsManagement', '/yuzuCheatsManagement', Record<never, never>, Record<never, never>>, 31 | '/yuzuSaveManagement': RouteRecordInfo<'/yuzuSaveManagement', '/yuzuSaveManagement', Record<never, never>, Record<never, never>>, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/types/DefaultConfig.ts: -------------------------------------------------------------------------------- 1 | import type {AppConfig} from "@/types"; 2 | 3 | export const defaultConfig: AppConfig = { 4 | "yuzu": { 5 | "yuzu_path": "D:\\Yuzu", 6 | "yuzu_version": "", 7 | "yuzu_firmware": "", 8 | "branch": "ea" 9 | }, 10 | "suyu": { 11 | "path": "D:\\Suyu", 12 | "version": "", 13 | "firmware": "", 14 | "branch": "dev" 15 | }, 16 | "ryujinx": { 17 | "path": "D:\\Ryujinx", 18 | "version": "", 19 | "firmware": "", 20 | "branch": "ava" 21 | }, 22 | "setting": { 23 | "ui": { 24 | "lastOpenEmuPage": "", 25 | "dark": true, 26 | "mode": "auto", 27 | "width": 1300, 28 | "height": 850 29 | }, 30 | "network": { 31 | "firmwareDownloadSource": "github", 32 | "githubApiMode": "direct", 33 | "githubDownloadMirror": "cloudflare_load_balance", 34 | "useDoh": true, 35 | "proxy": "system" 36 | }, 37 | "download": { 38 | "autoDeleteAfterInstall": true, 39 | "disableAria2Ipv6": true, 40 | "removeOldAria2LogFile": true, 41 | "verifyFirmwareMd5": true 42 | }, 43 | "other": { 44 | "rename_yuzu_to_cemu": false 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | // Composables 2 | 3 | export interface AppConfig { 4 | yuzu: YuzuConfig 5 | suyu: SuyuConfig 6 | ryujinx: RyujinxConfig 7 | setting: Setting 8 | } 9 | 10 | export interface YuzuConfig { 11 | yuzu_path: string 12 | yuzu_version: string 13 | yuzu_firmware: string 14 | branch: string 15 | } 16 | 17 | export interface SuyuConfig { 18 | path: string 19 | version: string 20 | firmware: string 21 | branch: string 22 | } 23 | 24 | export interface RyujinxConfig { 25 | path: string 26 | version: string 27 | firmware: string 28 | branch: string 29 | } 30 | 31 | export interface Setting { 32 | ui: UiSetting 33 | network: NetworkSetting 34 | download: DownloadSetting 35 | other: OtherSetting 36 | } 37 | 38 | export interface OtherSetting { 39 | rename_yuzu_to_cemu: boolean 40 | } 41 | 42 | export interface UiSetting { 43 | lastOpenEmuPage: string 44 | dark: boolean 45 | mode: string 46 | width: number 47 | height: number 48 | } 49 | 50 | export interface NetworkSetting { 51 | firmwareDownloadSource: string 52 | githubApiMode: string 53 | githubDownloadMirror: string 54 | useDoh: boolean 55 | proxy: string 56 | } 57 | 58 | export interface DownloadSetting { 59 | autoDeleteAfterInstall: boolean 60 | disableAria2Ipv6: boolean 61 | removeOldAria2LogFile: boolean 62 | verifyFirmwareMd5: boolean 63 | } 64 | 65 | export interface CommonResponse { 66 | code: number, 67 | msg: string, 68 | data: any 69 | } 70 | 71 | export interface CheatGameInfo { 72 | game_id: string, 73 | game_name: string, 74 | cheats_path: string, 75 | } 76 | 77 | export interface CheatItem { 78 | enable: boolean, 79 | title: string, 80 | } 81 | 82 | export interface CheatFileInfo { 83 | path: string, 84 | name: string, 85 | } 86 | 87 | export interface NameValueItem { 88 | name: string, 89 | value: string, 90 | } 91 | 92 | export interface YuzuSaveUserListItem { 93 | user_id: string, 94 | folder: string 95 | } 96 | 97 | export interface YuzuSaveBackupListItem { 98 | game_name: string, 99 | title_id: string, 100 | bak_time: number, 101 | filename: string, 102 | path: string, 103 | } 104 | 105 | export interface SaveGameInfo { 106 | title_id: string, 107 | game_name: string, 108 | folder: string, 109 | } 110 | -------------------------------------------------------------------------------- /frontend/src/utils/common.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | 3 | 4 | import {useAppStore} from "@/stores/app"; 5 | 6 | const appStore = useAppStore() 7 | 8 | export function openUrlWithDefaultBrowser(url: string) { 9 | window.eel.open_url_in_default_browser(url)() 10 | } 11 | 12 | export async function loadGameData() { 13 | if (appStore.gameDataInited && !('unknown' in appStore.gameData)) { 14 | return appStore.gameData 15 | } 16 | const resp = await window.eel.get_game_data()() 17 | const gameData = resp.code === 0 ? resp.data : {'unknown': 'unknown'} 18 | appStore.gameData = gameData 19 | return gameData 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/utils/markdown.ts: -------------------------------------------------------------------------------- 1 | import DOMPurify from "isomorphic-dompurify"; 2 | import { marked } from 'marked'; 3 | 4 | export default { 5 | parse(markdown: string) { 6 | return DOMPurify.sanitize(marked.parse(markdown) as string); 7 | }, 8 | } 9 | 10 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "types": ["vuetify"], 8 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 9 | 10 | "baseUrl": ".", 11 | "paths": { 12 | "@/*": ["./src/*"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/vite.config.mts: -------------------------------------------------------------------------------- 1 | // Plugins 2 | import AutoImport from 'unplugin-auto-import/vite' 3 | import Components from 'unplugin-vue-components/vite' 4 | import Fonts from 'unplugin-fonts/vite' 5 | import Layouts from 'vite-plugin-vue-layouts' 6 | import Vue from '@vitejs/plugin-vue' 7 | import VueRouter from 'unplugin-vue-router/vite' 8 | import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' 9 | 10 | // Utilities 11 | import { defineConfig } from 'vite' 12 | import { fileURLToPath, URL } from 'node:url' 13 | 14 | // https://vitejs.dev/config/ 15 | export default defineConfig({ 16 | plugins: [ 17 | VueRouter({ 18 | dts: 'src/typed-router.d.ts', 19 | }), 20 | Layouts(), 21 | AutoImport({ 22 | imports: [ 23 | 'vue', 24 | { 25 | 'vue-router/auto': ['useRoute', 'useRouter'], 26 | } 27 | ], 28 | dts: 'src/auto-imports.d.ts', 29 | eslintrc: { 30 | enabled: true, 31 | }, 32 | vueTemplate: true, 33 | }), 34 | Components({ 35 | dts: 'src/components.d.ts', 36 | }), 37 | Vue({ 38 | template: { transformAssetUrls }, 39 | }), 40 | // https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme 41 | Vuetify({ 42 | autoImport: true, 43 | styles: { 44 | configFile: 'src/styles/settings.scss', 45 | }, 46 | }), 47 | Fonts({ 48 | google: { 49 | families: [ { 50 | name: 'Roboto', 51 | styles: 'wght@100;300;400;500;700;900', 52 | }], 53 | }, 54 | }), 55 | ], 56 | define: { 'process.env': {} }, 57 | resolve: { 58 | alias: { 59 | '@': fileURLToPath(new URL('./src', import.meta.url)), 60 | }, 61 | extensions: [ 62 | '.js', 63 | '.json', 64 | '.jsx', 65 | '.mjs', 66 | '.ts', 67 | '.tsx', 68 | '.vue', 69 | ], 70 | }, 71 | server: { 72 | port: 3000, 73 | }, 74 | build: { 75 | outDir: '../web', 76 | emptyOutDir: true 77 | }, 78 | css: { 79 | preprocessorOptions: { 80 | sass: { 81 | api: 'modern-compiler', 82 | }, 83 | }, 84 | }, 85 | }) 86 | -------------------------------------------------------------------------------- /hooks/hook-api.py: -------------------------------------------------------------------------------- 1 | from api import __all__ 2 | 3 | 4 | hiddenimports = [] 5 | for m in __all__: 6 | hiddenimports.append(f'api.{m}') 7 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import gevent.monkey 4 | 5 | gevent.monkey.patch_ssl() 6 | gevent.monkey.patch_socket() 7 | 8 | import sys 9 | from config import config, dump_config 10 | from utils.webview2 import can_use_webview 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def start_ui(mode=None): 16 | import ui 17 | ui.main(mode=mode) 18 | return 0 19 | 20 | 21 | def start_webview_ui(): 22 | import ui_webview 23 | ui_webview.main() 24 | return 0 25 | 26 | 27 | def try_start_webview(): 28 | try: 29 | return start_webview_ui() 30 | except Exception as e: 31 | logger.error('Error occur in start_webview_ui', e) 32 | return fallback_to_browser() 33 | 34 | 35 | def fallback_to_browser(): 36 | config.setting.ui.mode = 'browser' 37 | dump_config() 38 | return start_ui(None) 39 | 40 | 41 | def create_parser(): 42 | parser = argparse.ArgumentParser() 43 | parser.add_argument( 44 | "-m", 45 | "--mode", 46 | choices=['webview', 'browser', 'chrome', 'edge', 'user default'], 47 | help="指定 ui 启动方式", 48 | ) 49 | parser.add_argument( 50 | "--switch-mode", 51 | choices=['auto', 'webview', 'browser', 'chrome', 'edge', 'user default'], 52 | help="切换 ui 启动方式", 53 | ) 54 | parser.add_argument( 55 | "--no-sentry", 56 | action='store_true', 57 | help="禁用 sentry", 58 | ) 59 | return parser 60 | 61 | 62 | def main(): 63 | parser = create_parser() 64 | args = parser.parse_args() 65 | logger.info(f'args: {args}') 66 | 67 | if args.switch_mode is not None: 68 | logger.info(f'switch mode: {args.switch_mode}') 69 | config.setting.ui.mode = args.switch_mode 70 | dump_config() 71 | return 0 72 | 73 | from module.external.bat_scripts import create_scripts 74 | create_scripts() 75 | 76 | if not args.no_sentry: 77 | from module.sentry import init_sentry 78 | init_sentry() 79 | 80 | ui_mode = args.mode or config.setting.ui.mode 81 | logger.info(f'ui mode: {ui_mode}') 82 | if ui_mode is None or ui_mode == 'auto': 83 | ui_mode = 'webview' if can_use_webview() else 'browser' 84 | if ui_mode == 'browser': 85 | return start_ui(None) 86 | elif ui_mode == 'webview': 87 | return try_start_webview() 88 | else: 89 | return start_ui(ui_mode) 90 | 91 | 92 | if __name__ == '__main__': 93 | sys.exit(main()) 94 | -------------------------------------------------------------------------------- /main_devnull.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | f = open(os.devnull, 'w') 5 | sys.stdout = f 6 | sys.stderr = f 7 | 8 | 9 | if __name__ == '__main__': 10 | from main import main 11 | main() 12 | -------------------------------------------------------------------------------- /module/aria2c.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triwinds/ns-emu-tools/ab6a1a301ad524e85b1b5f2796f382bba384e915/module/aria2c.exe -------------------------------------------------------------------------------- /module/common.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import subprocess 4 | from pathlib import Path 5 | from module.msg_notifier import send_notify 6 | from module.network import get_finial_url 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def check_and_install_msvc(): 12 | windir = Path(os.environ['windir']) 13 | if windir.joinpath(r'System32\msvcp140_atomic_wait.dll').exists(): 14 | from utils.common import find_installed_software, is_newer_version 15 | software_list = find_installed_software(r'Microsoft Visual C\+\+ .+ Redistributable') 16 | if not software_list: 17 | logger.info(f'msvc already installed, but version not found in registry.') 18 | return 19 | logger.debug(f'Installed msvc: {software_list}') 20 | if not any(is_newer_version('14.38', s['version']) for s in software_list): 21 | logger.info(f'show update msvc notification.') 22 | send_notify('如果在启动模拟器时提示 [无法定位程序输入点],可以试试更新你的 msvc') 23 | send_notify('下载链接:https://aka.ms/vs/17/release/VC_redist.x64.exe') 24 | return 25 | from module.downloader import download 26 | send_notify('开始下载 msvc 安装包...') 27 | logger.info('downloading msvc installer...') 28 | download_info = download(get_finial_url('https://aka.ms/vs/17/release/VC_redist.x64.exe')) 29 | install_file = download_info.files[0] 30 | send_notify('安装 msvc...') 31 | logger.info('install msvc...') 32 | process = subprocess.Popen([install_file.path]) 33 | # process.wait() 34 | 35 | 36 | def delete_path(path: str): 37 | import shutil 38 | path = Path(path) 39 | logger.info(f'delete_path: {str(path)}') 40 | if not path.exists(): 41 | send_notify(f'{str(path)} 不存在') 42 | return 43 | if path.is_dir(): 44 | logging.info(f'delete folder: {str(path)}') 45 | send_notify(f'正在删除 {str(path)} 目录...') 46 | shutil.rmtree(path, ignore_errors=True) 47 | elif path.is_file(): 48 | logging.info(f'delete file: {str(path)}') 49 | send_notify(f'正在删除 {str(path)} 文件...') 50 | os.remove(path) 51 | logger.info(f'delete_path done: {str(path)}') 52 | send_notify(f'{str(path)} 删除完成') 53 | 54 | 55 | if __name__ == '__main__': 56 | # infos = get_firmware_infos() 57 | # for info in infos: 58 | # print(info) 59 | # check_and_install_msvc() 60 | print(check_update()) 61 | -------------------------------------------------------------------------------- /module/dialogs.py: -------------------------------------------------------------------------------- 1 | # Copy from https://github.com/brentvollebregt/auto-py-to-exe/blob/master/auto_py_to_exe/dialogs.py 2 | import platform 3 | import sys 4 | try: 5 | from tkinter import Tk 6 | except ImportError: 7 | try: 8 | from Tkinter import Tk 9 | except ImportError: 10 | # If no versions of tkinter exist (most likely linux) provide a message 11 | if sys.version_info.major < 3: 12 | print("Error: Tkinter not found") 13 | print('For linux, you can install Tkinter by executing: "sudo apt-get install python-tk"') 14 | sys.exit(1) 15 | else: 16 | print("Error: tkinter not found") 17 | print('For linux, you can install tkinter by executing: "sudo apt-get install python3-tk"') 18 | sys.exit(1) 19 | try: 20 | from tkinter.filedialog import askopenfilename, askdirectory, askopenfilenames, asksaveasfilename 21 | except ImportError: 22 | from tkFileDialog import askopenfilename, askdirectory, askopenfilenames, asksaveasfilename 23 | 24 | 25 | def ask_file(file_type): 26 | """ Ask the user to select a file """ 27 | root = Tk() 28 | root.withdraw() 29 | root.wm_attributes('-topmost', 1) 30 | if (file_type is None) or (platform.system() == "Darwin"): 31 | file_path = askopenfilename(parent=root) 32 | else: 33 | if file_type == 'python': 34 | file_types = [('Python files', '*.py;*.pyw'), ('All files', '*')] 35 | elif file_type == 'icon': 36 | file_types = [('Icon files', '*.ico'), ('All files', '*')] 37 | elif file_type == 'json': 38 | file_types = [('JSON Files', '*.json'), ('All files', '*')] 39 | else: 40 | file_types = [('All files', '*')] 41 | file_path = askopenfilename(parent=root, filetypes=file_types) 42 | root.update() 43 | 44 | # bool(file_path) will help filter our the negative cases; an empty string or an empty tuple 45 | return file_path if bool(file_path) else None 46 | 47 | 48 | def ask_files(): 49 | """ Ask the user to select one or more files """ 50 | root = Tk() 51 | root.withdraw() 52 | root.wm_attributes('-topmost', 1) 53 | file_paths = askopenfilenames(parent=root) 54 | root.update() 55 | 56 | return file_paths if bool(file_paths) else None 57 | 58 | 59 | def ask_folder(): 60 | """ Ask the user to select a folder """ 61 | root = Tk() 62 | root.withdraw() 63 | root.wm_attributes('-topmost', 1) 64 | folder = askdirectory(parent=root) 65 | root.update() 66 | 67 | return folder if bool(folder) else None 68 | 69 | 70 | def ask_file_save_location(file_type): 71 | """ Ask the user where to save a file """ 72 | root = Tk() 73 | root.withdraw() 74 | root.wm_attributes('-topmost', 1) 75 | 76 | if (file_type is None) or (platform.system() == "Darwin"): 77 | file_path = asksaveasfilename(parent=root) 78 | else: 79 | if file_type == 'json': 80 | file_types = [('JSON Files', '*.json'), ('All files', '*')] 81 | else: 82 | file_types = [('All files', '*')] 83 | file_path = asksaveasfilename(parent=root, filetypes=file_types) 84 | root.update() 85 | 86 | if bool(file_path): 87 | if file_type == 'json': 88 | return file_path if file_path.endswith('.json') else file_path + '.json' 89 | else: 90 | return file_path 91 | else: 92 | return None 93 | -------------------------------------------------------------------------------- /module/external/bat_scripts.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | switch_ui_path = Path('切换 UI 启动模式.bat') 5 | switch_ui_template = """@echo off 6 | chcp>nul 2>nul 65001 7 | cd>nul 2>nul /D %~dp0 8 | set net_path="" 9 | if exist "NsEmuTools.exe" ( 10 | set net_path="NsEmuTools.exe" 11 | ) 12 | if exist "NsEmuTools-console.exe" ( 13 | set net_path="NsEmuTools-console.exe" 14 | ) 15 | if %net_path% == "" ( 16 | echo 无法在当前目录找到 NsEmuTools 可执行文件, 请将 bat 脚本与 exe 放置在同一目录下。 17 | pause 18 | ) 19 | echo %net_path% 20 | echo "切换 UI 启动模式" 21 | echo "0: 自动选择" 22 | echo "1: 通过 webview 启动" 23 | echo "2: 通过浏览器启动(自动查找浏览器)" 24 | echo "3: 通过 chrome 浏览器启动" 25 | echo "4: 通过 edge 浏览器启动" 26 | echo "5: 通过默认浏览器启动" 27 | set uc="0" 28 | set mode="auto" 29 | :GET_INPUT 30 | set /p uc=选择启动模式(输入数字): 31 | 2>NUL CALL :CASE_%uc% 32 | IF ERRORLEVEL 1 CALL :CASE_ERROR 33 | :CASE_0 34 | set mode="auto" 35 | GOTO END_CASE 36 | :CASE_1 37 | set mode="webview" 38 | GOTO END_CASE 39 | :CASE_2 40 | set mode="browser" 41 | GOTO END_CASE 42 | :CASE_3 43 | set mode="chrome" 44 | GOTO END_CASE 45 | :CASE_4 46 | set mode="edge" 47 | GOTO END_CASE 48 | :CASE_5 49 | set mode="user default" 50 | GOTO END_CASE 51 | :CASE_ERROR 52 | echo "无法识别的模式,请重新输入" 53 | GOTO GET_INPUT 54 | :END_CASE 55 | call %net_path% --switch-mode %mode% 56 | IF %ERRORLEVEL% == 0 ( 57 | echo "变更已保存,将于下次启动时生效。" 58 | ) else ( 59 | echo "发生未知错误。" 60 | ) 61 | pause 62 | exit 63 | """ 64 | 65 | 66 | def create_scripts(): 67 | with open(switch_ui_path, 'w', encoding='utf-8') as f: 68 | f.write(switch_ui_template) 69 | -------------------------------------------------------------------------------- /module/msg_notifier.py: -------------------------------------------------------------------------------- 1 | 2 | def dummy_notifier(msg): 3 | pass 4 | 5 | 6 | def eel_notifier(msg): 7 | import eel 8 | eel.updateTopBarMsg(msg) 9 | 10 | 11 | def eel_console_notifier(msg): 12 | import eel 13 | eel.appendConsoleMessage(msg) 14 | 15 | 16 | notifier = dummy_notifier 17 | 18 | 19 | def update_notifier(mode): 20 | global notifier 21 | if mode == 'eel': 22 | notifier = eel_notifier 23 | elif mode == 'eel-console': 24 | notifier = eel_console_notifier 25 | else: 26 | notifier = dummy_notifier 27 | 28 | 29 | def send_notify(msg): 30 | notifier(msg) 31 | -------------------------------------------------------------------------------- /module/nsz_wrapper.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from pathlib import Path 3 | 4 | from exception.common_exception import IgnoredException 5 | 6 | logger = getLogger(__name__) 7 | 8 | 9 | def reload_key(key_path): 10 | try: 11 | from nsz.nut.Keys import load 12 | load(key_path) 13 | except: 14 | raise IgnoredException("Failed to load key") 15 | 16 | 17 | def parse_nca_header(nca_path): 18 | if isinstance(nca_path, Path): 19 | nca_path = str(nca_path) 20 | from nsz.Fs.Nca import Nca 21 | nca = Nca() 22 | try: 23 | nca.open(nca_path) 24 | return nca.header 25 | finally: 26 | nca.close() 27 | 28 | 29 | def read_firmware_version_from_nca(nca_path): 30 | if isinstance(nca_path, Path): 31 | nca_path = str(nca_path) 32 | from nsz.Fs.Nca import Nca 33 | nca = Nca() 34 | try: 35 | nca.open(nca_path) 36 | if not nca.sectionFilesystems: 37 | logger.info('No filesystem section found in nca.') 38 | return None 39 | data: bytearray = nca.sectionFilesystems[0].read() 40 | idx = data.index(b'NX\x00\x00\x00\x00') + 0x60 41 | # print(data[idx:]) 42 | version = data[idx:idx + 0x10].replace(b'\x00', b'').decode() 43 | return version 44 | finally: 45 | nca.close() 46 | -------------------------------------------------------------------------------- /module/save_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shutil 3 | import time 4 | 5 | from module.yuzu import get_yuzu_nand_path 6 | from module.cheats import get_game_data, game_id_re 7 | from module.msg_notifier import send_notify 8 | from pathlib import Path 9 | from storage import storage, dump_storage 10 | from exception.common_exception import IgnoredException 11 | from utils.package import compress_folder, uncompress, is_7zfile 12 | from utils.common import is_path_in_use 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def get_yuzu_save_path(): 19 | # https://github.com/yuzu-emu/yuzu/blob/e264ab4ad0137224559ac0bacc68b905db65f5a8/src/core/file_sys/savedata_factory.cpp#L197 20 | return get_yuzu_nand_path().joinpath('user/save/0000000000000000') 21 | 22 | 23 | def _get_all_user_ids(): 24 | return [folder.name for folder in get_yuzu_save_path().glob('*') if len(folder.name) == 32] 25 | 26 | 27 | def get_users_in_save(): 28 | return [{'user_id': convert_to_uuid(uid), 'folder': uid} for uid in _get_all_user_ids()] 29 | 30 | 31 | def convert_to_uuid(user_id: str): 32 | tmp = '' 33 | for i in range(16): 34 | tmp = user_id[i*2:i*2+2] + tmp 35 | return f'{tmp[:8]}-{tmp[8:12]}-{tmp[12:16]}-{tmp[16:20]}-{tmp[20:]}'.lower() 36 | 37 | 38 | def list_all_games_by_user_folder(user_folder_name: str): 39 | user_save_folder = get_yuzu_save_path().joinpath(user_folder_name) 40 | res = [] 41 | for folder in user_save_folder.glob('*'): 42 | if game_id_re.match(folder.name): 43 | res.append({ 44 | 'title_id': folder.name, 45 | 'folder': str(folder.absolute()) 46 | }) 47 | return res 48 | 49 | 50 | def backup_folder(folder_path: str): 51 | yuzu_save_backup_path = Path(storage.yuzu_save_backup_path) 52 | if not yuzu_save_backup_path.exists(): 53 | yuzu_save_backup_path.mkdir(parents=True, exist_ok=True) 54 | folder_path = Path(folder_path) 55 | if is_path_in_use(folder_path): 56 | logger.info(f'{folder_path} is in use.') 57 | raise IgnoredException(f'{folder_path} 目录正在使用中,跳过备份。') 58 | backup_filename = f'yuzu_{folder_path.name}_{int(time.time())}.7z' 59 | backup_filepath = yuzu_save_backup_path.joinpath(backup_filename) 60 | logger.info(f'backup folder [{str(folder_path)}] to {str(backup_filepath)}') 61 | send_notify(f'正在备份文件夹 [{str(folder_path)}] 至 {str(backup_filepath)}') 62 | compress_folder(folder_path, backup_filepath) 63 | logger.info(f'{str(backup_filepath)} backup finished, size: {sizeof_fmt(backup_filepath.stat().st_size)}.') 64 | send_notify(f'{str(backup_filepath)} 备份完成, size: {sizeof_fmt(backup_filepath.stat().st_size)}') 65 | 66 | 67 | def sizeof_fmt(num, suffix="B"): 68 | for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: 69 | if abs(num) < 1024.0: 70 | return f"{num:3.1f}{unit}{suffix}" 71 | num /= 1024.0 72 | return f"{num:.1f}Yi{suffix}" 73 | 74 | 75 | def ask_and_update_yuzu_save_backup_folder(): 76 | from module.dialogs import ask_folder 77 | folder = ask_folder() 78 | logger.info(f'select yuzu_save_backup_folder: {folder}, current: {storage.yuzu_save_backup_path}') 79 | if not folder: 80 | send_notify('未选择文件夹, 取消变更.') 81 | return 82 | new_path = Path(folder).absolute() 83 | if Path(storage.yuzu_save_backup_path).absolute() == new_path: 84 | send_notify('文件夹未发生变动, 取消变更.') 85 | return 86 | storage.yuzu_save_backup_path = str(new_path.absolute()) 87 | dump_storage() 88 | logger.info(f'new yuzu_save_backup_path: {storage.yuzu_save_backup_path}') 89 | send_notify(f'yuzu 存档备份文件夹更改为: {storage.yuzu_save_backup_path}') 90 | 91 | 92 | def open_yuzu_save_backup_folder(): 93 | import subprocess 94 | path = Path(storage.yuzu_save_backup_path) 95 | path.mkdir(parents=True, exist_ok=True) 96 | logger.info(f'open explorer on path {path}') 97 | subprocess.Popen(f'explorer "{str(path.absolute())}"') 98 | 99 | 100 | def parse_backup_info(file: Path): 101 | res = {'filename': file.name, 'path': str(file.absolute())} 102 | if file.name.startswith('yuzu_') and file.name.endswith('.7z'): 103 | s = file.name[5:-3] 104 | title_id, bak_time = s.split('_') 105 | res['title_id'] = title_id 106 | res['bak_time'] = int(bak_time) * 1000 107 | return res 108 | 109 | 110 | def list_all_yuzu_backups(): 111 | path = Path(storage.yuzu_save_backup_path) 112 | res = [] 113 | if not path.exists(): 114 | return res 115 | for file in path.glob('yuzu_*.7z'): 116 | res.append(parse_backup_info(file)) 117 | return sorted(res, key=lambda x: x['bak_time'], reverse=True) 118 | 119 | 120 | def restore_yuzu_save_from_backup(user_folder_name: str, backup_path: str): 121 | backup_path = Path(backup_path) 122 | if not is_7zfile(backup_path): 123 | logger.info(f'{str(backup_path)} seems not a 7z file.') 124 | send_notify(f'{str(backup_path)} 看起来不是一个完整的 7z 文件,跳过还原.') 125 | return 126 | backup_info = parse_backup_info(backup_path) 127 | logger.info(f'backup_info: {backup_info}') 128 | user_save_path = get_yuzu_save_path().joinpath(user_folder_name) 129 | target_game_save_path = user_save_path.joinpath(backup_info['title_id']) 130 | if is_path_in_use(target_game_save_path): 131 | logger.info(f'{str(target_game_save_path)} is in use, skip restore.') 132 | send_notify(f'{str(target_game_save_path)} 目录正在使用中,跳过还原.') 133 | return 134 | logger.info(f'removing path: {str(target_game_save_path)}') 135 | send_notify(f'正在清空目录 {str(target_game_save_path)}') 136 | shutil.rmtree(target_game_save_path, ignore_errors=True) 137 | logger.info(f'uncompress to {str(target_game_save_path)}') 138 | send_notify(f'正在解压备份至 {str(user_save_path)}') 139 | uncompress(backup_path, user_save_path, False, '备份') 140 | logger.info(f'{str(backup_path)} restore done.') 141 | send_notify(f'{backup_path.name} 还原完成') 142 | 143 | 144 | if __name__ == '__main__': 145 | from pprint import pprint 146 | # print(get_all_user_ids()) 147 | # print(convert_to_uuid('97A1DAE861CD445AB9645267B3AB99BE')) 148 | # print(get_users_in_save()) 149 | # pprint(list_all_games_by_user_folder('97A1DAE861CD445AB9645267B3AB99BE')) 150 | # storage.yuzu_save_backup_path = 'R:/' 151 | # backup_folder('D:\\Yuzu\\user\\nand\\user\\save\\0000000000000000\\97A1DAE861CD445AB9645267B3AB99BE\\0100F3400332C000') 152 | # pprint(list_all_yuzu_backups()) 153 | restore_yuzu_save_from_backup('97A1DAE861CD445AB9645267B3AB99BE', 154 | 'D:\\yuzu_save_backup\\yuzu_0100F2C0115B6000_1685114415.7z') 155 | -------------------------------------------------------------------------------- /module/sentry.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | from config import current_version 3 | 4 | 5 | def sampler(sample_data): 6 | if 'wsgi_environ' in sample_data and sample_data['wsgi_environ']['PATH_INFO'] == '/eel.js': 7 | return 0.1 8 | return 0 9 | 10 | 11 | def init_sentry(): 12 | sentry_sdk.init( 13 | dsn="https://022fb678c5bc4859b6052fc409506f23@o527477.ingest.sentry.io/4504689953472512", 14 | auto_session_tracking=False, 15 | traces_sampler=sampler, 16 | release=f'ns-emu-tools@{current_version}' 17 | ) 18 | sentry_sdk.set_user({'ip_address': '{{auto}}'}) 19 | -------------------------------------------------------------------------------- /module/updater.py: -------------------------------------------------------------------------------- 1 | from module.downloader import download, download_path 2 | from module.msg_notifier import send_notify 3 | import sys 4 | from pathlib import Path 5 | import subprocess 6 | import logging 7 | from config import current_version 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | script_template = """@echo off 12 | chcp>nul 2>nul 65001 13 | echo 开始准备更新 14 | timeout /t 3 /nobreak 15 | taskkill /F /IM NsEmuTools* >nul 2>nul 16 | if exist "<old_exe>" ( 17 | echo 备份原文件至 "<old_exe>.bak" 18 | move /Y "<old_exe>" "<old_exe>.bak" 19 | ) 20 | if exist "_internal" ( 21 | move /Y "_internal" "_internal_bak" 22 | timeout /t 1 /nobreak 23 | ) 24 | if not exist "<upgrade_files_folder>" ( 25 | echo 无法找到更新文件 "<upgrade_files_folder>" 26 | pause 27 | ) else ( 28 | echo 复制文件中 29 | robocopy "<upgrade_files_folder>" . /MOVE /E /NFL /NDL /NC 30 | if exist "download/upgrade_files" ( 31 | timeout /t 1 /nobreak 32 | rmdir /s /q "download/upgrade_files" 33 | ) 34 | echo 启动程序 35 | start /b "NsEmuTools" "<target_place>" 36 | ) 37 | DEL "%~f0" 38 | """ 39 | 40 | 41 | def _parse_version(version_str): 42 | qualifier = 'zzzzzzzzzzzz' 43 | sp = version_str.split('-') 44 | if len(sp) == 2: 45 | version, qualifier = sp 46 | else: 47 | version = version_str 48 | version = version.strip() 49 | major, minor, incr = version.split('.') 50 | return int(major), int(minor), int(incr), qualifier 51 | 52 | 53 | def check_update(prerelease=False): 54 | from repository.my_info import get_all_release 55 | cur_ver_group = _parse_version(current_version) 56 | release_infos = get_all_release() 57 | remote_version = None 58 | if prerelease: 59 | remote_version = release_infos[0]['tag_name'] 60 | else: 61 | for ri in release_infos: 62 | if not ri['prerelease']: 63 | remote_version = ri['tag_name'] 64 | break 65 | if not remote_version: 66 | remote_version = release_infos[0]['tag_name'] 67 | remote_ver_group = _parse_version(remote_version) 68 | return cur_ver_group < remote_ver_group, remote_version 69 | 70 | 71 | def download_net_by_tag(tag: str): 72 | from repository.my_info import get_release_info_by_tag 73 | from module.network import get_github_download_url 74 | release_info = get_release_info_by_tag(tag) 75 | logger.info(f'start download NET release by tag: {tag}, release name: {release_info.get("name")}') 76 | execute_path = Path(sys.argv[0]) 77 | logger.info(f'execute_path: {execute_path}') 78 | asset_map = {asset['name']: asset for asset in release_info['assets']} 79 | target_asset = asset_map.get('NsEmuTools-dir.7z') 80 | if not target_asset: 81 | target_asset = asset_map.get(execute_path.name, asset_map.get('NsEmuTools.exe')) 82 | target_file_name = target_asset["name"] 83 | logger.info(f'target_file_name: {target_file_name}') 84 | logger.info(f'start download {target_file_name}, version: [{tag}]') 85 | send_notify(f'开始下载 {target_file_name}, 版本: [{tag}]') 86 | upgrade_files_path = download_path.joinpath('upgrade_files') 87 | info = download(get_github_download_url(target_asset['browser_download_url']), 88 | save_dir=str(upgrade_files_path.absolute()), 89 | options={'allow-overwrite': 'true'}) 90 | filepath = info.files[0].path.absolute() 91 | logger.info(f'{target_file_name} of [{tag}] downloaded to {filepath}') 92 | send_notify(f'{target_file_name} 版本: [{tag}] 已下载至') 93 | send_notify(f'{filepath}') 94 | return filepath 95 | 96 | 97 | def update_self_by_tag(tag: str): 98 | # upgrade_files_path = download_path.joinpath('upgrade_files') 99 | # upgrade_file_path = upgrade_files_path.joinpath('NsEmuTools.7z') 100 | upgrade_file_path = download_net_by_tag(tag) 101 | upgrade_files_folder = upgrade_file_path.parent 102 | if not upgrade_file_path: 103 | logger.error(f'something wrong in downloading.') 104 | send_notify(f'下载时出现问题, 更新已取消.') 105 | return 106 | if upgrade_file_path.name.endswith('.7z'): 107 | from utils.package import uncompress 108 | uncompress(upgrade_file_path, upgrade_file_path.parent) 109 | upgrade_file_path.unlink() 110 | upgrade_files_folder = upgrade_file_path.parent.joinpath('NsEmuTools') 111 | target_path = Path('NsEmuTools.exe') if Path('NsEmuTools.exe').exists() else Path('NsEmuTools-console.exe') 112 | script = script_template\ 113 | .replace('<old_exe>', str(Path(sys.argv[0]).absolute()))\ 114 | .replace('<upgrade_files_folder>', str(upgrade_files_folder))\ 115 | .replace('<target_place>', str(target_path.absolute())) 116 | logger.info(f'creating update script') 117 | with open('update.bat', 'w', encoding='utf-8') as f: 118 | f.write(script) 119 | script_path = Path(sys.argv[0]).parent.joinpath('update.bat').absolute() 120 | logger.info(f'execute script') 121 | subprocess.Popen(f'start cmd /c "{script_path}"', shell=True) 122 | try: 123 | from ui_webview import close_all_windows 124 | close_all_windows() 125 | except: 126 | pass 127 | send_notify(f'由于浏览器的安全限制,程序无法主动关闭当前窗口。因此请手动关闭当前窗口。') 128 | send_notify(f'webview 版本可以避免这个问题,如果你的系统版本比较新,可以尝试使用一下 webview 版本。') 129 | logger.info(f'exit') 130 | sys.exit() 131 | 132 | 133 | if __name__ == '__main__': 134 | # print(check_update()) 135 | print(_parse_version('0.0.1') < _parse_version('0.0.2')) 136 | print(_parse_version('0.0.1-beta1') < _parse_version('0.0.1')) 137 | print(_parse_version('0.0.1-beta1') < _parse_version('0.0.1-beta2')) 138 | print(_parse_version('0.0.1-alpha1') < _parse_version('0.0.1-beta2')) 139 | -------------------------------------------------------------------------------- /package.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | chcp>nul 2>nul 65001 3 | :path 4 | cd>nul 2>nul /D %~dp0 5 | rem call venv\Scripts\activate.bat 6 | rem pyinstaller --noconfirm --onefile --windowed --icon "./web/favicon.ico" --add-data "./module/*.exe;./module/" --add-data "./web;web/" "./main_devnull.py" --additional-hooks-dir=".\\hooks" --name "NsEmuTools" 7 | rem poetry run pyinstaller --noconfirm --onefile --console --upx-dir "./build_tools/upx/" --icon "./web/favicon.ico" --add-data "./module/*.exe;./module/" --add-data "./web;web/" "./main.py" --additional-hooks-dir=".\\hooks" --name "NsEmuTools-console" 8 | uv run pyinstaller --noconfirm --windowed --icon "./web/favicon.ico" --add-data "./module/*.exe;./module/" --add-data "./web;web/" "./main_devnull.py" --additional-hooks-dir=".\\hooks" --name "NsEmuTools" 9 | pause 10 | -------------------------------------------------------------------------------- /package_all.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | chcp>nul 2>nul 65001 3 | :path 4 | cd>nul 2>nul /D %~dp0 5 | rem call venv\Scripts\activate.bat 6 | uv run pyinstaller --noconfirm --onefile --windowed --icon "./web/favicon.ico" --add-data "./module/*.exe;./module/" --add-data "./web;web/" "./main_devnull.py" --additional-hooks-dir=".\\hooks" --name "NsEmuTools" 7 | uv run pyinstaller --noconfirm --onefile --console --icon "./web/favicon.ico" --add-data "./module/*.exe;./module/" --add-data "./web;web/" "./main.py" --additional-hooks-dir=".\\hooks" --name "NsEmuTools-console" 8 | uv run pyinstaller --noconfirm --windowed --icon "./web/favicon.ico" --add-data "./module/*.exe;./module/" --add-data "./web;web/" "./main_devnull.py" --additional-hooks-dir=".\\hooks" --name "NsEmuTools" 9 | uv run python build_tools/zip_files.py 10 | pause 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ns-emu-tools" 3 | version = "0.4.2" 4 | description = "" 5 | authors = [{ name = "triwinds", email = "triwinds@users.noreply.github.com" }] 6 | requires-python = ">=3.11" 7 | readme = "README.md" 8 | dependencies = [ 9 | "tqdm>=4.66.1,<5", 10 | "aria2p>=0.11.3,<0.12", 11 | "requests>=2.31.0,<3", 12 | "py7zr>=0.20.6,<0.21", 13 | "beautifulsoup4>=4.12.2,<5", 14 | "eel>=0.16.0,<0.17", 15 | "pywin32>=306,<307", 16 | "psutil>=5.9.5,<6", 17 | "gevent>=23.7.0,<24", 18 | "httplib2>=0.22.0,<0.23", 19 | "requests-cache>=1.1.0,<2", 20 | "dnspython[doh]>=2.4.2,<3", 21 | "sentry-sdk>=1.29.2,<2", 22 | "xmltodict>=0.13.0,<0.14", 23 | "chardet>=5.2.0,<6", 24 | "pywebview>=4.2.2,<5", 25 | "dataclasses-json>=0.5.14,<0.6", 26 | "pyinstaller>=6.3.0,<7", 27 | "nsz", 28 | "orjson>=3.10.18", 29 | ] 30 | 31 | [dependency-groups] 32 | dev = ["pyinstaller>=6.0.0,<7"] 33 | 34 | [tool.uv] 35 | 36 | [tool.uv.sources] 37 | nsz = { git = "https://github.com/triwinds/nsz" } 38 | 39 | [tool.hatch.build.targets.sdist] 40 | include = ["module"] 41 | 42 | [tool.hatch.build.targets.wheel] 43 | include = ["module"] 44 | 45 | [build-system] 46 | requires = ["hatchling"] 47 | build-backend = "hatchling.build" 48 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Ns Emu Tools 2 | 3 | 一个用于安装/更新 NS 模拟器的工具 4 | 5 |  6 |  7 |  8 |  9 |  10 | 11 | ## Features 12 | 13 | - ~~支持安装 Yuzu EA/正式版模拟器~~ 14 | - ~~支持 Yuzu 版本检测及更新~~ (yuzu 目前已经停止开发) 15 | - 支持安装 Ryubing/Ryujinx 正式/Canary 版模拟器 16 | - 支持 Ryujinx 版本检测及更新 17 | - 自动检测并安装 msvc 运行库 18 | - 支持安装及更新 NS 固件至模拟器 19 | - 支持固件版本检测 (感谢 [a709560839](https://tieba.baidu.com/home/main?id=tb.1.f9804802.YmDokXJSRkAJB0xF8XfaCQ&fr=pb) 提供的思路) 20 | - 管理模拟器密钥 21 | - Yuzu 金手指管理 22 | - aria2 多线程下载 23 | 24 | ## 使用方法 25 | 26 | ### 一、使用预构建的版本运行 27 | 28 | 从 [GitHub 发布页(稳定版本)](https://github.com/triwinds/ns-emu-tools/releases) 或 29 | [CI 自动构建](https://github.com/triwinds/ns-emu-tools/actions/workflows/ci-build.yaml) 下载 exe 文件,然后双击运行即可。 30 | <details> 31 | <summary>NsEmuTools.exe 和 NsEmuTools-console.exe 有什么区别?</summary> 32 | NsEmuTools.exe 和 NsEmuTools-console.exe 在实际的功能上并没有任何差异, 33 | 其主要的差别在于 console 会在启动的时候多一个命令行窗口,这也许可以解决某些杀毒软件的误报问题, 34 | 详情见 <a href="https://github.com/triwinds/ns-emu-tools/issues/2">#2</a>. 35 | </details> 36 | 37 | 38 | ### 二、使用源码运行 39 | 40 | 参见 [开发文档](doc/dev.md) 41 | 42 | 43 | ## 讨论组 44 | 45 | Telegram: [Telegram 讨论组](https://t.me/+mxI34BRClLUwZDcx) 46 | 47 | 48 | ## License 49 | 50 | 本项目的发布受 [AGPL-3.0](https://github.com/triwinds/ns-emu-tools/blob/main/LICENSE) 许可认证。 51 | 52 | ## 启动参数 53 | 54 | ``` 55 | usage: NsEmuTools-console.exe [-h] [-m {webview,browser,chrome,edge,user default}] 56 | [--switch-mode {auto,webview,browser,chrome,edge,user default}] 57 | 58 | options: 59 | -h, --help show this help message and exit 60 | -m {webview,browser,chrome,edge,user default}, --mode {webview,browser,chrome,edge,user default} 61 | 指定 ui 启动方式 62 | --switch-mode {auto,webview,browser,chrome,edge,user default} 63 | 切换 ui 启动方式 64 | ``` 65 | 66 | ## Credits 67 | 68 | - ~~[Yuzu](https://github.com/yuzu-emu/yuzu) - Yuzu 模拟器~~ 69 | - [Ryubing/Ryujinx](https://ryujinx.app/) - Ryujinx 模拟器 70 | - [Suyu](https://git.suyu.dev/suyu/suyu) - Suyu 模拟器 71 | - ~~[hactool](https://github.com/SciresM/hactool) - NS 固件解析~~ 72 | - [nsz](https://github.com/nicoboss/nsz) - NS 固件解析 73 | - [aria2](https://github.com/aria2/aria2) - aria2 下载器 74 | - [Github 镜像源](https://github.com/XIU2/UserScript/blob/master/GithubEnhanced-High-Speed-Download.user.js) - 来自 X.I.U 大佬的 Github 增强脚本 75 | - [pineappleEA](https://github.com/pineappleEA/pineapple-src) - Yuzu EA 版本来源 76 | - [THZoria/NX_Firmware](https://github.com/THZoria/NX_Firmware) - NS 固件来源 77 | - [darthsternie.net](https://darthsternie.net/switch-firmwares/) - NS 固件来源 78 | -------------------------------------------------------------------------------- /repository/domain/release_info.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Dict 3 | 4 | 5 | @dataclass 6 | class ReleaseAsset: 7 | name: str 8 | download_url: str 9 | 10 | def __init__(self, name, download_url): 11 | self.name = name 12 | self.download_url = download_url 13 | 14 | 15 | @dataclass 16 | class ReleaseInfo: 17 | name: str 18 | tag_name: str 19 | description: str 20 | assets: List[ReleaseAsset] 21 | 22 | 23 | def from_gitlab_api(release_info): 24 | assets = [] 25 | for asset in release_info['assets']['links']: 26 | assets.append(ReleaseAsset(asset['name'], asset['url'])) 27 | return ReleaseInfo( 28 | name=release_info['name'], 29 | tag_name=release_info['tag_name'], 30 | description=release_info['description'], 31 | assets=assets 32 | ) 33 | 34 | def from_github_api(release_info): 35 | assets = [] 36 | for asset in release_info['assets']: 37 | assets.append(ReleaseAsset(asset['name'], asset['browser_download_url'])) 38 | return ReleaseInfo( 39 | name=release_info['name'], 40 | tag_name=release_info['tag_name'], 41 | description=release_info['body'], 42 | assets=assets 43 | ) 44 | -------------------------------------------------------------------------------- /repository/my_info.py: -------------------------------------------------------------------------------- 1 | from module.network import request_github_api, session, get_finial_url 2 | 3 | 4 | def get_all_release(): 5 | with session.cache_disabled(): 6 | return request_github_api('https://api.github.com/repos/triwinds/ns-emu-tools/releases') 7 | 8 | 9 | def get_latest_release(prerelease=False): 10 | with session.cache_disabled(): 11 | data = get_all_release() 12 | release_list = data if prerelease else [i for i in data if i['prerelease'] is False] 13 | return release_list[0] 14 | 15 | 16 | def get_release_info_by_tag(tag: str): 17 | return request_github_api(f'https://api.github.com/repos/triwinds/ns-emu-tools/releases/tags/{tag}') 18 | 19 | 20 | def load_change_log(): 21 | resp = session.get(get_finial_url('https://raw.githubusercontent.com/triwinds/ns-emu-tools/main/changelog.md')) 22 | return resp.text 23 | -------------------------------------------------------------------------------- /repository/ryujinx.py: -------------------------------------------------------------------------------- 1 | from module.network import request_github_api, session, get_finial_url 2 | from repository.domain.release_info import * 3 | 4 | 5 | # They move them codes to https://git.ryujinx.app/ryubing/ryujinx 6 | # the api of releases https://git.ryujinx.app/api/v4/projects/1/releases (using GitLab) 7 | # Gitlab Canary releases: https://git.ryujinx.app/api/v4/projects/68/releases 8 | 9 | 10 | def get_all_ryujinx_release_infos(branch='mainline')-> List[ReleaseInfo]: 11 | if branch == 'canary': 12 | return get_all_canary_ryujinx_release_infos() 13 | # return request_github_api('https://api.github.com/repos/Ryubing/Stable-Releases/releases') 14 | resp = session.get('https://git.ryujinx.app/api/v4/projects/1/releases').json() 15 | res = [from_gitlab_api(item) for item in resp] 16 | return res 17 | 18 | 19 | def get_all_canary_ryujinx_release_infos() -> List[ReleaseInfo]: 20 | resp = session.get('https://git.ryujinx.app/api/v4/projects/68/releases').json() 21 | return [from_gitlab_api(item) for item in resp] 22 | 23 | 24 | def get_latest_ryujinx_release_info() -> ReleaseInfo: 25 | return get_all_ryujinx_release_infos()[0] 26 | 27 | 28 | def get_ryujinx_release_info_by_version(version, branch='mainline') -> ReleaseInfo: 29 | if branch == 'canary': 30 | return get_canary_ryujinx_release_info_by_version(version) 31 | # get from gitlab 32 | return from_gitlab_api(session.get(f'https://git.ryujinx.app/api/v4/projects/1/releases/{version}').json()) 33 | 34 | 35 | 36 | def get_canary_ryujinx_release_info_by_version(version) -> ReleaseInfo: 37 | return from_gitlab_api(session.get(f'https://git.ryujinx.app/api/v4/projects/68/releases/{version}').json()) 38 | 39 | 40 | def load_ryujinx_change_log(branch: str) -> str: 41 | infos = get_all_ryujinx_release_infos(branch) 42 | return infos[0].description 43 | -------------------------------------------------------------------------------- /repository/suyu.py: -------------------------------------------------------------------------------- 1 | from module.network import session, get_finial_url 2 | 3 | 4 | # Api doc: https://git.suyu.dev/api/swagger 5 | 6 | def load_suyu_releases(): 7 | resp = session.get(get_finial_url('https://git.suyu.dev/api/v1/repos/suyu/suyu/releases')) 8 | return resp.json() 9 | 10 | 11 | def get_release_by_tag_name(tag_name: str): 12 | resp = session.get(get_finial_url(f'https://git.suyu.dev/api/v1/repos/suyu/suyu/releases/tags/{tag_name}')) 13 | return resp.json() 14 | 15 | 16 | def get_all_suyu_release_versions(): 17 | res = [] 18 | data = load_suyu_releases() 19 | for item in data: 20 | res.append(item['tag_name']) 21 | return res 22 | -------------------------------------------------------------------------------- /repository/yuzu.py: -------------------------------------------------------------------------------- 1 | from module.network import request_github_api 2 | 3 | 4 | def get_all_yuzu_release_infos(): 5 | data = request_github_api('https://api.github.com/repos/pineappleEA/pineapple-src/releases') 6 | res = [item for item in data if item['author']['login'] == 'pineappleEA'] 7 | return res 8 | 9 | 10 | def get_all_yuzu_release_versions(branch: str): 11 | res = [] 12 | if branch.lower() == 'mainline': 13 | data = request_github_api('https://api.github.com/repos/yuzu-emu/yuzu-mainline/releases') 14 | for item in data: 15 | res.append(item['tag_name'][11:]) 16 | else: 17 | data = request_github_api('https://api.github.com/repos/pineappleEA/pineapple-src/releases') 18 | for item in data: 19 | if item['author']['login'] == 'pineappleEA': 20 | res.append(item['tag_name'][3:]) 21 | return res 22 | 23 | 24 | def get_latest_yuzu_release_info(): 25 | return get_all_yuzu_release_infos()[0] 26 | 27 | 28 | def get_yuzu_release_info_by_version(version, branch='ea'): 29 | if branch.lower() == 'mainline': 30 | url = f'https://api.github.com/repos/yuzu-emu/yuzu-mainline/releases/tags/mainline-0-{version}' 31 | else: 32 | url = f'https://api.github.com/repos/pineappleEA/pineapple-src/releases/tags/EA-{version}' 33 | return request_github_api(url) 34 | 35 | 36 | if __name__ == '__main__': 37 | print(get_all_yuzu_release_versions('mainline')) 38 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio==3.7.1 ; python_version >= "3.10" and python_version < "3.12" 2 | appdirs==1.4.4 ; python_version >= "3.10" and python_version < "3.12" 3 | aria2p==0.11.3 ; python_version >= "3.10" and python_version < "3.12" 4 | attrs==23.1.0 ; python_version >= "3.10" and python_version < "3.12" 5 | beautifulsoup4==4.12.2 ; python_version >= "3.10" and python_version < "3.12" 6 | bottle-websocket==0.2.9 ; python_version >= "3.10" and python_version < "3.12" 7 | bottle==0.12.25 ; python_version >= "3.10" and python_version < "3.12" 8 | brotli==1.0.9 ; python_version >= "3.10" and python_version < "3.12" and platform_python_implementation == "CPython" 9 | brotlicffi==1.0.9.2 ; python_version >= "3.10" and python_version < "3.12" and platform_python_implementation == "PyPy" 10 | cattrs==23.1.2 ; python_version >= "3.10" and python_version < "3.12" 11 | certifi==2023.7.22 ; python_version >= "3.10" and python_version < "3.12" 12 | cffi==1.15.1 ; python_version >= "3.10" and python_version < "3.12" and (sys_platform == "win32" or platform_python_implementation == "PyPy") 13 | chardet==5.2.0 ; python_version >= "3.10" and python_version < "3.12" 14 | charset-normalizer==3.2.0 ; python_version >= "3.10" and python_version < "3.12" 15 | clr-loader==0.2.6 ; python_version >= "3.10" and python_version < "3.12" and sys_platform == "win32" 16 | colorama==0.4.6 ; python_version >= "3.10" and python_version < "3.12" and (platform_system == "Windows" or sys_platform == "win32") 17 | dataclasses-json==0.5.14 ; python_version >= "3.10" and python_version < "3.12" 18 | dnspython[doh]==2.4.2 ; python_version >= "3.10" and python_version < "3.12" 19 | eel==0.16.0 ; python_version >= "3.10" and python_version < "3.12" 20 | exceptiongroup==1.1.3 ; python_version >= "3.10" and python_version < "3.11" 21 | future==0.18.3 ; python_version >= "3.10" and python_version < "3.12" 22 | gevent-websocket==0.10.1 ; python_version >= "3.10" and python_version < "3.12" 23 | gevent==23.7.0 ; python_version >= "3.10" and python_version < "3.12" 24 | greenlet==2.0.2 ; python_version >= "3.10" and platform_python_implementation == "CPython" and python_version < "3.12" 25 | h11==0.14.0 ; python_version >= "3.10" and python_version < "3.12" 26 | h2==4.1.0 ; python_version >= "3.10" and python_version < "3.12" 27 | hpack==4.0.0 ; python_version >= "3.10" and python_version < "3.12" 28 | httpcore==0.17.3 ; python_version >= "3.10" and python_version < "3.12" 29 | httplib2==0.22.0 ; python_version >= "3.10" and python_version < "3.12" 30 | httpx==0.24.1 ; python_version >= "3.10" and python_version < "3.12" 31 | hyperframe==6.0.1 ; python_version >= "3.10" and python_version < "3.12" 32 | idna==3.4 ; python_version >= "3.10" and python_version < "3.12" 33 | inflate64==0.3.1 ; python_version >= "3.10" and python_version < "3.12" 34 | loguru==0.7.0 ; python_version >= "3.10" and python_version < "3.12" 35 | marshmallow==3.20.1 ; python_version >= "3.10" and python_version < "3.12" 36 | multivolumefile==0.2.3 ; python_version >= "3.10" and python_version < "3.12" 37 | mypy-extensions==1.0.0 ; python_version >= "3.10" and python_version < "3.12" 38 | packaging==23.1 ; python_version >= "3.10" and python_version < "3.12" 39 | platformdirs==3.10.0 ; python_version >= "3.10" and python_version < "3.12" 40 | proxy-tools==0.1.0 ; python_version >= "3.10" and python_version < "3.12" 41 | psutil==5.9.5 ; python_version >= "3.10" and python_version < "3.12" 42 | py7zr==0.20.6 ; python_version >= "3.10" and python_version < "3.12" 43 | pybcj==1.0.1 ; python_version >= "3.10" and python_version < "3.12" 44 | pycparser==2.21 ; python_version >= "3.10" and python_version < "3.12" and (sys_platform == "win32" or platform_python_implementation == "PyPy") 45 | pycryptodomex==3.18.0 ; python_version >= "3.10" and python_version < "3.12" 46 | pyobjc-core==9.2 ; python_version >= "3.10" and python_version < "3.12" and sys_platform == "darwin" 47 | pyobjc-framework-cocoa==9.2 ; python_version >= "3.10" and python_version < "3.12" and sys_platform == "darwin" 48 | pyobjc-framework-security==9.2 ; python_version >= "3.10" and python_version < "3.12" and sys_platform == "darwin" 49 | pyobjc-framework-webkit==9.2 ; python_version >= "3.10" and python_version < "3.12" and sys_platform == "darwin" 50 | pyparsing==3.1.1 ; python_version >= "3.10" and python_version < "3.12" 51 | pyppmd==1.0.0 ; python_version >= "3.10" and python_version < "3.12" 52 | pythonnet==3.0.1 ; python_version >= "3.10" and python_version < "3.12" and sys_platform == "win32" 53 | pywebview==4.2.2 ; python_version >= "3.10" and python_version < "3.12" 54 | pywin32==306 ; python_version >= "3.10" and python_version < "3.12" 55 | pyzstd==0.15.9 ; python_version >= "3.10" and python_version < "3.12" 56 | qtpy==2.3.1 ; python_version >= "3.10" and python_version < "3.12" and sys_platform == "openbsd6" 57 | requests-cache==1.1.0 ; python_version >= "3.10" and python_version < "3.12" 58 | requests==2.31.0 ; python_version >= "3.10" and python_version < "3.12" 59 | sentry-sdk==1.29.2 ; python_version >= "3.10" and python_version < "3.12" 60 | setuptools==68.1.0 ; python_version >= "3.10" and python_version < "3.12" 61 | six==1.16.0 ; python_version >= "3.10" and python_version < "3.12" 62 | sniffio==1.3.0 ; python_version >= "3.10" and python_version < "3.12" 63 | soupsieve==2.4.1 ; python_version >= "3.10" and python_version < "3.12" 64 | texttable==1.6.7 ; python_version >= "3.10" and python_version < "3.12" 65 | toml==0.10.2 ; python_version >= "3.10" and python_version < "3.12" 66 | tqdm==4.66.1 ; python_version >= "3.10" and python_version < "3.12" 67 | typing-extensions==4.7.1 ; python_version >= "3.10" and python_version < "3.12" 68 | typing-inspect==0.9.0 ; python_version >= "3.10" and python_version < "3.12" 69 | url-normalize==1.4.3 ; python_version >= "3.10" and python_version < "3.12" 70 | urllib3==2.0.4 ; python_version >= "3.10" and python_version < "3.12" 71 | websocket-client==1.6.1 ; python_version >= "3.10" and python_version < "3.12" 72 | whichcraft==0.6.1 ; python_version >= "3.10" and python_version < "3.12" 73 | win32-setctime==1.1.0 ; python_version >= "3.10" and python_version < "3.12" and sys_platform == "win32" 74 | xmltodict==0.13.0 ; python_version >= "3.10" and python_version < "3.12" 75 | zope-event==5.0 ; python_version >= "3.10" and python_version < "3.12" 76 | zope-interface==6.0 ; python_version >= "3.10" and python_version < "3.12" 77 | -------------------------------------------------------------------------------- /send_release_notify.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import os 3 | 4 | 5 | bot_token = os.environ['TELEGRAM_TOKEN'] 6 | send_to = os.environ['TG_SEND_TO'] 7 | 8 | message_template = """New release of v%s 9 | 10 | %s 11 | 12 | Release page: [%s](%s) 13 | """ 14 | 15 | message_template2 = """New release of v%s 16 | 17 | ``` 18 | %s 19 | ``` 20 | 21 | Release page: [%s](%s) 22 | """ 23 | 24 | 25 | def send_message(msg: str): 26 | data = {'text': msg, 'chat_id': send_to, 'parse_mode': 'markdown'} 27 | resp = requests.post(f'https://api.telegram.org/bot{bot_token}/sendMessage', json=data) 28 | data = resp.json() 29 | # print(data) 30 | if not data.get('ok'): 31 | print(data) 32 | raise RuntimeError(data.get('description')) 33 | 34 | 35 | def get_all_release(): 36 | return requests.get('https://api.github.com/repos/triwinds/ns-emu-tools/releases').json() 37 | 38 | 39 | def get_latest_release(prerelease=False): 40 | data = get_all_release() 41 | release_list = data if prerelease else [i for i in data if i['prerelease'] is False] 42 | return release_list[0] 43 | 44 | 45 | def main(): 46 | release_info = get_latest_release(True) 47 | print(release_info) 48 | message = message_template % ( 49 | release_info['tag_name'], 50 | release_info['body'], 51 | release_info['tag_name'], 52 | release_info['html_url'] 53 | ) 54 | try: 55 | send_message(message) 56 | return 57 | except: 58 | pass 59 | message = message_template2 % ( 60 | release_info['tag_name'], 61 | release_info['body'], 62 | release_info['tag_name'], 63 | release_info['html_url'] 64 | ) 65 | send_message(message) 66 | 67 | 68 | if __name__ == '__main__': 69 | main() 70 | -------------------------------------------------------------------------------- /storage.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | import json 3 | import os 4 | from pathlib import Path 5 | from typing import Dict 6 | 7 | from dataclasses_json import dataclass_json, Undefined 8 | from config import config, YuzuConfig, RyujinxConfig, SuyuConfig 9 | import logging 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | storage_path = Path('storage.json') 14 | storage = None 15 | 16 | 17 | @dataclass_json(undefined=Undefined.EXCLUDE) 18 | @dataclass 19 | class Storage: 20 | yuzu_history: Dict[str, YuzuConfig] = field(default_factory=dict) 21 | suyu_history: Dict[str, SuyuConfig] = field(default_factory=dict) 22 | ryujinx_history: Dict[str, RyujinxConfig] = field(default_factory=dict) 23 | yuzu_save_backup_path: str = str(Path(r'D:\\yuzu_save_backup')) 24 | 25 | 26 | def dump_storage(): 27 | logger.info(f'saving storage to {storage_path.absolute()}') 28 | with open(storage_path, 'w', encoding='utf-8') as f: 29 | f.write(storage.to_json(ensure_ascii=False, indent=2)) 30 | 31 | 32 | if os.path.exists(storage_path): 33 | with open(storage_path, 'r', encoding='utf-8') as f: 34 | storage = Storage.from_dict(json.load(f)) 35 | if not storage: 36 | storage = Storage() 37 | dump_storage() 38 | 39 | 40 | def add_yuzu_history(yuzu_config: YuzuConfig, dump=True): 41 | yuzu_path = Path(yuzu_config.yuzu_path) 42 | storage.yuzu_history[str(yuzu_path.absolute())] = yuzu_config 43 | if dump: 44 | dump_storage() 45 | 46 | 47 | def add_ryujinx_history(ryujinx_config: RyujinxConfig, dump=True): 48 | ryujinx_path = Path(ryujinx_config.path) 49 | storage.ryujinx_history[str(ryujinx_path.absolute())] = ryujinx_config 50 | if dump: 51 | dump_storage() 52 | 53 | 54 | def add_suyu_history(suyu_config: SuyuConfig, dump=True): 55 | suyu_path = Path(suyu_config.path) 56 | storage.suyu_history[str(suyu_path.absolute())] = suyu_config 57 | if dump: 58 | dump_storage() 59 | 60 | 61 | def delete_history_path(emu_type: str, path_to_delete: str): 62 | if emu_type == 'yuzu': 63 | history = storage.yuzu_history 64 | elif emu_type == 'suyu': 65 | history = storage.suyu_history 66 | else: 67 | history = storage.ryujinx_history 68 | abs_path = str(Path(path_to_delete).absolute()) 69 | if abs_path in history: 70 | del history[abs_path] 71 | logger.info(f'{emu_type} path {abs_path} deleted.') 72 | dump_storage() 73 | 74 | 75 | if __name__ == '__main__': 76 | print(storage) 77 | -------------------------------------------------------------------------------- /ui.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | import eel 4 | from config import config, dump_config, shared 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def can_use_chrome(): 10 | """ Identify if Chrome is available for Eel to use """ 11 | import os 12 | from eel import chrome 13 | chrome_instance_path = chrome.find_path() 14 | return chrome_instance_path is not None and os.path.exists(chrome_instance_path) 15 | 16 | 17 | def can_use_edge(): 18 | try: 19 | import winreg 20 | key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r'Software\Microsoft\Edge\BLBeacon', 0, winreg.KEY_READ) 21 | with key: 22 | version: str = winreg.QueryValueEx(key, 'version')[0] 23 | logger.info(f'Edge version: {version}') 24 | return int(version.split('.')[0]) > 70 and _find_edge_win() is not None 25 | except: 26 | return False 27 | 28 | 29 | def _find_edge_win() -> Optional[str]: 30 | import winreg as reg 31 | import os 32 | reg_path = r'SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe' 33 | for install_type in reg.HKEY_CURRENT_USER, reg.HKEY_LOCAL_MACHINE: 34 | try: 35 | reg_key = reg.OpenKey(install_type, reg_path, 0, reg.KEY_READ) 36 | edge_path = reg.QueryValue(reg_key, None) 37 | reg_key.Close() 38 | if not os.path.isfile(edge_path): 39 | continue 40 | except WindowsError: 41 | edge_path = None 42 | else: 43 | break 44 | return edge_path 45 | 46 | 47 | def start_edge_in_app_mode(page, port, size=(1280, 720)): 48 | if port == 0: 49 | from module.network import get_available_port 50 | port = get_available_port() 51 | url = f'http://127.0.0.1:{port}/{page}' 52 | import subprocess 53 | try: 54 | subprocess.Popen(f'"{_find_edge_win()}" --app={url}', 55 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, shell=True) 56 | except Exception as e: 57 | logger.info(f'Fail to start Edge with full path, fallback with "start" command, exception: {str(e)}') 58 | subprocess.Popen(f'start msedge --app={url}', 59 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, shell=True) 60 | eel.start(url, port=port, mode=False, size=size) 61 | 62 | 63 | def import_api_modules(): 64 | import api 65 | 66 | 67 | def main(port=0, mode=None, dev=False): 68 | import_api_modules() 69 | logger.info('eel init starting...') 70 | # eel.init('vue/public') if dev else eel.init("web") 71 | eel.init("web") 72 | shutdown_delay = 114514 if dev else 1 73 | logger.info('eel init finished.') 74 | from module.msg_notifier import update_notifier 75 | default_page = f'' 76 | update_notifier('eel-console') 77 | if mode is None: 78 | if can_use_chrome(): 79 | mode = 'chrome' 80 | elif can_use_edge(): 81 | mode = 'edge' 82 | else: 83 | mode = 'user default' 84 | size = (config.setting.ui.width, config.setting.ui.height) 85 | logger.info(f'browser mode: {mode}, size: {size}') 86 | if port == 0: 87 | from module.network import get_available_port 88 | port = get_available_port() 89 | logger.info(f'starting eel at port: {port}') 90 | if mode == 'edge': 91 | try: 92 | shared['mode'] = mode 93 | start_edge_in_app_mode(default_page, port, size) 94 | except Exception as e: 95 | logger.info(f'Fail to start with Edge, fallback to default browser, exception: {str(e)}') 96 | mode = 'user default' 97 | config.setting.ui.mode = mode 98 | dump_config() 99 | shared['mode'] = mode 100 | eel.start(default_page, port=port, size=size, mode=mode, shutdown_delay=shutdown_delay) 101 | else: 102 | shared['mode'] = mode 103 | eel.start(default_page, port=port, size=size, mode=mode, shutdown_delay=shutdown_delay) 104 | 105 | 106 | if __name__ == '__main__': 107 | import gevent.monkey 108 | 109 | gevent.monkey.patch_ssl() 110 | gevent.monkey.patch_socket() 111 | main(8888, False, True) 112 | -------------------------------------------------------------------------------- /ui_webview.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import eel 5 | import webview 6 | from utils.webview2 import ensure_runtime_components 7 | from config import config, shared, dump_config 8 | from threading import Timer 9 | 10 | logger = logging.getLogger(__name__) 11 | default_page = f'' 12 | port = 0 13 | 14 | 15 | def import_api_modules(): 16 | import api 17 | 18 | 19 | def check_webview_status(): 20 | if 'ui_init_time' in shared: 21 | return 22 | config.setting.ui.mode = 'browser' 23 | dump_config() 24 | from tkinter import messagebox 25 | messagebox.showerror('未检测到活动的会话', '未能检测到活动的会话,这可能是由于当前系统的 webview2 组件存在问题造成的,' 26 | '已将 ui 切换至浏览器模式,请重新启动程序.') 27 | 28 | 29 | def maximize_window(): 30 | if os.name != 'nt': 31 | return 32 | from webview.platforms.winforms import WinForms, Func, Type, BrowserView 33 | 34 | def _maximize(): 35 | BrowserView.instances['master'].WindowState = WinForms.FormWindowState.Maximized 36 | BrowserView.instances['master'].Invoke(Func[Type](_maximize)) 37 | 38 | 39 | def get_window_size(): 40 | return webview.windows[0].width, webview.windows[0].height 41 | 42 | 43 | def post_start(fullscreen): 44 | Timer(10.0, check_webview_status).start() 45 | if fullscreen: 46 | Timer(0.5, maximize_window).start() 47 | shared['mode'] = 'webview' 48 | eel.start(default_page, port=port, mode=False) 49 | 50 | 51 | def close_all_windows(): 52 | if webview.windows: 53 | logger.info('Closing all windows...') 54 | for win in webview.windows: 55 | win.destroy() 56 | 57 | 58 | def main(): 59 | if ensure_runtime_components(): 60 | return 61 | global port 62 | import_api_modules() 63 | logger.info('eel init starting...') 64 | eel.init('vue/public') if port else eel.init("web") 65 | logger.info('eel init finished.') 66 | from module.msg_notifier import update_notifier 67 | update_notifier('eel-console') 68 | if port == 0: 69 | from module.network import get_available_port 70 | port = get_available_port() 71 | url = f'http://127.0.0.1:{port}/{default_page}' 72 | logger.info(f'start webview with url: {url}') 73 | width, height = config.setting.ui.width, config.setting.ui.height 74 | sw, sh = webview.screens[0].width, webview.screens[0].height 75 | fullscreen = sw - width < 3 and height / sh > 0.9 76 | logger.info(f'window size: {(width, height)}, fullscreen: {fullscreen}') 77 | webview.create_window('NS EMU TOOLS', url, width=width, height=height, text_select=True) 78 | webview.start(func=post_start, args=[fullscreen]) 79 | 80 | 81 | if __name__ == '__main__': 82 | import gevent.monkey 83 | 84 | gevent.monkey.patch_ssl() 85 | gevent.monkey.patch_socket() 86 | main() 87 | -------------------------------------------------------------------------------- /update_game_data.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | import requests_cache 5 | import re 6 | import json 7 | 8 | 9 | # requests_cache.install_cache('ryujinx_issue', expire_after=114514) 10 | game_re = re.compile(r'^(.*?) - ([\da-zA-Z]{16})#39;) 11 | gh_token = os.environ.get('gh_token') 12 | headers = { 13 | 'Authorization': f'Bearer {gh_token}' 14 | } if gh_token else {} 15 | 16 | 17 | def update_with_page(game_data, page): 18 | resp = requests.get(f'https://api.github.com/repos/Ryujinx/Ryujinx-Games-List/issues?page={page}', 19 | headers=headers) 20 | # print(resp.headers) 21 | print(f'handle page {page}') 22 | issues = resp.json() 23 | print(f'issues size: {len(issues)}') 24 | if not issues: 25 | return False 26 | for issue in issues: 27 | title = issue['title'] 28 | groups = game_re.findall(title) 29 | if not groups: 30 | continue 31 | title, game_id = groups[0] 32 | game_data[game_id] = title 33 | return True 34 | 35 | 36 | def update_all(): 37 | page = 0 38 | game_data = {} 39 | while True: 40 | page += 1 41 | if not update_with_page(game_data, page): 42 | break 43 | if game_data: 44 | with open('game_data.json', 'w', encoding='utf-8') as f: 45 | json.dump(game_data, f, ensure_ascii=False, indent=2) 46 | 47 | 48 | def update_latest(): 49 | import os 50 | if not os.path.exists('game_data.json'): 51 | game_data = {} 52 | else: 53 | with open('game_data.json', 'r', encoding='utf-8') as f: 54 | game_data = json.load(f) 55 | update_with_page(game_data, 1) 56 | with open('game_data.json', 'w', encoding='utf-8') as f: 57 | json.dump(game_data, f, ensure_ascii=False, indent=2) 58 | 59 | 60 | if __name__ == '__main__': 61 | update_latest() 62 | -------------------------------------------------------------------------------- /update_requirements.bat: -------------------------------------------------------------------------------- 1 | poetry export --without-hashes --format=requirements.txt > requirements.txt 2 | -------------------------------------------------------------------------------- /utils/admin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def run_with_admin_privilege(executable, argument_line): 8 | import ctypes 9 | ret = ctypes.windll.shell32.ShellExecuteW(None, u"runas", executable, argument_line, None, 1) 10 | logger.info(f'run_with_admin_privilege ret code: {ret}') 11 | return ret 12 | 13 | 14 | def check_is_admin(): 15 | import ctypes 16 | import os 17 | try: 18 | return os.getuid() == 0 19 | except AttributeError: 20 | return ctypes.windll.shell32.IsUserAnAdmin() != 0 21 | 22 | 23 | if __name__ == '__main__': 24 | run_with_admin_privilege('cmd', r'/c copy D:\py\ns-emu-tools\test.json D:\py\ns-emu-tools\test2.json') 25 | 26 | -------------------------------------------------------------------------------- /utils/common.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from pathlib import Path 4 | from module.msg_notifier import send_notify 5 | import logging 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | path_unicode_re = re.compile(r'\\x([\da-f]{4})') 10 | 11 | 12 | def callback(hwnd, strings): 13 | from win32 import win32gui 14 | window_title = win32gui.GetWindowText(hwnd) 15 | # left, top, right, bottom = win32gui.GetWindowRect(hwnd) 16 | if window_title: 17 | strings.append(window_title) 18 | return True 19 | 20 | 21 | def get_all_window_name(): 22 | from win32 import win32gui 23 | win_list = [] # list of strings containing win handles and window titles 24 | win32gui.EnumWindows(callback, win_list) # populate list 25 | return win_list 26 | 27 | 28 | def decode_yuzu_path(raw_path_in_config: str): 29 | # raw_path_in_config = raw_path_in_config.replace("'", "\'") 30 | raw_path_in_config = path_unicode_re.sub(r'\\u\1', raw_path_in_config) 31 | # return eval(f"'{raw_path_in_config}'") 32 | return raw_path_in_config.encode().decode("unicode-escape") 33 | 34 | 35 | def find_all_instances(process_name: str, exe_path: Path = None): 36 | import psutil 37 | result = [] 38 | for p in psutil.process_iter(): 39 | if p.name().startswith(process_name): 40 | if exe_path is not None: 41 | process_path = Path(p.exe()).parent.absolute() 42 | if exe_path.absolute() != process_path: 43 | continue 44 | result.append(p) 45 | return result 46 | 47 | 48 | def kill_all_instances(process_name: str, exe_path: Path = None): 49 | processes = find_all_instances(process_name, exe_path) 50 | if processes: 51 | for p in processes: 52 | send_notify(f'关闭进程 {p.name()} [{p.pid}]') 53 | p.kill() 54 | time.sleep(1) 55 | 56 | 57 | def is_path_in_use(file_path): 58 | # Only works under windows 59 | if isinstance(file_path, Path): 60 | path = file_path 61 | else: 62 | path = Path(file_path) 63 | if not path.exists(): 64 | return False 65 | try: 66 | path.rename(path) 67 | except PermissionError: 68 | return True 69 | else: 70 | return False 71 | 72 | 73 | def get_installed_software(): 74 | import winreg 75 | 76 | def foo(hive, flag): 77 | aReg = winreg.ConnectRegistry(None, hive) 78 | aKey = winreg.OpenKey(aReg, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", 79 | 0, winreg.KEY_READ | flag) 80 | count_subkey = winreg.QueryInfoKey(aKey)[0] 81 | software_list = [] 82 | for i in range(count_subkey): 83 | software = {} 84 | try: 85 | asubkey_name = winreg.EnumKey(aKey, i) 86 | asubkey = winreg.OpenKey(aKey, asubkey_name) 87 | software['name'] = winreg.QueryValueEx(asubkey, "DisplayName")[0] 88 | 89 | try: 90 | software['version'] = winreg.QueryValueEx(asubkey, "DisplayVersion")[0] 91 | except EnvironmentError: 92 | software['version'] = 'undefined' 93 | try: 94 | software['publisher'] = winreg.QueryValueEx(asubkey, "Publisher")[0] 95 | except EnvironmentError: 96 | software['publisher'] = 'undefined' 97 | software_list.append(software) 98 | except EnvironmentError: 99 | continue 100 | 101 | return software_list 102 | 103 | try: 104 | sl = (foo(winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_32KEY) + 105 | foo(winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_64KEY) + 106 | foo(winreg.HKEY_CURRENT_USER, 0)) 107 | return sl 108 | except Exception as e: 109 | logger.info('Exception occurred in get_software_list, exception is: {}'.format(e)) 110 | return [] 111 | 112 | 113 | def find_installed_software(name_pattern: str): 114 | import re 115 | software_list = get_installed_software() 116 | pattern = re.compile(name_pattern) 117 | final_list = [s for s in software_list if pattern.search(s['name']) is not None] 118 | return final_list 119 | 120 | 121 | def is_newer_version(min_version, current_version): 122 | cur_range = current_version.split(".") 123 | min_range = min_version.split(".") 124 | for index in range(len(cur_range)): 125 | if len(min_range) > index: 126 | try: 127 | return int(cur_range[index]) >= int(min_range[index]) 128 | except: 129 | return False 130 | return False 131 | 132 | 133 | if __name__ == '__main__': 134 | from pprint import pp 135 | # from config import config 136 | # print(is_path_in_use(config.yuzu.yuzu_path)) 137 | pp(get_installed_software()) 138 | test_l = find_installed_software(r'Microsoft Visual C\+\+ .+ Redistributable') 139 | print(any(is_newer_version('14.34', s['version']) for s in test_l)) 140 | -------------------------------------------------------------------------------- /utils/hardware.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import logging 3 | 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | def get_gpu_info(): 9 | # https://github.com/SummaLabs/DLS/blob/master/app/backend/env/hardware.py 10 | gpu_info = [] 11 | try: 12 | command = "nvidia-smi --query-gpu=index,name,uuid,memory.total,memory.free," \ 13 | "memory.used,count,utilization.gpu,utilization.memory --format=csv" 14 | output = execute_command(command) 15 | lines = output.splitlines() 16 | lines.pop(0) 17 | for line in lines: 18 | tokens = line.split(", ") 19 | if len(tokens) > 6: 20 | gpu_info.append({'id': tokens[0], 'name': tokens[1], 'mem': tokens[3], 'cores': tokens[6], 21 | 'mem_free': tokens[4], 'mem_used': tokens[5], 22 | 'util_gpu': tokens[7], 'util_mem': tokens[8]}) 23 | except OSError: 24 | logger.info("GPU device is not available") 25 | 26 | return gpu_info 27 | 28 | 29 | def execute_command(cmd): 30 | process = subprocess.Popen(cmd, stdout=subprocess.PIPE) 31 | return process.communicate()[0].decode() 32 | 33 | 34 | def get_cpu_info(): 35 | import platform 36 | return platform.processor() 37 | 38 | 39 | def get_win32_cpu_info(): 40 | # https://github.com/pydata/numexpr/blob/master/numexpr/cpuinfo.py 41 | import re 42 | import sys 43 | from utils.string_util import auto_decode 44 | pkey = r"HARDWARE\DESCRIPTION\System\CentralProcessor" 45 | try: 46 | import _winreg 47 | except ImportError: # Python 3 48 | import winreg as _winreg 49 | info = [] 50 | try: 51 | # XXX: Bad style to use so long `try:...except:...`. Fix it! 52 | 53 | prgx = re.compile(r"family\s+(?P<FML>\d+)\s+model\s+(?P<MDL>\d+)" 54 | r"\s+stepping\s+(?P<STP>\d+)", re.IGNORECASE) 55 | chnd = _winreg.OpenKey(_winreg.HKEY_LOCAL_MACHINE, pkey) 56 | pnum = 0 57 | while 1: 58 | try: 59 | proc = _winreg.EnumKey(chnd, pnum) 60 | except _winreg.error: 61 | break 62 | else: 63 | pnum += 1 64 | info.append({"Processor": proc}) 65 | phnd = _winreg.OpenKey(chnd, proc) 66 | pidx = 0 67 | while True: 68 | try: 69 | name, value, vtpe = _winreg.EnumValue(phnd, pidx) 70 | except _winreg.error: 71 | break 72 | else: 73 | pidx = pidx + 1 74 | 75 | if isinstance(value, bytes): 76 | value = value.rstrip(b'\0') 77 | value = auto_decode(value) 78 | info[-1][name] = str(value).strip() 79 | if name == "Identifier": 80 | srch = prgx.search(value) 81 | if srch: 82 | info[-1]["Family"] = int(srch.group("FML")) 83 | info[-1]["Model"] = int(srch.group("MDL")) 84 | info[-1]["Stepping"] = int(srch.group("STP")) 85 | except: 86 | logger.info('Fail to get cpu info.') 87 | return info 88 | 89 | 90 | if __name__ == '__main__': 91 | # print(get_gpu_info()) 92 | print(get_win32_cpu_info()) 93 | -------------------------------------------------------------------------------- /utils/package.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import logging 3 | 4 | import py7zr 5 | 6 | from exception.common_exception import IgnoredException 7 | from module.msg_notifier import send_notify 8 | import os 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def uncompress(filepath: Path, target_path, delete_on_error=True, 15 | exception_msg='当前下载的文件看起来不太正常,请重新下载试试'): 16 | if isinstance(target_path, str): 17 | target_path = Path(target_path) 18 | try: 19 | if filepath.name.lower().endswith(".zip"): 20 | import zipfile 21 | with zipfile.ZipFile(filepath, 'r') as zf: 22 | zf.extractall(str(target_path.absolute())) 23 | elif filepath.name.lower().endswith(".7z"): 24 | import py7zr 25 | with py7zr.SevenZipFile(filepath) as zf: 26 | zf.extractall(str(target_path.absolute())) 27 | elif filepath.name.lower().endswith(".tar.xz"): 28 | import tarfile 29 | with tarfile.open(filepath, 'r') as tf: 30 | tf.extractall(str(target_path.absolute())) 31 | except Exception as e: 32 | logger.error(f'Fail to uncompress file: {filepath}', exc_info=True) 33 | if delete_on_error: 34 | send_notify(f'文件解压失败,正在删除异常的文件 [{filepath}]') 35 | os.remove(filepath) 36 | raise IgnoredException(exception_msg) 37 | 38 | 39 | def compress_folder(folder_path: Path, save_path): 40 | import py7zr 41 | if isinstance(save_path, str): 42 | save_path = Path(save_path) 43 | directory = str(folder_path.absolute()) 44 | rootdir = os.path.basename(directory) 45 | try: 46 | logger.info(f'compress {folder_path} to {save_path}') 47 | zf: py7zr.SevenZipFile 48 | with py7zr.SevenZipFile(save_path, 'w') as zf: 49 | for dirpath, dirnames, filenames in os.walk(directory): 50 | for filename in filenames: 51 | # Write the file named filename to the archive, 52 | # giving it the archive name 'arcname'. 53 | filepath = os.path.join(dirpath, filename) 54 | parentpath = os.path.relpath(filepath, directory) 55 | arcname = os.path.join(rootdir, parentpath) 56 | zf.write(filepath, arcname) 57 | except Exception as e: 58 | logger.error(f'Fail to compress file {folder_path} to {save_path}', exc_info=True) 59 | raise IgnoredException(f'备份失败, {str(e)}') 60 | 61 | 62 | def is_7zfile(filepath: Path): 63 | return py7zr.is_7zfile(filepath) 64 | -------------------------------------------------------------------------------- /utils/string_util.py: -------------------------------------------------------------------------------- 1 | import chardet 2 | 3 | 4 | def auto_decode(input_bytes: bytes): 5 | det_res = chardet.detect(input_bytes) 6 | if det_res['encoding']: 7 | return input_bytes.decode(det_res['encoding']) 8 | return input_bytes.decode() 9 | -------------------------------------------------------------------------------- /utils/webview2.py: -------------------------------------------------------------------------------- 1 | # modify from https://github.com/r0x0r/pywebview/blob/master/webview/platforms/winforms.py 2 | 3 | import winreg 4 | import logging 5 | import os 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def get_dot_net_version(): 11 | net_key, version = None, None 12 | try: 13 | net_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full') 14 | version, _ = winreg.QueryValueEx(net_key, 'Release') 15 | finally: 16 | if net_key: 17 | winreg.CloseKey(net_key) 18 | return version 19 | 20 | 21 | def is_chromium(verbose=False): 22 | from utils.common import is_newer_version 23 | 24 | def edge_build(key_type, key, description=''): 25 | try: 26 | windows_key = None 27 | if key_type == 'HKEY_CURRENT_USER': 28 | path = rf'Microsoft\EdgeUpdate\Clients\{key}' 29 | else: 30 | path = rf'WOW6432Node\Microsoft\EdgeUpdate\Clients\{key}' 31 | with winreg.OpenKey(getattr(winreg, key_type), rf'SOFTWARE\{path}') as windows_key: 32 | build, _ = winreg.QueryValueEx(windows_key, 'pv') 33 | return str(build) 34 | except Exception as e: 35 | pass 36 | return '0' 37 | 38 | try: 39 | build_versions = [ 40 | # runtime 41 | {'key': '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'description': 'Microsoft Edge WebView2 Runtime'}, 42 | # beta 43 | {'key': '{2CD8A007-E189-409D-A2C8-9AF4EF3C72AA}', 'description': 'Microsoft Edge WebView2 Beta'}, 44 | # dev 45 | {'key': '{0D50BFEC-CD6A-4F9A-964C-C7416E3ACB10}', 'description': 'Microsoft Edge WebView2 Developer'}, 46 | # canary 47 | {'key': '{65C35B14-6C1D-4122-AC46-7148CC9D6497}', 'description': 'Microsoft Edge WebView2 Canary'}, 48 | ] 49 | 50 | for item in build_versions: 51 | for key_type in ('HKEY_CURRENT_USER', 'HKEY_LOCAL_MACHINE'): 52 | build = edge_build(key_type, item['key'], item['description']) 53 | if is_newer_version('105.0.0.0', build): 54 | if verbose: 55 | logger.info(f'webview2 version: {build}, description: {item["description"]}') 56 | return True 57 | 58 | except Exception as e: 59 | logger.exception(e) 60 | return False 61 | 62 | 63 | def ensure_runtime_components(): 64 | flag = False 65 | version = get_dot_net_version() 66 | logger.info(f'dot net version: {version}') 67 | if version < 394802: # .NET 4.6.2 68 | install_dot_net() 69 | flag = True 70 | if not is_chromium(verbose=True): 71 | install_webview2() 72 | flag = True 73 | if flag: 74 | show_msgbox('重启程序', '组件安装完成后, 请重新启动程序.', 0) 75 | return flag 76 | 77 | 78 | def can_use_webview(): 79 | version = get_dot_net_version() 80 | return version >= 394802 and is_chromium() 81 | 82 | 83 | def show_msgbox(title, content, style): 84 | import ctypes # An included library with Python install. 85 | # Styles: 86 | # 0 : OK 87 | # 1 : OK | Cancel 88 | # 2 : Abort | Retry | Ignore 89 | # 3 : Yes | No | Cancel 90 | # 4 : Yes | No 91 | # 5 : Retry | Cancel 92 | # 6 : Cancel | Try Again | Continue 93 | return ctypes.windll.user32.MessageBoxW(0, content, title, style) 94 | 95 | 96 | def get_download_file_name(resp): 97 | if 'Content-Disposition' in resp.headers: 98 | import cgi 99 | value, params = cgi.parse_header(resp.headers['Content-Disposition']) 100 | return params['filename'] 101 | if resp.url.find('/'): 102 | return resp.url.rsplit('/', 1)[1] 103 | return 'index' 104 | 105 | 106 | def download_file(url): 107 | import requests 108 | resp = requests.get(url) 109 | local_filename = get_download_file_name(resp) 110 | with open(local_filename, 'wb') as f: 111 | f.write(resp.content) 112 | logger.info(f'[{local_filename}] download success.') 113 | return local_filename 114 | 115 | 116 | def install_dot_net(): 117 | ret = show_msgbox("运行组件缺失", "缺失 .NET Framework 组件, 是否下载安装?", 4) 118 | if ret == 7: 119 | raise RuntimeError('缺失 .NET Framework 组件') 120 | fn = download_file('https://go.microsoft.com/fwlink/?LinkId=2203304') 121 | logger.info('installing .NET Framework ...') 122 | os.system(fn) 123 | logger.info('removing .NET Framework installer.') 124 | os.remove(fn) 125 | 126 | 127 | def install_webview2(): 128 | ret = show_msgbox("运行组件缺失", "缺失 Microsoft Edge WebView2 组件, 是否下载安装?", 4) 129 | if ret == 7: 130 | raise RuntimeError('缺失 Microsoft Edge WebView2 组件') 131 | fn = download_file('https://go.microsoft.com/fwlink/p/?LinkId=2124703') 132 | logger.info('installing webview2...') 133 | os.system(fn) 134 | logger.info('removing webview2 installer.') 135 | os.remove(fn) 136 | 137 | 138 | if __name__ == '__main__': 139 | import config 140 | 141 | # check_runtime_components() 142 | install_webview2() 143 | --------------------------------------------------------------------------------