├── .eslintrc.json ├── .github └── workflows │ ├── ci.yml │ ├── pr.yml │ └── updater.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── README_CN.md ├── README_EN.md ├── UPDATE_LOG.md ├── index.html ├── md ├── demo1.png ├── demo2.png ├── demo3.png ├── demo4.png ├── demo5.png ├── demo6.png ├── demo7.png └── icon.png ├── package.json ├── postcss.config.cjs ├── public └── vite.svg ├── scripts ├── aarch.mjs ├── publish.mjs ├── updatelog.mjs └── updater.mjs ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── build.sh │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ ├── cmds.rs │ ├── config │ │ ├── common_config.rs │ │ ├── config.rs │ │ ├── draft.rs │ │ └── mod.rs │ ├── core │ │ ├── clipboard.rs │ │ ├── database.rs │ │ ├── handle.rs │ │ ├── mod.rs │ │ ├── sysopt.rs │ │ ├── tray.rs │ │ └── window_manager.rs │ ├── main.rs │ └── utils │ │ ├── dirs.rs │ │ ├── dispatch_util.rs │ │ ├── hotkey_util.rs │ │ ├── img_util.rs │ │ ├── json_util.rs │ │ ├── log_print.rs │ │ ├── mod.rs │ │ ├── string_util.rs │ │ └── window_util.rs └── tauri.conf.json ├── src ├── App.vue ├── assets │ ├── about-icon.png │ ├── about.svg │ ├── backspace.svg │ ├── delete.svg │ ├── fuzhi.svg │ ├── gh-desktop.png │ ├── shezhi.svg │ ├── sousuo.svg │ └── vue.svg ├── components │ ├── ClipBoardItem.vue │ ├── ClipBoardList.vue │ ├── HotKeyItem.vue │ ├── KeyMapBar.vue │ ├── MainLayout.vue │ ├── SearchBar.vue │ ├── TagGroup.vue │ └── child │ │ ├── clipboard │ │ └── SearchNoResult.vue │ │ └── config │ │ ├── HotKeyInput.vue │ │ ├── LeftSectionItem.vue │ │ ├── RightSectionAbout.vue │ │ ├── RightSectionCommonConfig.vue │ │ └── base │ │ ├── BaseSelect.vue │ │ └── BaseSwitch.vue ├── config │ └── constants.js ├── i18n │ ├── index.js │ └── locales │ │ ├── en.yaml │ │ └── zh.yaml ├── main.js ├── router │ └── router.js ├── service │ ├── cmds.js │ ├── globalListener.js │ ├── msg.js │ ├── recordService.js │ ├── shortCutUtil.js │ ├── store.js │ └── windowUtil.js ├── style.css └── views │ ├── Config.vue │ └── Main.vue ├── tailwind.config.cjs ├── vite.config.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "extends": ["eslint:recommended", "plugin:vue/vue3-recommended", "prettier"], 6 | "rules": {} 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Release CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - v** 8 | 9 | jobs: 10 | publish-tauri: 11 | permissions: 12 | contents: write 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | platform: [macos-latest, windows-latest] 17 | 18 | runs-on: ${{ matrix.platform }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: setup node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | - name: install Rust stable 26 | uses: dtolnay/rust-toolchain@stable 27 | - name: install dependencies (ubuntu only) 28 | if: matrix.platform == 'ubuntu-20.04' 29 | run: | 30 | sudo apt-get update 31 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 32 | - name: install frontend dependencies 33 | run: yarn install # change this to npm or pnpm depending on which one you use 34 | - uses: tauri-apps/tauri-action@v0 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} 38 | TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} 39 | with: 40 | tagName: app-v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version 41 | releaseName: "App v__VERSION__" 42 | releaseBody: "See the assets to download this version and install." 43 | releaseDraft: true 44 | prerelease: false 45 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Pull request 2 | 3 | # This action works with pull requests and pushes 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | prettier: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | with: 18 | # Make sure the actual branch is checked out when running on pull requests 19 | ref: ${{ github.head_ref }} 20 | 21 | - name: Reconfigure git to use HTTP authentication 22 | run: > 23 | git config --global url."https://github.com/".insteadOf 24 | ssh://git@github.com/ 25 | 26 | - name: Get yarn cache dir path 27 | id: yarn-cache-dir-path 28 | run: echo "::set-output name=dir::$(yarn cache dir)" 29 | 30 | - name: Yarn Cache 31 | uses: actions/cache@v2 32 | id: yarn-cache 33 | with: 34 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 35 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 36 | restore-keys: | 37 | ${{ runner.os }}-yarn- 38 | 39 | - name: Yarn install 40 | run: yarn install 41 | 42 | - name: Run Prettier 43 | uses: creyD/prettier_action@v4.3 44 | with: 45 | prettier_options: --check **/*.{js,md,vue,css,html} 46 | github_token: ${{ secrets.GITHUB_TOKEN }} 47 | -------------------------------------------------------------------------------- /.github/workflows/updater.yml: -------------------------------------------------------------------------------- 1 | name: Updater CI 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | release-update: 7 | runs-on: macos-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v2 11 | 12 | - name: Reconfigure git to use HTTP authentication 13 | run: > 14 | git config --global url."https://github.com/".insteadOf 15 | ssh://git@github.com/ 16 | 17 | - name: Get yarn cache dir path 18 | id: yarn-cache-dir-path 19 | run: echo "::set-output name=dir::$(yarn cache dir)" 20 | 21 | - name: Yarn Cache 22 | uses: actions/cache@v2 23 | id: yarn-cache 24 | with: 25 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-yarn- 29 | 30 | - name: Yarn install 31 | run: yarn install 32 | 33 | - name: Release updater file 34 | run: yarn run updater 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src-tauri 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2022] [ChurchTao] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | Lanaya 5 |
6 |

7 | 8 |

9 | 一个简洁易用的剪贴板管理 10 |

11 | 12 |
13 | GitHub all releases 14 | GitHub 15 | GitHub Repo stars 16 |
17 | 18 |

19 | English | 20 | 中文 21 |

22 | 23 | ## 简介 24 | 25 | `Lanaya` 来自于`DOTA2`中的圣堂刺客, 简洁易用,全键盘操作的剪贴板管理工具 26 | 27 | ### 当初写这个项目是学习阶段,写的简陋,目前正在准备重写 2.0 版本,敬请期待... 28 | 29 | ## 功能 30 | 31 | - 通过关键词搜索 32 | - 全快捷键操作 33 | - 设置历史条数范围 34 | - 多语言 35 | - 自动更新 36 | - 输入 `f:xxx` 搜索收藏的记录 37 | - 输入 `t:xxx` 搜索标签分类 38 | 39 | ## 未完成 40 | 41 | - [x] 引入`taildwind`管理 css 42 | - [x] 新增复制图片历史的功能 43 | - [ ] 增加主题 44 | - [x] 新增收藏夹功能 45 | - [ ] 增加`Windows`,`Linux`的适配 46 | - [x] 使用`Rust`实现后台监听剪切板 47 | - [x] 使用`Rust`实现`Sqlite`的数据库操作 48 | 49 | ## 下载 50 | 51 | 从 [release](https://github.com/ChurchTao/Lanaya/releases) 中下载. 52 | 53 | ### Mac 用户 54 | 55 | 如果提示`软件已损坏,请移到废纸篓`,可以使用命令 `xattr -cr /Applications/Lanaya.app` 解决 56 | 57 | ## 开发 58 | 59 | 你需要安装 `Rust` 和 `Nodejs`,详细步骤查看 [这里](https://tauri.app/zh-cn/v1/guides/getting-started/prerequisites) ,然后按如下命令进行开发 60 | 61 | ```shell 62 | npm install 63 | ``` 64 | 65 | 然后 66 | 67 | ```shell 68 | cargo install tauri-cli # 提示没有 cargo tauri 命令需先执行安装 69 | cargo tauri dev 70 | ``` 71 | 72 | 如果需要构建 73 | 74 | ```shell 75 | cargo tauri build 76 | ``` 77 | 78 | ## 截图 79 | 80 |
81 | demo1 82 | demo2 83 | demo3 84 | demo4 85 | demo4 86 | demo6 87 | demo7 88 |
89 | 90 | ## 建议 91 | 92 | 👏🏻 非常欢迎提`Issue`和`PR`!毕竟一个人的力量有限。 93 | 94 | ## 技术栈 95 | 96 | `Lanaya` 基于如下技术栈: 97 | 98 | - [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Build smaller, faster, and more secure desktop applications with a web frontend. 99 | - [vitejs/vite](https://github.com/vitejs/vite): Next generation frontend tooling. It's fast! 100 | - [vue3](https://github.com/vuejs/core): An approachable, performant and versatile framework for building web user interfaces. 101 | - [tailwindlabs](https://github.com/tailwindlabs) Creators of Tailwind CSS and Headless UI, and authors of Refactoring UI. 102 | 103 | ## License 104 | 105 | Apache-2.0 license. See [License here](./LICENSE) for details. 106 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | Lanaya 5 |
6 |

7 | 8 |

9 | 一个简洁易用的剪贴板管理 10 |

11 | 12 |
13 | GitHub all releases 14 | GitHub 15 | GitHub Repo stars 16 |
17 | 18 |

19 | English | 20 | 中文 21 |

22 | 23 | ## 简介 24 | 25 | `Lanaya` 来自于`DOTA2`中的圣堂刺客, 简洁易用,全键盘操作的剪贴板管理工具 26 | 27 | ## 功能 28 | 29 | - 通过关键词搜索 30 | - 全快捷键操作 31 | - 设置历史条数范围 32 | - 多语言 33 | - 自动更新 34 | - 输入 `f:xxx` 搜索收藏的记录 35 | - 输入 `t:xxx` 搜索标签分类 36 | 37 | ## 未完成 38 | 39 | - [x] 引入`taildwind`管理 css 40 | - [x] 新增复制图片历史的功能 41 | - [ ] 增加主题 42 | - [x] 新增收藏夹功能 43 | - [ ] 增加`Windows`,`Linux`的适配 44 | - [x] 使用`Rust`实现后台监听剪切板 45 | - [x] 使用`Rust`实现`Sqlite`的数据库操作 46 | 47 | ## 下载 48 | 49 | 从 [release](https://github.com/ChurchTao/Lanaya/releases) 中下载. 50 | 51 | ### Mac 用户 52 | 53 | 如果提示`软件已损坏,请移到废纸篓`,可以使用命令 `xattr -cr /Applications/Lanaya.app` 解决 54 | 55 | ## 开发 56 | 57 | 你需要安装 `Rust` 和 `Nodejs`,详细步骤查看 [这里](https://tauri.app/zh-cn/v1/guides/getting-started/prerequisites) ,然后按如下命令进行开发 58 | 59 | ```shell 60 | npm install 61 | ``` 62 | 63 | 然后 64 | 65 | ```shell 66 | cargo install tauri-cli # 提示没有 cargo tauri 命令需先执行安装 67 | cargo tauri dev 68 | ``` 69 | 70 | 如果需要构建 71 | 72 | ```shell 73 | cargo tauri build 74 | ``` 75 | 76 | ## 截图 77 | 78 |
79 | demo1 80 | demo2 81 | demo3 82 | demo4 83 | demo4 84 | demo6 85 | demo7 86 |
87 | 88 | ## 建议 89 | 90 | 👏🏻 非常欢迎提`Issue`和`PR`!毕竟一个人的力量有限。 91 | 92 | ## 技术栈 93 | 94 | `Lanaya` 基于如下技术栈: 95 | 96 | - [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Build smaller, faster, and more secure desktop applications with a web frontend. 97 | - [vitejs/vite](https://github.com/vitejs/vite): Next generation frontend tooling. It's fast! 98 | - [vue3](https://github.com/vuejs/core): An approachable, performant and versatile framework for building web user interfaces. 99 | - [tailwindlabs](https://github.com/tailwindlabs) Creators of Tailwind CSS and Headless UI, and authors of Refactoring UI. 100 | 101 | ## License 102 | 103 | Apache-2.0 license. See [License here](./LICENSE) for details. 104 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | Lanaya 5 |
6 |

7 | 8 |

9 | A clipboard management with easy to use. 10 |

11 | 12 |
13 | GitHub all releases 14 | GitHub 15 | GitHub Repo stars 16 |
17 | 18 |

19 | English | 20 | 简体中文 21 |

22 | 23 | ## Introduction 24 | 25 | Lanaya comes from Templar Assassin in Dota2, which is a clipboard management software with convenient and simple interaction. 26 | 27 | ### When I first wrote this project, it was in the learning stage and the writing was rudimentary. I am currently preparing to rewrite version 2.0, so stay tuned... 28 | 29 | ## Features 30 | 31 | - Search by keywords. 32 | - All shortcut to manage. 33 | - Setting history record range. 34 | - Multi-language. 35 | - Auto updater. 36 | - Input `f:xxx` to search favorite records. 37 | - Tag records and search by tags with `t:xxx,yyy`. 38 | 39 | ## Todos 40 | 41 | - [x] add `taildwind` to manage css. 42 | - [x] add copy image history. 43 | - [ ] add theme. 44 | - [x] add favorite. 45 | - [ ] add `Windows`,`Linux` support. 46 | - [x] use `Rust` to implement clipboard listener. 47 | - [x] use `Rust` to implement `Sqlite` database operation. 48 | 49 | ## Download 50 | 51 | Download from [release](https://github.com/ChurchTao/Lanaya/releases). 52 | 53 | ### Mac OS 54 | 55 | If you got error: 'Lanaya' is damaged and can’t be opened. You should move it to the Trash. You can use `xattr -cr /Applications/Lanaya.app` to solve it. 56 | 57 | ## Development 58 | 59 | You should install Rust and Nodejs, see [here](https://tauri.app/v1/guides/getting-started/prerequisites) for more details. Then install Nodejs packages. 60 | 61 | ```shell 62 | npm install 63 | ``` 64 | 65 | Then run 66 | 67 | ```shell 68 | cargo install tauri-cli # output with [no such subcommand: `tauri`] please install first 69 | 70 | cargo tauri dev 71 | ``` 72 | 73 | Or you can build it 74 | 75 | ```shell 76 | cargo tauri build 77 | ``` 78 | 79 | ## Screenshots 80 | 81 |
82 | demo1 83 | demo2 84 | demo3 85 | demo4 86 | demo5 87 | demo6 88 | demo7 89 |
90 | 91 | ## Contributions 92 | 93 | Issue and PR welcome! 94 | 95 | ## Acknowledgement 96 | 97 | Lanaya was based on or inspired by these projects and so on: 98 | 99 | - [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Build smaller, faster, and more secure desktop applications with a web frontend. 100 | - [vitejs/vite](https://github.com/vitejs/vite): Next generation frontend tooling. It's fast! 101 | - [vue3](https://github.com/vuejs/core): An approachable, performant and versatile framework for building web user interfaces. 102 | - [tailwindlabs](https://github.com/tailwindlabs) Creators of Tailwind CSS and Headless UI, and authors of Refactoring UI. 103 | 104 | ## License 105 | 106 | Apache-2.0 license. See [License here](./LICENSE) for details. 107 | -------------------------------------------------------------------------------- /UPDATE_LOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All changes will be documented in this file. 4 | 5 | ## v1.2.1 6 | 7 | - CN 8 | - 新增`windows`系统支持 [#12](https://github.com/ChurchTao/Lanaya/pull/12) 9 | - 暂时关闭了`自动更新`功能,因为官方的打包程序有问题 10 | - EN 11 | - Add windows support [#12](https://github.com/ChurchTao/Lanaya/pull/12) 12 | - Temporarily turned off the `auto update` function, because the official packaging program has problems 13 | 14 | ## v1.1.9 15 | 16 | ### Feature 17 | 18 | - CN 19 | - 新增功能`自动粘贴`,可以自动粘贴前一个窗口的焦点输入框 [#8](https://github.com/ChurchTao/Lanaya/pull/8) 20 | - 新增配置`删除前确认` [#7](https://github.com/ChurchTao/Lanaya/pull/7) 21 | - EN 22 | - Add function `auto paste`, can automatically paste the focus input box of the previous window [#8](https://github.com/ChurchTao/Lanaya/pull/8) 23 | - Add configuration `delete before confirm` [#7](https://github.com/ChurchTao/Lanaya/pull/7) 24 | 25 | ## v1.1.8 26 | 27 | ### Feature 28 | 29 | - CN 30 | - 新增功能,添加标签、搜索标签 31 | - EN 32 | - Add function, add tag, search tag 33 | 34 | ## v1.1.7 35 | 36 | - CN 37 | 38 | - 窗口出现时,会自动聚焦到搜索框 [#4](https://github.com/ChurchTao/Lanaya/pull/4) 39 | - 修复了当复制 html 标签时,列表框结果展示异常的问题 [#5](https://github.com/ChurchTao/Lanaya/pull/5) 40 | 41 | - EN 42 | - When the window appears, it will automatically focus on the search box [#4](https://github.com/ChurchTao/Lanaya/pull/4) 43 | - Fixed the problem that the list box result display is abnormal when copying html tags [#5](https://github.com/ChurchTao/Lanaya/pull/5) 44 | 45 | ## v1.1.6 46 | 47 | - CN 48 | - 新增引入了 `daisyui` 49 | - 升级了 `vite` 50 | - 修复了`限制条数`功能不生效的问题 51 | - EN 52 | - Added `daisyui` 53 | - Upgraded `vite` 54 | - Fixed the problem that the `limit number` function does not take effect 55 | 56 | ## v1.1.5 57 | 58 | - CN 59 | - 我忘了解开 `blur` 的注释.. 60 | - EN 61 | - I forgot to remove the comment of `blur` 62 | 63 | ## v1.1.4 64 | 65 | ### Bug Fixes 66 | 67 | - CN 68 | - 修复了因为 `objc` 导致的内存泄露 69 | - 修复了显示速度 70 | - EN 71 | - Fixed the memory leak caused by `objc` 72 | - Fixed the display speed 73 | 74 | ## v1.1.3 75 | 76 | - CN 77 | - 修复了截取字符串时,不安全的切片导致的崩溃问题 78 | - EN 79 | - Fixed the problem of unsafe slice caused by crash when cutting strings 80 | 81 | ## v1.1.2 82 | 83 | ### Feature 84 | 85 | - CN 86 | - 新增`删除某一条`功能 87 | - EN: 88 | - add `delete one` feature. 89 | 90 | ### Bug Fixes 91 | 92 | - CN 93 | - 修复了大文本复制时,会导致 UI 渲染卡顿的问题 94 | - 修复了搜索收藏记录时,无法搜索到图片的问题 95 | - 修复了清空记录时,会清除收藏的记录的问题 96 | - EN 97 | - Fixed the problem that large text copy will cause UI rendering to stall 98 | - Fixed the problem that the image cannot be searched when searching for favorite records 99 | - Fixed the problem that the record will be cleared when the record is cleared 100 | 101 | ## v1.1.1 102 | 103 | ### Bug Fixes 104 | 105 | - CN 106 | - 修复了搜索时的 SQL 注入,导致崩溃的问题 107 | - EN 108 | - Fixed the problem of SQL injection when searching, causing a crash 109 | 110 | ## v1.1.0 111 | 112 | ### Feature 113 | 114 | - CN 115 | - 新增`收藏`功能 116 | - EN: 117 | - add `favorite` feature. 118 | 119 | ### Bug Fixes 120 | 121 | - CN 122 | - 修复复制 html 时,会导致显示问题 123 | - EN 124 | - Fixed the problem that copying html will cause display problems 125 | 126 | ## v1.0.3 127 | 128 | ### Feature 129 | 130 | - CN 131 | - 新增复制图片历史的功能 132 | - 使用`Rust`实现后台监听剪切板 133 | - 使用`Rust`实现`Sqlite`的数据库操作 134 | - EN: 135 | - add copy image history. 136 | - use `Rust` to implement clipboard listener. 137 | - use `Rust` to implement `Sqlite` database 138 | 139 | ### Bug Fixes 140 | 141 | - CN 142 | - 唤起窗口不限于主界面 143 | - 优化搜索高亮显示效果 144 | - 修复了复制文件时,会导致循环替换复制顺序的问题 145 | - EN 146 | - Wake up the window is not limited to the main interface 147 | - Optimize the search highlight display effect 148 | - Fixed the problem that copying files will cause the loop replacement copy order 149 | 150 | ## v1.0.2 151 | 152 | ### Bug Fixes 153 | 154 | - CN 155 | - 修复了复制文件时,会导致循环替换复制顺序的问题 156 | - EN 157 | - Fixed the problem that copying files will cause the loop replacement copy order 158 | 159 | ## v1.0.1 160 | 161 | ### Bug Fixes 162 | 163 | - CN 164 | - 修复了复制图片时,因 SCP 协议导致的图片无法显示的问题 165 | - EN 166 | - Fixed the problem that the image could not be displayed due to the SCP protocol when copying the image 167 | 168 | ## v1.0.0 169 | 170 | ### Feature 171 | 172 | - CN 173 | - 新增复制图片历史的功能 174 | - 使用`Rust`实现后台监听剪切板 175 | - 使用`Rust`实现`Sqlite`的数据库操作 176 | - EN: 177 | - add copy image history. 178 | - use `Rust` to implement clipboard listener. 179 | - use `Rust` to implement `Sqlite` database 180 | 181 | ### Bug Fixes 182 | 183 | - CN 184 | - 唤起窗口不限于主界面 185 | - 优化搜索高亮显示效果 186 | - EN 187 | - Wake up the window is not limited to the main interface 188 | - Optimize the search highlight display effect 189 | 190 | ## v0.1.1 191 | 192 | ### Feature 193 | 194 | - 实装 限制剪切板记录条数 195 | 196 | ### Bug Fixes 197 | 198 | - 主界面失去焦点时,不会自动关闭界面 199 | 200 | ## v0.1.0 201 | 202 | ### Feature 203 | 204 | - 实装 多语言 205 | - 实装 自动更新 206 | - 实装 快捷键设置 207 | - 实装 开机启动 208 | 209 | ## v0.0.9 210 | 211 | ### Feature 212 | 213 | - 这是一个测试版本,新增了,设置页面,自动版本更新(手动检测的还没写好),自动打包 ci,以及一些交互优化,过年了,先这样啦~ 214 | - This is a test version, with new additions, settings page, automatic version update (manual detection has not yet been written), automatic packaging ci, and some interactive optimizations, Chinese New Year, let’s do this first~ 215 | - 实装 快捷键设置 216 | - 实装 开机启动 217 | 218 | ## v0.0.8 219 | 220 | ### Feature 221 | 222 | - 新增了配置、关于两个页面,功能未全部实现 223 | - 优化主页面的交互 224 | 225 | ## v0.0.1 226 | 227 | ### Documentation 228 | 229 | - Base function complete. 230 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Lanaya 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /md/demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/md/demo1.png -------------------------------------------------------------------------------- /md/demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/md/demo2.png -------------------------------------------------------------------------------- /md/demo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/md/demo3.png -------------------------------------------------------------------------------- /md/demo4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/md/demo4.png -------------------------------------------------------------------------------- /md/demo5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/md/demo5.png -------------------------------------------------------------------------------- /md/demo6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/md/demo6.png -------------------------------------------------------------------------------- /md/demo7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/md/demo7.png -------------------------------------------------------------------------------- /md/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/md/icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lanaya", 3 | "private": true, 4 | "version": "1.2.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "aarch": "node scripts/aarch.mjs", 11 | "updater": "node scripts/updater.mjs", 12 | "publish": "node scripts/publish.mjs", 13 | "tauri": "tauri", 14 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src", 15 | "format": "prettier --write .", 16 | "check": "prettier --check **/*.{js,md,vue,css,html}" 17 | }, 18 | "dependencies": { 19 | "@headlessui/vue": "^1.7.16", 20 | "@heroicons/vue": "^2.0.18", 21 | "@intlify/unplugin-vue-i18n": "^1.4.0", 22 | "@tauri-apps/api": "^1.5.1", 23 | "hotkeys-js": "^3.12.0", 24 | "md5": "^2.3.0", 25 | "pinia": "^2.1.7", 26 | "vue": "^3.3.7", 27 | "vue-i18n": "^9.6.0", 28 | "vue-router": "^4.2.5" 29 | }, 30 | "devDependencies": { 31 | "@actions/github": "^6.0.0", 32 | "@tauri-apps/cli": "^1.5.6", 33 | "@vitejs/plugin-vue": "^4.4.0", 34 | "autoprefixer": "^10.4.16", 35 | "daisyui": "^3.9.3", 36 | "eslint": "^8.52.0", 37 | "eslint-config-prettier": "^9.0.0", 38 | "eslint-plugin-vue": "^9.18.0", 39 | "fs-extra": "^11.1.1", 40 | "node-fetch": "^3.3.2", 41 | "postcss": "^8.4.31", 42 | "prettier": "3.0.3", 43 | "tailwindcss": "^3.3.5", 44 | "vite": "^4.5.0" 45 | } 46 | } -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/aarch.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Build and upload assets for macOS(aarch) 3 | */ 4 | import fs from "fs-extra"; 5 | import path from "path"; 6 | import { exit } from "process"; 7 | import { createRequire } from "module"; 8 | import { getOctokit } from "@actions/github"; 9 | 10 | const require = createRequire(import.meta.url); 11 | 12 | async function resolve() { 13 | if (!process.env.GITHUB_TOKEN) { 14 | throw new Error("GITHUB_TOKEN is required"); 15 | } 16 | if (!process.env.GITHUB_REPOSITORY) { 17 | throw new Error("GITHUB_REPOSITORY is required"); 18 | } 19 | if (!process.env.TAURI_PRIVATE_KEY) { 20 | throw new Error("TAURI_PRIVATE_KEY is required"); 21 | } 22 | if (!process.env.TAURI_KEY_PASSWORD) { 23 | throw new Error("TAURI_KEY_PASSWORD is required"); 24 | } 25 | 26 | const { version } = require("../package.json"); 27 | 28 | const cwd = process.cwd(); 29 | const bundlePath = path.join(cwd, "src-tauri/target/release/bundle"); 30 | const join = (p) => path.join(bundlePath, p); 31 | 32 | const appPathList = [ 33 | join("macos/Lanaya.aarch64.app.tar.gz"), 34 | join("macos/Lanaya.aarch64.app.tar.gz.sig"), 35 | ]; 36 | 37 | for (const appPath of appPathList) { 38 | if (fs.pathExistsSync(appPath)) { 39 | fs.removeSync(appPath); 40 | } 41 | } 42 | 43 | fs.copyFileSync(join("macos/Lanaya.app.tar.gz"), appPathList[0]); 44 | fs.copyFileSync(join("macos/Lanaya.app.tar.gz.sig"), appPathList[1]); 45 | 46 | const options = { owner: "ChurchTao", repo: "Lanaya" }; 47 | const github = getOctokit(process.env.GITHUB_TOKEN); 48 | const { data: release } = await github.rest.repos.getReleaseByTag({ 49 | ...options, 50 | tag: `v${version}`, 51 | }); 52 | 53 | if (!release.id) throw new Error("failed to find the release"); 54 | 55 | await uploadAssets(release.id, [ 56 | join(`dmg/Lanaya_${version}_aarch64.dmg`), 57 | ...appPathList, 58 | ]); 59 | } 60 | 61 | // From tauri-apps/tauri-action 62 | // https://github.com/tauri-apps/tauri-action/blob/dev/packages/action/src/upload-release-assets.ts 63 | async function uploadAssets(releaseId, assets) { 64 | const github = getOctokit(process.env.GITHUB_TOKEN); 65 | 66 | // Determine content-length for header to upload asset 67 | const contentLength = (filePath) => fs.statSync(filePath).size; 68 | 69 | for (const assetPath of assets) { 70 | const headers = { 71 | "content-type": "application/zip", 72 | "content-length": contentLength(assetPath), 73 | }; 74 | 75 | const ext = path.extname(assetPath); 76 | const filename = path.basename(assetPath).replace(ext, ""); 77 | const assetName = path.dirname(assetPath).includes(`target${path.sep}debug`) 78 | ? `${filename}-debug${ext}` 79 | : `${filename}${ext}`; 80 | 81 | console.log(`[INFO]: Uploading ${assetName}...`); 82 | try { 83 | await github.rest.repos.uploadReleaseAsset({ 84 | headers, 85 | name: assetName, 86 | data: fs.readFileSync(assetPath), 87 | owner: "ChurchTao", 88 | repo: "Lanaya", 89 | release_id: releaseId, 90 | }); 91 | } catch (error) { 92 | console.log(error.message); 93 | } 94 | } 95 | } 96 | 97 | if (process.platform === "darwin" && process.arch === "arm64") { 98 | resolve(); 99 | } else { 100 | console.error("invalid"); 101 | exit(1); 102 | } 103 | -------------------------------------------------------------------------------- /scripts/publish.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import { createRequire } from "module"; 3 | import { execSync } from "child_process"; 4 | import { resolveUpdateLog } from "./updatelog.mjs"; 5 | 6 | const require = createRequire(import.meta.url); 7 | 8 | // publish 9 | async function resolvePublish() { 10 | const flag = process.argv[2] ?? "patch"; 11 | const packageJson = require("../package.json"); 12 | const tauriJson = require("../src-tauri/tauri.conf.json"); 13 | 14 | let [a, b, c] = packageJson.version.split(".").map(Number); 15 | 16 | if (flag === "major") { 17 | a += 1; 18 | b = 0; 19 | c = 0; 20 | } else if (flag === "minor") { 21 | b += 1; 22 | c = 0; 23 | } else if (flag === "patch") { 24 | c += 1; 25 | } else throw new Error(`invalid flag "${flag}"`); 26 | 27 | const nextVersion = `${a}.${b}.${c}`; 28 | packageJson.version = nextVersion; 29 | tauriJson.package.version = nextVersion; 30 | 31 | // 发布更新前先写更新日志 32 | const nextTag = `v${nextVersion}`; 33 | await resolveUpdateLog(nextTag); 34 | 35 | await fs.writeFile( 36 | "./package.json", 37 | JSON.stringify(packageJson, undefined, 2), 38 | ); 39 | await fs.writeFile( 40 | "./src-tauri/tauri.conf.json", 41 | JSON.stringify(tauriJson, undefined, 2), 42 | ); 43 | 44 | execSync("git add ./package.json"); 45 | execSync("git add ./src-tauri/tauri.conf.json"); 46 | execSync("git add ./UPDATE_LOG.md"); 47 | execSync(`git commit -m "v${nextVersion}"`); 48 | execSync(`git tag -a v${nextVersion} -m "v${nextVersion}"`); 49 | execSync(`git push`); 50 | execSync(`git push origin v${nextVersion}`); 51 | console.log(`Publish Successfully...`); 52 | } 53 | 54 | resolvePublish(); 55 | -------------------------------------------------------------------------------- /scripts/updatelog.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "path"; 3 | 4 | const UPDATE_LOG = "UPDATE_LOG.md"; 5 | 6 | // parse the UPDATELOG.md 7 | export async function resolveUpdateLog(tag) { 8 | const cwd = process.cwd(); 9 | 10 | const reTitle = /^## v[\d\.]+/; 11 | const reEnd = /^---/; 12 | 13 | const file = path.join(cwd, UPDATE_LOG); 14 | 15 | if (!(await fs.pathExists(file))) { 16 | throw new Error("could not found UPDATELOG.md"); 17 | } 18 | 19 | const data = await fs.readFile(file).then((d) => d.toString("utf8")); 20 | 21 | const map = {}; 22 | let p = ""; 23 | 24 | data.split("\n").forEach((line) => { 25 | if (reTitle.test(line)) { 26 | p = line.slice(3).trim(); 27 | if (!map[p]) { 28 | map[p] = []; 29 | } else { 30 | throw new Error(`Tag ${p} dup`); 31 | } 32 | } else if (reEnd.test(line)) { 33 | p = ""; 34 | } else if (p) { 35 | map[p].push(line); 36 | } 37 | }); 38 | 39 | if (!map[tag]) { 40 | throw new Error(`could not found "${tag}" in UPDATELOG.md`); 41 | } 42 | 43 | return map[tag].join("\n").trim(); 44 | } 45 | -------------------------------------------------------------------------------- /scripts/updater.mjs: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { getOctokit, context } from "@actions/github"; 3 | import { resolveUpdateLog } from "./updatelog.mjs"; 4 | 5 | const UPDATE_TAG_NAME = "updater"; 6 | const UPDATE_JSON_FILE = "update.json"; 7 | 8 | /// generate update.json 9 | /// upload to update tag's release asset 10 | async function resolveUpdater() { 11 | if (process.env.GITHUB_TOKEN === undefined) { 12 | throw new Error("GITHUB_TOKEN is required"); 13 | } 14 | 15 | const options = { owner: context.repo.owner, repo: context.repo.repo }; 16 | const github = getOctokit(process.env.GITHUB_TOKEN); 17 | 18 | const { data: tags } = await github.rest.repos.listTags({ 19 | ...options, 20 | per_page: 10, 21 | page: 1, 22 | }); 23 | 24 | // get the latest publish tag 25 | const tag = tags.find((t) => t.name.startsWith("v")); 26 | 27 | console.log(tag); 28 | 29 | const { data: latestRelease } = await github.rest.repos.getReleaseByTag({ 30 | ...options, 31 | tag: tag.name, 32 | }); 33 | 34 | const updateData = { 35 | name: tag.name, 36 | notes: await resolveUpdateLog(tag.name), // use updatelog.md 37 | pub_date: new Date().toISOString(), 38 | platforms: { 39 | "darwin-aarch64": { signature: "", url: "" }, 40 | "darwin-intel": { signature: "", url: "" }, 41 | // "linux-x86_64": { signature: "", url: "" }, 42 | "windows-x86_64": { signature: "", url: "" }, 43 | }, 44 | }; 45 | 46 | const promises = latestRelease.assets.map(async (asset) => { 47 | const { name, browser_download_url } = asset; 48 | 49 | // win64 url 50 | if (name.endsWith(".msi.zip") && name.includes("en-US")) { 51 | updateData.platforms["windows-x86_64"].url = browser_download_url; 52 | } 53 | // win64 signature 54 | if (name.endsWith(".msi.zip.sig") && name.includes("en-US")) { 55 | const sig = await getSignature(browser_download_url); 56 | updateData.platforms["windows-x86_64"].signature = sig; 57 | } 58 | 59 | // darwin url (intel) 60 | if (name.endsWith(".app.tar.gz") && !name.includes("aarch")) { 61 | updateData.platforms["darwin-intel"].url = browser_download_url; 62 | } 63 | // darwin signature (intel) 64 | if (name.endsWith(".app.tar.gz.sig") && !name.includes("aarch")) { 65 | const sig = await getSignature(browser_download_url); 66 | updateData.platforms["darwin-intel"].signature = sig; 67 | } 68 | 69 | // darwin url (aarch) 70 | if (name.endsWith("aarch64.app.tar.gz")) { 71 | updateData.platforms["darwin-aarch64"].url = browser_download_url; 72 | } 73 | // darwin signature (aarch) 74 | if (name.endsWith("aarch64.app.tar.gz.sig")) { 75 | const sig = await getSignature(browser_download_url); 76 | updateData.platforms["darwin-aarch64"].signature = sig; 77 | } 78 | 79 | // // linux url 80 | // if (name.endsWith(".AppImage.tar.gz")) { 81 | // updateData.platforms["linux-x86_64"].url = browser_download_url; 82 | // } 83 | // // linux signature 84 | // if (name.endsWith(".AppImage.tar.gz.sig")) { 85 | // const sig = await getSignature(browser_download_url); 86 | // updateData.platforms["linux-x86_64"].signature = sig; 87 | // } 88 | }); 89 | 90 | await Promise.allSettled(promises); 91 | console.log(updateData); 92 | 93 | // maybe should test the signature as well 94 | // delete the null field 95 | Object.entries(updateData.platforms).forEach(([key, value]) => { 96 | if (!value.url) { 97 | console.log(`[Error]: failed to parse release for "${key}"`); 98 | delete updateData.platforms[key]; 99 | } 100 | }); 101 | 102 | // update the update.json 103 | const { data: updateRelease } = await github.rest.repos.getReleaseByTag({ 104 | ...options, 105 | tag: UPDATE_TAG_NAME, 106 | }); 107 | 108 | // delete the old assets 109 | for (let asset of updateRelease.assets) { 110 | if (asset.name === UPDATE_JSON_FILE) { 111 | await github.rest.repos.deleteReleaseAsset({ 112 | ...options, 113 | asset_id: asset.id, 114 | }); 115 | } 116 | } 117 | 118 | // upload new assets 119 | await github.rest.repos.uploadReleaseAsset({ 120 | ...options, 121 | release_id: updateRelease.id, 122 | name: UPDATE_JSON_FILE, 123 | data: JSON.stringify(updateData, null, 2), 124 | }); 125 | } 126 | 127 | // get the signature file content 128 | async function getSignature(url) { 129 | const response = await fetch(url, { 130 | method: "GET", 131 | headers: { "Content-Type": "application/octet-stream" }, 132 | }); 133 | 134 | return response.text(); 135 | } 136 | 137 | resolveUpdater().catch(console.error); 138 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lanaya" 3 | version = "1.2.1" 4 | description = "a simple clipboard manager" 5 | authors = ["churchTao"] 6 | license = "Apache-2.0 license" 7 | repository = "https://github.com/ChurchTao/Lanaya" 8 | default-run = "lanaya" 9 | edition = "2021" 10 | rust-version = "1.59" 11 | 12 | [build-dependencies] 13 | tauri-build = { version = "1.2.1", features = [] } 14 | 15 | [dependencies] 16 | serde_json = "1.0" 17 | serde = { version = "1.0", features = ["derive"] } 18 | tauri = { version = "1.2.3", features = [ 19 | "global-shortcut-all", 20 | "macos-private-api", 21 | "notification-all", 22 | "os-all", 23 | "shell-open", 24 | "system-tray", 25 | "window-all", 26 | ] } 27 | window-shadows = { git = "https://github.com/tauri-apps/window-shadows" } 28 | auto-launch = "0.4" 29 | once_cell = "1.17.0" 30 | anyhow = "1.0" 31 | parking_lot = "0.12.1" 32 | dunce = "1.0.3" 33 | rust-crypto = { version = "0.2.36" } 34 | rusqlite = { version = "0.28.0", features = ["bundled"] } 35 | chrono = "0.4.23" 36 | arboard = "3.2.1" 37 | base64 = "0.21.0" 38 | image = "0.24.5" 39 | rdev = "0.5.2" 40 | active-win-pos-rs = "0.7" 41 | winapi = { version = "0.3", features = ["winuser"] } 42 | 43 | [target.'cfg(target_os = "macos")'.dependencies] 44 | cocoa = "0.24.1" 45 | 46 | [features] 47 | # by default Tauri runs in production mode 48 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 49 | default = ["custom-protocol"] 50 | # this feature is used for production builds where `devPath` points to the filesystem 51 | # DO NOT remove this 52 | custom-protocol = ["tauri/custom-protocol"] 53 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | sips -z 16 16 icon.png --out tmp.iconset/icon_16x16.png 4 | sips -z 32 32 icon.png --out tmp.iconset/icon_16x16@2x.png 5 | sips -z 32 32 icon.png --out tmp.iconset/icon_32x32.png 6 | sips -z 64 64 icon.png --out tmp.iconset/icon_32x32@2x.png 7 | sips -z 128 128 icon.png --out tmp.iconset/icon_128x128.png 8 | sips -z 256 256 icon.png --out tmp.iconset/icon_128x128@2x.png 9 | sips -z 256 256 icon.png --out tmp.iconset/icon_256x256.png 10 | sips -z 512 512 icon.png --out tmp.iconset/icon_256x256@2x.png 11 | sips -z 512 512 icon.png --out tmp.iconset/icon_512x512.png 12 | sips -z 1024 1024 icon.png --out tmp.iconset/icon_512x512@2x.png 13 | 14 | iconutil -c icns tmp.iconset -o icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/src/cmds.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config, 3 | config::{CommonConfig, Config}, 4 | core::{ 5 | clipboard::{ClipBoardOprator, ImageDataDB}, 6 | database::{QueryReq, Record, SqliteDB}, 7 | handle::Handle, 8 | }, 9 | log_err, 10 | utils::{json_util, dispatch_util}, 11 | utils::window_util::{focus_window}, 12 | PreviousProcessId, 13 | }; 14 | use tauri::State; 15 | 16 | type CmdResult = Result; 17 | 18 | #[tauri::command] 19 | pub fn get_common_config() -> CmdResult { 20 | Ok(Config::common().data().clone()) 21 | } 22 | 23 | #[tauri::command] 24 | pub fn set_common_config(config: CommonConfig) -> CmdResult { 25 | Config::common().draft().patch_config(config); 26 | Config::common().apply(); 27 | log_err!(Config::common().data().save_file()); 28 | 29 | // todo enable_auto_launch 30 | // todo hotkeys 31 | Handle::refresh_common_config(); 32 | log_err!(Handle::update_systray()); 33 | Ok(()) 34 | } 35 | 36 | #[tauri::command] 37 | pub async fn change_language(language: String) -> CmdResult { 38 | let _ = config::modify_common_config(CommonConfig { 39 | language: Some(language), 40 | ..CommonConfig::default() 41 | }) 42 | .await; 43 | Ok(()) 44 | } 45 | 46 | #[tauri::command] 47 | pub async fn change_record_limit(limit: u32) -> CmdResult { 48 | let _ = config::modify_common_config(CommonConfig { 49 | record_limit: Some(limit), 50 | ..CommonConfig::default() 51 | }) 52 | .await; 53 | Ok(()) 54 | } 55 | 56 | #[tauri::command] 57 | pub async fn change_auto_launch(enable: bool) -> CmdResult { 58 | let _ = config::modify_common_config(CommonConfig { 59 | enable_auto_launch: Some(enable), 60 | ..CommonConfig::default() 61 | }) 62 | .await; 63 | Ok(()) 64 | } 65 | 66 | #[tauri::command] 67 | pub async fn change_auto_paste(enable: bool) -> CmdResult { 68 | if enable == true { 69 | dispatch_util::request_permissions(); 70 | } 71 | let _ = config::modify_common_config(CommonConfig { 72 | enable_auto_paste: Some(enable), 73 | ..CommonConfig::default() 74 | }) 75 | .await; 76 | Ok(()) 77 | } 78 | 79 | #[tauri::command] 80 | pub async fn change_delete_confirm(enable: bool) -> CmdResult { 81 | let _ = config::modify_common_config(CommonConfig { 82 | enable_delete_confirm: Some(enable), 83 | ..CommonConfig::default() 84 | }).await; 85 | Ok(()) 86 | } 87 | 88 | #[tauri::command] 89 | pub async fn change_theme_mode(theme_mode: String) -> CmdResult { 90 | let _ = config::modify_common_config(CommonConfig { 91 | theme_mode: Some(theme_mode), 92 | ..CommonConfig::default() 93 | }) 94 | .await; 95 | Ok(()) 96 | } 97 | 98 | #[tauri::command] 99 | pub async fn change_hotkeys(hotkeys: Vec) -> CmdResult { 100 | let _ = config::modify_common_config(CommonConfig { 101 | hotkeys: Some(hotkeys), 102 | ..CommonConfig::default() 103 | }) 104 | .await; 105 | Ok(()) 106 | } 107 | 108 | #[tauri::command] 109 | pub fn clear_data() -> bool { 110 | match SqliteDB::new().clear_data() { 111 | Ok(()) => true, 112 | Err(_) => false, 113 | } 114 | } 115 | 116 | #[tauri::command] 117 | pub fn insert_record(r: Record) -> bool { 118 | match SqliteDB::new().insert_record(r) { 119 | Ok(_i) => true, 120 | Err(e) => { 121 | println!("err:{}", e); 122 | false 123 | } 124 | } 125 | } 126 | 127 | #[tauri::command] 128 | pub fn insert_if_not_exist(r: Record) -> bool { 129 | match SqliteDB::new().insert_if_not_exist(r) { 130 | Ok(_i) => true, 131 | Err(e) => { 132 | println!("err:{}", e); 133 | false 134 | } 135 | } 136 | } 137 | 138 | #[tauri::command] 139 | pub fn find_all_record() -> Vec { 140 | SqliteDB::new().find_all().unwrap() 141 | } 142 | 143 | #[tauri::command] 144 | pub fn mark_favorite(id: u64) -> bool { 145 | match SqliteDB::new().mark_favorite(id) { 146 | Ok(_i) => true, 147 | Err(e) => { 148 | println!("err:{}", e); 149 | false 150 | } 151 | } 152 | } 153 | 154 | #[tauri::command] 155 | pub fn save_tags(id: u64, tags: String) -> bool { 156 | match SqliteDB::new().save_tags(id, tags) { 157 | Ok(_i) => true, 158 | Err(e) => { 159 | println!("err:{}", e); 160 | false 161 | } 162 | } 163 | } 164 | 165 | #[tauri::command] 166 | pub fn delete_by_id(id: u64) -> bool { 167 | match SqliteDB::new().delete_by_id(id) { 168 | Ok(_i) => true, 169 | Err(e) => { 170 | println!("err:{}", e); 171 | false 172 | } 173 | } 174 | } 175 | 176 | #[tauri::command] 177 | pub fn find_by_key(query: QueryReq) -> Vec { 178 | SqliteDB::new().find_by_key(query).unwrap() 179 | } 180 | 181 | #[tauri::command] 182 | pub fn delete_over_limit(limit: usize) -> bool { 183 | match SqliteDB::new().delete_over_limit(limit) { 184 | Ok(res) => res, 185 | Err(e) => { 186 | println!("err:{}", e); 187 | false 188 | } 189 | } 190 | } 191 | 192 | #[tauri::command] 193 | pub fn write_to_clip(id: u64) -> bool { 194 | let record = SqliteDB::new().find_by_id(id); 195 | match record { 196 | Ok(r) => { 197 | if r.data_type == "text" { 198 | let _ = ClipBoardOprator::set_text(r.content); 199 | } else if r.data_type == "image" { 200 | let image_data: ImageDataDB = json_util::parse(&r.content).unwrap(); 201 | let _ = ClipBoardOprator::set_image(image_data); 202 | } 203 | true 204 | } 205 | Err(e) => { 206 | println!("err:{}", e); 207 | false 208 | } 209 | } 210 | } 211 | 212 | #[tauri::command] 213 | pub fn focus_previous_window(previous_process_id: State) -> CmdResult { 214 | focus_window(*previous_process_id.0.lock().unwrap()); 215 | Ok(()) 216 | } 217 | 218 | #[tauri::command] 219 | pub fn paste_in_previous_window(previous_process_id: State) -> CmdResult { 220 | focus_window(*previous_process_id.0.lock().unwrap()); 221 | dispatch_util::paste(); 222 | Ok(()) 223 | } -------------------------------------------------------------------------------- /src-tauri/src/config/common_config.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::{dirs, json_util}; 2 | use anyhow::Result; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Default, Debug, Clone, Deserialize, Serialize)] 6 | pub struct CommonConfig { 7 | // i18n 8 | pub language: Option, 9 | /// `light` or `dark` or todo `system` 10 | pub theme_mode: Option, 11 | /// can the app auto startup 12 | pub enable_auto_launch: Option, 13 | /// can the app paste automatically in previous window 14 | pub enable_auto_paste: Option, 15 | /// delete confirm 16 | pub enable_delete_confirm: Option, 17 | /// hotkey map 18 | /// format: {func},{key} 19 | pub hotkeys: Option>, 20 | // pub font_family: Option, 21 | // pub font_size: Option, 22 | pub record_limit: Option, 23 | } 24 | 25 | impl CommonConfig { 26 | pub fn new() -> Self { 27 | match dirs::config_path().and_then(|path| json_util::read::(&path)) { 28 | Ok(config) => { 29 | // 先拿 template 30 | // 再拿 config 去覆盖 31 | let mut template = Self::template(); 32 | template.merge(config); 33 | template 34 | } 35 | Err(_) => Self::template(), 36 | } 37 | } 38 | 39 | pub fn template() -> Self { 40 | // todo windows 快捷键不一样,需要兼容 41 | Self { 42 | language: match cfg!(feature = "default-meta") { 43 | false => Some("zh".into()), 44 | true => Some("en".into()), 45 | }, 46 | theme_mode: Some("light".into()), 47 | enable_auto_launch: Some(false), 48 | enable_auto_paste: Some(false), 49 | enable_delete_confirm: Some(true), 50 | record_limit: Some(100), 51 | hotkeys: Some(vec![ 52 | "clear-history:8+16+91".into(), 53 | "global-shortcut:16+67+91".into(), 54 | ]), 55 | } 56 | } 57 | 58 | pub fn save_file(&self) -> Result<()> { 59 | json_util::save(&dirs::config_path()?, &self) 60 | } 61 | 62 | pub fn merge(&mut self, other: Self) { 63 | if let Some(language) = other.language { 64 | self.language = Some(language); 65 | } 66 | if let Some(theme_mode) = other.theme_mode { 67 | self.theme_mode = Some(theme_mode); 68 | } 69 | if let Some(enable_auto_launch) = other.enable_auto_launch { 70 | self.enable_auto_launch = Some(enable_auto_launch); 71 | } 72 | if let Some(enable_auto_paste) = other.enable_auto_paste { 73 | self.enable_auto_paste = Some(enable_auto_paste); 74 | } 75 | if let Some(delete_confirm) = other.enable_delete_confirm { 76 | self.enable_delete_confirm = Some(delete_confirm); 77 | } 78 | if let Some(record_limit) = other.record_limit { 79 | self.record_limit = Some(record_limit); 80 | } 81 | if let Some(hotkeys) = other.hotkeys { 82 | self.hotkeys = Some(hotkeys); 83 | } 84 | } 85 | 86 | pub fn patch_config(&mut self, patch: CommonConfig) { 87 | macro_rules! patch { 88 | ($key: tt) => { 89 | if patch.$key.is_some() { 90 | self.$key = patch.$key; 91 | } 92 | }; 93 | } 94 | patch!(language); 95 | patch!(theme_mode); 96 | patch!(enable_auto_launch); 97 | patch!(enable_auto_paste); 98 | patch!(enable_delete_confirm); 99 | patch!(hotkeys); 100 | patch!(record_limit); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src-tauri/src/config/config.rs: -------------------------------------------------------------------------------- 1 | use super::{CommonConfig, Draft}; 2 | use crate::{ 3 | core::handle, 4 | core::sysopt, 5 | log_err, 6 | utils::{dirs, json_util}, 7 | }; 8 | use anyhow::Result; 9 | use once_cell::sync::OnceCell; 10 | use std::fs; 11 | 12 | pub struct Config { 13 | common_config: Draft, 14 | } 15 | 16 | impl Config { 17 | pub fn global() -> &'static Config { 18 | static CONFIG: OnceCell = OnceCell::new(); 19 | 20 | CONFIG.get_or_init(|| Config { 21 | common_config: Draft::from(CommonConfig::new()), 22 | }) 23 | } 24 | 25 | pub fn common() -> Draft { 26 | Self::global().common_config.clone() 27 | } 28 | 29 | /// 初始化配置 30 | pub fn init_config() -> Result<()> { 31 | log_err!(dirs::app_home_dir().map(|app_dir| { 32 | if !app_dir.exists() { 33 | let _ = fs::create_dir_all(&app_dir); 34 | } 35 | })); 36 | log_err!(dirs::app_data_dir().map(|app_dir| { 37 | if !app_dir.exists() { 38 | let _ = fs::create_dir_all(&app_dir); 39 | } 40 | })); 41 | log_err!(dirs::app_data_img_dir().map(|app_dir| { 42 | if !app_dir.exists() { 43 | let _ = fs::create_dir_all(&app_dir); 44 | } 45 | })); 46 | log_err!(dirs::config_path().map(|path| { 47 | if !path.exists() { 48 | log_err!(json_util::save(&path, &CommonConfig::template())); 49 | } 50 | })); 51 | Ok(()) 52 | } 53 | } 54 | 55 | /// 修改通用配置文件的入口 56 | pub async fn modify_common_config(patch: CommonConfig) -> Result<()> { 57 | Config::common().draft().patch_config(patch.clone()); 58 | 59 | let auto_launch = patch.enable_auto_launch; 60 | let auto_paste = patch.enable_auto_paste; 61 | let delete_confirm = patch.enable_delete_confirm; 62 | let language = patch.language; 63 | let theme_mode = patch.theme_mode; 64 | let record_limit = patch.record_limit; 65 | let hotkeys = patch.hotkeys; 66 | 67 | match { 68 | if auto_launch.is_some() { 69 | sysopt::Sysopt::global().update_launch()?; 70 | } 71 | 72 | if hotkeys.is_some() { 73 | let _ = handle::Handle::refresh_global_shortcut(); 74 | handle::Handle::notice_to_window(handle::MsgTypeEnum::ChangeHotKeys, hotkeys)?; 75 | } 76 | 77 | if language.is_some() { 78 | handle::Handle::update_systray()?; 79 | handle::Handle::notice_to_window(handle::MsgTypeEnum::ChangeLanguage, language)?; 80 | } 81 | 82 | if theme_mode.is_some() { 83 | // todo send msg to frontend 84 | } 85 | 86 | if record_limit.is_some() { 87 | handle::Handle::notice_to_window(handle::MsgTypeEnum::ChangeRecordLimit, record_limit)?; 88 | } 89 | 90 | if auto_paste.is_some() { 91 | handle::Handle::notice_to_window(handle::MsgTypeEnum::ChangeAutoPaste, auto_paste)?; 92 | } 93 | 94 | if delete_confirm.is_some() { 95 | handle::Handle::notice_to_window(handle::MsgTypeEnum::ChangeDeleteConfirm, delete_confirm)?; 96 | } 97 | 98 | >::Ok(()) 99 | } { 100 | Ok(()) => { 101 | Config::common().apply(); 102 | Config::common().data().save_file()?; 103 | Ok(()) 104 | } 105 | Err(err) => { 106 | Config::common().discard(); 107 | Err(err) 108 | } 109 | } 110 | } 111 | 112 | #[test] 113 | fn test_config() { 114 | log_err!(Config::init_config()); 115 | let common = Config::common(); 116 | println!("common: {:?}", common.data()); 117 | } 118 | -------------------------------------------------------------------------------- /src-tauri/src/config/draft.rs: -------------------------------------------------------------------------------- 1 | use super::CommonConfig; 2 | use parking_lot::{MappedMutexGuard, Mutex, MutexGuard}; 3 | use std::sync::Arc; 4 | 5 | // 草稿,用于修改配置,T=正式数据 Option=草稿数据 6 | #[derive(Debug, Clone)] 7 | pub struct Draft { 8 | inner: Arc)>>, 9 | } 10 | 11 | macro_rules! draft_define { 12 | ($id: ident) => { 13 | impl Draft<$id> { 14 | // 获取正式数据 15 | #[allow(unused)] 16 | pub fn data(&self) -> MappedMutexGuard<$id> { 17 | MutexGuard::map(self.inner.lock(), |guard| &mut guard.0) 18 | } 19 | 20 | // 获取最后修改记录 21 | pub fn latest(&self) -> MappedMutexGuard<$id> { 22 | MutexGuard::map(self.inner.lock(), |inner| { 23 | if inner.1.is_none() { 24 | &mut inner.0 25 | } else { 26 | inner.1.as_mut().unwrap() 27 | } 28 | }) 29 | } 30 | 31 | // 获取草稿数据 32 | pub fn draft(&self) -> MappedMutexGuard<$id> { 33 | MutexGuard::map(self.inner.lock(), |inner| { 34 | if inner.1.is_none() { 35 | inner.1 = Some(inner.0.clone()); 36 | } 37 | inner.1.as_mut().unwrap() 38 | }) 39 | } 40 | 41 | // 同步草稿数据到正式数据 42 | pub fn apply(&self) -> Option<$id> { 43 | let mut inner = self.inner.lock(); 44 | match inner.1.take() { 45 | Some(draft) => { 46 | let old_value = inner.0.to_owned(); 47 | inner.0 = draft.to_owned(); 48 | Some(old_value) 49 | } 50 | None => None, 51 | } 52 | } 53 | 54 | // 丢弃草稿数据 55 | #[allow(unused)] 56 | pub fn discard(&self) -> Option<$id> { 57 | let mut inner = self.inner.lock(); 58 | inner.1.take() 59 | } 60 | } 61 | 62 | impl From<$id> for Draft<$id> { 63 | fn from(data: $id) -> Self { 64 | Draft { 65 | inner: Arc::new(Mutex::new((data, None))), 66 | } 67 | } 68 | } 69 | }; 70 | } 71 | 72 | draft_define!(CommonConfig); 73 | -------------------------------------------------------------------------------- /src-tauri/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | mod common_config; 2 | mod config; 3 | mod draft; 4 | 5 | pub use self::common_config::*; 6 | pub use self::config::*; 7 | pub use self::draft::*; 8 | -------------------------------------------------------------------------------- /src-tauri/src/core/clipboard.rs: -------------------------------------------------------------------------------- 1 | use super::database; 2 | use super::handle::{self, MsgTypeEnum}; 3 | use crate::config::Config; 4 | use crate::core::database::Record; 5 | use crate::utils::{img_util, json_util, string_util}; 6 | use anyhow::Result; 7 | use arboard::Clipboard; 8 | use chrono::Duration; 9 | use serde::{Deserialize, Serialize}; 10 | 11 | use std::thread; 12 | const CHANGE_DEFAULT_MSG: &str = "ok"; 13 | 14 | pub struct ClipboardWatcher; 15 | 16 | pub struct ClipBoardOprator; 17 | 18 | #[derive(Default, Debug, Clone, Deserialize, Serialize)] 19 | pub struct ImageDataDB { 20 | pub width: usize, 21 | pub height: usize, 22 | pub base64: String, 23 | } 24 | 25 | impl ClipBoardOprator { 26 | pub fn set_text(text: String) -> Result<()> { 27 | let mut clipboard = Clipboard::new()?; 28 | clipboard.set_text(text)?; 29 | Ok(()) 30 | } 31 | 32 | pub fn set_image(data: ImageDataDB) -> Result<()> { 33 | let mut clipboard = Clipboard::new()?; 34 | let img_data = img_util::base64_to_rgba8(&data.base64).unwrap(); 35 | clipboard.set_image(img_data)?; 36 | Ok(()) 37 | } 38 | } 39 | 40 | impl ClipboardWatcher { 41 | pub fn start() { 42 | tauri::async_runtime::spawn(async { 43 | // 1000毫秒检测一次剪切板变化 44 | let wait_millis = 1000i64; 45 | let mut last_content_md5 = String::new(); 46 | let mut last_img_md5 = String::new(); 47 | let mut clipboard = Clipboard::new().unwrap(); 48 | println!("start clipboard watcher"); 49 | loop { 50 | let mut need_notify = false; 51 | let db = database::SqliteDB::new(); 52 | let text = clipboard.get_text(); 53 | let _ = text.map(|text| { 54 | let content_origin = text.clone(); 55 | let content = text.trim(); 56 | let md5 = string_util::md5(&content_origin); 57 | if !content.is_empty() && md5 != last_content_md5 { 58 | // 说明有新内容 59 | let content_preview = if content.len() > 1000 { 60 | Some(content.chars().take(1000).collect()) 61 | } else { 62 | Some(content.to_string()) 63 | }; 64 | let res = db.insert_if_not_exist(Record { 65 | content: content_origin, 66 | content_preview, 67 | data_type: "text".to_string(), 68 | is_favorite: false, 69 | ..Default::default() 70 | }); 71 | match res { 72 | Ok(_) => { 73 | need_notify = true; 74 | } 75 | Err(e) => { 76 | println!("insert record error: {}", e); 77 | } 78 | } 79 | last_content_md5 = md5; 80 | } 81 | }); 82 | 83 | let img = clipboard.get_image(); 84 | let _ = img.map(|img| { 85 | let img_md5 = string_util::md5_by_bytes(&img.bytes); 86 | if img_md5 != last_img_md5 { 87 | // 有新图片产生 88 | let base64 = img_util::rgba8_to_base64(&img); 89 | let content_db = ImageDataDB { 90 | width: img.width, 91 | height: img.height, 92 | base64, 93 | }; 94 | // 压缩画质作为预览图,防止渲染时非常卡顿 95 | let jpeg_base64 = img_util::rgba8_to_jpeg_base64(&img, 75); 96 | let content_preview_db = ImageDataDB { 97 | width: img.width, 98 | height: img.height, 99 | base64: jpeg_base64, 100 | }; 101 | let content = json_util::stringfy(&content_db).unwrap(); 102 | let content_preview = json_util::stringfy(&content_preview_db).unwrap(); 103 | let res = db.insert_if_not_exist(Record { 104 | content, 105 | content_preview: Some(content_preview), 106 | data_type: "image".to_string(), 107 | is_favorite: false, 108 | ..Default::default() 109 | }); 110 | match res { 111 | Ok(_) => { 112 | drop(img); 113 | need_notify = true; 114 | } 115 | Err(e) => { 116 | println!("insert record error: {}", e); 117 | } 118 | } 119 | last_img_md5 = img_md5; 120 | } 121 | }); 122 | let limit = Config::common().latest().record_limit.clone(); 123 | if let Some(l) = limit { 124 | let res = db.delete_over_limit(l as usize); 125 | if let Ok(success) = res { 126 | if success { 127 | need_notify = true; 128 | } 129 | } 130 | } 131 | if need_notify { 132 | handle::Handle::notice_to_window( 133 | MsgTypeEnum::ChangeClipBoard, 134 | CHANGE_DEFAULT_MSG, 135 | ) 136 | .unwrap(); 137 | } 138 | thread::sleep(Duration::milliseconds(wait_millis).to_std().unwrap()); 139 | } 140 | }); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src-tauri/src/core/database.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::dirs::app_data_dir; 2 | use crate::utils::string_util; 3 | use anyhow::Result; 4 | use rusqlite::{Connection, OpenFlags}; 5 | use std::fs::File; 6 | use std::path::Path; 7 | 8 | #[derive(serde::Serialize, serde::Deserialize, Debug, Default, PartialEq)] 9 | pub struct Record { 10 | pub id: u64, 11 | pub content: String, 12 | pub content_preview: Option, 13 | // data_type(文本=text、图片=image) 14 | pub data_type: String, 15 | pub md5: String, 16 | pub create_time: u64, 17 | pub is_favorite: bool, 18 | pub tags: String, 19 | // 仅在搜索返回时使用 20 | pub content_highlight: Option, 21 | } 22 | 23 | #[derive(serde::Serialize, serde::Deserialize, Debug, Default)] 24 | pub struct QueryReq { 25 | pub key: Option, 26 | pub limit: Option, 27 | pub is_favorite: Option, 28 | pub tags: Option>, 29 | } 30 | 31 | pub struct SqliteDB { 32 | conn: Connection, 33 | } 34 | 35 | const SQLITE_FILE: &str = "data_v1_1_8.sqlite"; 36 | 37 | #[allow(unused)] 38 | impl SqliteDB { 39 | pub fn new() -> Self { 40 | let data_dir = app_data_dir().unwrap().join(SQLITE_FILE); 41 | let c = Connection::open_with_flags(data_dir, OpenFlags::SQLITE_OPEN_READ_WRITE).unwrap(); 42 | SqliteDB { conn: c } 43 | } 44 | 45 | pub fn init() { 46 | let data_dir = app_data_dir().unwrap().join(SQLITE_FILE); 47 | if !Path::new(&data_dir).exists() { 48 | File::create(&data_dir).unwrap(); 49 | } 50 | let c = Connection::open_with_flags(data_dir, OpenFlags::SQLITE_OPEN_READ_WRITE).unwrap(); 51 | let sql = r#" 52 | create table if not exists record 53 | ( 54 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 55 | content TEXT, 56 | content_preview TEXT, 57 | data_type VARCHAR(20) DEFAULT '', 58 | md5 VARCHAR(200) DEFAULT '', 59 | create_time INTEGER, 60 | is_favorite INTEGER DEFAULT 0, 61 | tags VARCHAR(256) DEFAULT '' 62 | ); 63 | "#; 64 | c.execute(sql, ()).unwrap(); 65 | } 66 | 67 | pub fn insert_record(&self, r: Record) -> Result { 68 | let sql = "insert into record (content,md5,create_time,is_favorite,data_type,content_preview) values (?1,?2,?3,?4,?5,?6)"; 69 | let md5 = string_util::md5(r.content.as_str()); 70 | let now = chrono::Local::now().timestamp_millis() as u64; 71 | let content_preview = r.content_preview.unwrap_or("".to_string()); 72 | let res = self.conn.execute( 73 | sql, 74 | ( 75 | &r.content, 76 | md5, 77 | now, 78 | &r.is_favorite, 79 | &r.data_type, 80 | content_preview, 81 | ), 82 | )?; 83 | Ok(self.conn.last_insert_rowid()) 84 | } 85 | 86 | fn find_record_by_md5(&self, md5: String) -> Result { 87 | let sql = "SELECT id, content, md5, create_time, is_favorite FROM record WHERE md5 = ?1"; 88 | let r = self.conn.query_row(sql, [md5], |row| { 89 | Ok(Record { 90 | id: row.get(0)?, 91 | ..Default::default() 92 | }) 93 | })?; 94 | Ok(r) 95 | } 96 | 97 | // 更新时间 98 | fn update_record_create_time(&self, r: Record) -> Result<()> { 99 | let sql = "update record set create_time = ?2 where id = ?1"; 100 | // 获取当前毫秒级时间戳 101 | let now = chrono::Local::now().timestamp_millis() as u64; 102 | self.conn.execute(sql, [&r.id, &now])?; 103 | Ok(()) 104 | } 105 | 106 | pub fn insert_if_not_exist(&self, r: Record) -> Result<()> { 107 | let md5 = string_util::md5(r.content.as_str()); 108 | match self.find_record_by_md5(md5) { 109 | Ok(res) => { 110 | self.update_record_create_time(res)?; 111 | } 112 | Err(_e) => { 113 | self.insert_record(r)?; 114 | } 115 | } 116 | Ok(()) 117 | } 118 | 119 | pub fn md5_is_exist(&self, md5: String) -> Result { 120 | let sql = "SELECT count(*) FROM record WHERE md5 = ?1"; 121 | let count: u32 = self.conn.query_row(sql, [md5], |row| row.get(0))?; 122 | Ok(count > 0) 123 | } 124 | 125 | // 清除数据 126 | pub fn clear_data(&self) -> Result<()> { 127 | let sql = "delete from record where is_favorite = 0"; 128 | self.conn.execute(sql, ())?; 129 | Ok(()) 130 | } 131 | 132 | pub fn delete_by_id(&self, id: u64) -> Result<()> { 133 | let sql = "delete from record where id = ?1"; 134 | self.conn.execute(sql, [&id])?; 135 | Ok(()) 136 | } 137 | 138 | // 标记为收藏,如有已经收藏了的则取消收藏 139 | pub fn mark_favorite(&self, id: u64) -> Result<()> { 140 | let record = self.find_by_id(id)?; 141 | let sql = "update record set is_favorite = ?2 where id = ?1"; 142 | let is_favorite = if record.is_favorite { 0 } else { 1 }; 143 | self.conn.execute(sql, [&id, &is_favorite])?; 144 | Ok(()) 145 | } 146 | 147 | pub fn save_tags(&self, id: u64, tags: String) -> Result<()> { 148 | let sql = "update record set tags = ?2 where id = ?1"; 149 | self.conn.execute(sql, (&id, &tags))?; 150 | Ok(()) 151 | } 152 | 153 | pub fn find_all(&self) -> Result> { 154 | let sql = "SELECT id, content_preview, data_type, md5, create_time, is_favorite, tags FROM record order by create_time desc"; 155 | let mut stmt = self.conn.prepare(sql)?; 156 | let mut rows = stmt.query([])?; 157 | let mut res = vec![]; 158 | while let Some(row) = rows.next()? { 159 | let data_type: String = row.get(2)?; 160 | let content: String = row.get(1)?; 161 | let tags: String = row.get(6)?; 162 | let r = Record { 163 | id: row.get(0)?, 164 | content, 165 | content_preview: None, 166 | data_type, 167 | md5: row.get(3)?, 168 | create_time: row.get(4)?, 169 | is_favorite: row.get(5)?, 170 | content_highlight: None, 171 | tags, 172 | }; 173 | res.push(r); 174 | } 175 | Ok(res) 176 | } 177 | 178 | pub fn find_by_key(&self, req: QueryReq) -> Result> { 179 | let mut sql: String = String::new(); 180 | sql.push_str( 181 | "SELECT id, content_preview, md5, create_time, is_favorite, data_type, tags FROM record where 1=1", 182 | ); 183 | let mut limit: usize = 300; 184 | let mut params: Vec = vec![]; 185 | if let Some(l) = req.limit { 186 | limit = l; 187 | } 188 | params.push(limit.to_string()); 189 | if let Some(k) = &req.key { 190 | params.push(format!("%{}%", k)); 191 | sql.push_str( 192 | format!(" and data_type='text' and content like ?{}", params.len()).as_str(), 193 | ); 194 | } 195 | if let Some(is_fav) = req.is_favorite { 196 | let is_fav_int = if is_fav { 1 } else { 0 }; 197 | params.push(is_fav_int.to_string()); 198 | sql.push_str(format!(" and is_favorite = ?{}", params.len()).as_str()); 199 | } 200 | if let Some(tags) = req.tags { 201 | for tag in tags.iter() { 202 | params.push(format!("%{}%", tag)); 203 | sql.push_str(format!(" and tags like ?{}", params.len()).as_str()); 204 | } 205 | } 206 | let sql = format!("{} order by create_time desc limit ?1", sql); 207 | let mut stmt = self.conn.prepare(&sql)?; 208 | let mut rows = stmt.query(rusqlite::params_from_iter(params))?; 209 | let mut res = vec![]; 210 | while let Some(row) = rows.next()? { 211 | let data_type: String = row.get(5)?; 212 | let content: String = row.get(1)?; 213 | let tags: String = row.get(6)?; 214 | let content_highlight = req 215 | .key 216 | .as_ref() 217 | .map(|key| string_util::highlight(key.as_str(), content.as_str())); 218 | let r = Record { 219 | id: row.get(0)?, 220 | content, 221 | content_preview: None, 222 | data_type, 223 | md5: row.get(2)?, 224 | create_time: row.get(3)?, 225 | is_favorite: row.get(4)?, 226 | content_highlight, 227 | tags, 228 | }; 229 | res.push(r); 230 | } 231 | Ok(res) 232 | } 233 | 234 | //删除超过limit的记录 235 | pub fn delete_over_limit(&self, limit: usize) -> Result { 236 | // 先查询count,如果count - limit > 50 就删除 超出limit部分记录 主要是防止频繁重建数据库 237 | let mut stmt = self 238 | .conn 239 | .prepare("SELECT count(*) FROM record where is_favorite = 0")?; 240 | let mut rows = stmt.query([])?; 241 | let count: usize = rows.next()?.unwrap().get(0).unwrap(); 242 | if count < 10 + limit { 243 | return Ok(false); 244 | } 245 | let remove_num = count - limit; 246 | let sql = "DELETE FROM record WHERE is_favorite = 0 and id in (SELECT id FROM record where is_favorite = 0 order by create_time asc limit ?1)"; 247 | self.conn.execute(sql, [remove_num])?; 248 | Ok(true) 249 | } 250 | 251 | pub fn find_by_id(&self, id: u64) -> Result { 252 | let sql = "SELECT id, content, data_type, md5, create_time, is_favorite, tags FROM record where id = ?1"; 253 | let r = self.conn.query_row(sql, [&id], |row| { 254 | Ok(Record { 255 | id: row.get(0)?, 256 | content: row.get(1)?, 257 | content_preview: None, 258 | data_type: row.get(2)?, 259 | md5: row.get(3)?, 260 | create_time: row.get(4)?, 261 | is_favorite: row.get(5)?, 262 | content_highlight: None, 263 | tags: row.get(6)?, 264 | }) 265 | })?; 266 | Ok(r) 267 | } 268 | } 269 | 270 | #[test] 271 | fn test_sqlite_insert() { 272 | SqliteDB::init(); 273 | let r = Record { 274 | content: "123456".to_string(), 275 | md5: "e10adc3949ba59abbe56e057f20f883e".to_string(), 276 | create_time: 1234568, 277 | ..Default::default() 278 | }; 279 | assert_eq!(SqliteDB::new().insert_record(r).unwrap(), 1_i64) 280 | } 281 | -------------------------------------------------------------------------------- /src-tauri/src/core/handle.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | tray::Tray, 3 | window_manager::{WindowInfo, WindowType}, 4 | }; 5 | use crate::{config::Config, log_err, utils::{hotkey_util, window_util}, PreviousProcessId}; 6 | use anyhow::{bail, Result}; 7 | use once_cell::sync::OnceCell; 8 | use parking_lot::Mutex; 9 | use serde::Serialize; 10 | use std::sync::Arc; 11 | use tauri::{AppHandle, GlobalShortcutManager, Manager, Window}; 12 | use tauri::State; 13 | use window_shadows::set_shadow; 14 | 15 | #[derive(Debug, Default, Clone)] 16 | pub struct Handle { 17 | pub app_handle: Arc>>, 18 | } 19 | 20 | pub enum MsgTypeEnum { 21 | ChangeLanguage, 22 | ChangeRecordLimit, 23 | ChangeHotKeys, 24 | ChangeClipBoard, 25 | ChangeAutoPaste, 26 | ChangeDeleteConfirm, 27 | } 28 | 29 | impl Handle { 30 | pub fn global() -> &'static Handle { 31 | static HANDLE: OnceCell = OnceCell::new(); 32 | 33 | HANDLE.get_or_init(|| Handle { 34 | app_handle: Arc::new(Mutex::new(None)), 35 | }) 36 | } 37 | 38 | pub fn init(&self, app_handle: AppHandle) { 39 | *self.app_handle.lock() = Some(app_handle); 40 | } 41 | 42 | pub fn get_window(&self) -> Option { 43 | self.app_handle 44 | .lock() 45 | .as_ref() 46 | .and_then(|a| a.get_window("main")) 47 | } 48 | 49 | fn get_manager(&self) -> Result { 50 | let app_handle = self.app_handle.lock(); 51 | if app_handle.is_none() { 52 | bail!("failed to get the hotkey manager"); 53 | } 54 | Ok(app_handle.as_ref().unwrap().global_shortcut_manager()) 55 | } 56 | 57 | pub fn refresh_common_config() { 58 | if let Some(window) = Self::global().get_window() { 59 | log_err!(window.emit("lanaya://refresh-common-config", "yes")); 60 | } 61 | } 62 | 63 | pub fn update_systray() -> Result<()> { 64 | let app_handle = Self::global().app_handle.lock(); 65 | if app_handle.is_none() { 66 | bail!("update_systray unhandled error"); 67 | } 68 | Tray::update_systray(app_handle.as_ref().unwrap())?; 69 | Ok(()) 70 | } 71 | 72 | #[allow(unused)] 73 | pub fn update_systray_select_item() -> Result<()> { 74 | let app_handle = Self::global().app_handle.lock(); 75 | if app_handle.is_none() { 76 | bail!("update_systray_select_item unhandled error"); 77 | } 78 | Tray::update_select_item(app_handle.as_ref().unwrap())?; 79 | Ok(()) 80 | } 81 | 82 | pub fn notice_to_window(msg_type: MsgTypeEnum, msg: S) -> Result<()> { 83 | let app_handle = Self::global().app_handle.lock(); 84 | if app_handle.is_none() { 85 | bail!("notice_all_window unhandled error"); 86 | } 87 | match msg_type { 88 | MsgTypeEnum::ChangeLanguage => { 89 | let window = app_handle.as_ref().unwrap().get_window("main"); 90 | if window.is_some() { 91 | if let Some(win) = window { 92 | win.emit("lanaya://change-language", msg)?; 93 | }; 94 | } 95 | } 96 | MsgTypeEnum::ChangeRecordLimit => { 97 | let window = app_handle.as_ref().unwrap().get_window("main"); 98 | if window.is_some() { 99 | if let Some(win) = window { 100 | win.emit("lanaya://change-record-limit", msg)?; 101 | }; 102 | } 103 | } 104 | MsgTypeEnum::ChangeHotKeys => { 105 | let window = app_handle.as_ref().unwrap().get_window("main"); 106 | if window.is_some() { 107 | if let Some(win) = window { 108 | win.emit("lanaya://change-hotkeys", msg)?; 109 | }; 110 | } 111 | } 112 | MsgTypeEnum::ChangeClipBoard => { 113 | let window = app_handle.as_ref().unwrap().get_window("main"); 114 | if window.is_some() { 115 | if let Some(win) = window { 116 | win.emit("lanaya://change-clipboard", msg)?; 117 | }; 118 | } 119 | } 120 | MsgTypeEnum::ChangeAutoPaste => { 121 | let window = app_handle.as_ref().unwrap().get_window("main"); 122 | if window.is_some() { 123 | if let Some(win) = window { 124 | win.emit("lanaya://change-auto-paste", msg)?; 125 | }; 126 | } 127 | } 128 | MsgTypeEnum::ChangeDeleteConfirm => { 129 | let window = app_handle.as_ref().unwrap().get_window("main"); 130 | if window.is_some() { 131 | if let Some(win) = window { 132 | win.emit("lanaya://change-delete-confirm", msg)?; 133 | }; 134 | } 135 | } 136 | } 137 | Ok(()) 138 | } 139 | 140 | pub fn refresh_global_shortcut() -> Result<()> { 141 | let hotkeys_new = Config::common().latest().hotkeys.clone(); 142 | if let Some(hotkeys) = hotkeys_new { 143 | // hotkeys 中找到 global-shortcut 开头的第一个 144 | let mut global_shortcut = None; 145 | for hotkey in hotkeys { 146 | if hotkey.starts_with("global-shortcut") { 147 | global_shortcut = Some(hotkey); 148 | break; 149 | } 150 | } 151 | // 目前只有一个 global-shortcut,此处可以改为循环 152 | if let Some(global_shortcut) = global_shortcut { 153 | let mut shortcut_manager = Self::global().get_manager()?; 154 | let _ = shortcut_manager.unregister_all(); 155 | let hot_key_arr: Vec<&str> = global_shortcut.split(':').collect(); 156 | if hot_key_arr[1].is_empty() { 157 | // 如果没有配置 global-shortcut,则等于清空快捷键,直接返回 158 | return Ok(()); 159 | } 160 | let hot_key_arr: Vec<&str> = hot_key_arr[1].split('+').collect(); 161 | let hot_key_arr: Vec = hot_key_arr 162 | .iter() 163 | .map(|x| x.parse::().unwrap()) 164 | .collect(); 165 | let short_cut_name = hotkey_util::get_short_cut_name(hot_key_arr, true); 166 | let _ = shortcut_manager.register(short_cut_name.as_str(), || { 167 | Self::open_window(WindowType::Main) 168 | }); 169 | } 170 | } 171 | Ok(()) 172 | } 173 | 174 | pub fn open_window(window_type: WindowType) { 175 | let binding = Self::global().app_handle.lock(); 176 | let previous_process_id: State = binding 177 | .as_ref() 178 | .expect("Couldn't get app_handle") 179 | .state(); 180 | let mut p_id = previous_process_id.0.lock().unwrap(); 181 | *p_id = window_util::get_active_process_id(); 182 | let app_handle = binding.as_ref().unwrap(); 183 | 184 | let window_info = match window_type { 185 | WindowType::Config => WindowInfo::config(), 186 | WindowType::Main => WindowInfo::main(), 187 | }; 188 | 189 | let label = window_info.label.as_str(); 190 | let title = window_info.title.as_str(); 191 | let url = window_info.url.as_str(); 192 | 193 | if let Some(window) = app_handle.get_window(label) { 194 | if window.is_visible().unwrap() { 195 | let _ = window.close(); 196 | return; 197 | } 198 | let _ = window.unminimize(); 199 | let _ = window.show(); 200 | let _ = window.set_focus(); 201 | return; 202 | } 203 | 204 | let new_window = tauri::window::WindowBuilder::new( 205 | app_handle, 206 | label.to_string(), 207 | tauri::WindowUrl::App(url.into()), 208 | ) 209 | .title(title) 210 | .center() 211 | .visible(false) 212 | .resizable(window_info.resizable) 213 | .fullscreen(window_info.fullscreenable) 214 | .always_on_top(window_info.always_on_top) 215 | .inner_size(window_info.width, window_info.height) 216 | .transparent(window_info.transparent) 217 | .decorations(window_info.decorations) 218 | .skip_taskbar(window_info.skip_taskbar) 219 | .center() 220 | .build(); 221 | match new_window { 222 | Ok(window) => { 223 | let _ = window.show(); 224 | let _ = window.set_focus(); 225 | if let WindowType::Main = window_type { 226 | set_shadow(&window, true).expect("Unsupported platform!"); 227 | } 228 | } 229 | Err(e) => { 230 | println!("create_window error: {}", e); 231 | } 232 | } 233 | } 234 | 235 | } 236 | -------------------------------------------------------------------------------- /src-tauri/src/core/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod clipboard; 2 | pub mod database; 3 | pub mod handle; 4 | pub mod sysopt; 5 | pub mod tray; 6 | pub mod window_manager; 7 | -------------------------------------------------------------------------------- /src-tauri/src/core/sysopt.rs: -------------------------------------------------------------------------------- 1 | use crate::{config::Config, log_err}; 2 | use anyhow::{anyhow, Result}; 3 | use auto_launch::{AutoLaunch, AutoLaunchBuilder}; 4 | use once_cell::sync::OnceCell; 5 | use parking_lot::Mutex; 6 | use std::sync::Arc; 7 | use tauri::utils::platform::current_exe; 8 | 9 | pub struct Sysopt { 10 | auto_launch: Arc>>, 11 | } 12 | 13 | impl Sysopt { 14 | pub fn global() -> &'static Sysopt { 15 | static SYSOPT: OnceCell = OnceCell::new(); 16 | 17 | SYSOPT.get_or_init(|| Sysopt { 18 | auto_launch: Arc::new(Mutex::new(None)), 19 | }) 20 | } 21 | 22 | pub fn init_launch(&self) -> Result<()> { 23 | let enable = { Config::common().latest().enable_auto_launch }; 24 | let enable = enable.unwrap_or(false); 25 | 26 | println!("enable auto launch: {}", enable); 27 | 28 | let app_exe = current_exe()?; 29 | let app_exe = dunce::canonicalize(app_exe)?; 30 | let app_name = app_exe 31 | .file_stem() 32 | .and_then(|f| f.to_str()) 33 | .ok_or(anyhow!("failed to get file stem"))?; 34 | let app_path = app_exe 35 | .as_os_str() 36 | .to_str() 37 | .ok_or(anyhow!("failed to get app_path"))? 38 | .to_string(); 39 | #[cfg(target_os = "windows")] 40 | let app_path = format!("\"{app_path}\""); 41 | 42 | // use the /Applications/Lanaya.app path 43 | #[cfg(target_os = "macos")] 44 | let app_path = (|| -> Option { 45 | let path = std::path::PathBuf::from(&app_path); 46 | let path = path.parent()?.parent()?.parent()?; 47 | let extension = path.extension()?.to_str()?; 48 | match extension == "app" { 49 | true => Some(path.as_os_str().to_str()?.to_string()), 50 | false => None, 51 | } 52 | })() 53 | .unwrap_or(app_path); 54 | println!("app_path: {}", app_path); 55 | 56 | let auto = AutoLaunchBuilder::new() 57 | .set_app_name(app_name) 58 | .set_app_path(&app_path) 59 | .build()?; 60 | 61 | if let Ok(true) = auto.is_enabled() { 62 | if enable { 63 | return Ok(()); 64 | } 65 | } 66 | 67 | // macos每次启动都更新登录项,避免重复设置登录项 68 | #[cfg(target_os = "macos")] 69 | let _ = auto.disable(); 70 | 71 | if enable { 72 | auto.enable()?; 73 | } 74 | *self.auto_launch.lock() = Some(auto); 75 | 76 | Ok(()) 77 | } 78 | 79 | pub fn update_launch(&self) -> Result<()> { 80 | let auto_launch = self.auto_launch.lock(); 81 | 82 | if auto_launch.is_none() { 83 | drop(auto_launch); 84 | return self.init_launch(); 85 | } 86 | let enable = { Config::common().latest().enable_auto_launch }; 87 | let enable = enable.unwrap_or(false); 88 | let auto_launch = auto_launch.as_ref().unwrap(); 89 | 90 | match enable { 91 | true => auto_launch.enable()?, 92 | false => log_err!(auto_launch.disable()), // 忽略关闭的错误 93 | }; 94 | 95 | Ok(()) 96 | } 97 | 98 | /// todo listen clipboard loop 99 | #[allow(unused)] 100 | pub fn init_clipboard_listener(&self) {} 101 | } 102 | -------------------------------------------------------------------------------- /src-tauri/src/core/tray.rs: -------------------------------------------------------------------------------- 1 | use super::handle::Handle; 2 | use super::window_manager::WindowType; 3 | use crate::config; 4 | use crate::config::{CommonConfig, Config}; 5 | use anyhow::Result; 6 | use tauri::{ 7 | AppHandle, CustomMenuItem, Manager, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem, 8 | SystemTraySubmenu, 9 | }; 10 | 11 | pub struct Tray {} 12 | 13 | impl Tray { 14 | pub fn tray_menu(app_handle: &AppHandle) -> SystemTrayMenu { 15 | let zh = { Config::common().latest().language == Some("zh".into()) }; 16 | let version = app_handle.package_info().version.to_string(); 17 | if zh { 18 | SystemTrayMenu::new() 19 | .add_item(CustomMenuItem::new("open_window", "显示界面")) 20 | .add_item(CustomMenuItem::new("hide_window", "隐藏界面").accelerator("Esc")) 21 | .add_native_item(SystemTrayMenuItem::Separator) 22 | .add_submenu(SystemTraySubmenu::new( 23 | "语言", 24 | SystemTrayMenu::new() 25 | .add_item(CustomMenuItem::new("language_zh", "简体中文")) 26 | .add_item(CustomMenuItem::new("language_en", "English")), 27 | )) 28 | .add_item(CustomMenuItem::new("more_config", "更多设置")) 29 | .add_native_item(SystemTrayMenuItem::Separator) 30 | .add_item(CustomMenuItem::new("app_version", format!("版本 {version}")).disabled()) 31 | .add_item(CustomMenuItem::new("quit", "退出").accelerator("CmdOrControl+Q")) 32 | } else { 33 | SystemTrayMenu::new() 34 | .add_item(CustomMenuItem::new("open_window", "Show Window")) 35 | .add_item(CustomMenuItem::new("hide_window", "Hide Window").accelerator("Esc")) 36 | .add_native_item(SystemTrayMenuItem::Separator) 37 | .add_submenu(SystemTraySubmenu::new( 38 | "Language", 39 | SystemTrayMenu::new() 40 | .add_item(CustomMenuItem::new("language_zh", "简体中文")) 41 | .add_item(CustomMenuItem::new("language_en", "English")), 42 | )) 43 | .add_item(CustomMenuItem::new("more_config", "More Config")) 44 | .add_native_item(SystemTrayMenuItem::Separator) 45 | .add_item( 46 | CustomMenuItem::new("app_version", format!("Version {version}")).disabled(), 47 | ) 48 | .add_item(CustomMenuItem::new("quit", "Quit").accelerator("CmdOrControl+Q")) 49 | } 50 | } 51 | 52 | pub fn update_systray(app_handle: &AppHandle) -> Result<()> { 53 | app_handle 54 | .tray_handle() 55 | .set_menu(Tray::tray_menu(app_handle))?; 56 | Tray::update_select_item(app_handle)?; 57 | Ok(()) 58 | } 59 | 60 | pub fn update_select_item(app_handle: &AppHandle) -> Result<()> { 61 | let language = { Config::common().latest().language.clone() }; 62 | 63 | let tray = app_handle.tray_handle(); 64 | 65 | if let Some(language) = language { 66 | if language == "zh" { 67 | let _ = tray.get_item("language_zh").set_selected(true); 68 | let _ = tray.get_item("language_en").set_selected(false); 69 | } else { 70 | let _ = tray.get_item("language_en").set_selected(true); 71 | let _ = tray.get_item("language_zh").set_selected(false); 72 | } 73 | } 74 | Ok(()) 75 | } 76 | 77 | pub fn on_system_tray_event(app_handle: &AppHandle, event: SystemTrayEvent) { 78 | match event { 79 | SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() { 80 | "open_window" => { 81 | Handle::open_window(WindowType::Main); 82 | } 83 | "hide_window" => { 84 | let window = app_handle.get_window("main"); 85 | if let Some(window) = window { 86 | window.hide().unwrap(); 87 | } 88 | } 89 | "language_zh" => change_language("zh".into()), 90 | "language_en" => change_language("en".into()), 91 | "more_config" => Handle::open_window(WindowType::Config), 92 | "quit" => { 93 | app_handle.exit(0); 94 | std::process::exit(0); 95 | } 96 | _ => {} 97 | }, 98 | #[cfg(target_os = "windows")] 99 | SystemTrayEvent::LeftClick { .. } => { 100 | if let Some(window) = app_handle.get_window("main") { 101 | if let Err(err) = window.show() { 102 | println!("Failed to show window: {}", err); 103 | } 104 | if let Err(err) = window.set_focus() { 105 | println!("Failed to set focus on window: {}", err); 106 | } 107 | } 108 | } 109 | _ => {} 110 | } 111 | } 112 | } 113 | 114 | // 切换模式语言 115 | fn change_language(mode: String) { 116 | tauri::async_runtime::spawn(async move { 117 | match config::modify_common_config(CommonConfig { 118 | language: Some(mode), 119 | ..CommonConfig::default() 120 | }) 121 | .await 122 | { 123 | Ok(_) => println!("change_language: success"), 124 | Err(err) => println!("change_language: {}", err), 125 | } 126 | }); 127 | } 128 | -------------------------------------------------------------------------------- /src-tauri/src/core/window_manager.rs: -------------------------------------------------------------------------------- 1 | pub enum WindowType { 2 | Config, 3 | Main, 4 | } 5 | pub struct WindowInfo { 6 | pub label: String, 7 | pub title: String, 8 | pub url: String, 9 | pub width: f64, 10 | pub height: f64, 11 | pub resizable: bool, 12 | // todo tauri no this API 13 | // minimizable: bool, 14 | pub fullscreenable: bool, 15 | pub always_on_top: bool, 16 | pub transparent: bool, 17 | pub decorations: bool, 18 | pub skip_taskbar: bool, 19 | } 20 | 21 | impl WindowInfo { 22 | pub fn main() -> Self { 23 | WindowInfo { 24 | label: "main".into(), 25 | title: "Lanaya".into(), 26 | url: "/".into(), 27 | width: 800.0, 28 | height: 600.0, 29 | resizable: false, 30 | // minimizable: false, 31 | fullscreenable: false, 32 | always_on_top: false, 33 | transparent: true, 34 | decorations: false, 35 | skip_taskbar: true, 36 | } 37 | } 38 | pub fn config() -> Self { 39 | WindowInfo { 40 | label: "config".into(), 41 | title: "设置".into(), 42 | url: "/config".into(), 43 | width: 640.0, 44 | height: 480.0, 45 | resizable: false, 46 | // minimizable: false, 47 | fullscreenable: false, 48 | always_on_top: false, 49 | transparent: false, 50 | decorations: true, 51 | skip_taskbar: false, 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | use tauri::{App, Manager, SystemTray}; 7 | use window_shadows::set_shadow; 8 | 9 | use crate::config::Config; 10 | use crate::core::clipboard; 11 | use crate::core::database::SqliteDB; 12 | use crate::core::sysopt; 13 | use crate::core::tray; 14 | mod cmds; 15 | mod config; 16 | mod core; 17 | mod utils; 18 | use std::sync::Mutex; 19 | 20 | pub struct PreviousProcessId(Mutex); 21 | 22 | fn main() { 23 | let app = tauri::Builder::default() 24 | .setup(|app| { 25 | set_up(app); 26 | Ok(()) 27 | }) 28 | .system_tray(SystemTray::new()) 29 | .on_system_tray_event(core::tray::Tray::on_system_tray_event) 30 | .manage(PreviousProcessId(Default::default())) 31 | .invoke_handler(tauri::generate_handler![ 32 | cmds::get_common_config, 33 | cmds::set_common_config, 34 | cmds::change_language, 35 | cmds::change_record_limit, 36 | cmds::change_auto_launch, 37 | cmds::change_auto_paste, 38 | cmds::change_theme_mode, 39 | cmds::change_hotkeys, 40 | cmds::clear_data, 41 | cmds::insert_record, 42 | cmds::insert_if_not_exist, 43 | cmds::find_all_record, 44 | cmds::mark_favorite, 45 | cmds::save_tags, 46 | cmds::find_by_key, 47 | cmds::delete_over_limit, 48 | cmds::write_to_clip, 49 | cmds::delete_by_id, 50 | cmds::focus_previous_window, 51 | cmds::paste_in_previous_window, 52 | cmds::change_delete_confirm, 53 | ]) 54 | .build(tauri::generate_context!()) 55 | .expect("error while build tauri application"); 56 | 57 | app.run(|app_handle, e| match e { 58 | tauri::RunEvent::ExitRequested { api, .. } => { 59 | api.prevent_exit(); 60 | } 61 | tauri::RunEvent::Exit => { 62 | app_handle.exit(0); 63 | } 64 | _ => {} 65 | }); 66 | } 67 | 68 | fn set_up(app: &mut App) { 69 | // Make the docker NOT to have an active app when started 70 | #[cfg(target_os = "macos")] 71 | app.set_activation_policy(tauri::ActivationPolicy::Accessory); 72 | let window = app.get_window("main").unwrap(); 73 | set_shadow(&window, true).expect("Unsupported platform!"); 74 | core::handle::Handle::global().init(app.app_handle()); 75 | log_err!(Config::init_config()); 76 | log_err!(tray::Tray::update_systray(&app.app_handle())); 77 | log_err!(sysopt::Sysopt::global().init_launch()); 78 | let _ = core::handle::Handle::refresh_global_shortcut(); 79 | SqliteDB::init(); 80 | clipboard::ClipboardWatcher::start(); 81 | } 82 | -------------------------------------------------------------------------------- /src-tauri/src/utils/dirs.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::path::PathBuf; 3 | use tauri::api::path::home_dir; 4 | 5 | static APP_DIR: &str = "lanaya"; 6 | static CONFIG_FILE: &str = "config.json"; 7 | 8 | /// get the app home dir 9 | pub fn app_home_dir() -> Result { 10 | #[cfg(target_os = "windows")] 11 | { 12 | use tauri::utils::platform::current_exe; 13 | 14 | let app_exe = current_exe()?; 15 | let app_exe = dunce::canonicalize(app_exe)?; 16 | let app_dir = app_exe 17 | .parent() 18 | .ok_or(anyhow::anyhow!("failed to get the portable app dir"))?; 19 | Ok(PathBuf::from(app_dir).join(".config").join(APP_DIR)) 20 | 21 | } 22 | 23 | #[cfg(not(target_os = "windows"))] 24 | Ok(home_dir() 25 | .ok_or(anyhow::anyhow!("failed to get the app home dir"))? 26 | .join(".config") 27 | .join(APP_DIR)) 28 | } 29 | 30 | /// logs dir 31 | #[allow(unused)] 32 | pub fn app_logs_dir() -> Result { 33 | Ok(app_home_dir()?.join("logs")) 34 | } 35 | 36 | pub fn config_path() -> Result { 37 | Ok(app_home_dir()?.join(CONFIG_FILE)) 38 | } 39 | 40 | #[allow(unused)] 41 | pub fn app_data_dir() -> Result { 42 | Ok(app_home_dir()?.join("data")) 43 | } 44 | 45 | pub fn app_data_img_dir() -> Result { 46 | Ok(app_data_dir()?.join("img")) 47 | } 48 | 49 | #[test] 50 | fn test() { 51 | println!("app_home_dir: {:?}", app_home_dir()); 52 | println!("app_logs_dir: {:?}", app_logs_dir()); 53 | println!("config_path: {:?}", config_path()); 54 | println!("app_data_dir: {:?}", app_data_dir()); 55 | } 56 | -------------------------------------------------------------------------------- /src-tauri/src/utils/dispatch_util.rs: -------------------------------------------------------------------------------- 1 | use rdev::{simulate, EventType, Key, SimulateError}; 2 | use std::{thread, time}; 3 | 4 | pub fn paste() { 5 | // Run command + v to paste 6 | // Same approach as both Maccy and Clipy, reference: https://github.com/p0deje/Maccy/blob/master/Maccy/Clipboard.swift#L101 7 | dispatch(&EventType::KeyPress(Key::MetaLeft)); 8 | dispatch(&EventType::KeyPress(Key::KeyV)); 9 | dispatch(&EventType::KeyRelease(Key::KeyV)); 10 | dispatch(&EventType::KeyRelease(Key::MetaLeft)); 11 | } 12 | 13 | pub fn request_permissions() { 14 | // Simply press and release the shift key. First time the OS will ask for permissions, then do it without asking. 15 | dispatch(&EventType::KeyPress(Key::ShiftLeft)); 16 | dispatch(&EventType::KeyRelease(Key::ShiftLeft)); 17 | } 18 | 19 | fn dispatch(event_type: &EventType) { 20 | match simulate(event_type) { 21 | Ok(()) => (), 22 | Err(SimulateError) => { 23 | println!("We could not dispatch {:?}", event_type); 24 | } 25 | } 26 | // Let the OS catchup (at least MacOS) 27 | sleep(20) 28 | } 29 | 30 | fn sleep(ms: u64) { 31 | let delay = time::Duration::from_millis(ms); 32 | thread::sleep(delay); 33 | } -------------------------------------------------------------------------------- /src-tauri/src/utils/hotkey_util.rs: -------------------------------------------------------------------------------- 1 | /// keyCode to keyName 2 | /// @param {Number} keyCode 3 | /// @return {String} keyName 4 | fn key_code_to_name(key_code: u32) -> String { 5 | match key_code { 6 | 8 => "backspace", 7 | 9 => "tab", 8 | 12 => "clear", 9 | 13 => "enter", 10 | 27 => "esc", 11 | 32 => "space", 12 | 37 => "left", 13 | 38 => "up", 14 | 39 => "right", 15 | 40 => "down", 16 | 46 => "delete", 17 | 45 => "insert", 18 | 36 => "home", 19 | 35 => "end", 20 | 33 => "pageup", 21 | 34 => "pagedown", 22 | 20 => "capslock", 23 | 96 => "num_0", 24 | 97 => "num_1", 25 | 98 => "num_2", 26 | 99 => "num_3", 27 | 100 => "num_4", 28 | 101 => "num_5", 29 | 102 => "num_6", 30 | 103 => "num_7", 31 | 104 => "num_8", 32 | 105 => "num_9", 33 | 106 => "num_multiply", 34 | 107 => "num_add", 35 | 108 => "num_enter", 36 | 109 => "num_subtract", 37 | 110 => "num_decimal", 38 | 111 => "num_divide", 39 | 188 => ",", 40 | 190 => ".", 41 | 191 => "/", 42 | 192 => "`", 43 | 189 => "-", 44 | 187 => "=", 45 | 186 => ";", 46 | 222 => "'", 47 | 219 => "[", 48 | 221 => "]", 49 | 220 => "\\", 50 | _ => "", 51 | } 52 | .to_string() 53 | } 54 | 55 | fn modifier_code_to_name(modifier_code: u32) -> String { 56 | match modifier_code { 57 | 16 => "shift", 58 | 18 => "alt", 59 | 17 => "ctrl", 60 | 91 => "command", 61 | _ => "", 62 | } 63 | .to_string() 64 | } 65 | 66 | pub fn get_short_cut_name(key_code_arr: Vec, is_first_word_upper_case: bool) -> String { 67 | let mut key_str = String::new(); 68 | let mut modifier = String::new(); 69 | let mut normal_key = String::new(); 70 | for key_code in key_code_arr { 71 | if !modifier_code_to_name(key_code).is_empty() { 72 | modifier += &capitalized(&modifier_code_to_name(key_code), is_first_word_upper_case); 73 | modifier += "+"; 74 | } else if !key_code_to_name(key_code).is_empty() { 75 | key_str = capitalized(&key_code_to_name(key_code), is_first_word_upper_case); 76 | } else { 77 | normal_key = capitalized( 78 | &String::from_utf8(vec![key_code as u8]).unwrap(), 79 | is_first_word_upper_case, 80 | ); 81 | } 82 | } 83 | if modifier.is_empty() && key_str.is_empty() { 84 | return "".to_string(); 85 | } 86 | // 若只有modifier,不显示 87 | if !modifier.is_empty() && key_str.is_empty() && normal_key.is_empty() { 88 | return "".to_string(); 89 | } 90 | // 若只有keyStr,不显示 91 | if modifier.is_empty() && !key_str.is_empty() && !normal_key.is_empty() { 92 | return "".to_string(); 93 | } 94 | modifier + &key_str + &normal_key 95 | } 96 | 97 | fn capitalized(name: &str, is_first_word_upper_case: bool) -> String { 98 | let name = name.to_lowercase(); 99 | if !is_first_word_upper_case { 100 | return name; 101 | } 102 | let capitalized_first = name.chars().next().unwrap().to_uppercase().to_string(); 103 | if name.len() == 1 { 104 | return capitalized_first; 105 | } 106 | let rest = &name[1..]; 107 | capitalized_first + rest 108 | } 109 | 110 | #[test] 111 | fn test() { 112 | let hot_key_str = "global-shortcut:16+67+91"; 113 | let hot_key_arr: Vec<&str> = hot_key_str.split(":").collect(); 114 | let hot_key_arr: Vec<&str> = hot_key_arr[1].split("+").collect(); 115 | let hot_key_arr: Vec = hot_key_arr 116 | .iter() 117 | .map(|x| x.parse::().unwrap()) 118 | .collect(); 119 | let short_cut_name = get_short_cut_name(hot_key_arr, true); 120 | println!("{}", short_cut_name); 121 | } 122 | -------------------------------------------------------------------------------- /src-tauri/src/utils/img_util.rs: -------------------------------------------------------------------------------- 1 | use super::string_util; 2 | use anyhow::Result; 3 | use arboard::ImageData; 4 | use image::ImageEncoder; 5 | use std::io::{BufReader, BufWriter, Cursor}; 6 | 7 | pub fn rgba8_to_base64(img: &ImageData) -> String { 8 | let mut bytes: Vec = Vec::new(); 9 | image::codecs::png::PngEncoder::new(BufWriter::new(Cursor::new(&mut bytes))) 10 | .write_image( 11 | &img.bytes, 12 | img.width as u32, 13 | img.height as u32, 14 | image::ColorType::Rgba8, 15 | ) 16 | .unwrap(); 17 | string_util::base64_encode(bytes.as_slice()) 18 | } 19 | 20 | pub fn rgba8_to_jpeg_base64(img: &ImageData, quality: u8) -> String { 21 | let mut bytes: Vec = Vec::new(); 22 | image::codecs::jpeg::JpegEncoder::new_with_quality( 23 | BufWriter::new(Cursor::new(&mut bytes)), 24 | quality, 25 | ) 26 | .write_image( 27 | &img.bytes, 28 | img.width as u32, 29 | img.height as u32, 30 | image::ColorType::Rgba8, 31 | ) 32 | .unwrap(); 33 | string_util::base64_encode(bytes.as_slice()) 34 | } 35 | 36 | pub fn base64_to_rgba8(base64: &str) -> Result { 37 | let bytes = string_util::base64_decode(base64); 38 | let reader = 39 | image::io::Reader::with_format(BufReader::new(Cursor::new(bytes)), image::ImageFormat::Png); 40 | match reader.decode() { 41 | Ok(img) => { 42 | let rgba = img.into_rgba8(); 43 | let (width, height) = rgba.dimensions(); 44 | Ok(ImageData { 45 | width: width as usize, 46 | height: height as usize, 47 | bytes: rgba.into_raw().into(), 48 | }) 49 | } 50 | Err(_) => Err(anyhow::anyhow!("decode image error")), 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src-tauri/src/utils/json_util.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Context, Result}; 2 | use serde::{de::DeserializeOwned, Serialize}; 3 | use serde_json; 4 | use std::{fs, path::PathBuf}; 5 | 6 | pub fn read(path: &PathBuf) -> Result { 7 | if !path.exists() { 8 | bail!("file not found \"{}\"", path.display()); 9 | } 10 | 11 | let json_str = fs::read_to_string(path) 12 | .context(format!("failed to read the file \"{}\"", path.display()))?; 13 | 14 | serde_json::from_str::(&json_str).context(format!( 15 | "failed to read the file with yaml format \"{}\"", 16 | path.display() 17 | )) 18 | } 19 | 20 | pub fn save(path: &PathBuf, data: &T) -> Result<()> { 21 | let data_str = serde_json::to_string(data)?; 22 | println!("data_str: {}", data_str); 23 | let path_str = path.as_os_str().to_string_lossy().to_string(); 24 | fs::write(path, data_str.as_bytes()).context(format!("failed to save file \"{path_str}\"")) 25 | } 26 | 27 | pub fn parse(json_str: &str) -> Result { 28 | serde_json::from_str::(json_str).context("failed to parse json string") 29 | } 30 | 31 | pub fn stringfy(data: &T) -> Result { 32 | Ok(serde_json::to_string(data)?) 33 | } 34 | -------------------------------------------------------------------------------- /src-tauri/src/utils/log_print.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! log_err { 3 | ($result: expr) => { 4 | if let Err(err) = $result { 5 | println!("err: {}", err); 6 | } 7 | }; 8 | 9 | ($result: expr, $err_str: expr) => { 10 | if let Err(_) = $result { 11 | println!("err: {}", $err_str); 12 | } 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src-tauri/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dirs; 2 | pub mod hotkey_util; 3 | pub mod img_util; 4 | pub mod json_util; 5 | pub mod log_print; 6 | pub mod string_util; 7 | pub mod dispatch_util; 8 | pub mod window_util; 9 | -------------------------------------------------------------------------------- /src-tauri/src/utils/string_util.rs: -------------------------------------------------------------------------------- 1 | use base64::engine::general_purpose; 2 | use base64::Engine; 3 | use crypto::digest::Digest; 4 | use crypto::md5::Md5; 5 | 6 | pub fn md5(s: &str) -> String { 7 | let mut hasher = Md5::new(); 8 | hasher.input_str(s); 9 | hasher.result_str() 10 | } 11 | 12 | #[allow(unused)] 13 | pub fn md5_by_bytes(bytes: &[u8]) -> String { 14 | let mut hasher = Md5::new(); 15 | hasher.input(bytes); 16 | hasher.result_str() 17 | } 18 | 19 | pub fn base64_encode(bytes: &[u8]) -> String { 20 | general_purpose::STANDARD.encode(bytes) 21 | } 22 | 23 | pub fn base64_decode(base64: &str) -> Vec { 24 | general_purpose::STANDARD.decode(base64).unwrap() 25 | } 26 | 27 | // 如果content中 包含key的话,就把key用key高亮起来 28 | pub fn highlight(key: &str, content: &str) -> String { 29 | let mut res = String::new(); 30 | let mut start = 0; 31 | let mut end; 32 | 33 | while let Some(i) = content[start..].to_lowercase().find(&key.to_lowercase()) { 34 | end = start + i; 35 | res.push_str(&content[start..end]); 36 | res.push_str(&format!( 37 | "[highlight]{}[/highlight]", 38 | &content[end..end + key.len()] 39 | )); 40 | start = end + key.len(); 41 | } 42 | res.push_str(&content[start..]); 43 | res = escape_html(&res); 44 | res = res 45 | .replace("[highlight]", "") 46 | .replace("[/highlight]", ""); 47 | res 48 | } 49 | 50 | fn escape_html(html: &str) -> String { 51 | html.replace("<", "<").replace(">", ">") 52 | } 53 | 54 | #[test] 55 | fn test_highlight() { 56 | let res = highlight("hello", "hello worldhello"); 57 | println!("{}", res); 58 | } 59 | -------------------------------------------------------------------------------- /src-tauri/src/utils/window_util.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "macos")] 2 | use active_win_pos_rs::get_active_window; 3 | 4 | #[cfg(target_os = "macos")] 5 | use cocoa::base::{nil}; 6 | 7 | #[cfg(target_os = "macos")] 8 | use cocoa::appkit::{NSRunningApplication, NSApplicationActivateIgnoringOtherApps}; 9 | 10 | #[cfg(target_os = "windows")] 11 | use winapi::um::winuser::{GetForegroundWindow, SetForegroundWindow}; 12 | 13 | #[cfg(target_os = "windows")] 14 | use winapi::um::winuser::GetWindowThreadProcessId; 15 | 16 | #[cfg(target_os = "windows")] 17 | use winapi::shared::minwindef::LPDWORD; 18 | 19 | pub fn get_active_process_id() -> i32 { 20 | #[cfg(target_os = "macos")] 21 | { 22 | match get_active_window() { 23 | Ok(active_window) => { 24 | let process_id: i32 = active_window.process_id.try_into().unwrap(); 25 | process_id 26 | }, 27 | Err(()) => { 28 | println!("error occurred while getting the active window"); 29 | 0 30 | } 31 | } 32 | } 33 | 34 | #[cfg(target_os = "windows")] 35 | unsafe { 36 | let hwnd = GetForegroundWindow(); 37 | if hwnd.is_null() { 38 | println!("Could not get active window"); 39 | return 0; 40 | } 41 | 42 | let mut process_id: u32 = 0; 43 | 44 | GetWindowThreadProcessId(hwnd, &mut process_id as LPDWORD); 45 | 46 | if process_id == 0 { 47 | println!("Could not get process id of active window"); 48 | return 0; 49 | } 50 | 51 | process_id as i32 52 | } 53 | } 54 | 55 | pub fn focus_window(process_id: i32) { 56 | if process_id == 0 { 57 | return; 58 | } 59 | 60 | #[cfg(target_os = "macos")] 61 | unsafe { 62 | let current_app = NSRunningApplication::runningApplicationWithProcessIdentifier(nil, process_id); 63 | current_app.activateWithOptions_(NSApplicationActivateIgnoringOtherApps); 64 | } 65 | 66 | #[cfg(target_os = "windows")] 67 | unsafe { 68 | let hwnd = GetForegroundWindow(); 69 | 70 | if hwnd.is_null() { 71 | println!("Could not get active window"); 72 | return; 73 | } 74 | 75 | if SetForegroundWindow(hwnd) == 0 { 76 | println!("Could not focus on window"); 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeBuildCommand": "npm run build", 4 | "beforeDevCommand": "npm run dev", 5 | "devPath": "http://localhost:5173", 6 | "distDir": "../dist" 7 | }, 8 | "package": { 9 | "productName": "Lanaya", 10 | "version": "1.2.1" 11 | }, 12 | "tauri": { 13 | "macOSPrivateApi": true, 14 | "allowlist": { 15 | "shell": { 16 | "open": true 17 | }, 18 | "window": { 19 | "all": true 20 | }, 21 | "globalShortcut": { 22 | "all": true 23 | }, 24 | "notification": { 25 | "all": true 26 | }, 27 | "os": { 28 | "all": true 29 | } 30 | }, 31 | "systemTray": { 32 | "iconPath": "icons/icon.png", 33 | "iconAsTemplate": true 34 | }, 35 | "bundle": { 36 | "active": true, 37 | "targets": "all", 38 | "category": "DeveloperTool", 39 | "copyright": "© 2023 churchTao All Rights Reserved", 40 | "deb": { 41 | "depends": [] 42 | }, 43 | "externalBin": [], 44 | "icon": [ 45 | "icons/32x32.png", 46 | "icons/128x128.png", 47 | "icons/128x128@2x.png", 48 | "icons/icon.icns", 49 | "icons/icon.ico" 50 | ], 51 | "identifier": "com.church.lanaya", 52 | "longDescription": "Easy to use, full keyboard, clipboard manager", 53 | "macOS": { 54 | "entitlements": null, 55 | "exceptionDomain": "", 56 | "frameworks": [], 57 | "providerShortName": null, 58 | "signingIdentity": null 59 | }, 60 | "resources": [], 61 | "shortDescription": "Clipboard manager", 62 | "windows": { 63 | "certificateThumbprint": null, 64 | "digestAlgorithm": "sha256", 65 | "timestampUrl": "", 66 | "wix": { 67 | "language": [ 68 | "zh-CN", 69 | "en-US" 70 | ] 71 | } 72 | } 73 | }, 74 | "security": { 75 | "csp": "default-src 'self';img-src 'self' data: *;" 76 | }, 77 | "updater": { 78 | "active": false, 79 | "dialog": true, 80 | "endpoints": [ 81 | "https://github.com/churchTao/Lanaya/releases/download/updater/update.json" 82 | ], 83 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFFQkYyRTBBMzk3MkM0QTcKUldTbnhISTVDaTYvSHJXbFpZYzNydW8yU0lEN2JjOFFYTHNpZFZnMmxRTWM1SUtjM0ZlcThlaVkK" 84 | }, 85 | "windows": [ 86 | { 87 | "fullscreen": false, 88 | "height": 600, 89 | "resizable": false, 90 | "title": "Lanaya", 91 | "width": 800, 92 | "transparent": true, 93 | "decorations": false, 94 | "skipTaskbar": true, 95 | "center": true 96 | } 97 | ] 98 | } 99 | } -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/assets/about-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src/assets/about-icon.png -------------------------------------------------------------------------------- /src/assets/about.svg: -------------------------------------------------------------------------------- 1 | 3 | 6 | -------------------------------------------------------------------------------- /src/assets/backspace.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/fuzhi.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/gh-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChurchTao/Lanaya/8e50414f8d5c8fc70b6dfac0af9cc91813ee87b8/src/assets/gh-desktop.png -------------------------------------------------------------------------------- /src/assets/shezhi.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/sousuo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ClipBoardItem.vue: -------------------------------------------------------------------------------- 1 | 114 | 225 | 255 | 272 | -------------------------------------------------------------------------------- /src/components/ClipBoardList.vue: -------------------------------------------------------------------------------- 1 | 35 | 57 | 95 | -------------------------------------------------------------------------------- /src/components/HotKeyItem.vue: -------------------------------------------------------------------------------- 1 | 9 | 16 | 35 | -------------------------------------------------------------------------------- /src/components/KeyMapBar.vue: -------------------------------------------------------------------------------- 1 | 20 | 37 | 44 | -------------------------------------------------------------------------------- /src/components/MainLayout.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/components/SearchBar.vue: -------------------------------------------------------------------------------- 1 | 19 | 31 | 37 | -------------------------------------------------------------------------------- /src/components/TagGroup.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 118 | 119 | 124 | -------------------------------------------------------------------------------- /src/components/child/clipboard/SearchNoResult.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 35 | 36 | -------------------------------------------------------------------------------- /src/components/child/config/HotKeyInput.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /src/components/child/config/LeftSectionItem.vue: -------------------------------------------------------------------------------- 1 | 19 | 39 | 40 | 67 | -------------------------------------------------------------------------------- /src/components/child/config/RightSectionAbout.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 73 | 74 | 79 | -------------------------------------------------------------------------------- /src/components/child/config/RightSectionCommonConfig.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 97 | 231 | 232 | 237 | -------------------------------------------------------------------------------- /src/components/child/config/base/BaseSelect.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 89 | -------------------------------------------------------------------------------- /src/components/child/config/base/BaseSwitch.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 29 | -------------------------------------------------------------------------------- /src/config/constants.js: -------------------------------------------------------------------------------- 1 | export const languageOptions = [ 2 | { 3 | name: "简体中文", 4 | value: "zh", 5 | }, 6 | { 7 | name: "English", 8 | value: "en", 9 | }, 10 | ]; 11 | 12 | export const themeOptions = [ 13 | { 14 | name: "Light", 15 | value: "light", 16 | }, 17 | { 18 | name: "Dark", 19 | value: "dark", 20 | }, 21 | ]; 22 | 23 | export const recordLimitOptions = [ 24 | { name: "50", value: 50 }, 25 | { name: "100", value: 100 }, 26 | { name: "200", value: 200 }, 27 | { name: "300", value: 300 }, 28 | ]; 29 | 30 | export const hotkeys_func_enum = { 31 | COPY: "copy", 32 | QUICK_COPY: "quick-copy", 33 | MOVE_SELECTED: "move-selected", 34 | CLOSE_WINDOW: "close-window", 35 | GLOBAL_SHORTCUT: "global-shortcut", 36 | CLEAR_HISTORY: "clear-history", 37 | }; 38 | 39 | /** 40 | * { keymap: ["⏎"], tips: "hotkeys.copy" }, 41 | { keymap: ["⌘"], tips: "hotkeys.quick-copy" }, 42 | { keymap: ["↑", "↓"], tips: "hotkeys.move-selected" }, 43 | { keymap: ["Esc"], tips: "hotkeys.close-window" }, 44 | */ 45 | export const defaultHotkeys = [ 46 | { 47 | func: hotkeys_func_enum.COPY, 48 | keys: [13], 49 | }, 50 | { 51 | func: hotkeys_func_enum.QUICK_COPY, 52 | keys: [91], 53 | }, 54 | { 55 | func: hotkeys_func_enum.MOVE_SELECTED, 56 | keys: [38, 40], 57 | }, 58 | { 59 | func: hotkeys_func_enum.CLOSE_WINDOW, 60 | keys: [27], 61 | }, 62 | { 63 | func: hotkeys_func_enum.CLEAR_HISTORY, 64 | keys: [], 65 | }, 66 | { 67 | func: hotkeys_func_enum.GLOBAL_SHORTCUT, 68 | keys: [], 69 | }, 70 | ]; 71 | -------------------------------------------------------------------------------- /src/i18n/index.js: -------------------------------------------------------------------------------- 1 | import { createI18n } from "vue-i18n"; 2 | import en from "./locales/en.yaml"; 3 | import zh from "./locales/zh.yaml"; 4 | 5 | let language = window.localStorage.getItem("language") || "zh"; 6 | 7 | export const i18n = createI18n({ 8 | locale: language, 9 | fallbackLocale: language, 10 | legacy: false, 11 | messages: { 12 | en, 13 | zh, 14 | }, 15 | }); 16 | 17 | export const i18nt = i18n.global.t; 18 | 19 | export function setLanguage(locale) { 20 | i18n.global.locale.value = locale; 21 | window.localStorage.setItem("language", locale); 22 | } 23 | -------------------------------------------------------------------------------- /src/i18n/locales/en.yaml: -------------------------------------------------------------------------------- 1 | search: 2 | placeholder: "Search" 3 | tags: 4 | placeholder: "Add tag" 5 | hotkeys: 6 | copy: "Copy" 7 | quick-copy: "Quick Copy" 8 | move-selected: "Move Selected" 9 | close-window: "Close" 10 | clear-history: "Clear History" 11 | global-shortcut: "Pop Up" 12 | dialogs: 13 | delete_favorite: 14 | title: "Delete favorite?" 15 | message: "Are you sure you want to delete this favorite?" 16 | config: 17 | section: 18 | common: "Common" 19 | about: "About" 20 | common: 21 | enable_auto_launch: "Auto Launch" 22 | language: "Language" 23 | record_limit: "Record Limit" 24 | theme_mode: "Theme(unrealized)" 25 | enable_auto_paste: "Auto paste (requires accessibility permissions)" 26 | hotkeys: "Hotkeys" 27 | hotkeys_placeholder: "Input Shortcut" 28 | enable_delete_confirm: "Delete need confirm" 29 | about: 30 | version: "Version" 31 | change-log: "Change Log" 32 | check-update: "Check Update" 33 | thanks: "If you find it useful, maybe you can give me a star." 34 | -------------------------------------------------------------------------------- /src/i18n/locales/zh.yaml: -------------------------------------------------------------------------------- 1 | search: 2 | placeholder: "关键词搜索" 3 | tags: 4 | placeholder: "输入标签" 5 | hotkeys: 6 | copy: "复制" 7 | quick-copy: "快捷复制" 8 | move-selected: "移动选择" 9 | close-window: "关闭窗口" 10 | clear-history: "清空历史" 11 | global-shortcut: "全局唤起" 12 | dialogs: 13 | delete_favorite: 14 | title: "删除收藏?" 15 | message: "你确认要删除这条记录吗?" 16 | config: 17 | section: 18 | common: "通用" 19 | about: "关于" 20 | common: 21 | enable_auto_launch: "开机时启动" 22 | language: "语言" 23 | record_limit: "历史记录条数" 24 | theme_mode: "主题(敬请期待)" 25 | enable_auto_paste: "自动粘贴 (需要获取权限)" 26 | hotkeys: "快捷键" 27 | hotkeys_placeholder: "输入键盘快捷键" 28 | enable_delete_confirm: "删除需要确认" 29 | about: 30 | version: "版本号" 31 | change-log: "更新日志" 32 | check-update: "检查更新" 33 | thanks: "如果你觉得好用,也许可以给我点个star。" 34 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import "./style.css"; 3 | import App from "./App.vue"; 4 | import router from "./router/router.js"; 5 | import { createPinia } from "pinia"; 6 | import { listenLanguageChange } from "./service/globalListener"; 7 | import { i18n, setLanguage } from "./i18n"; 8 | 9 | const pinia = createPinia(); 10 | const app = createApp(App); 11 | app.use(router); 12 | app.use(i18n); 13 | app.use(pinia); 14 | app.mount("#app"); 15 | listenLanguageChange((data) => { 16 | setLanguage(data); 17 | }); 18 | -------------------------------------------------------------------------------- /src/router/router.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from "vue-router"; 2 | 3 | import Main from "../views/Main.vue"; 4 | import Config from "../views/Config.vue"; 5 | 6 | const routes = [ 7 | { path: "/", component: Main }, 8 | { path: "/config", component: Config }, 9 | ]; 10 | 11 | const router = createRouter({ 12 | history: createWebHistory(), 13 | routes, 14 | }); 15 | 16 | export default router; 17 | -------------------------------------------------------------------------------- /src/service/cmds.js: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/tauri"; 2 | import { sendNotice } from "@/service/msg"; 3 | 4 | export async function getCommonConfig() { 5 | return invoke("get_common_config"); 6 | } 7 | 8 | export async function setCommonConfig(config) { 9 | return invoke("set_common_config", { config }); 10 | } 11 | 12 | export async function setLanguage(language) { 13 | return invoke("change_language", { language }); 14 | } 15 | 16 | export async function setRecordLimit(limit) { 17 | return invoke("change_record_limit", { limit }); 18 | } 19 | 20 | export async function setAutoLaunch(enable) { 21 | return invoke("change_auto_launch", { enable }); 22 | } 23 | 24 | export async function setAutoPaste(enable) { 25 | return invoke("change_auto_paste", { enable }); 26 | } 27 | 28 | export async function setThemeMode(themeMode) { 29 | return invoke("change_theme_mode", { themeMode }); 30 | } 31 | 32 | export async function setHotkeys(hotkeys) { 33 | return invoke("change_hotkeys", { hotkeys }); 34 | } 35 | 36 | export async function clearData() { 37 | return invoke("clear_data"); 38 | } 39 | 40 | export async function insertRecord(record) { 41 | return invoke("insert_record", { record }); 42 | } 43 | 44 | export async function insertIfNotExist(r) { 45 | return invoke("insert_if_not_exist", { r }); 46 | } 47 | 48 | export async function findAllRecord() { 49 | return invoke("find_all_record"); 50 | } 51 | 52 | export async function markFavorite(id) { 53 | return invoke("mark_favorite", { id }); 54 | } 55 | 56 | export async function saveTags(id, tags) { 57 | return invoke("save_tags", { id, tags: tags.join(",") }); 58 | } 59 | 60 | export async function findByKey(query) { 61 | return invoke("find_by_key", { query }); 62 | } 63 | 64 | export async function deleteOverLimit(limit) { 65 | return invoke("delete_over_limit", { limit }); 66 | } 67 | 68 | export async function writeToClip(id) { 69 | invoke("write_to_clip", { id }); 70 | sendNotice("", "Copy!"); 71 | } 72 | 73 | export async function deleteById(id) { 74 | return invoke("delete_by_id", { id }); 75 | } 76 | 77 | export async function focusPreviousWindow() { 78 | return invoke("focus_previous_window"); 79 | } 80 | 81 | export async function pasteInPreviousWindow() { 82 | return invoke("paste_in_previous_window"); 83 | } 84 | 85 | export async function setDeleteConfirm(enable) { 86 | return invoke("change_delete_confirm", { enable }); 87 | } 88 | -------------------------------------------------------------------------------- /src/service/globalListener.js: -------------------------------------------------------------------------------- 1 | import { listen } from "@tauri-apps/api/event"; 2 | 3 | export const listenLanguageChange = async (consumer) => { 4 | const unListen = await listen("lanaya://change-language", async (event) => { 5 | consumer(event.payload); 6 | }); 7 | return unListen; 8 | }; 9 | 10 | export const listenRecordLimitChange = async (consumer) => { 11 | const unListen = await listen( 12 | "lanaya://change-record-limit", 13 | async (event) => { 14 | consumer(event.payload); 15 | }, 16 | ); 17 | return unListen; 18 | }; 19 | 20 | export const listenHotkeysChange = async (consumer) => { 21 | const unListen = await listen("lanaya://change-hotkeys", async (event) => { 22 | consumer(event.payload); 23 | }); 24 | return unListen; 25 | }; 26 | 27 | export const listenClipboardChange = async (consumer) => { 28 | const unListen = await listen("lanaya://change-clipboard", async (event) => { 29 | consumer(event.payload); 30 | }); 31 | return unListen; 32 | }; 33 | 34 | export const listenAutoPasteChange = async (consumer) => { 35 | const unListen = await listen("lanaya://change-auto-paste", async (event) => { 36 | consumer(event.payload); 37 | }); 38 | return unListen; 39 | }; 40 | 41 | export const listenDeleteConfirmChange = async (consumer) => { 42 | const unListen = await listen( 43 | "lanaya://change-delete-confirm", 44 | async (event) => { 45 | consumer(event.payload); 46 | }, 47 | ); 48 | return unListen; 49 | }; 50 | 51 | export const listenWindowBlur = async (consumer) => { 52 | const unlistenBlur = await listen("tauri://blur", async (event) => { 53 | consumer(event); 54 | }); 55 | return unlistenBlur; 56 | }; 57 | -------------------------------------------------------------------------------- /src/service/msg.js: -------------------------------------------------------------------------------- 1 | import { 2 | isPermissionGranted, 3 | requestPermission, 4 | sendNotification, 5 | } from "@tauri-apps/api/notification"; 6 | 7 | export async function sendNotice(title, body) { 8 | let permissionGranted = await isPermissionGranted(); 9 | if (!permissionGranted) { 10 | const permission = await requestPermission(); 11 | permissionGranted = permission === "granted"; 12 | } 13 | if (permissionGranted) { 14 | sendNotification({ title, body }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/service/recordService.js: -------------------------------------------------------------------------------- 1 | import { 2 | clearData, 3 | insertIfNotExist, 4 | findAllRecord, 5 | markFavorite, 6 | saveTags as saveTagsCmd, 7 | findByKey, 8 | deleteOverLimit, 9 | } from "./cmds"; 10 | 11 | /** 12 | struct Record { 13 | pub id: u64, 14 | pub content: String, 15 | pub md5: String, 16 | pub create_time: u64, 17 | pub is_favorite: bool, 18 | } 19 | 20 | pub struct QueryReq { 21 | pub key: Option, 22 | pub limit: Option, 23 | pub is_favorite: Option, 24 | } 25 | */ 26 | 27 | async function selectPage(searchKey = "", isFavorite = undefined, limit = 300) { 28 | // 如果 searchKey 以f:开头,那么就是查询收藏的记录 29 | if (searchKey === "") { 30 | return await findAllRecord(limit); 31 | } 32 | if (searchKey.startsWith("f:")) { 33 | isFavorite = true; 34 | searchKey = searchKey.substring(2); 35 | } 36 | let query = { 37 | limit, 38 | }; 39 | if (searchKey.startsWith("t:")) { 40 | query.tags = searchKey.substring(2).split(",").filter(Boolean); 41 | } else if (searchKey !== "") { 42 | query.key = searchKey; 43 | } 44 | if (isFavorite !== undefined) { 45 | query.is_favorite = isFavorite; 46 | } 47 | return await findByKey(query); 48 | } 49 | 50 | async function insertRecord(content, limit = 300) { 51 | let newRecord = { 52 | id: 0, 53 | content: content, 54 | md5: "", 55 | is_favorite: false, 56 | create_time: 0, 57 | }; 58 | await insertIfNotExist(newRecord); 59 | removeOverLimit(limit); 60 | } 61 | 62 | /** 63 | * 删除超过limit的记录 64 | * @param {*} limit 65 | * @returns 66 | */ 67 | async function removeOverLimit(limit = 300) { 68 | await deleteOverLimit(limit); 69 | } 70 | 71 | async function updateRecord(record) { 72 | await insertIfNotExist(record); 73 | } 74 | 75 | async function clearAll() { 76 | return await clearData(); 77 | } 78 | 79 | async function markFav(id) { 80 | await markFavorite(id); 81 | } 82 | 83 | async function saveTags(id, tags) { 84 | return await saveTagsCmd(id, tags); 85 | } 86 | 87 | export { selectPage, insertRecord, updateRecord, clearAll, markFav, saveTags }; 88 | -------------------------------------------------------------------------------- /src/service/shortCutUtil.js: -------------------------------------------------------------------------------- 1 | // Special Keys 2 | const _keyMap = { 3 | 8: { name: "backspace", keyStr: "⌫" }, 4 | 9: { name: "tab", keyStr: "Tab" }, 5 | 12: { name: "clear", keyStr: "Clear" }, 6 | 13: { name: "enter", keyStr: "↩" }, 7 | 27: { name: "esc", keyStr: "Esc" }, 8 | 32: { name: "space", keyStr: "Space" }, 9 | 37: { name: "left", keyStr: "←" }, 10 | 38: { name: "up", keyStr: "↑" }, 11 | 39: { name: "right", keyStr: "→" }, 12 | 40: { name: "down", keyStr: "↓" }, 13 | 46: { name: "delete", keyStr: "Del" }, 14 | 45: { name: "insert", keyStr: "Ins" }, 15 | 36: { name: "home", keyStr: "↖" }, 16 | 35: { name: "end", keyStr: "↘" }, 17 | 33: { name: "pageup", keyStr: "⇞" }, 18 | 34: { name: "pagedown", keyStr: "⇟" }, 19 | 20: { name: "capslock", keyStr: "⇪" }, 20 | 96: { name: "num_0", keyStr: "0" }, 21 | 97: { name: "num_1", keyStr: "1" }, 22 | 98: { name: "num_2", keyStr: "2" }, 23 | 99: { name: "num_3", keyStr: "3" }, 24 | 100: { name: "num_4", keyStr: "4" }, 25 | 101: { name: "num_5", keyStr: "5" }, 26 | 102: { name: "num_6", keyStr: "6" }, 27 | 103: { name: "num_7", keyStr: "7" }, 28 | 104: { name: "num_8", keyStr: "8" }, 29 | 105: { name: "num_9", keyStr: "9" }, 30 | 106: { name: "num_multiply", keyStr: "*" }, 31 | 107: { name: "num_add", keyStr: "+" }, 32 | 108: { name: "num_enter", keyStr: "⏎" }, 33 | 109: { name: "num_subtract", keyStr: "-" }, 34 | 110: { name: "num_decimal", keyStr: "." }, 35 | 111: { name: "num_divide", keyStr: "/" }, 36 | 188: { name: ",", keyStr: "," }, 37 | 190: { name: ".", keyStr: "." }, 38 | 191: { name: "/", keyStr: "/" }, 39 | 192: { name: "`", keyStr: "`" }, 40 | 189: { name: "-", keyStr: "-" }, 41 | 187: { name: "=", keyStr: "=" }, 42 | 186: { name: ";", keyStr: ";" }, 43 | 222: { name: "'", keyStr: "'" }, 44 | 219: { name: "[", keyStr: "[" }, 45 | 221: { name: "]", keyStr: "]" }, 46 | 220: { name: "\\", keyStr: "\\" }, 47 | }; 48 | const _modifier = { 49 | 16: { name: "shift", keyStr: "⇧" }, 50 | 18: { name: "alt", keyStr: "⌥" }, 51 | 17: { name: "ctrl", keyStr: "⌃" }, 52 | 91: { name: "command", keyStr: "⌘" }, 53 | }; 54 | 55 | export function getShortCutShow(keyCodeArr) { 56 | keyCodeArr = keyCodeArr.sort((a, b) => a - b); 57 | let keyStr = ""; 58 | let modifier = ""; 59 | let normalKey = ""; 60 | keyCodeArr.forEach((keyCode) => { 61 | keyCode = parseInt(keyCode); 62 | if (_modifier[keyCode]) { 63 | modifier += _modifier[keyCode].keyStr; 64 | } else if (_keyMap[keyCode]) { 65 | keyStr = _keyMap[keyCode].keyStr; 66 | } else { 67 | normalKey = String.fromCharCode(keyCode).toUpperCase(); 68 | } 69 | }); 70 | if (modifier === "" && keyStr === "") { 71 | return ""; 72 | } 73 | // 若只有modifier,不显示 74 | if (modifier !== "" && keyStr === "" && normalKey === "") { 75 | return ""; 76 | } 77 | // 若只有keyStr,不显示 78 | if (modifier === "" && keyStr !== "" && normalKey !== "") { 79 | return ""; 80 | } 81 | return modifier + keyStr + normalKey; 82 | } 83 | 84 | // 返回数组形式 85 | export function getShortCutShowAnyway(keyCodeArr) { 86 | keyCodeArr = keyCodeArr.sort((a, b) => a - b); 87 | let keyStr = []; 88 | let modifier = []; 89 | let normalKey = []; 90 | keyCodeArr.forEach((keyCode) => { 91 | keyCode = parseInt(keyCode); 92 | if (_modifier[keyCode]) { 93 | modifier.push(_modifier[keyCode].keyStr); 94 | } else if (_keyMap[keyCode]) { 95 | keyStr.push(_keyMap[keyCode].keyStr); 96 | } else { 97 | normalKey.push(String.fromCharCode(keyCode).toUpperCase()); 98 | } 99 | }); 100 | return [...modifier, ...keyStr, ...normalKey]; 101 | } 102 | 103 | /** 104 | * 返回如:commond+shift+c 或者 Commond+Shift+C 105 | * @param {*} keyCodeArr 106 | * @param {*} isFirstWordUpperCase 107 | * @returns 108 | */ 109 | export function getShortCutName(keyCodeArr, isFirstWordUpperCase = false) { 110 | keyCodeArr = keyCodeArr.sort((a, b) => a - b); 111 | let keyStr = ""; 112 | let modifier = ""; 113 | let normalKey = ""; 114 | keyCodeArr.forEach((keyCode) => { 115 | if (_modifier[keyCode]) { 116 | modifier += 117 | capitalized(_modifier[keyCode].name, isFirstWordUpperCase) + "+"; 118 | } else if (_keyMap[keyCode]) { 119 | keyStr = capitalized(_keyMap[keyCode].name, isFirstWordUpperCase); 120 | } else { 121 | normalKey = capitalized( 122 | String.fromCharCode(keyCode), 123 | isFirstWordUpperCase, 124 | ); 125 | } 126 | }); 127 | if (modifier === "" && keyStr === "") { 128 | return ""; 129 | } 130 | // 若只有modifier,不显示 131 | if (modifier !== "" && keyStr === "" && normalKey === "") { 132 | return ""; 133 | } 134 | // 若只有keyStr,不显示 135 | if (modifier === "" && keyStr !== "" && normalKey !== "") { 136 | return ""; 137 | } 138 | return modifier + keyStr + normalKey; 139 | } 140 | 141 | function capitalized(name, isFirstWordUpperCase = false) { 142 | name = name.toLowerCase(); 143 | if (!isFirstWordUpperCase) { 144 | return name; 145 | } 146 | const capitalizedFirst = name[0].toUpperCase(); 147 | if (name.length === 1) { 148 | return capitalizedFirst; 149 | } 150 | const rest = name.slice(1); 151 | return capitalizedFirst + rest; 152 | } 153 | 154 | export function isDiff(keyCodeArr1, keyCodeArr2) { 155 | if (keyCodeArr1.length !== keyCodeArr2.length) { 156 | return true; 157 | } 158 | keyCodeArr1 = keyCodeArr1.sort((a, b) => a - b); 159 | keyCodeArr2 = keyCodeArr2.sort((a, b) => a - b); 160 | for (let i = 0; i < keyCodeArr1.length; i++) { 161 | if (keyCodeArr1[i] !== keyCodeArr2[i]) { 162 | return true; 163 | } 164 | } 165 | return false; 166 | } 167 | -------------------------------------------------------------------------------- /src/service/store.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { getCommonConfig, setCommonConfig } from "../service/cmds"; 3 | import { 4 | listenRecordLimitChange, 5 | listenAutoPasteChange, 6 | listenDeleteConfirmChange, 7 | } from "@/service/globalListener"; 8 | import { defaultHotkeys } from "../config/constants"; 9 | 10 | export const globalData = defineStore("globalData", { 11 | state: () => { 12 | return { 13 | common_config: { 14 | language: "zh", 15 | theme_mode: "light", 16 | enable_auto_launch: false, 17 | enable_auto_paste: false, 18 | enable_delete_confirm: false, 19 | hotkeys: ["clear-history:8+18", "global-shortcut:18+32"], 20 | record_limit: 100, 21 | }, 22 | short_cut: [], 23 | }; 24 | }, 25 | getters: { 26 | config(state) { 27 | return state.common_config; 28 | }, 29 | short_cuts(state) { 30 | return state.short_cut; 31 | }, 32 | }, 33 | actions: { 34 | async initCommonConfig() { 35 | console.log("initCommonConfig"); 36 | await this.refreshCommonConfig(); 37 | await this.initListener(); 38 | }, 39 | async initListener() { 40 | await listenRecordLimitChange((newLimitNum) => { 41 | this.common_config.record_limit = newLimitNum; 42 | }); 43 | await listenAutoPasteChange((value) => { 44 | this.common_config.enable_auto_paste = value; 45 | }); 46 | await listenDeleteConfirmChange((value) => { 47 | console.log("listenDeleteConfirmChange", value); 48 | this.common_config.enable_delete_confirm = value; 49 | }); 50 | }, 51 | async refreshCommonConfig() { 52 | let res = await getCommonConfig(); 53 | this.common_config = res; 54 | if (res.hotkeys) { 55 | await this.patchShotCuts(res.hotkeys); 56 | } 57 | }, 58 | async patchShotCuts(hotkeys) { 59 | let short_cuts = []; 60 | defaultHotkeys.forEach((item) => { 61 | let find = hotkeys.find((hotkey) => { 62 | return hotkey.startsWith(item.func); 63 | }); 64 | if (find) { 65 | let strArr = find.split(":")[1].split("+"); 66 | if (strArr.length > 0 && strArr[0] !== "") { 67 | let keys = strArr.map((item) => { 68 | return parseInt(item); 69 | }); 70 | short_cuts.push({ 71 | func: item.func, 72 | keys, 73 | }); 74 | } 75 | } else { 76 | short_cuts.push(item); 77 | } 78 | }); 79 | this.short_cut = short_cuts; 80 | }, 81 | // 持久化 82 | async saveCommonConfig() { 83 | await setCommonConfig(this.common_config); 84 | }, 85 | }, 86 | }); 87 | 88 | // modify like this: 89 | // store.$patch((state) => { 90 | // state.common_config.language = 'en' 91 | // }) 92 | -------------------------------------------------------------------------------- /src/service/windowUtil.js: -------------------------------------------------------------------------------- 1 | import { appWindow } from "@tauri-apps/api/window"; 2 | 3 | let closeWindowTimer = null; 4 | 5 | let skipNextClose = false; 6 | 7 | export function keepWindowOpen() { 8 | skipNextClose = true; 9 | } 10 | 11 | export async function closeWindowLater(delay) { 12 | if (skipNextClose) { 13 | skipNextClose = false; 14 | return; 15 | } 16 | if (closeWindowTimer) { 17 | clearTimeout(closeWindowTimer); 18 | closeWindowTimer = null; 19 | } 20 | await appWindow.hide(); 21 | // 延迟5秒如果未重新打开window则close 22 | closeWindowTimer = setTimeout(async () => { 23 | // 等待关闭window 24 | let visible = await appWindow.isVisible(); 25 | if (!visible) { 26 | await appWindow.close(); 27 | } 28 | }, delay); 29 | } 30 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | /* ./src/index.css */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | :root { 7 | background-color: transparent; 8 | box-sizing: border-box; 9 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 10 | font-size: 16px; 11 | line-height: 24px; 12 | font-weight: 400; 13 | font-synthesis: none; 14 | text-rendering: optimizeLegibility; 15 | -webkit-font-smoothing: antialiased; 16 | -moz-osx-font-smoothing: grayscale; 17 | -webkit-text-size-adjust: 100%; 18 | --docsearch-muted-color: #909399; 19 | --docsearch-modal-background: #fafafa; 20 | --docsearch-highlight-color: #409eff; 21 | --docsearch-spacing: 12px; 22 | --docsearch-icon-stroke-width: 1.4; 23 | --docsearch-container-background: rgba(101, 108, 133, 0.8); 24 | --docsearch-logo-color: #5468ff; 25 | --docsearch-modal-width: 560px; 26 | --docsearch-modal-height: 600px; 27 | --docsearch-searchbox-height: 56px; 28 | --docsearch-hit-height: 56px; 29 | --docsearch-hit-max-height: 112px; 30 | --docsearch-hit-color: #444950; 31 | --docsearch-hit-active-color: #fff; 32 | --docsearch-hit-background: #fff; 33 | --docsearch-hit-shadow: 0 1px 3px 0 #d4d9e1; 34 | --docsearch-key-shadow: inset 0 -2px 0 0 #cdcde6, inset 0 0 1px 1px #fff, 35 | 0 1px 2px 1px rgba(30, 35, 90, 0.4); 36 | } 37 | 38 | html, 39 | body { 40 | margin: 0; 41 | padding: 0; 42 | width: 100%; 43 | overflow: hidden; 44 | -webkit-touch-callout: none; 45 | -webkit-user-select: none; 46 | -khtml-user-select: none; 47 | -moz-user-select: none; 48 | -ms-user-select: none; 49 | user-select: none; 50 | scroll-behavior: smooth; 51 | /* font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; */ 52 | } 53 | 54 | img { 55 | -webkit-user-drag: none; 56 | -khtml-user-drag: none; 57 | -moz-user-drag: none; 58 | -o-user-drag: none; 59 | } 60 | 61 | input { 62 | background: none; 63 | outline: none; 64 | border: 0; 65 | } 66 | 67 | @media (prefers-color-scheme: light) { 68 | :root { 69 | } 70 | } 71 | 72 | @media (prefers-color-scheme: dark) { 73 | :root { 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/views/Config.vue: -------------------------------------------------------------------------------- 1 | 2 | 32 | 33 | 54 | 55 | 80 | -------------------------------------------------------------------------------- /src/views/Main.vue: -------------------------------------------------------------------------------- 1 | 2 | 17 | 314 | 315 | 316 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./index.html", "./src/**/*.{vue,js}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [require("daisyui")], 8 | // daisyUI config (optional) 9 | daisyui: { 10 | styled: true, 11 | themes: false, 12 | base: true, 13 | utils: true, 14 | logs: true, 15 | rtl: false, 16 | prefix: "", 17 | darkTheme: "halloween", 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import path from "path"; 3 | import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite"; 4 | import vue from "@vitejs/plugin-vue"; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | vue(), 9 | VueI18nPlugin({ 10 | include: path.resolve(__dirname, "./src/i18n/locales/**"), 11 | compositionOnly: false, 12 | }), 13 | ], 14 | // 防止 vite 输出复杂的 rust 错误 15 | clearScreen: false, 16 | // Tauri 使用固定端口,若此端口不可用将会导致程序错误 17 | server: { 18 | strictPort: true, 19 | }, 20 | // 使用 `TAURI_PLATFORM`、`TAURI_ARCH`、`TAURI_FAMILY`, 21 | // `TAURI_PLATFORM_VERSION`、`TAURI_PLATFORM_TYPE` 和 `TAURI_DEBUG` 环境变量 22 | envPrefix: ["VITE_", "TAURI_"], 23 | resolve: { 24 | alias: { 25 | "@": path.resolve("./src"), 26 | "@root": path.resolve("."), 27 | }, 28 | }, 29 | build: { 30 | // Tauri 支持 es2021 31 | target: ["es2021", "chrome100", "safari13"], 32 | // 不为调试构建压缩构建体积 33 | minify: !process.env.TAURI_DEBUG ? "esbuild" : false, 34 | // 为调试构建生成源代码映射 (sourcemap) 35 | sourcemap: !!process.env.TAURI_DEBUG, 36 | }, 37 | }); 38 | --------------------------------------------------------------------------------