├── .github ├── ISSUE_TEMPLATE │ └── bug.md └── workflows │ └── release.yml ├── .gitignore ├── .readme ├── demo.gif ├── img.png ├── img_model_example.png └── img_model_example_inside.png ├── LICENSE ├── README.md ├── README_EN.md ├── debug backend.bat ├── index.html ├── main_mac.spec ├── main_win.spec ├── model └── .gitignore ├── package.json ├── public ├── lazyeat.png └── mediapipe │ ├── gesture_recognizer.task │ └── wasm │ ├── vision_wasm_internal.js │ ├── vision_wasm_internal.wasm │ ├── vision_wasm_nosimd_internal.js │ └── vision_wasm_nosimd_internal.wasm ├── requirements.txt ├── src-py ├── VoiceController.py ├── main.py └── router │ ├── __init__.py │ └── ws.py ├── src-tauri ├── .gitignore ├── Cargo.toml ├── Info.plist ├── build.rs ├── capabilities │ ├── default.json │ └── desktop.json ├── src │ ├── lib.rs │ └── main.rs ├── tauri.conf.json └── tauri.macos.conf.json ├── src ├── App.vue ├── AppMediaPipe.vue ├── components │ ├── AutoStart.vue │ ├── CircleProgress.vue │ ├── DevTool.vue │ ├── GestureCard.vue │ ├── GestureIcon.vue │ └── Menu.vue ├── hand_landmark │ ├── VideoDetector.vue │ ├── detector.ts │ └── gesture_handler.ts ├── locales │ ├── en.ts │ ├── i18n.ts │ └── zh.ts ├── main.ts ├── py_api.ts ├── router │ └── index.ts ├── store │ └── app.ts ├── utils │ └── subWindow.ts ├── view │ ├── mainWindow │ │ ├── Guide.vue │ │ ├── Home.vue │ │ └── Update.vue │ ├── mainwindow │ │ └── MainWindow.vue │ └── subWindow │ │ └── SubWindow.vue └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Create a bug report 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | # 环境 11 | ### window / linux / mac 版本 12 | 13 | ### Lazyeat 版本 14 | 15 | # 问题 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "release" 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | 8 | # This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release. 9 | 10 | jobs: 11 | release-lazyeat: 12 | permissions: 13 | contents: write 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | include: 18 | - platform: "macos-latest" # for Arm based macs (M1 and above). 19 | args: "--target aarch64-apple-darwin" 20 | # - platform: "macos-latest" # for Intel based macs. 21 | # args: "--target x86_64-apple-darwin" 22 | # - platform: 'ubuntu-22.04' # for Tauri v1 you could replace this with ubuntu-20.04. 23 | # args: '' 24 | - platform: "windows-latest" 25 | args: "" 26 | 27 | runs-on: ${{ matrix.platform }} 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: setup node 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: lts/* 35 | 36 | - name: install Rust stable 37 | uses: dtolnay/rust-toolchain@stable 38 | with: 39 | # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds. 40 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 41 | 42 | - name: setup python 43 | uses: actions/setup-python@v4 44 | with: 45 | python-version: 3.11 46 | 47 | - name: Download and extract VOSK model 48 | run: | 49 | curl -L https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip -o vosk-model.zip 50 | mkdir -p temp 51 | unzip vosk-model.zip -d temp 52 | mv temp/*/* model/ # 自动处理嵌套目录 53 | 54 | - name: install dependencies (ubuntu only) 55 | if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above. 56 | run: | 57 | sudo apt-get update 58 | sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 59 | # webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2. 60 | # You can remove the one that doesn't apply to your app to speed up the workflow a bit. 61 | 62 | - name: install python and frontend dependencies 63 | run: npm run install-reqs 64 | 65 | - name: Build icon and python backend(windows) 66 | if: matrix.platform == 'windows-latest' 67 | run: | 68 | npm run build:icons 69 | npm run build:py 70 | 71 | - name: Build icon and python backend(macOS) 72 | if: matrix.platform == 'macos-latest' 73 | run: | 74 | npm run build:icons 75 | npm run build:icons-mac 76 | npm run build:py-mac 77 | 78 | - uses: tauri-apps/tauri-action@v0 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | with: 82 | tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version. 83 | releaseName: "v__VERSION__" 84 | releaseBody: "See the assets to download this version and install." 85 | releaseDraft: true 86 | prerelease: false 87 | args: ${{ matrix.args }} 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vosk-model-small-cn-0.22.zip 2 | lazyeat-ad 3 | meidiapipe/ 4 | main_win.spec 5 | *.exe 6 | .idea 7 | example 8 | .cursorrules 9 | big-model/ 10 | main_bak 11 | main.dist 12 | main.build 13 | 14 | # tauri 15 | package-lock.json 16 | 17 | # Logs 18 | logs 19 | *.log 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | pnpm-debug.log* 24 | lerna-debug.log* 25 | 26 | node_modules 27 | dist 28 | dist-ssr 29 | *.local 30 | 31 | # Editor directories and files 32 | .vscode/* 33 | !.vscode/extensions.json 34 | .idea 35 | .DS_Store 36 | *.suo 37 | *.ntvs* 38 | *.njsproj 39 | *.sln 40 | *.sw? 41 | 42 | # Byte-compiled / optimized / DLL files 43 | __pycache__/ 44 | *.py[cod] 45 | *$py.class 46 | 47 | # C extensions 48 | *.so 49 | 50 | # Distribution / packaging 51 | .Python 52 | build/ 53 | develop-eggs/ 54 | dist/ 55 | downloads/ 56 | eggs/ 57 | .eggs/ 58 | lib/ 59 | lib64/ 60 | parts/ 61 | sdist/ 62 | var/ 63 | wheels/ 64 | share/python-wheels/ 65 | *.egg-info/ 66 | .installed.cfg 67 | *.egg 68 | MANIFEST 69 | 70 | # PyInstaller 71 | # Usually these files are written by a python script from a template 72 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 73 | *.manifest 74 | 75 | # Installer logs 76 | pip-log.txt 77 | pip-delete-this-directory.txt 78 | 79 | # Unit test / coverage reports 80 | htmlcov/ 81 | .tox/ 82 | .nox/ 83 | .coverage 84 | .coverage.* 85 | .cache 86 | nosetests.xml 87 | coverage.xml 88 | *.cover 89 | *.py,cover 90 | .hypothesis/ 91 | .pytest_cache/ 92 | cover/ 93 | 94 | # Translations 95 | *.mo 96 | *.pot 97 | 98 | # Django stuff: 99 | *.log 100 | local_settings.py 101 | db.sqlite3 102 | db.sqlite3-journal 103 | 104 | # Flask stuff: 105 | instance/ 106 | .webassets-cache 107 | 108 | # Scrapy stuff: 109 | .scrapy 110 | 111 | # Sphinx documentation 112 | docs/_build/ 113 | 114 | # PyBuilder 115 | .pybuilder/ 116 | target/ 117 | 118 | # Jupyter Notebook 119 | .ipynb_checkpoints 120 | 121 | # IPython 122 | profile_default/ 123 | ipython_config.py 124 | 125 | # pyenv 126 | # For a library or package, you might want to ignore these files since the code is 127 | # intended to run in multiple environments; otherwise, check them in: 128 | # .python-version 129 | 130 | # pipenv 131 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 132 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 133 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 134 | # install all needed dependencies. 135 | #Pipfile.lock 136 | 137 | # UV 138 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 139 | # This is especially recommended for binary packages to ensure reproducibility, and is more 140 | # commonly ignored for libraries. 141 | #uv.lock 142 | 143 | # poetry 144 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 145 | # This is especially recommended for binary packages to ensure reproducibility, and is more 146 | # commonly ignored for libraries. 147 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 148 | #poetry.lock 149 | 150 | # pdm 151 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 152 | #pdm.lock 153 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 154 | # in version control. 155 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 156 | .pdm.toml 157 | .pdm-python 158 | .pdm-build/ 159 | 160 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 161 | __pypackages__/ 162 | 163 | # Celery stuff 164 | celerybeat-schedule 165 | celerybeat.pid 166 | 167 | # SageMath parsed files 168 | *.sage.py 169 | 170 | # Environments 171 | .env 172 | .venv 173 | env/ 174 | venv/ 175 | ENV/ 176 | env.bak/ 177 | venv.bak/ 178 | 179 | # Spyder project settings 180 | .spyderproject 181 | .spyproject 182 | 183 | # Rope project settings 184 | .ropeproject 185 | 186 | # mkdocs documentation 187 | /site 188 | 189 | # mypy 190 | .mypy_cache/ 191 | .dmypy.json 192 | dmypy.json 193 | 194 | # Pyre type checker 195 | .pyre/ 196 | 197 | # pytype static type analyzer 198 | .pytype/ 199 | 200 | # Cython debug symbols 201 | cython_debug/ 202 | 203 | # PyCharm 204 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 205 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 206 | # and can be added to the global gitignore or merged into this file. For a more nuclear 207 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 208 | #.idea/ 209 | 210 | # PyPI configuration file 211 | .pypirc 212 | -------------------------------------------------------------------------------- /.readme/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maplelost/lazyeat/cd460e636c435690bf308566fe4fa28d9f74d2a0/.readme/demo.gif -------------------------------------------------------------------------------- /.readme/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maplelost/lazyeat/cd460e636c435690bf308566fe4fa28d9f74d2a0/.readme/img.png -------------------------------------------------------------------------------- /.readme/img_model_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maplelost/lazyeat/cd460e636c435690bf308566fe4fa28d9f74d2a0/.readme/img_model_example.png -------------------------------------------------------------------------------- /.readme/img_model_example_inside.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maplelost/lazyeat/cd460e636c435690bf308566fe4fa28d9f74d2a0/.readme/img_model_example_inside.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | banner
4 |
5 |
6 | 7 | ![GitHub stars](https://img.shields.io/github/stars/maplelost/lazyeat) 8 | ![GitHub forks](https://img.shields.io/github/forks/maplelost/lazyeat?style=flat) 9 | 10 | [English README](README_EN.md) 11 | 12 |
13 |

14 | 15 | # 🍕 Lazyeat 16 | 17 | Lazyeat 吃饭时看剧/刷网页不想沾油手? 18 | 19 | 对着摄像头比划手势就能暂停视频/全屏/切换视频! 20 | 21 | 如果你觉得对你有用的话,不妨给我一个 star⭐ 吧~ 22 | 23 | 如果有任何的想法或者建议,都可以在 QQ 群 [452246065](https://jq.qq.com/?_wv=1027&k=452246065) 中讨论喔! 24 | 25 | | 平台 | 支持状态 | 下载地址 | 26 | | :--------: | :-------: | :------------------------------------------------------------------: | 27 | | 🪟 Windows | ✅ 支持 | [下载最新版本](https://github.com/maplelost/lazyeat/releases/latest) | 28 | | 🍎 Mac | ✅ 支持 | [下载最新版本](https://github.com/maplelost/lazyeat/releases/latest) | 29 | | 🐧 Linux | ⏳ 开发中 | / | 30 | | 🤖 Android | ⏳ 开发中 | / | 31 | | 📱 iOS | ⏳ 开发中 | / | 32 | 33 | ## 功能 34 | 35 | - 单指滑动控制光标 36 | - 双指/Rock 执行鼠标单击 37 | - ok 手势控制页面滚动 38 | - 四指并拢发送按键 39 | - 支持语音输入 40 | 41 | ![demo.gif](.readme/demo.gif) 42 | 43 | # 🌠 截图 44 | 45 | 视频演示:https://www.bilibili.com/video/BV11SXTYTEJi/?spm_id_from=333.1387.homepage.video_card.click 46 | 47 |
48 | 49 |
50 | 51 | # 快速开始 52 | 53 | ``` 54 | # 版本号声明,以下为我的开发环境 55 | \Desktop\lazyeat> python --version 56 | Python 3.11.11 57 | (2025年4月19日 python 3.12.7 以及以上版本 pyinstaller 打包会失败) 58 | 59 | Desktop\lazyeat> rustc --version 60 | rustc 1.85.1 (4eb161250 2025-03-15) 61 | 62 | \Desktop\lazyeat> node --version 63 | v22.14.0 64 | ``` 65 | 66 | ### 安装 rust 和 node 67 | 68 | [rust](https://www.rust-lang.org/zh-CN/tools/install) 和 [node](https://nodejs.org/zh-cn/) 69 | 70 | ### 项目根目录打开项目(vscode,pycharm 等) 71 | 72 | 项目根目录(也就是 lazyeat 的根目录) 73 | (如:C:\Users\你的用户名\Desktop\lazyeat,也可以直接打开文件夹后在地址栏输入 cmd) 74 | 75 | ### 安装 npm 以及 python 环境 76 | 77 | ```bash 78 | npm run install-reqs 79 | ``` 80 | 81 | 这一步遇到问题可以尝试使用管理员方式运行 cmd 再运行该命令 82 | 83 | ### build tauri 图标 84 | 85 | ```bash 86 | npm run build:icons 87 | ``` 88 | 89 | ### pyinstaller 打包 90 | 91 | ```bash 92 | npm run build:py 93 | # 打包 mac 版本 94 | # npm run build:py-mac 95 | # 打包 linux 版本 96 | # npm run build:py-linux 97 | ``` 98 | 99 | ### 下载语音识别模型并解压到 model 文件夹下 100 | 101 | ```bash 102 | https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip 103 | ``` 104 | 105 | ![img.png](.readme/img_model_example_inside.png) 106 | 107 | ### 运行 tauri dev 开发环境 108 | 109 | ```bash 110 | npm run tauri dev 111 | ``` 112 | 113 | ### 额外说明 114 | 115 | #### 打包成生产环境(不发布就不需要) 116 | 117 | ```bash 118 | npm run tauri build 119 | ``` 120 | 121 | 打包后在 **lazyeat\src-tauri\target\release**目录下找到 exe 文件运行即可。 122 | 123 | #### python 后端 debug 124 | 125 | 如果你需要 debug python 后端,那么先 pyinstaller 打包,再运行 `python src-py/main.py`。 126 | 127 | 因为 `npm run tauri dev` 需要生成 [tauri.conf.json](src-tauri/tauri.conf.json) 中编写的 sidecar。 128 | 详见:https://v2.tauri.app/zh-cn/develop/sidecar/ 129 | 130 | # 📢 语音识别模型替换 131 | 132 | [小模型](https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip) [大模型](https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip) 133 | 134 | 前面的步骤下载的是小模型,如果需要使用大模型,下载后解压到 `model/` 替换 135 | 136 | ![img.png](.readme/img_model_example.png) 137 | 138 | # 📝 TODO 139 | 140 | - [ ] (2025 年 3 月 12 日) 嵌入 browser-use ,语音控制浏览器 141 | - [ ] (2025 年 3 月 24 日) 开发安卓版本 142 | 143 | [//]: # "# 📚 References" 144 | 145 | # 开发问题 146 | 147 | tauri build 失败:[tauri build 失败](https://github.com/tauri-apps/tauri/issues/7338) 148 | 149 | cargo 被墙:[cargo 被墙,换源](https://www.chenreal.com/post/599) 150 | 151 | [非代码异常问题总结](https://github.com/maplelost/lazyeat/issues/30) 152 | 153 | ``` 154 | # 不知道有没有用 155 | rm -rf ~/.cargo/.package-cache 156 | ``` 157 | 158 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | banner
4 |
5 |
6 | 7 | ![GitHub stars](https://img.shields.io/github/stars/maplelost/lazyeat) 8 | ![GitHub forks](https://img.shields.io/github/forks/maplelost/lazyeat?style=flat) 9 | 10 | [中文 README](README.md) 11 | 12 |
13 |

14 | 15 | # 🍕 Lazyeat 16 | 17 | Lazyeat is a touch-free controller for eating! Don't want to get your hands greasy while watching videos or browsing the 18 | web while eating? 19 | 20 | Just make hand gestures in front of your camera to pause videos, toggle fullscreen, or switch videos! 21 | 22 | If you find this useful, please give it a star! 23 | 24 | Feel free to join 25 | our [![Discord](https://img.shields.io/discord/1357641609176551566?label=Join%20Discord&logo=discord)](https://discord.gg/nTH6qRng) 26 | community to share your ideas and suggestions! 27 | 28 | # 🌠 Screenshots 29 | 30 | Video Demo: https://www.bilibili.com/video/BV11SXTYTEJi/?spm_id_from=333.1387.homepage.video_card.click 31 | 32 | ![img.png](.readme/img.png) 33 | 34 | # Quick Start 35 | 36 | ``` 37 | # Version Information 38 | \Desktop\lazyeat> python --version 39 | Python 3.11.11 40 | 41 | Desktop\lazyeat> rustc --version 42 | rustc 1.85.1 (4eb161250 2025-03-15) 43 | 44 | \Desktop\lazyeat> node --version 45 | v22.14.0 46 | ``` 47 | 48 | ```bash 49 | # 1. Install npm and python environment 50 | npm run install-reqs 51 | 52 | # 2. Build tauri icons 53 | npm run build:icons 54 | 55 | # 3. pyinstaller packaging 56 | npm run py-build 57 | 58 | # 4. tauri development mode 59 | npm run tauri dev 60 | 61 | # 5. tauri production build 62 | # npm run tauri build 63 | ``` 64 | 65 | If you need to debug the backend, first use pyinstaller to package, then run `python src-py/main.py`. 66 | `npm run tauri dev` requires first generating the sidecar written in [tauri.conf.json](src-tauri/tauri.conf.json). 67 | See: https://v2.tauri.app/zh-cn/develop/sidecar/ 68 | 69 | # 📢 Speech Recognition Model Download 70 | 71 | [Small Model](https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip) 72 | 73 | [Large Model](https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip) 74 | 75 | After downloading, extract to the `model` folder at the same level as the `exe` to use the speech recognition feature 76 | 77 | ![img.png](.readme/img_model_example.png) 78 | 79 | # 📝 TODO 80 | 81 | - [ ] (March 12, 2025) Integrate browser-use for voice-controlled browser navigation 82 | - [ ] (March 24, 2025) Develop Android version 83 | 84 | # Development Issues 85 | 86 | ## Tauri Build Issues 87 | 88 | If you encounter build failures with Tauri, check out this 89 | issue: [tauri build failure](https://github.com/tauri-apps/tauri/issues/7338) 90 | 91 | ## Cargo Network Issues 92 | 93 | If you're experiencing network issues with Cargo (common in some regions), you can try changing the 94 | source: [cargo blocked, change source](https://www.chenreal.com/post/599) 95 | 96 | ```bash 97 | # Clear cargo cache (may help with some issues) 98 | rm -rf ~/.cargo/.package-cache 99 | ``` 100 | 101 | # Star History 102 | 103 | [![Star History Chart](https://api.star-history.com/svg?repos=maplelost/lazyeat&type=Date)](https://www.star-history.com/#maplelost/lazyeat&Date) 104 | -------------------------------------------------------------------------------- /debug backend.bat: -------------------------------------------------------------------------------- 1 | .\"Lazyeat Backend.exe" 2 | pause -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + Vue + Typescript App 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /main_mac.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | import sys 3 | import site 4 | 5 | # Get site-packages directory 6 | site_packages = site.getsitepackages()[0] 7 | 8 | a = Analysis( 9 | ['src-py/main.py'], 10 | pathex=[], 11 | binaries=[], 12 | datas=[ 13 | (f'{site_packages}/vosk/*', 'vosk'), 14 | # Update paths for other `datas` entries 15 | ], 16 | hookspath=[], 17 | hooksconfig={}, 18 | runtime_hooks=[], 19 | excludes=[], 20 | noarchive=False, 21 | optimize=0, 22 | ) 23 | pyz = PYZ(a.pure) 24 | exe = EXE( 25 | pyz, 26 | a.scripts, 27 | [], 28 | exclude_binaries=True, 29 | name='Lazyeat Backend-aarch64-apple-darwin', 30 | debug=False, 31 | bootloader_ignore_signals=False, 32 | strip=False, 33 | upx=True, 34 | console=True, 35 | ) 36 | coll = COLLECT( 37 | exe, 38 | a.binaries, 39 | a.datas, 40 | strip=False, 41 | upx=True, 42 | upx_exclude=[], 43 | name='backend-py', 44 | ) -------------------------------------------------------------------------------- /main_win.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | import sys 3 | import site 4 | 5 | # Get site-packages directory 6 | site_packages = site.getsitepackages()[1] 7 | 8 | a = Analysis( 9 | ['src-py\\main.py'], 10 | pathex=[], 11 | binaries=[], 12 | datas=[ 13 | (f'{site_packages}\\vosk\\*', 'vosk'), 14 | (f'{site_packages}\\uvicorn\\*', 'uvicorn'), 15 | ], 16 | hiddenimports=[ 17 | 'vosk', 18 | 'uvicorn', 19 | 'uvicorn.logging', 20 | 'uvicorn.protocols', 21 | ], 22 | hookspath=[], 23 | hooksconfig={}, 24 | runtime_hooks=[], 25 | excludes=[], 26 | noarchive=False, 27 | optimize=0, 28 | ) 29 | pyz = PYZ(a.pure) 30 | 31 | exe = EXE( 32 | pyz, 33 | a.scripts, 34 | [], 35 | exclude_binaries=True, 36 | name='Lazyeat Backend-x86_64-pc-windows-msvc', 37 | debug=False, 38 | bootloader_ignore_signals=False, 39 | strip=False, 40 | upx=True, 41 | console=True, 42 | disable_windowed_traceback=False, 43 | argv_emulation=False, 44 | target_arch=None, 45 | codesign_identity=None, 46 | entitlements_file=None, 47 | ) 48 | coll = COLLECT( 49 | exe, 50 | a.binaries, 51 | a.datas, 52 | strip=False, 53 | upx=True, 54 | upx_exclude=[], 55 | name='backend-py', 56 | ) 57 | -------------------------------------------------------------------------------- /model/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tauri", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "install-reqs": "npm install && pip install -r requirements.txt", 8 | "dev": "vite", 9 | "build:py": "pyinstaller --noconfirm --distpath src-tauri/bin/ main_win.spec", 10 | "build:py-mac": "pyinstaller --noconfirm --distpath src-tauri/bin/ main_mac.spec", 11 | "build:icons": "npx tauri icon public/lazyeat.png", 12 | "build:icons-mac": "npx tauri icon public/lazyeat.png --output src-tauri/icons/mac", 13 | "build": "vite build", 14 | "preview": "vite preview", 15 | "tauri": "npx tauri", 16 | "tauri dev": "npx tauri" 17 | }, 18 | "dependencies": { 19 | "@icon-park/vue-next": "^1.4.2", 20 | "@mediapipe/tasks-vision": "^0.10.22-rc.20250304", 21 | "@tauri-apps/api": "^2", 22 | "@tauri-apps/plugin-autostart": "^2.2.0", 23 | "@tauri-apps/plugin-notification": "^2.2.2", 24 | "@tauri-apps/plugin-opener": "^2.2.6", 25 | "@tauri-apps/plugin-store": "^2.2.0", 26 | "@tauri-apps/plugin-window-state": "^2.2.1", 27 | "element-plus": "^2.9.5", 28 | "pinia": "^3.0.1", 29 | "pinia-shared-state": "^1.0.1", 30 | "vue": "^3.5.13", 31 | "vue-i18n": "^9.14.4", 32 | "vue-router": "^4.5.0" 33 | }, 34 | "devDependencies": { 35 | "@tauri-apps/cli": "^2", 36 | "@vitejs/plugin-vue": "^5.2.1", 37 | "naive-ui": "^2.41.0", 38 | "sass-embedded": "^1.85.1", 39 | "typescript": "~5.6.2", 40 | "vite": "^6.0.3", 41 | "vue-tsc": "^2.1.10" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/lazyeat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maplelost/lazyeat/cd460e636c435690bf308566fe4fa28d9f74d2a0/public/lazyeat.png -------------------------------------------------------------------------------- /public/mediapipe/gesture_recognizer.task: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maplelost/lazyeat/cd460e636c435690bf308566fe4fa28d9f74d2a0/public/mediapipe/gesture_recognizer.task -------------------------------------------------------------------------------- /public/mediapipe/wasm/vision_wasm_internal.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maplelost/lazyeat/cd460e636c435690bf308566fe4fa28d9f74d2a0/public/mediapipe/wasm/vision_wasm_internal.wasm -------------------------------------------------------------------------------- /public/mediapipe/wasm/vision_wasm_nosimd_internal.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maplelost/lazyeat/cd460e636c435690bf308566fe4fa28d9f74d2a0/public/mediapipe/wasm/vision_wasm_nosimd_internal.wasm -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pynput 2 | fastapi 3 | opencv-python 4 | numpy 5 | uvicorn 6 | vosk 7 | pygrabber 8 | pyinstaller 9 | sounddevice 10 | -------------------------------------------------------------------------------- /src-py/VoiceController.py: -------------------------------------------------------------------------------- 1 | import json 2 | import threading 3 | import sounddevice as sd 4 | import numpy as np 5 | 6 | from vosk import Model, KaldiRecognizer 7 | 8 | big_model_path = "big-model" 9 | small_model_path = "model" 10 | 11 | 12 | class VoiceController: 13 | def __init__(self, model_type='small'): 14 | if model_type == 'small': 15 | self.model = Model(small_model_path) 16 | else: 17 | self.model = Model(big_model_path) 18 | 19 | self.zh_text = None 20 | self.is_recording = False 21 | self.recognizer = KaldiRecognizer(self.model, 16000) 22 | # 初始化输入流 23 | self.stream = sd.InputStream( 24 | samplerate=16000, 25 | channels=1, 26 | blocksize=4096, 27 | dtype='int16' # 对应原来 PyAudio 的 paInt16 28 | ) 29 | self.stream.start() 30 | 31 | def record_audio(self): 32 | self.frames = [] 33 | print("录音开始...") 34 | 35 | # 持续录音直到标志改变 36 | while self.is_recording: 37 | data, _ = self.stream.read(4096) 38 | self.frames.append(data.tobytes()) # 转成 bytes,保持跟原先 pyaudio 一致 39 | 40 | def start_record_thread(self): 41 | self.is_recording = True 42 | threading.Thread(target=self.record_audio, daemon=True).start() 43 | 44 | def stop_record(self): 45 | self.is_recording = False 46 | 47 | def transcribe_audio(self) -> str: 48 | self.recognizer.Reset() 49 | 50 | # 分块处理音频数据 51 | for chunk in self.frames: 52 | self.recognizer.AcceptWaveform(chunk) 53 | 54 | result = json.loads(self.recognizer.FinalResult()) 55 | text = result.get('text', '') 56 | text = text.replace(' ', '') 57 | print(f"识别结果: {text}") 58 | return text 59 | 60 | 61 | if __name__ == '__main__': 62 | pass 63 | # from PyQt5.QtWidgets import QApplication, QPushButton 64 | # 65 | # app = QApplication([]) 66 | # 67 | # # 点击按钮开始录音 68 | # voice_controller = VoiceController() 69 | # 70 | # 71 | # def btn_clicked(): 72 | # if voice_controller.is_recording: 73 | # voice_controller.stop_record() 74 | # text = voice_controller.transcribe_audio() 75 | # print(text) 76 | # else: 77 | # voice_controller.start_record_thread() 78 | # 79 | # 80 | # btn = QPushButton('开始录音') 81 | # btn.clicked.connect(btn_clicked) 82 | # btn.show() 83 | # 84 | # app.exec_() 85 | -------------------------------------------------------------------------------- /src-py/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import uvicorn 5 | from fastapi import FastAPI 6 | from fastapi.middleware.cors import CORSMiddleware 7 | 8 | from router.ws import router as ws_router 9 | 10 | if hasattr(sys, 'frozen'): 11 | # pyinstaller打包成exe时,sys.argv[0]的值是exe的路径 12 | # os.path.dirname(sys.argv[0])可以获取exe的所在目录 13 | # os.chdir()可以将工作目录更改为exe的所在目录 14 | os.chdir(os.path.dirname(sys.argv[0])) 15 | 16 | app = FastAPI() 17 | app.add_middleware( 18 | CORSMiddleware, 19 | allow_origins=["*"], 20 | allow_credentials=True, 21 | allow_methods=["*"], 22 | allow_headers=["*"], 23 | ) 24 | 25 | # 添加 WebSocket 路由 26 | app.include_router(ws_router) 27 | 28 | 29 | @app.get("/") 30 | def read_root(): 31 | return "ready" 32 | 33 | 34 | @app.get("/shutdown") 35 | def shutdown(): 36 | import signal 37 | import os 38 | os.kill(os.getpid(), signal.SIGINT) 39 | 40 | 41 | if __name__ == '__main__': 42 | port = 62334 43 | 44 | print(f"Starting server at http://127.0.0.1:{port}/docs") 45 | uvicorn.run(app, host="127.0.0.1", port=port) 46 | -------------------------------------------------------------------------------- /src-py/router/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maplelost/lazyeat/cd460e636c435690bf308566fe4fa28d9f74d2a0/src-py/router/__init__.py -------------------------------------------------------------------------------- /src-py/router/ws.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from typing import Dict, List, Optional, Union 4 | 5 | from fastapi import APIRouter, WebSocket, WebSocketDisconnect 6 | from pynput.keyboard import Controller as KeyboardController 7 | from pynput.keyboard import Key 8 | from pynput.mouse import Button, Controller 9 | 10 | router = APIRouter() 11 | 12 | # 存储活跃的WebSocket连接 13 | active_connection: Optional[WebSocket] = None 14 | 15 | 16 | @dataclass 17 | class WebSocketMessage: 18 | """WebSocket消息数据类""" 19 | 20 | type: str 21 | msg: str = "" 22 | title: str = "提示" 23 | duration: int = 1 24 | data: Dict = None 25 | 26 | def __post_init__(self): 27 | if self.data is None: 28 | self.data = {"x": 0, "y": 0, "key_str": ""} 29 | 30 | def to_dict(self) -> Dict: 31 | """将消息转换为字典格式""" 32 | return { 33 | "type": self.type, 34 | "msg": self.msg, 35 | "title": self.title, 36 | "duration": self.duration, 37 | "data": self.data, 38 | } 39 | 40 | 41 | class WebSocketMessageType: 42 | """WebSocket消息类型常量""" 43 | 44 | # 系统消息类型 45 | INFO = "info" 46 | SUCCESS = "success" 47 | WARNING = "warning" 48 | ERROR = "error" 49 | 50 | # 鼠标操作类型 51 | MOUSE_MOVE = "mouse_move" 52 | MOUSE_CLICK = "mouse_click" 53 | MOUSE_SCROLL_UP = "mouse_scroll_up" 54 | MOUSE_SCROLL_DOWN = "mouse_scroll_down" 55 | 56 | # 键盘操作类型 57 | SEND_KEYS = "send_keys" 58 | 59 | # 语音操作类型 60 | VOICE_RECORD = "voice_record" 61 | VOICE_STOP = "voice_stop" 62 | 63 | 64 | class MessageSender: 65 | """消息发送器,发送给前端""" 66 | 67 | @staticmethod 68 | async def send_message( 69 | ws_data_type: str, msg: str, title: str = "提示", duration: int = 1 70 | ) -> None: 71 | """ 72 | 发送消息到WebSocket客户端 73 | 74 | Args: 75 | ws_data_type: 消息类型 76 | msg: 消息内容 77 | title: 消息标题 78 | duration: 显示持续时间 79 | """ 80 | if not active_connection: 81 | return 82 | 83 | try: 84 | message = WebSocketMessage( 85 | type=ws_data_type, msg=msg, title=title, duration=duration 86 | ) 87 | await active_connection.send_json(message.to_dict()) 88 | except Exception as e: 89 | print(f"发送消息失败: {e}") 90 | 91 | 92 | class GestureHandler: 93 | """手势控制器""" 94 | 95 | def __init__(self): 96 | self.keyboard = KeyboardController() 97 | self.mouse = Controller() 98 | 99 | def move_mouse(self, x: int, y: int) -> None: 100 | """移动鼠标到指定位置""" 101 | self.mouse.position = (x, y) 102 | 103 | def click_mouse(self) -> None: 104 | """点击鼠标左键""" 105 | self.mouse.click(Button.left) 106 | 107 | def scroll_up(self) -> None: 108 | """向上滚动""" 109 | self.mouse.scroll(0, 1) 110 | 111 | def scroll_down(self) -> None: 112 | """向下滚动""" 113 | self.mouse.scroll(0, -1) 114 | 115 | def send_keys(self, key_str: str) -> None: 116 | """ 117 | 发送按键事件(支持组合键) 118 | 119 | Args: 120 | key_str: 按键字符串(如 'ctrl+r' 或 'F11') 121 | """ 122 | try: 123 | keys = [self.__parse_key(key) for key in key_str.split("+")] 124 | self.__execute_keys(keys) 125 | except Exception as e: 126 | print(f"发送按键失败: {e}") 127 | 128 | def __parse_key(self, key_str: str) -> Union[str, Key]: 129 | """ 130 | 解析单个按键 131 | 132 | Args: 133 | key_str: 按键字符串 134 | 135 | Returns: 136 | 解析后的按键对象 137 | """ 138 | key_str = key_str.strip().lower() 139 | 140 | if hasattr(Key, key_str): 141 | return getattr(Key, key_str) 142 | elif len(key_str) == 1: 143 | return key_str 144 | elif key_str.startswith("f"): 145 | try: 146 | return getattr(Key, key_str) 147 | except AttributeError: 148 | raise ValueError(f"无效的功能键: {key_str}") 149 | else: 150 | raise ValueError(f"无效的按键: {key_str}") 151 | 152 | def __execute_keys(self, keys: List[Union[str, Key]]) -> None: 153 | """ 154 | 执行按键序列 155 | 156 | Args: 157 | keys: 按键列表 158 | """ 159 | pressed_keys = [] 160 | try: 161 | # 按下所有键 162 | for key in keys: 163 | self.keyboard.press(key) 164 | pressed_keys.append(key) 165 | 166 | # 释放所有键(按相反顺序) 167 | for key in reversed(pressed_keys): 168 | self.keyboard.release(key) 169 | except Exception as e: 170 | print(f"执行按键失败: {e}") 171 | 172 | 173 | class VoiceHandler: 174 | """语音处理控制器""" 175 | 176 | def __init__(self): 177 | from VoiceController import VoiceController 178 | 179 | self.controller: Optional[VoiceController] = None 180 | try: 181 | self.controller = VoiceController() 182 | except Exception as e: 183 | print(f"语音控制器初始化失败: {e}") 184 | 185 | async def start_recording(self, websocket: WebSocket) -> None: 186 | """开始录音""" 187 | if self.controller and not self.controller.is_recording: 188 | self.controller.start_record_thread() 189 | 190 | async def stop_recording( 191 | self, websocket: WebSocket, gesture_handler: GestureHandler 192 | ) -> None: 193 | """停止录音并处理结果""" 194 | if self.controller and self.controller.is_recording: 195 | self.controller.stop_record() 196 | 197 | # 获取识别结果并输入 198 | text = self.controller.transcribe_audio() 199 | if text: 200 | gesture_handler.keyboard.type(text) 201 | gesture_handler.keyboard.tap(Key.enter) 202 | 203 | 204 | @router.websocket("/ws_lazyeat") 205 | async def websocket_endpoint(websocket: WebSocket): 206 | """WebSocket端点处理函数""" 207 | global active_connection 208 | 209 | await websocket.accept() 210 | active_connection = websocket 211 | 212 | gesture_handler = GestureHandler() 213 | voice_handler = VoiceHandler() 214 | 215 | try: 216 | while True: 217 | data_str = await websocket.receive_text() 218 | await _handle_message(data_str, websocket, gesture_handler, voice_handler) 219 | except WebSocketDisconnect: 220 | active_connection = None 221 | except Exception as e: 222 | print(f"WebSocket处理错误: {e}") 223 | active_connection = None 224 | 225 | 226 | async def _handle_message( 227 | data_str: str, 228 | websocket: WebSocket, 229 | gesture_handler: GestureHandler, 230 | voice_handler: VoiceHandler, 231 | ) -> None: 232 | """处理WebSocket消息""" 233 | try: 234 | message = WebSocketMessage(**json.loads(data_str)) 235 | data = message.data 236 | 237 | # 处理鼠标操作 238 | if message.type == WebSocketMessageType.MOUSE_MOVE: 239 | gesture_handler.move_mouse(data["x"], data["y"]) 240 | elif message.type == WebSocketMessageType.MOUSE_CLICK: 241 | gesture_handler.click_mouse() 242 | elif message.type == WebSocketMessageType.MOUSE_SCROLL_UP: 243 | gesture_handler.scroll_up() 244 | elif message.type == WebSocketMessageType.MOUSE_SCROLL_DOWN: 245 | gesture_handler.scroll_down() 246 | 247 | # 处理键盘操作 248 | elif message.type == WebSocketMessageType.SEND_KEYS: 249 | gesture_handler.send_keys(data["key_str"]) 250 | 251 | # 处理语音操作 252 | elif message.type == WebSocketMessageType.VOICE_RECORD: 253 | await voice_handler.start_recording(websocket) 254 | elif message.type == WebSocketMessageType.VOICE_STOP: 255 | await voice_handler.stop_recording(websocket, gesture_handler) 256 | 257 | except json.JSONDecodeError: 258 | print("无效的JSON数据") 259 | except Exception as e: 260 | print(f"处理消息失败: {e}") 261 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | 2 2 | 3 | Cargo.lock 4 | bin/ 5 | icons/ 6 | 7 | # Generated by Cargo 8 | # will have compiled files and executables 9 | /target/ 10 | 11 | # Generated by Tauri 12 | # will have schema files for capabilities auto-completion 13 | /gen/schemas 14 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "Lazyeat" 3 | version = "0.3.11" 4 | description = "Lazyeat 手势识别" 5 | authors = ["https://github.com/maplelost"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "tauri_app_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2", features = [] } 19 | 20 | [dependencies] 21 | tauri = { version = "2", features = ["devtools"] } 22 | tauri-plugin-opener = "2" 23 | serde = { version = "1", features = ["derive"] } 24 | serde_json = "1" 25 | tauri-plugin-shell = "2" 26 | tauri-plugin-store = "2" 27 | tauri-plugin-notification = "2" 28 | 29 | [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 30 | tauri-plugin-autostart = "2" 31 | tauri-plugin-window-state = "2" 32 | 33 | -------------------------------------------------------------------------------- /src-tauri/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSCameraUsageDescription 6 | 请允许本程序访问您的摄像头 7 | 8 | 9 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": ["main", "NewWindow_2"], 6 | "permissions": [ 7 | "core:default", 8 | "shell:default", 9 | "shell:allow-execute", 10 | "shell:allow-spawn", 11 | "core:path:default", 12 | "core:event:default", 13 | "core:window:default", 14 | "core:app:default", 15 | "core:resources:default", 16 | "core:menu:default", 17 | "core:tray:default", 18 | "core:window:allow-destroy", 19 | "core:window:allow-set-title", 20 | "store:default", 21 | 22 | "notification:default", 23 | "notification:allow-is-permission-granted", 24 | "notification:allow-notify", 25 | "notification:allow-show", 26 | "notification:allow-request-permission", 27 | 28 | "opener:default", 29 | "opener:allow-open-path", 30 | "opener:allow-reveal-item-in-dir", 31 | "opener:allow-default-urls", 32 | "opener:allow-open-url", 33 | 34 | "core:webview:default", 35 | "core:window:allow-show", 36 | "core:window:allow-hide", 37 | "core:webview:allow-create-webview-window", 38 | "core:window:allow-set-position", 39 | "core:window:allow-set-size" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src-tauri/capabilities/desktop.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "desktop-capability", 3 | "platforms": [ 4 | "macOS", 5 | "windows", 6 | "linux" 7 | ], 8 | "windows": [ 9 | "main" 10 | ], 11 | "permissions": [ 12 | "autostart:allow-enable", 13 | "autostart:allow-disable", 14 | "autostart:allow-is-enabled", 15 | "window-state:default" 16 | ] 17 | } -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ 2 | #[tauri::command] 3 | fn greet(name: &str) -> String { 4 | format!("Hello, {}! You've been greeted from Rust!", name) 5 | } 6 | 7 | // 提取sidecar启动逻辑到单独的函数 8 | async fn start_sidecar(app: tauri::AppHandle) -> Result { 9 | let sidecar = app 10 | .shell() 11 | .sidecar("Lazyeat Backend") 12 | .map_err(|e| format!("无法找到sidecar: {}", e))?; 13 | 14 | let (_rx, _child) = sidecar 15 | .spawn() 16 | .map_err(|e| format!("无法启动sidecar: {}", e))?; 17 | 18 | Ok("Sidecar已启动".to_string()) 19 | } 20 | 21 | // 保留命令供可能的手动调用 22 | #[tauri::command] 23 | async fn run_sidecar(app: tauri::AppHandle) -> Result { 24 | start_sidecar(app).await 25 | } 26 | 27 | use tauri_plugin_autostart::MacosLauncher; 28 | use tauri_plugin_autostart::ManagerExt; 29 | use tauri_plugin_shell::process::CommandEvent; 30 | use tauri_plugin_shell::ShellExt; 31 | 32 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 33 | pub fn run() { 34 | tauri::Builder::default() 35 | .plugin(tauri_plugin_notification::init()) 36 | // .plugin(tauri_plugin_window_state::Builder::new().build()) // 窗口状态管理,启用了导致 sub-window 无法设置decorations 37 | .plugin(tauri_plugin_store::Builder::new().build()) 38 | .plugin(tauri_plugin_autostart::init( 39 | MacosLauncher::LaunchAgent, 40 | Some(vec!["--flag1", "--flag2"]), 41 | )) 42 | .plugin(tauri_plugin_shell::init()) 43 | .plugin(tauri_plugin_opener::init()) 44 | .setup(|app| { 45 | // 在应用启动时自动启动sidecar 46 | let app_handle = app.handle().clone(); 47 | tauri::async_runtime::spawn(async move { 48 | match start_sidecar(app_handle).await { 49 | Ok(msg) => println!("{}", msg), 50 | Err(e) => eprintln!("启动sidecar失败: {}", e), 51 | } 52 | }); 53 | Ok(()) 54 | }) 55 | .invoke_handler(tauri::generate_handler![greet, run_sidecar]) 56 | .run(tauri::generate_context!()) 57 | .expect("error while running tauri application"); 58 | } 59 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | tauri_app_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "Lazyeat", 4 | "version": "0.3.11", 5 | "identifier": "com.Lazyeat.maplelost", 6 | "build": { 7 | "beforeDevCommand": "npm run dev", 8 | "devUrl": "http://localhost:1420", 9 | "beforeBuildCommand": "npm run build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "Lazyeat", 16 | "width": 800, 17 | "height": 600, 18 | "devtools": true 19 | } 20 | ], 21 | "security": { 22 | "csp": null 23 | } 24 | }, 25 | "bundle": { 26 | "active": true, 27 | "externalBin": ["bin/backend-py/Lazyeat Backend"], 28 | "resources": { 29 | "bin/backend-py/_internal": "_internal/", 30 | "../model": "model/", 31 | "../debug backend.bat": "/" 32 | }, 33 | "macOS": { 34 | "dmg": { 35 | "appPosition": { 36 | "x": 180, 37 | "y": 170 38 | }, 39 | "applicationFolderPosition": { 40 | "x": 480, 41 | "y": 170 42 | }, 43 | "windowSize": { 44 | "height": 400, 45 | "width": 660 46 | } 47 | }, 48 | "files": {}, 49 | "hardenedRuntime": true, 50 | "minimumSystemVersion": "10.13" 51 | }, 52 | "targets": ["dmg", "msi"], 53 | "icon": [ 54 | "icons/32x32.png", 55 | "icons/128x128.png", 56 | "icons/128x128@2x.png", 57 | "icons/mac/icon.icns", 58 | "icons/icon.ico" 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src-tauri/tauri.macos.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "identifier": "com.Lazyeat.maplelost", 4 | "bundle": { 5 | "resources": [], 6 | "macOS": { 7 | "files": { 8 | "Resources/model": "../model", 9 | "Frameworks": "./bin/backend-py/_internal" 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 36 | -------------------------------------------------------------------------------- /src/AppMediaPipe.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 15 | 16 | 35 | -------------------------------------------------------------------------------- /src/components/AutoStart.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 32 | -------------------------------------------------------------------------------- /src/components/CircleProgress.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 62 | 63 | -------------------------------------------------------------------------------- /src/components/DevTool.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 31 | 32 | -------------------------------------------------------------------------------- /src/components/GestureCard.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 23 | 24 | 83 | -------------------------------------------------------------------------------- /src/components/GestureIcon.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | 19 | 24 | -------------------------------------------------------------------------------- /src/components/Menu.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/hand_landmark/VideoDetector.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 254 | 255 | 265 | -------------------------------------------------------------------------------- /src/hand_landmark/detector.ts: -------------------------------------------------------------------------------- 1 | import { GestureHandler } from "@/hand_landmark/gesture_handler"; 2 | import { 3 | FilesetResolver, 4 | GestureRecognizer, 5 | GestureRecognizerOptions, 6 | } from "@mediapipe/tasks-vision"; 7 | 8 | // 手势枚举 9 | export enum HandGesture { 10 | // 食指举起,移动鼠标 11 | ONLY_INDEX_UP = "only_index_up", 12 | 13 | // 食指和中指同时竖起 - 鼠标左键点击 14 | INDEX_AND_MIDDLE_UP = "index_and_middle_up", 15 | ROCK_GESTURE = "rock_gesture", 16 | 17 | // 三根手指同时竖起 - 滚动屏幕 18 | THREE_FINGERS_UP = "three_fingers_up", 19 | SCROLL_GESTURE_2 = "scroll_gesture_2", 20 | 21 | // 四根手指同时竖起 22 | FOUR_FINGERS_UP = "four_fingers_up", 23 | 24 | // 五根手指同时竖起 - 暂停/开始 识别 25 | STOP_GESTURE = "stop_gesture", 26 | 27 | // 拇指和食指同时竖起 - 语音识别 28 | VOICE_GESTURE_START = "voice_gesture_start", 29 | VOICE_GESTURE_STOP = "voice_gesture_stop", 30 | 31 | // 其他手势 32 | DELETE_GESTURE = "delete_gesture", 33 | 34 | OTHER = "other", 35 | } 36 | 37 | interface HandLandmark { 38 | x: number; 39 | y: number; 40 | z: number; 41 | } 42 | 43 | export interface HandInfo { 44 | landmarks: HandLandmark[]; 45 | handedness: "Left" | "Right"; 46 | score: number; 47 | categoryName?: string; 48 | } 49 | 50 | interface DetectionResult { 51 | leftHand?: HandInfo; 52 | rightHand?: HandInfo; 53 | // 原始检测结果,以防需要访问其他数据 54 | rawResult: any; 55 | } 56 | 57 | /** 58 | * 检测器类 - 负责手势识别和手势分类 59 | * 主要职责: 60 | * 1. 初始化和管理MediaPipe HandLandmarker 61 | * 2. 检测视频帧中的手部 62 | * 3. 分析手势类型(手指竖起等) 63 | * 4. 提供手部关键点查询方法 64 | */ 65 | export class Detector { 66 | private detector: GestureRecognizer | null = null; 67 | private gestureHandler: GestureHandler | null = null; 68 | 69 | async initialize(useCanvas = false) { 70 | const vision = await FilesetResolver.forVisionTasks("/mediapipe/wasm"); 71 | try { 72 | const params = { 73 | baseOptions: { 74 | modelAssetPath: "/mediapipe/gesture_recognizer.task", 75 | delegate: "GPU", 76 | }, 77 | runningMode: "VIDEO", 78 | numHands: 1, 79 | } as GestureRecognizerOptions; 80 | if (useCanvas) { 81 | params.canvas = document.createElement("canvas"); 82 | } 83 | 84 | this.detector = await GestureRecognizer.createFromOptions(vision, params); 85 | this.gestureHandler = new GestureHandler(); 86 | } catch (error: any) { 87 | // macos 旧设备的 wkwebview 对 webgl 兼容性不好,需要手动创建 canvas 88 | if (error.toString().includes("kGpuService")) { 89 | await this.initialize(true); 90 | } else { 91 | throw error; 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * 从视频帧检测手部 98 | */ 99 | async detect(video: HTMLVideoElement): Promise { 100 | if (!this.detector) { 101 | throw new Error("检测器未初始化"); 102 | } 103 | 104 | const result = await this.detector.recognize(video); 105 | const detection: DetectionResult = { 106 | rawResult: result, 107 | }; 108 | 109 | if (result.landmarks && result.handedness) { 110 | for (let i = 0; i < result.landmarks.length; i++) { 111 | const hand: HandInfo = { 112 | landmarks: result.landmarks[i], 113 | handedness: result.handedness[i][0].categoryName as "Left" | "Right", 114 | score: result.handedness[i][0].score, 115 | }; 116 | 117 | if (result.gestures.length > 0) { 118 | hand.categoryName = result.gestures[0][0].categoryName; 119 | } 120 | 121 | if (hand.handedness === "Left") { 122 | detection.leftHand = hand; 123 | } else { 124 | detection.rightHand = hand; 125 | } 126 | } 127 | } 128 | 129 | return detection; 130 | } 131 | 132 | /** 133 | * 便捷方法:获取特定手指的关键点 134 | */ 135 | static getFingerLandmarks( 136 | hand: HandInfo | undefined, 137 | fingerIndex: number 138 | ): HandLandmark[] | null { 139 | if (!hand) return null; 140 | 141 | const fingerIndices = { 142 | thumb: [1, 2, 3, 4], 143 | index: [5, 6, 7, 8], 144 | middle: [9, 10, 11, 12], 145 | ring: [13, 14, 15, 16], 146 | pinky: [17, 18, 19, 20], 147 | }; 148 | 149 | const indices = Object.values(fingerIndices)[fingerIndex]; 150 | return indices.map((i) => hand.landmarks[i]); 151 | } 152 | 153 | /** 154 | * 获取手指尖点 155 | */ 156 | static getFingerTip( 157 | hand: HandInfo | undefined, 158 | fingerIndex: number 159 | ): HandLandmark | null { 160 | if (!hand) return null; 161 | 162 | const tipIndices = [4, 8, 12, 16, 20]; 163 | return hand.landmarks[tipIndices[fingerIndex]]; 164 | } 165 | 166 | /** 167 | * 检测手指是否竖起 168 | */ 169 | static _fingersUp(hand: HandInfo): number[] { 170 | const fingers: number[] = []; 171 | const tipIds = [4, 8, 12, 16, 20]; // 从大拇指开始,依次为每个手指指尖 172 | 173 | // 检测大拇指 174 | if (hand.handedness === "Right") { 175 | if (hand.landmarks[tipIds[0]].x < hand.landmarks[tipIds[0] - 1].x) { 176 | fingers.push(0); 177 | } else { 178 | fingers.push(1); 179 | } 180 | } else { 181 | if (hand.landmarks[tipIds[0]].x > hand.landmarks[tipIds[0] - 1].x) { 182 | fingers.push(0); 183 | } else { 184 | fingers.push(1); 185 | } 186 | } 187 | 188 | // 检测其他四个手指 189 | for (let id = 1; id < 5; id++) { 190 | if (hand.landmarks[tipIds[id]].y < hand.landmarks[tipIds[id] - 2].y) { 191 | fingers.push(1); 192 | } else { 193 | fingers.push(0); 194 | } 195 | } 196 | 197 | return fingers; 198 | } 199 | 200 | /** 201 | * 获取单个手的手势类型 202 | */ 203 | public static getSingleHandGesture(hand: HandInfo): HandGesture { 204 | const fingers = this._fingersUp(hand); 205 | const fingerState = fingers.join(","); 206 | 207 | // 定义手势映射表 208 | const gestureMap = new Map([ 209 | // 食指举起,移动鼠标 210 | ["0,1,0,0,0", HandGesture.ONLY_INDEX_UP], 211 | 212 | // 鼠标左键点击手势 213 | ["0,1,1,0,0", HandGesture.INDEX_AND_MIDDLE_UP], 214 | ["0,1,0,0,1", HandGesture.ROCK_GESTURE], 215 | ["1,1,0,0,1", HandGesture.ROCK_GESTURE], 216 | 217 | // 滚动屏幕手势 218 | ["0,1,1,1,0", HandGesture.THREE_FINGERS_UP], 219 | ["1,0,1,1,1", HandGesture.SCROLL_GESTURE_2], 220 | ["0,0,1,1,1", HandGesture.SCROLL_GESTURE_2], 221 | 222 | // 四根手指同时竖起 223 | ["0,1,1,1,1", HandGesture.FOUR_FINGERS_UP], 224 | 225 | // 五根手指同时竖起 - 暂停/开始 识别 226 | ["1,1,1,1,1", HandGesture.STOP_GESTURE], 227 | 228 | // 拇指和食指同时竖起 - 语音识别 229 | ["1,0,0,0,1", HandGesture.VOICE_GESTURE_START], 230 | 231 | // 其他手势 232 | ["0,0,0,0,0", HandGesture.VOICE_GESTURE_STOP], 233 | ]); 234 | 235 | if (gestureMap.has(fingerState)) { 236 | return gestureMap.get(fingerState) as HandGesture; 237 | } 238 | 239 | // 检查删除手势 240 | if (this._isDeleteGesture(hand, fingers)) { 241 | return HandGesture.DELETE_GESTURE; 242 | } 243 | 244 | // 返回默认值 245 | return HandGesture.OTHER; 246 | } 247 | 248 | /** 249 | * 检查是否为删除手势 250 | */ 251 | private static _isDeleteGesture(hand: HandInfo, fingers: number[]): boolean { 252 | const THUMB_INDEX = 4; 253 | const FINGER_TIPS = [8, 12, 16, 20]; 254 | const distance_threshold = 0.05; 255 | 256 | const isThumbExtended = fingers[0] === 1; 257 | const areOtherFingersClosed = fingers 258 | .slice(1) 259 | .every((finger) => finger === 0); 260 | const isThumbLeftmost = FINGER_TIPS.every( 261 | (tipIndex) => 262 | hand.landmarks[THUMB_INDEX].x > 263 | hand.landmarks[tipIndex].x + distance_threshold 264 | ); 265 | 266 | return isThumbExtended && areOtherFingersClosed && isThumbLeftmost; 267 | } 268 | 269 | /** 270 | * 处理检测结果并执行相应动作 271 | */ 272 | async process(detection: DetectionResult): Promise { 273 | const rightHandGesture = detection.rightHand 274 | ? Detector.getSingleHandGesture(detection.rightHand) 275 | : HandGesture.OTHER; 276 | const leftHandGesture = detection.leftHand 277 | ? Detector.getSingleHandGesture(detection.leftHand) 278 | : HandGesture.OTHER; 279 | 280 | // 优先使用右手 281 | let effectiveGesture = rightHandGesture; 282 | if (detection.rightHand) { 283 | effectiveGesture = rightHandGesture; 284 | } else if (detection.leftHand) { 285 | effectiveGesture = leftHandGesture; 286 | } 287 | 288 | // 将手势处理交给GestureHandler 289 | if (detection.rightHand) { 290 | this.gestureHandler?.handleGesture(effectiveGesture, detection.rightHand); 291 | } else if (detection.leftHand) { 292 | this.gestureHandler?.handleGesture(effectiveGesture, detection.leftHand); 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/hand_landmark/gesture_handler.ts: -------------------------------------------------------------------------------- 1 | import { HandGesture, HandInfo } from "@/hand_landmark/detector"; 2 | import i18n from "@/locales/i18n"; 3 | import use_app_store from "@/store/app"; 4 | 5 | // WebSocket数据类型定义 6 | enum WsDataType { 7 | // 系统消息类型 8 | INFO = "info", 9 | SUCCESS = "success", 10 | WARNING = "warning", 11 | ERROR = "error", 12 | 13 | // 鼠标操作类型 14 | MOUSE_MOVE = "mouse_move", 15 | MOUSE_CLICK = "mouse_click", 16 | MOUSE_SCROLL_UP = "mouse_scroll_up", 17 | MOUSE_SCROLL_DOWN = "mouse_scroll_down", 18 | 19 | // 键盘操作类型 20 | SEND_KEYS = "send_keys", 21 | 22 | // 语音操作类型 23 | VOICE_RECORD = "voice_record", 24 | VOICE_STOP = "voice_stop", 25 | } 26 | 27 | interface WsData { 28 | type: WsDataType; 29 | msg?: string; 30 | duration?: number; 31 | title?: string; 32 | data?: { 33 | x?: number; 34 | y?: number; 35 | key_str?: string; 36 | }; 37 | } 38 | 39 | /** 40 | * 动作触发器类 - 负责发送操作命令到系统 41 | * 主要职责: 42 | * 1. 维护WebSocket连接 43 | * 2. 提供各种操作方法(移动鼠标、点击等) 44 | * 3. 发送通知 45 | */ 46 | export class TriggerAction { 47 | private ws: WebSocket | null = null; 48 | 49 | constructor() { 50 | this.connectWebSocket(); 51 | } 52 | 53 | private connectWebSocket() { 54 | try { 55 | this.ws = new WebSocket("ws://127.0.0.1:62334/ws_lazyeat"); 56 | this.ws.onmessage = (event: MessageEvent) => { 57 | const response: WsData = JSON.parse(event.data); 58 | const app_store = use_app_store(); 59 | app_store.sub_window_info(response.msg || ""); 60 | }; 61 | this.ws.onopen = () => { 62 | console.log("ws_lazyeat connected"); 63 | }; 64 | this.ws.onclose = () => { 65 | console.log("ws_lazyeat closed, retrying..."); 66 | this.ws = null; 67 | setTimeout(() => this.connectWebSocket(), 3000); 68 | }; 69 | this.ws.onerror = (error) => { 70 | console.error("ws_lazyeat error:", error); 71 | this.ws?.close(); 72 | }; 73 | } catch (error) { 74 | console.error("Failed to create WebSocket instance:", error); 75 | this.ws = null; 76 | setTimeout(() => this.connectWebSocket(), 1000); 77 | } 78 | } 79 | 80 | private send(data: { type: WsDataType } & Partial>) { 81 | const message: WsData = { 82 | type: data.type, 83 | msg: data.msg || "", 84 | title: data.title || "Lazyeat", 85 | duration: data.duration || 1, 86 | data: data.data || {}, 87 | }; 88 | this.ws?.send(JSON.stringify(message)); 89 | } 90 | 91 | moveMouse(x: number, y: number) { 92 | this.send({ 93 | type: WsDataType.MOUSE_MOVE, 94 | data: { x, y }, 95 | }); 96 | } 97 | 98 | clickMouse() { 99 | this.send({ 100 | type: WsDataType.MOUSE_CLICK, 101 | }); 102 | } 103 | 104 | scrollUp() { 105 | this.send({ 106 | type: WsDataType.MOUSE_SCROLL_UP, 107 | }); 108 | } 109 | 110 | scrollDown() { 111 | this.send({ 112 | type: WsDataType.MOUSE_SCROLL_DOWN, 113 | }); 114 | } 115 | 116 | sendKeys(key_str: string) { 117 | this.send({ 118 | type: WsDataType.SEND_KEYS, 119 | data: { key_str }, 120 | }); 121 | } 122 | 123 | voiceRecord() { 124 | this.send({ 125 | type: WsDataType.VOICE_RECORD, 126 | }); 127 | } 128 | 129 | voiceStop() { 130 | this.send({ 131 | type: WsDataType.VOICE_STOP, 132 | }); 133 | } 134 | } 135 | 136 | /** 137 | * 手势处理器类 - 负责将手势转换为具体操作 138 | * 主要职责: 139 | * 1. 接收识别到的手势类型 140 | * 2. 根据手势执行相应动作 141 | * 3. 处理防抖和连续手势确认 142 | */ 143 | export class GestureHandler { 144 | private triggerAction: TriggerAction; 145 | private previousGesture: HandGesture | null = null; 146 | private previousGestureCount: number = 0; 147 | private minGestureCount: number = 5; 148 | 149 | // 鼠标移动参数 150 | private screen_width: number = window.screen.width; 151 | private screen_height: number = window.screen.height; 152 | private smoothening = 7; // 平滑系数 153 | private prev_loc_x: number = 0; 154 | private prev_loc_y: number = 0; 155 | private prev_three_fingers_y: number = 0; // 添加三根手指上一次的 Y 坐标 156 | private prev_scroll2_y: number = 0; 157 | 158 | // 时间间隔参数 159 | private lastClickTime: number = 0; 160 | private lastScrollTime: number = 0; 161 | private lastFullScreenTime: number = 0; 162 | private lastDeleteTime: number = 0; 163 | 164 | // 时间间隔常量(毫秒) 165 | private readonly CLICK_INTERVAL = 500; // 点击间隔 166 | private readonly SCROLL_INTERVAL = 100; // 滚动间隔 167 | private readonly FULL_SCREEN_INTERVAL = 1500; // 全屏切换间隔 168 | 169 | // 语音识别参数 170 | private voice_recording: boolean = false; 171 | 172 | private app_store: any; 173 | constructor() { 174 | this.triggerAction = new TriggerAction(); 175 | this.app_store = use_app_store(); 176 | } 177 | 178 | /** 179 | * 处理食指上举手势 - 鼠标移动 180 | */ 181 | private handleIndexFingerUp(hand: HandInfo) { 182 | const indexTip = this.getFingerTip(hand, 1); // 食指指尖 183 | if (!indexTip) return; 184 | 185 | try { 186 | // 将 hand 的坐标转换为视频坐标 187 | const video_x = 188 | this.app_store.VIDEO_WIDTH - indexTip.x * this.app_store.VIDEO_WIDTH; 189 | const video_y = indexTip.y * this.app_store.VIDEO_HEIGHT; 190 | 191 | /** 192 | * 辅助方法:将值从一个范围映射到另一个范围 193 | */ 194 | function mapRange( 195 | value: number, 196 | fromMin: number, 197 | fromMax: number, 198 | toMin: number, 199 | toMax: number 200 | ): number { 201 | return ( 202 | ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin 203 | ); 204 | } 205 | 206 | // 将视频坐标映射到屏幕坐标 207 | // 由于 x 轴方向相反,所以需要翻转 208 | let screenX = mapRange( 209 | video_x, 210 | this.app_store.config.boundary_left, 211 | this.app_store.config.boundary_left + 212 | this.app_store.config.boundary_width, 213 | 0, 214 | this.screen_width 215 | ); 216 | 217 | let screenY = mapRange( 218 | video_y, 219 | this.app_store.config.boundary_top, 220 | this.app_store.config.boundary_top + 221 | this.app_store.config.boundary_height, 222 | 0, 223 | this.screen_height 224 | ); 225 | 226 | // 应用平滑处理 227 | screenX = 228 | this.prev_loc_x + (screenX - this.prev_loc_x) / this.smoothening; 229 | screenY = 230 | this.prev_loc_y + (screenY - this.prev_loc_y) / this.smoothening; // 消除抖动 231 | 232 | this.prev_loc_x = screenX; 233 | this.prev_loc_y = screenY; 234 | 235 | // 移动鼠标 236 | this.app_store.sub_windows.x = screenX + 10; 237 | this.app_store.sub_windows.y = screenY; 238 | this.triggerAction.moveMouse(screenX, screenY); 239 | } catch (error) { 240 | console.error("处理鼠标移动失败:", error); 241 | } 242 | } 243 | 244 | /** 245 | * 处理食指和中指同时竖起手势 - 鼠标左键点击 246 | */ 247 | private handleMouseClick() { 248 | const now = Date.now(); 249 | if (now - this.lastClickTime < this.CLICK_INTERVAL) { 250 | return; 251 | } 252 | this.lastClickTime = now; 253 | 254 | this.triggerAction.clickMouse(); 255 | } 256 | 257 | /** 258 | * 处理三根手指同时竖起手势 - 滚动屏幕 259 | */ 260 | private handleScroll(hand: HandInfo) { 261 | const indexTip = this.getFingerTip(hand, 1); 262 | const middleTip = this.getFingerTip(hand, 2); 263 | const ringTip = this.getFingerTip(hand, 3); 264 | if (!indexTip || !middleTip || !ringTip) { 265 | this.prev_three_fingers_y = 0; 266 | return; 267 | } 268 | 269 | const now = Date.now(); 270 | if (now - this.lastScrollTime < this.SCROLL_INTERVAL) { 271 | return; 272 | } 273 | this.lastScrollTime = now; 274 | 275 | // 计算三根手指的平均 Y 坐标 276 | const currentY = (indexTip.y + middleTip.y + ringTip.y) / 3; 277 | 278 | // 如果是第一次检测到手势,记录当前 Y 坐标 279 | if (this.prev_three_fingers_y === 0) { 280 | this.prev_three_fingers_y = currentY; 281 | return; 282 | } 283 | 284 | // 计算 Y 坐标的变化 285 | const deltaY = currentY - this.prev_three_fingers_y; 286 | 287 | // 如果变化超过阈值,则触发滚动 288 | if (Math.abs(deltaY) > 0.008) { 289 | if (deltaY < 0) { 290 | // 手指向上移动,向上滚动 291 | this.triggerAction.scrollUp(); 292 | } else { 293 | // 手指向下移动,向下滚动 294 | this.triggerAction.scrollDown(); 295 | } 296 | // 更新上一次的 Y 坐标 297 | this.prev_three_fingers_y = currentY; 298 | } 299 | } 300 | 301 | // 拇指和食指捏合,滚动屏幕 302 | private handleScroll2(hand: HandInfo) { 303 | const indexTip = this.getFingerTip(hand, 1); 304 | const thumbTip = this.getFingerTip(hand, 0); 305 | if (!indexTip || !thumbTip) { 306 | this.prev_scroll2_y = 0; 307 | return; 308 | } 309 | 310 | const now = Date.now(); 311 | if (now - this.lastScrollTime < this.SCROLL_INTERVAL) { 312 | return; 313 | } 314 | this.lastScrollTime = now; 315 | 316 | // 计算食指和拇指的距离 317 | const distance = Math.sqrt( 318 | (indexTip.x - thumbTip.x) ** 2 + (indexTip.y - thumbTip.y) ** 2 319 | ); 320 | 321 | console.log(i18n.global.t("当前食指和拇指距离"), distance); 322 | 323 | // 如果距离大于阈值,说明没有捏合,重置上一次的 Y 坐标 324 | if ( 325 | distance > 326 | this.app_store.config.scroll_gesture_2_thumb_and_index_threshold 327 | ) { 328 | this.prev_scroll2_y = 0; 329 | return; 330 | } 331 | 332 | // 如果是第一次检测到捏合,记录当前 Y 坐标 333 | if (this.prev_scroll2_y === 0) { 334 | this.prev_scroll2_y = indexTip.y; 335 | return; 336 | } 337 | 338 | // 计算 Y 339 | const deltaY = indexTip.y - this.prev_scroll2_y; 340 | 341 | // 如果变化超过阈值,则触发滚动 342 | if (Math.abs(deltaY) > 0.008) { 343 | if (deltaY < 0) { 344 | // 手指向上移动,向上滚动 345 | this.triggerAction.scrollUp(); 346 | } else { 347 | // 手指向下移动,向下滚动 348 | this.triggerAction.scrollDown(); 349 | } 350 | // 更新上一次的 Y 坐标 351 | this.prev_scroll2_y = indexTip.y; 352 | } 353 | } 354 | 355 | /** 356 | * 处理四根手指同时竖起手势 - 发送快捷键 357 | */ 358 | private handleFourFingers() { 359 | try { 360 | const key_str = this.app_store.config.four_fingers_up_send || "f"; 361 | const now = Date.now(); 362 | if (now - this.lastFullScreenTime < this.FULL_SCREEN_INTERVAL) { 363 | return; 364 | } 365 | this.lastFullScreenTime = now; 366 | 367 | this.triggerAction.sendKeys(key_str); 368 | } catch (error) { 369 | console.error("处理四指手势失败:", error); 370 | } 371 | } 372 | 373 | /** 374 | * 处理拇指和小指同时竖起手势 - 开始语音识别 375 | */ 376 | async handleVoiceStart() { 377 | if (this.voice_recording) { 378 | return; 379 | } 380 | await this.app_store.sub_window_info("开始语音识别"); 381 | this.voice_recording = true; 382 | this.triggerAction.voiceRecord(); 383 | } 384 | 385 | /** 386 | * 处理拳头手势 - 停止语音识别 387 | */ 388 | async handleVoiceStop() { 389 | if (!this.voice_recording) { 390 | return; 391 | } 392 | await this.app_store.sub_window_success("停止语音识别"); 393 | this.voice_recording = false; 394 | this.triggerAction.voiceStop(); 395 | } 396 | 397 | /** 398 | * 处理删除手势 399 | */ 400 | private handleDelete() { 401 | const now = Date.now(); 402 | if (now - this.lastDeleteTime < 300) { 403 | return; 404 | } 405 | this.lastDeleteTime = now; 406 | this.triggerAction.sendKeys("backspace"); 407 | } 408 | 409 | /** 410 | * 处理停止手势 411 | */ 412 | async handleStopGesture(): Promise { 413 | const toogle_detect = () => { 414 | this.app_store.flag_detecting = !this.app_store.flag_detecting; 415 | }; 416 | 417 | if (this.previousGestureCount >= 60) { 418 | toogle_detect(); 419 | this.previousGestureCount = 0; 420 | 421 | // 暂停手势识别后,更新 sub-window 进度条 422 | this.app_store.sub_windows.progress = 0; 423 | } else { 424 | this.previousGestureCount++; 425 | 426 | if (this.previousGestureCount >= 20) { 427 | // 更新 sub-window 进度条 428 | this.app_store.sub_windows.progress = Math.floor( 429 | (this.previousGestureCount / 60) * 100 430 | ); 431 | } 432 | } 433 | } 434 | 435 | /** 436 | * 获取手指尖点 437 | */ 438 | private getFingerTip(hand: HandInfo, fingerIndex: number) { 439 | if (!hand) return null; 440 | 441 | const tipIndices = [4, 8, 12, 16, 20]; 442 | return hand.landmarks[tipIndices[fingerIndex]]; 443 | } 444 | 445 | /** 446 | * 处理手势 447 | */ 448 | handleGesture(gesture: HandGesture, hand: HandInfo) { 449 | // 更新手势连续性计数 450 | if (gesture === this.previousGesture) { 451 | this.previousGestureCount++; 452 | } else { 453 | this.previousGesture = gesture; 454 | this.previousGestureCount = 1; 455 | } 456 | 457 | // 首先处理停止手势 458 | if (gesture === HandGesture.STOP_GESTURE) { 459 | if (hand.categoryName === "Open_Palm") { 460 | this.handleStopGesture(); 461 | } 462 | return; 463 | } 464 | 465 | // 如果手势识别已暂停,则不处理 466 | if (!this.app_store.flag_detecting) { 467 | return; 468 | } 469 | 470 | // 只要切换手势就停止语音识别 471 | if (gesture !== HandGesture.VOICE_GESTURE_START && this.voice_recording) { 472 | this.handleVoiceStop(); 473 | return; 474 | } 475 | 476 | // 鼠标移动手势直接执行,不需要连续确认 477 | if (gesture === HandGesture.ONLY_INDEX_UP) { 478 | this.handleIndexFingerUp(hand); 479 | return; 480 | } 481 | 482 | // 其他手势需要连续确认才执行 483 | if (this.previousGestureCount >= this.minGestureCount) { 484 | switch (gesture) { 485 | case HandGesture.ROCK_GESTURE: 486 | case HandGesture.INDEX_AND_MIDDLE_UP: 487 | this.handleMouseClick(); 488 | break; 489 | case HandGesture.SCROLL_GESTURE_2: 490 | this.handleScroll2(hand); 491 | break; 492 | // case HandGesture.THREE_FINGERS_UP: 493 | // this.handleScroll(hand); 494 | // break; 495 | case HandGesture.FOUR_FINGERS_UP: 496 | this.handleFourFingers(); 497 | break; 498 | case HandGesture.VOICE_GESTURE_START: 499 | this.handleVoiceStart(); 500 | break; 501 | case HandGesture.DELETE_GESTURE: 502 | this.handleDelete(); 503 | break; 504 | } 505 | } 506 | } 507 | } 508 | -------------------------------------------------------------------------------- /src/locales/en.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | "手势识别控制": "Gesture Recognition Control", 3 | "运行中": "Running", 4 | "已停止": "Stopped", 5 | "开机自启动": "Auto Start", 6 | "显示识别窗口": "Show Recognition Window", 7 | "摄像头选择": "Camera Selection", 8 | 9 | "手势操作指南": "Gesture Guide", 10 | "光标控制": "Cursor Control", 11 | "竖起食指滑动控制光标位置": "Slide with index finger to control cursor position", 12 | "单击操作": "Click Operation", 13 | "双指举起执行鼠标单击": "Raise two fingers to perform mouse click", 14 | "Rock手势执行鼠标单击": "Rock gesture to perform mouse click", 15 | "滚动控制": "Scroll Control", 16 | "三指上下滑动控制页面滚动": "Slide three fingers up/down to control page scrolling", 17 | "(okay手势)食指和拇指捏合滚动页面": "(okay gesture)Pinch with index and thumb to scroll page", 18 | "食指和拇指距离小于": "Index and thumb distance less than", 19 | "触发捏合": "Trigger pinch", 20 | "默认值0.02": "Default value 0.02", 21 | "可以通过右键->检查->控制台->捏合手势->查看当前距离": "Can check current distance by right-click -> inspect -> console -> pinch gesture", 22 | 23 | "全屏控制": "Full Screen Control", 24 | "四指并拢发送按键": "Four fingers together to send key", 25 | "点击设置快捷键": "Click to set shortcut", 26 | "请按下按键...": "Please press keys...", 27 | "点击设置": "Click to set", 28 | "退格": "Backspace", 29 | "发送退格键": "Send backspace key", 30 | "开始语音识别": "Start Voice Recognition", 31 | "六指手势开始语音识别": "Six fingers gesture to start voice recognition", 32 | "结束语音识别": "End Voice Recognition", 33 | "拳头手势结束语音识别": "Fist gesture to end voice recognition", 34 | "暂停/继续": "Pause/Resume", 35 | "单手张开1.5秒 暂停/继续 手势识别": "Open one hand for 1.5 seconds to pause/resume gesture recognition", 36 | 37 | "识别框x": "Recognition box x", 38 | "识别框y": "Recognition box y", 39 | "识别框宽": "Recognition box width", 40 | "识别框高": "Recognition box height", 41 | 42 | // 通知 43 | "Lazyeat": "Lazyeat", 44 | "提示": "Tip", 45 | "停止语音识别": "Stop Voice Recognition", 46 | "手势识别": "Gesture Recognition", 47 | "继续手势识别": "Continue Gesture Recognition", 48 | "暂停手势识别": "Pause Gesture Recognition", 49 | }; 50 | -------------------------------------------------------------------------------- /src/locales/i18n.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from "vue-i18n"; 2 | 3 | // 导入语言包 4 | import en from "./en"; 5 | import zh from "./zh"; 6 | 7 | // 创建 i18n 实例 8 | const i18n = createI18n({ 9 | locale: navigator.language.split("-")[0], // 使用系统语言作为默认语言 10 | // locale: "en", // 使用系统语言作为默认语言 11 | fallbackLocale: "zh", // 回退语言 12 | messages: { 13 | en, 14 | zh, 15 | }, 16 | }); 17 | 18 | export default i18n; 19 | -------------------------------------------------------------------------------- /src/locales/zh.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | "手势识别控制": "手势识别控制", 3 | "运行中": "运行中", 4 | "已停止": "已停止", 5 | "显示识别窗口": "显示识别窗口", 6 | "摄像头选择": "摄像头选择", 7 | "手势操作指南": "手势操作指南", 8 | "开机自启动": "开机自启动", 9 | 10 | "光标控制": "光标控制", 11 | "竖起食指滑动控制光标位置": "竖起食指滑动控制光标位置", 12 | "单击操作": "单击操作", 13 | "双指举起执行鼠标单击": "双指举起执行鼠标单击", 14 | "Rock手势执行鼠标单击": "Rock手势执行鼠标单击", 15 | "滚动控制": "滚动控制", 16 | "三指上下滑动控制页面滚动": "三指上下滑动控制页面滚动", 17 | "(okay手势)食指和拇指捏合滚动页面": "(okay手势)食指和拇指捏合滚动页面", 18 | "食指和拇指距离小于": "食指和拇指距离小于", 19 | "触发捏合": "触发捏合", 20 | "全屏控制": "全屏控制", 21 | "四指并拢发送按键": "四指并拢发送按键", 22 | "点击设置快捷键": "点击设置快捷键", 23 | "请按下按键...": "请按下按键...", 24 | "点击设置": "点击设置", 25 | "退格": "退格", 26 | "发送退格键": "发送退格键", 27 | "开始语音识别": "开始语音识别", 28 | "六指手势开始语音识别": "六指手势开始语音识别", 29 | "结束语音识别": "结束语音识别", 30 | "拳头手势结束语音识别": "拳头手势结束语音识别", 31 | "暂停/继续": "暂停/继续", 32 | "单手张开1.5秒 暂停/继续 手势识别": "单手张开1.5秒 暂停/继续 手势识别", 33 | "识别框x": "识别框x", 34 | "识别框y": "识别框y", 35 | "识别框宽": "识别框宽", 36 | "识别框高": "识别框高", 37 | "默认值0.02": "默认值0.02", 38 | "可以通过右键->检查->控制台->捏合手势->查看当前距离": "可以通过右键->检查->控制台->捏合手势->查看当前距离", 39 | 40 | // 通知 41 | "Lazyeat": "Lazyeat", 42 | "提示": "提示", 43 | "停止语音识别": "停止语音识别", 44 | "手势识别": "手势识别", 45 | "继续手势识别": "继续手势识别", 46 | "暂停手势识别": "暂停手势识别", 47 | }; 48 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import i18n from "@/locales/i18n"; 2 | import router from "@/router"; 3 | import { createPinia } from "pinia"; 4 | import { PiniaSharedState } from "pinia-shared-state"; 5 | import { createApp } from "vue"; 6 | 7 | import App from "@/App.vue"; 8 | 9 | import { 10 | create, 11 | NAlert, 12 | NButton, 13 | NCard, 14 | NCheckbox, 15 | NDivider, 16 | NForm, 17 | NFormItem, 18 | NIcon, 19 | NImage, 20 | NInput, 21 | NLayout, 22 | NLayoutContent, 23 | NLayoutFooter, 24 | NLayoutHeader, 25 | NMenu, 26 | NMessageProvider, 27 | NSelect, 28 | NSpace, 29 | NInputNumber, 30 | NSpin, 31 | NSwitch, 32 | NProgress, 33 | NTag, 34 | } from "naive-ui"; 35 | 36 | // 引入element-plus 37 | import "element-plus/dist/index.css"; 38 | 39 | const naive = create({ 40 | components: [ 41 | NButton, 42 | NLayout, 43 | NLayoutHeader, 44 | NLayoutContent, 45 | NLayoutFooter, 46 | NMenu, 47 | NSpace, 48 | NImage, 49 | NDivider, 50 | NSwitch, 51 | NSelect, 52 | NSpin, 53 | NIcon, 54 | NInput, 55 | NInputNumber, 56 | NForm, 57 | NFormItem, 58 | NCheckbox, 59 | NCard, 60 | NMessageProvider, 61 | NAlert, 62 | NTag, 63 | NProgress, 64 | ], 65 | }); 66 | 67 | const app = createApp(App); 68 | const pinia = createPinia(); 69 | // Pass the plugin to your application's pinia plugin 70 | pinia.use( 71 | PiniaSharedState({ 72 | // Enables the plugin for all stores. Defaults to true. 73 | enable: true, 74 | // If set to true this tab tries to immediately recover the shared state from another tab. Defaults to true. 75 | initialize: false, 76 | // Enforce a type. One of native, idb, localstorage or node. Defaults to native. 77 | type: "localstorage", 78 | }) 79 | ); 80 | 81 | app.use(naive); 82 | app.use(pinia); 83 | app.use(i18n); 84 | app.use(router); 85 | app.mount("#app"); 86 | -------------------------------------------------------------------------------- /src/py_api.ts: -------------------------------------------------------------------------------- 1 | const port = 62334; 2 | const base_url = `http://localhost:${port}`; 3 | 4 | class PyApi { 5 | async ready(): Promise { 6 | try { 7 | await fetch(`${base_url}/`, { 8 | signal: AbortSignal.timeout(1000), 9 | }); 10 | return true; 11 | } catch (error) { 12 | return false; 13 | } 14 | } 15 | 16 | async shutdown() { 17 | try { 18 | await fetch(`${base_url}/shutdown`, { 19 | method: "GET", 20 | signal: AbortSignal.timeout(500), 21 | }); 22 | } catch (error) { 23 | console.error("关闭服务失败:", error); 24 | } 25 | } 26 | } 27 | 28 | const pyApi = new PyApi(); 29 | 30 | export default pyApi; 31 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import MainWindow from "@/view/mainWindow/MainWindow.vue"; 2 | import SubWindow from "@/view/subWindow/SubWindow.vue"; 3 | import Home from "@/view/mainWindow/Home.vue"; 4 | import Update from "@/view/mainWindow/Update.vue"; 5 | import Guide from "@/view/mainWindow/Guide.vue"; 6 | import { createRouter, createWebHistory } from "vue-router"; 7 | 8 | const routes = [ 9 | { 10 | path: "/", 11 | name: "mainWindow", 12 | component: MainWindow, 13 | children: [ 14 | { 15 | path: "", 16 | name: "home", 17 | component: Home, 18 | }, 19 | { 20 | path: "update", 21 | name: "update", 22 | component: Update, 23 | }, 24 | { 25 | path: "guide", 26 | name: "guide", 27 | component: Guide, 28 | } 29 | ] 30 | }, 31 | { 32 | path: "/sub-window", 33 | name: "subWindow", 34 | component: SubWindow, 35 | }, 36 | ]; 37 | 38 | const router = createRouter({ 39 | // 设置成 html5 模式,subWindow 才能正常工作 40 | history: createWebHistory(import.meta.env.BASE_URL), 41 | routes, 42 | }); 43 | 44 | export default router; 45 | -------------------------------------------------------------------------------- /src/store/app.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | 3 | interface Camera { 4 | deviceId: string; 5 | label: string; 6 | kind: string; 7 | } 8 | 9 | enum NotiType { 10 | INFO = "info", 11 | SUCCESS = "success", 12 | WARNING = "warning", 13 | ERROR = "error", 14 | } 15 | 16 | export const use_app_store = defineStore("app-store", { 17 | state: () => ({ 18 | config: { 19 | auto_start: false, 20 | show_window: false, 21 | four_fingers_up_send: "f", 22 | selected_camera_id: "", 23 | 24 | // 识别框 25 | boundary_left: 100, 26 | boundary_top: 100, 27 | boundary_width: 100, 28 | boundary_height: 100, 29 | 30 | // 手势识别 31 | scroll_gesture_2_thumb_and_index_threshold: 0.02, // 食指和拇指距离阈值 32 | }, 33 | 34 | sub_windows: { 35 | x: 0, 36 | y: 0, 37 | progress: 0, 38 | notification: "", 39 | noti_type: NotiType.INFO, 40 | }, 41 | 42 | mission_running: false, 43 | cameras: [] as Camera[], 44 | VIDEO_WIDTH: 640, 45 | VIDEO_HEIGHT: 480, 46 | flag_detecting: false, 47 | }), 48 | // PiniaSharedState 来共享不同 tauri 窗口之间的状态 49 | share: { 50 | // Override global config for this store. 51 | enable: true, 52 | initialize: true, 53 | }, 54 | actions: { 55 | is_macos() { 56 | return navigator.userAgent.includes("Mac"); 57 | }, 58 | is_windows() { 59 | return navigator.userAgent.includes("Windows"); 60 | }, 61 | is_linux() { 62 | return navigator.userAgent.includes("Linux"); 63 | }, 64 | 65 | async sub_window_info(body: string) { 66 | this.sub_windows.notification = body; 67 | this.sub_windows.noti_type = NotiType.INFO; 68 | }, 69 | 70 | async sub_window_success(body: string) { 71 | this.sub_windows.notification = body; 72 | this.sub_windows.noti_type = NotiType.SUCCESS; 73 | }, 74 | 75 | async sub_window_warning(body: string) { 76 | this.sub_windows.notification = body; 77 | this.sub_windows.noti_type = NotiType.WARNING; 78 | }, 79 | 80 | async sub_window_error(body: string) { 81 | this.sub_windows.notification = body; 82 | this.sub_windows.noti_type = NotiType.ERROR; 83 | }, 84 | }, 85 | }); 86 | 87 | export default use_app_store; 88 | -------------------------------------------------------------------------------- /src/utils/subWindow.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WebviewWindow, 3 | getAllWebviewWindows, 4 | } from "@tauri-apps/api/webviewWindow"; 5 | 6 | export const SUB_WINDOW_WIDTH = 130; 7 | export const SUB_WINDOW_HEIGHT = 130; 8 | 9 | export async function createSubWindow(url: string, title: string) { 10 | let message = ""; 11 | let success = true; 12 | try { 13 | const allWindows = await getAllWebviewWindows(); 14 | const windownsLen = allWindows.length; 15 | const label = `NewWindow_${windownsLen + 1}`; 16 | const openUrl = url || "index.html"; 17 | const newTitle = title || "新窗口"; 18 | const openTitle = `${newTitle}-${windownsLen + 1}`; 19 | const webview_window = new WebviewWindow(label, { 20 | url: openUrl, 21 | title: openTitle, 22 | parent: "main", 23 | zoomHotkeysEnabled: false, 24 | 25 | width: SUB_WINDOW_WIDTH, 26 | height: SUB_WINDOW_HEIGHT, 27 | minWidth: SUB_WINDOW_WIDTH, 28 | minHeight: SUB_WINDOW_HEIGHT, 29 | alwaysOnTop: true, 30 | decorations: false, // 隐藏窗口边框 31 | visible: false, 32 | resizable: false, 33 | }); 34 | webview_window.once("tauri://created", async () => { 35 | message = "打开成功"; 36 | }); 37 | 38 | webview_window.once("tauri://error", function (e) { 39 | message = `打开${openTitle}报错: ${e}`; 40 | success = false; 41 | }); 42 | return { success: success, message: message, webview: webview_window }; 43 | } catch (error) { 44 | return { success: false, message: error }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/view/mainWindow/Guide.vue: -------------------------------------------------------------------------------- 1 | 141 | 142 | 193 | 194 | 225 | -------------------------------------------------------------------------------- /src/view/mainWindow/Home.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 107 | 108 | 128 | 129 | 146 | -------------------------------------------------------------------------------- /src/view/mainWindow/Update.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 55 | 56 | -------------------------------------------------------------------------------- /src/view/mainwindow/MainWindow.vue: -------------------------------------------------------------------------------- 1 | 137 | 138 | 189 | 190 | 203 | 204 | 251 | -------------------------------------------------------------------------------- /src/view/subWindow/SubWindow.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 83 | 84 | 94 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "*.vue" { 4 | import type { DefineComponent } from "vue"; 5 | const component: DefineComponent<{}, {}, any>; 6 | export default component; 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["src/*"] // 将 @ 映射到 src 目录 26 | } 27 | }, 28 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 29 | "references": [{ "path": "./tsconfig.node.json" }] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import path from "path"; 4 | import { fileURLToPath } from "url"; 5 | import { dirname } from "path"; 6 | 7 | // @ts-expect-error process is a nodejs global 8 | const host = process.env.TAURI_DEV_HOST; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = dirname(__filename); 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig(async () => ({ 15 | plugins: [vue()], 16 | 17 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 18 | // 19 | // 1. prevent vite from obscuring rust errors 20 | clearScreen: false, 21 | // 2. tauri expects a fixed port, fail if that port is not available 22 | server: { 23 | port: 1420, 24 | strictPort: true, 25 | host: host || false, 26 | hmr: host 27 | ? { 28 | protocol: "ws", 29 | host, 30 | port: 1421, 31 | } 32 | : undefined, 33 | watch: { 34 | // 3. tell vite to ignore watching `src-tauri` 35 | ignored: ["**/src-tauri/**"], 36 | }, 37 | }, 38 | resolve: { 39 | alias: { 40 | "@": path.resolve(__dirname, "src"), // 使用绝对路径 41 | }, 42 | }, 43 | })); 44 | --------------------------------------------------------------------------------