├── .editorconfig ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── build ├── entitlements.mac.plist ├── icon.icns └── icon.png ├── components.json ├── dev-app-update.yml ├── electron-builder.config.cjs ├── electron.vite.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── resources ├── stopTrayIconTemplate.png ├── stopTrayIconTemplate@2x.png ├── trayIcon.ico ├── trayIconTemplate.png └── trayIconTemplate@2x.png ├── scripts ├── build-rs.sh ├── fix-pnpm-windows.js └── release.js ├── src ├── main │ ├── config.ts │ ├── index.ts │ ├── keyboard.ts │ ├── llm.ts │ ├── menu.ts │ ├── renderer-handlers.ts │ ├── serve.ts │ ├── state.ts │ ├── tipc.ts │ ├── tray.ts │ ├── updater.ts │ ├── utils.ts │ └── window.ts ├── preload │ ├── index.d.ts │ └── index.ts ├── renderer │ ├── index.html │ └── src │ │ ├── App.tsx │ │ ├── assets │ │ ├── begin-record.wav │ │ └── end-record.wav │ │ ├── components │ │ ├── app-layout.tsx │ │ ├── setup.tsx │ │ ├── ui │ │ │ ├── button.tsx │ │ │ ├── control.tsx │ │ │ ├── dialog.tsx │ │ │ ├── input.tsx │ │ │ ├── select.tsx │ │ │ ├── spinner.tsx │ │ │ ├── switch.tsx │ │ │ ├── textarea.tsx │ │ │ └── tooltip.tsx │ │ └── updater.tsx │ │ ├── css │ │ ├── spinner.css │ │ └── tailwind.css │ │ ├── env.d.ts │ │ ├── lib │ │ ├── event-emitter.d.ts │ │ ├── event-emitter.js │ │ ├── query-client.ts │ │ ├── recorder.ts │ │ ├── sound.ts │ │ ├── tipc-client.ts │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── pages │ │ ├── index.tsx │ │ ├── panel.tsx │ │ ├── settings-about.tsx │ │ ├── settings-data.tsx │ │ ├── settings-general.tsx │ │ ├── settings-providers.tsx │ │ ├── settings.tsx │ │ └── setup.tsx │ │ └── router.tsx └── shared │ ├── index.ts │ ├── shims.d.ts │ └── types.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.web.json └── whispo-rs ├── Cargo.lock ├── Cargo.toml └── src └── main.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build/release 2 | 3 | on: push 4 | 5 | jobs: 6 | release: 7 | runs-on: ${{ matrix.os }} 8 | 9 | if: | 10 | startsWith(github.ref, 'refs/tags/v') || 11 | contains(github.event.head_commit.message, '[release]') 12 | 13 | strategy: 14 | matrix: 15 | os: [macos-latest, windows-latest] 16 | 17 | steps: 18 | - name: Check out Git repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Install Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 22 25 | 26 | - name: Install setuptools 27 | if: matrix.os == 'macos-latest' 28 | run: brew install python-setuptools 29 | 30 | - uses: actions-rust-lang/setup-rust-toolchain@v1 31 | 32 | - name: Install pnpm 33 | run: npm i -g pnpm@9 34 | 35 | - name: Fix pnpm 36 | run: pnpm fix-pnpm-windows 37 | 38 | - name: Get pnpm cache directory path 39 | id: pnpm-cache-dir-path 40 | shell: "bash" 41 | run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT 42 | 43 | - uses: actions/cache@v4 44 | id: pnpm-cache 45 | with: 46 | path: ${{ steps.pnpm-cache-dir-path.outputs.dir }} 47 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 48 | restore-keys: | 49 | ${{ runner.os }}-pnpm- 50 | 51 | - name: Install deps 52 | run: pnpm i 53 | 54 | - name: Release 55 | run: pnpm run release 56 | env: 57 | APPLE_ID: ${{ secrets.APPLE_ID }} 58 | APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} 59 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 60 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} 61 | CSC_LINK: ${{ secrets.CSC_LINK }} 62 | GITHUB_TOKEN: ${{ secrets.GH_PUBLISH_TOKEN }} 63 | # https://github.com/electron-userland/electron-builder/issues/3179 64 | USE_HARD_LINKS: false 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .DS_Store 5 | *.log* 6 | target/ 7 | bin/ 8 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | pnpm-lock.yaml 4 | LICENSE.md 5 | tsconfig.json 6 | tsconfig.*.json 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "plugins": ["prettier-plugin-tailwindcss"] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/src/renderer", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tailwindCSS.experimental.classRegex": [ 3 | ["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"] 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 2024 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Whispo 2 | 3 | AI-powered dictation tool. 4 | 5 | ## Download 6 | 7 | Currently building for macOS (Apple Silicon) and Windows x64. 8 | 9 | [Releases](https://github.com/egoist/whispo/releases/latest) 10 | 11 | ## Preview 12 | 13 | 14 | https://github.com/user-attachments/assets/2344a817-f36c-42b0-9ebc-cdd6e926b7a0 15 | 16 | 17 | ## Features 18 | 19 | - Hold `Ctrl` key to record your voice, release to transcribe it. 20 | - Automatically insert the transcript into the application you are using. 21 | - Works with any application that supports text input. 22 | - Data is stored locally on your machine. 23 | - Transcrbing with OpenAI Whisper (provided by OpenAI or Groq). 24 | - Support custom API URL so you can use your own API to transcribe. 25 | - Supports post-processing your transcript with LLMs (e.g. OpenAI, Groq and Gemini). 26 | 27 | ## License 28 | 29 | [AGPL-3.0](./LICENSE) 30 | -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | com.apple.security.device.microphone 12 | 13 | com.apple.security.device.audio-input 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/whispo/1bebd3b1bc7f2636726edc014f60549e6a1f6e07/build/icon.icns -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/whispo/1bebd3b1bc7f2636726edc014f60549e6a1f6e07/build/icon.png -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/renderer/src/css/tailwind.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "~/components", 15 | "utils": "~/lib/utils", 16 | "ui": "~/components/ui", 17 | "lib": "~/lib", 18 | "hooks": "~/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /dev-app-update.yml: -------------------------------------------------------------------------------- 1 | provider: github 2 | owner: egoist 3 | repo: chatkit-desktop 4 | updaterCacheDirName: desktop-updater-whispo2 5 | -------------------------------------------------------------------------------- /electron-builder.config.cjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('electron-builder').Configuration} */ 4 | module.exports = { 5 | appId: "app.whispo", 6 | productName: "Whispo", 7 | directories: { 8 | buildResources: "build", 9 | }, 10 | files: [ 11 | "!**/.vscode/*", 12 | "!src/*", 13 | "!scripts/*", 14 | "!electron.vite.config.{js,ts,mjs,cjs}", 15 | "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}", 16 | "!{.env,.env.*,.npmrc,pnpm-lock.yaml}", 17 | "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}", 18 | "!*.{js,cjs,mjs,ts}", 19 | "!components.json", 20 | "!.prettierrc", 21 | '!whispo-rs/*' 22 | ], 23 | asarUnpack: ["resources/**", "node_modules/**"], 24 | win: { 25 | executableName: "whispo", 26 | }, 27 | nsis: { 28 | artifactName: "${name}-${version}-setup.${ext}", 29 | shortcutName: "${productName}", 30 | uninstallDisplayName: "${productName}", 31 | createDesktopShortcut: "always", 32 | }, 33 | mac: { 34 | binaries: [`resources/bin/whispo-rs${process.platform === 'darwin' ? '' : '.exe'}`], 35 | artifactName: "${productName}-${version}-${arch}.${ext}", 36 | entitlementsInherit: "build/entitlements.mac.plist", 37 | extendInfo: [ 38 | { 39 | NSCameraUsageDescription: 40 | "Application requests access to the device's camera.", 41 | }, 42 | { 43 | NSMicrophoneUsageDescription: 44 | "Application requests access to the device's microphone.", 45 | }, 46 | { 47 | NSDocumentsFolderUsageDescription: 48 | "Application requests access to the user's Documents folder.", 49 | }, 50 | { 51 | NSDownloadsFolderUsageDescription: 52 | "Application requests access to the user's Downloads folder.", 53 | }, 54 | ], 55 | notarize: process.env.APPLE_TEAM_ID 56 | ? { 57 | teamId: process.env.APPLE_TEAM_ID, 58 | } 59 | : undefined, 60 | }, 61 | dmg: { 62 | artifactName: "${productName}-${version}-${arch}.${ext}", 63 | }, 64 | linux: { 65 | target: ["AppImage", "snap", "deb"], 66 | maintainer: "electronjs.org", 67 | category: "Utility", 68 | }, 69 | appImage: { 70 | artifactName: "${name}-${version}.${ext}", 71 | }, 72 | npmRebuild: false, 73 | publish: { 74 | provider: "github", 75 | owner: "egoist", 76 | repo: "whispo", 77 | }, 78 | removePackageScripts: true, 79 | } 80 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path" 2 | import { defineConfig, externalizeDepsPlugin } from "electron-vite" 3 | import react from "@vitejs/plugin-react" 4 | import tsconfigPaths from "vite-tsconfig-paths" 5 | import pkg from "./package.json" 6 | 7 | const builderConfig = require("./electron-builder.config.cjs") 8 | 9 | const define = { 10 | "process.env.APP_ID": JSON.stringify(builderConfig.appId), 11 | "process.env.PRODUCT_NAME": JSON.stringify(builderConfig.productName), 12 | "process.env.APP_VERSION": JSON.stringify(pkg.version), 13 | "process.env.IS_MAC": JSON.stringify(process.platform === "darwin"), 14 | } 15 | 16 | export default defineConfig({ 17 | main: { 18 | plugins: [tsconfigPaths(), externalizeDepsPlugin({})], 19 | define, 20 | }, 21 | preload: { 22 | plugins: [tsconfigPaths(), externalizeDepsPlugin()], 23 | }, 24 | renderer: { 25 | define, 26 | plugins: [tsconfigPaths(), react()], 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whispo", 3 | "version": "0.1.7", 4 | "type": "module", 5 | "description": "AI powered dictation", 6 | "main": "./out/main/index.js", 7 | "author": "whispo.app", 8 | "homepage": "https://whispo.app", 9 | "scripts": { 10 | "format": "prettier --write .", 11 | "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", 12 | "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", 13 | "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", 14 | "typecheck": "npm run typecheck:node && npm run typecheck:web", 15 | "start": "electron-vite preview", 16 | "dev": "electron-vite dev --watch", 17 | "build": "npm run typecheck && electron-vite build && npm run build-rs", 18 | "postinstall": "electron-builder install-app-deps", 19 | "build:unpack": "npm run build && electron-builder --dir", 20 | "build:win": "npm run build && electron-builder --win --config electron-builder.config.cjs", 21 | "build:mac": "electron-vite build && electron-builder --mac --config electron-builder.config.cjs", 22 | "build:linux": "electron-vite build && electron-builder --linux --config electron-builder.config.cjs", 23 | "release": "node ./scripts/release.js", 24 | "fix-pnpm-windows": "node ./scripts/fix-pnpm-windows.js", 25 | "build-rs": "sh scripts/build-rs.sh" 26 | }, 27 | "dependencies": { 28 | "@egoist/electron-panel-window": "^8.0.3" 29 | }, 30 | "devDependencies": { 31 | "@radix-ui/react-switch": "^1.1.1", 32 | "@egoist/tailwindcss-icons": "^1.8.1", 33 | "@egoist/tipc": "^0.3.2", 34 | "@electron-toolkit/preload": "^3.0.1", 35 | "@electron-toolkit/tsconfig": "^1.0.1", 36 | "@electron-toolkit/utils": "^3.0.0", 37 | "@google/generative-ai": "^0.21.0", 38 | "@iconify-json/mingcute": "^1.2.1", 39 | "@radix-ui/react-dialog": "^1.1.2", 40 | "@radix-ui/react-icons": "^1.3.0", 41 | "@radix-ui/react-select": "^2.1.2", 42 | "@radix-ui/react-slot": "^1.1.0", 43 | "@radix-ui/react-tooltip": "^1.1.3", 44 | "@tanstack/react-query": "^5.59.14", 45 | "@types/node": "^20.14.8", 46 | "@types/react": "^18.3.3", 47 | "@types/react-dom": "^18.3.0", 48 | "@vitejs/plugin-react": "^4.3.1", 49 | "autoprefixer": "^10.4.20", 50 | "bumpp": "^9.7.1", 51 | "class-variance-authority": "^0.7.0", 52 | "clsx": "^2.1.1", 53 | "dayjs": "^1.11.13", 54 | "electron": "^31.0.2", 55 | "electron-builder": "^24.13.3", 56 | "electron-updater": "^6.1.7", 57 | "electron-vite": "^2.3.0", 58 | "lucide-react": "^0.452.0", 59 | "prettier": "^3.3.3", 60 | "prettier-plugin-tailwindcss": "^0.6.8", 61 | "react": "^18.3.1", 62 | "react-dom": "^18.3.1", 63 | "react-router-dom": "^6.27.0", 64 | "tailwind-merge": "^2.5.3", 65 | "tailwind-variants": "^0.2.1", 66 | "tailwindcss": "^3.4.13", 67 | "tailwindcss-animate": "^1.0.7", 68 | "typescript": "^5.6.3", 69 | "vite": "^5.4.8", 70 | "vite-tsconfig-paths": "^5.0.1" 71 | }, 72 | "packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4" 73 | } 74 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /resources/stopTrayIconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/whispo/1bebd3b1bc7f2636726edc014f60549e6a1f6e07/resources/stopTrayIconTemplate.png -------------------------------------------------------------------------------- /resources/stopTrayIconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/whispo/1bebd3b1bc7f2636726edc014f60549e6a1f6e07/resources/stopTrayIconTemplate@2x.png -------------------------------------------------------------------------------- /resources/trayIcon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/whispo/1bebd3b1bc7f2636726edc014f60549e6a1f6e07/resources/trayIcon.ico -------------------------------------------------------------------------------- /resources/trayIconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/whispo/1bebd3b1bc7f2636726edc014f60549e6a1f6e07/resources/trayIconTemplate.png -------------------------------------------------------------------------------- /resources/trayIconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/whispo/1bebd3b1bc7f2636726edc014f60549e6a1f6e07/resources/trayIconTemplate@2x.png -------------------------------------------------------------------------------- /scripts/build-rs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir -p resources/bin 4 | 5 | cd whispo-rs 6 | 7 | cargo build -r 8 | 9 | cp target/release/whispo-rs ../resources/bin/whispo-rs 10 | -------------------------------------------------------------------------------- /scripts/fix-pnpm-windows.js: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | 3 | if (process.platform === "win32") { 4 | const pnpmPath = process.env.npm_execpath 5 | 6 | const content = fs.readFileSync(pnpmPath, "utf8") 7 | 8 | const fixedContent = content.replace(/^#.+/, "#!node") 9 | 10 | fs.writeFileSync(pnpmPath, fixedContent) 11 | } 12 | -------------------------------------------------------------------------------- /scripts/release.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { execSync } from "child_process" 3 | 4 | /** 5 | * 6 | * @param {string} command 7 | * @param {{cwd?: string}} options 8 | * @returns 9 | */ 10 | const run = (command, { cwd } = {}) => { 11 | return execSync(command, { 12 | cwd, 13 | stdio: "inherit", 14 | env: { 15 | ...process.env, 16 | }, 17 | }) 18 | } 19 | 20 | const desktopDir = process.cwd() 21 | 22 | run(`rm -rf dist`, { cwd: desktopDir }) 23 | 24 | run (`pnpm build-rs`) 25 | 26 | if (process.platform === "darwin") { 27 | run(`pnpm build:mac --arm64 --publish always`, { 28 | cwd: desktopDir, 29 | }) 30 | } else { 31 | run(`pnpm build:win --publish always`, { 32 | cwd: desktopDir, 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /src/main/config.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron" 2 | import path from "path" 3 | import fs from "fs" 4 | import { Config } from "@shared/types" 5 | 6 | export const dataFolder = path.join(app.getPath("appData"), process.env.APP_ID) 7 | 8 | export const recordingsFolder = path.join(dataFolder, "recordings") 9 | 10 | export const configPath = path.join(dataFolder, "config.json") 11 | 12 | const getConfig = () => { 13 | try { 14 | return JSON.parse(fs.readFileSync(configPath, "utf8")) as Config 15 | } catch { 16 | return {} 17 | } 18 | } 19 | 20 | class ConfigStore { 21 | config: Config | undefined 22 | 23 | constructor() { 24 | this.config = getConfig() 25 | } 26 | 27 | get() { 28 | return this.config || {} 29 | } 30 | 31 | save(config: Config) { 32 | this.config = config 33 | fs.mkdirSync(dataFolder, { recursive: true }) 34 | fs.writeFileSync(configPath, JSON.stringify(config)) 35 | } 36 | } 37 | 38 | export const configStore = new ConfigStore() 39 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, Menu } from "electron" 2 | import { electronApp, optimizer } from "@electron-toolkit/utils" 3 | import { 4 | createMainWindow, 5 | createPanelWindow, 6 | createSetupWindow, 7 | makePanelWindowClosable, 8 | WINDOWS, 9 | } from "./window" 10 | import { listenToKeyboardEvents } from "./keyboard" 11 | import { registerIpcMain } from "@egoist/tipc/main" 12 | import { router } from "./tipc" 13 | import { registerServeProtocol, registerServeSchema } from "./serve" 14 | import { createAppMenu } from "./menu" 15 | import { initTray } from "./tray" 16 | import { isAccessibilityGranted } from "./utils" 17 | 18 | registerServeSchema() 19 | 20 | // This method will be called when Electron has finished 21 | // initialization and is ready to create browser windows. 22 | // Some APIs can only be used after this event occurs. 23 | app.whenReady().then(() => { 24 | // Set app user model id for windows 25 | electronApp.setAppUserModelId(process.env.APP_ID) 26 | 27 | const accessibilityGranted = isAccessibilityGranted() 28 | 29 | Menu.setApplicationMenu(createAppMenu()) 30 | 31 | registerIpcMain(router) 32 | 33 | registerServeProtocol() 34 | 35 | if (accessibilityGranted) { 36 | createMainWindow() 37 | } else { 38 | createSetupWindow() 39 | } 40 | 41 | createPanelWindow() 42 | 43 | listenToKeyboardEvents() 44 | 45 | initTray() 46 | 47 | import("./updater").then((res) => res.init()).catch(console.error) 48 | 49 | // Default open or close DevTools by F12 in development 50 | // and ignore CommandOrControl + R in production. 51 | // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils 52 | app.on("browser-window-created", (_, window) => { 53 | optimizer.watchWindowShortcuts(window) 54 | }) 55 | 56 | app.on("activate", function () { 57 | if (accessibilityGranted) { 58 | if (!WINDOWS.get("main")) { 59 | createMainWindow() 60 | } 61 | } else { 62 | if (!WINDOWS.get("setup")) { 63 | createSetupWindow() 64 | } 65 | } 66 | }) 67 | 68 | app.on("before-quit", () => { 69 | makePanelWindowClosable() 70 | }) 71 | }) 72 | 73 | // Quit when all windows are closed, except on macOS. There, it's common 74 | // for applications and their menu bar to stay active until the user quits 75 | // explicitly with Cmd + Q. 76 | app.on("window-all-closed", () => { 77 | if (process.platform !== "darwin") { 78 | app.quit() 79 | } 80 | }) 81 | 82 | // In this file you can include the rest of your app"s specific main process 83 | // code. You can also put them in separate files and require them here. 84 | -------------------------------------------------------------------------------- /src/main/keyboard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getWindowRendererHandlers, 3 | showPanelWindowAndStartRecording, 4 | stopRecordingAndHidePanelWindow, 5 | WINDOWS, 6 | } from "./window" 7 | import { systemPreferences } from "electron" 8 | import { configStore } from "./config" 9 | import { state } from "./state" 10 | import { spawn } from "child_process" 11 | import path from "path" 12 | 13 | const rdevPath = path 14 | .join( 15 | __dirname, 16 | `../../resources/bin/whispo-rs${process.env.IS_MAC ? "" : ".exe"}`, 17 | ) 18 | .replace("app.asar", "app.asar.unpacked") 19 | 20 | type RdevEvent = { 21 | event_type: "KeyPress" | "KeyRelease" 22 | data: { 23 | key: "ControlLeft" | "BackSlash" | string 24 | } 25 | time: { 26 | secs_since_epoch: number 27 | } 28 | } 29 | 30 | export const writeText = (text: string) => { 31 | return new Promise((resolve, reject) => { 32 | const child = spawn(rdevPath, ["write", text]) 33 | 34 | child.stdout.on("data", (data) => { 35 | console.log(`stdout: ${data}`) 36 | }) 37 | 38 | child.stderr.on("data", (data) => { 39 | console.error(`stderr: ${data}`) 40 | }) 41 | 42 | child.on("close", (code) => { 43 | // writeText will trigger KeyPress event of the key A 44 | // I don't know why 45 | keysPressed.clear() 46 | 47 | if (code === 0) { 48 | resolve() 49 | } else { 50 | reject(new Error(`child process exited with code ${code}`)) 51 | } 52 | }) 53 | }) 54 | } 55 | 56 | const parseEvent = (event: any) => { 57 | try { 58 | const e = JSON.parse(String(event)) 59 | e.data = JSON.parse(e.data) 60 | return e as RdevEvent 61 | } catch { 62 | return null 63 | } 64 | } 65 | 66 | // keys that are currently pressed down without releasing 67 | // excluding ctrl 68 | // when other keys are pressed, pressing ctrl will not start recording 69 | const keysPressed = new Map() 70 | 71 | const hasRecentKeyPress = () => { 72 | if (keysPressed.size === 0) return false 73 | 74 | const now = Date.now() / 1000 75 | return [...keysPressed.values()].some((time) => { 76 | // 10 seconds 77 | // for some weird reasons sometime KeyRelease event is missing for some keys 78 | // so they stay in the map 79 | // therefore we have to check if the key was pressed in the last 10 seconds 80 | return now - time < 10 81 | }) 82 | } 83 | 84 | export function listenToKeyboardEvents() { 85 | let isHoldingCtrlKey = false 86 | let startRecordingTimer: NodeJS.Timeout | undefined 87 | let isPressedCtrlKey = false 88 | 89 | if (process.env.IS_MAC) { 90 | if (!systemPreferences.isTrustedAccessibilityClient(false)) { 91 | return 92 | } 93 | } 94 | 95 | const cancelRecordingTimer = () => { 96 | if (startRecordingTimer) { 97 | clearTimeout(startRecordingTimer) 98 | startRecordingTimer = undefined 99 | } 100 | } 101 | 102 | const handleEvent = (e: RdevEvent) => { 103 | if (e.event_type === "KeyPress") { 104 | if (e.data.key === "ControlLeft") { 105 | isPressedCtrlKey = true 106 | } 107 | 108 | if (e.data.key === "Escape" && state.isRecording) { 109 | const win = WINDOWS.get("panel") 110 | if (win) { 111 | stopRecordingAndHidePanelWindow() 112 | } 113 | 114 | return 115 | } 116 | 117 | if (configStore.get().shortcut === "ctrl-slash") { 118 | if (e.data.key === "Slash" && isPressedCtrlKey) { 119 | getWindowRendererHandlers("panel")?.startOrFinishRecording.send() 120 | } 121 | } else { 122 | if (e.data.key === "ControlLeft") { 123 | if (hasRecentKeyPress()) { 124 | console.log("ignore ctrl because other keys are pressed", [ 125 | ...keysPressed.keys(), 126 | ]) 127 | return 128 | } 129 | 130 | if (startRecordingTimer) { 131 | // console.log('already started recording timer') 132 | return 133 | } 134 | 135 | startRecordingTimer = setTimeout(() => { 136 | isHoldingCtrlKey = true 137 | 138 | console.log("start recording") 139 | 140 | showPanelWindowAndStartRecording() 141 | }, 800) 142 | } else { 143 | keysPressed.set(e.data.key, e.time.secs_since_epoch) 144 | cancelRecordingTimer() 145 | 146 | // when holding ctrl key, pressing any other key will stop recording 147 | if (isHoldingCtrlKey) { 148 | stopRecordingAndHidePanelWindow() 149 | } 150 | 151 | isHoldingCtrlKey = false 152 | } 153 | } 154 | } else if (e.event_type === "KeyRelease") { 155 | keysPressed.delete(e.data.key) 156 | 157 | if (e.data.key === "ControlLeft") { 158 | isPressedCtrlKey = false 159 | } 160 | 161 | if (configStore.get().shortcut === "ctrl-slash") return 162 | 163 | cancelRecordingTimer() 164 | 165 | if (e.data.key === "ControlLeft") { 166 | console.log("release ctrl") 167 | if (isHoldingCtrlKey) { 168 | getWindowRendererHandlers("panel")?.finishRecording.send() 169 | } else { 170 | stopRecordingAndHidePanelWindow() 171 | } 172 | 173 | isHoldingCtrlKey = false 174 | } 175 | } 176 | } 177 | 178 | const child = spawn(rdevPath, ["listen"], {}) 179 | 180 | child.stdout.on("data", (data) => { 181 | if (import.meta.env.DEV) { 182 | console.log(String(data)) 183 | } 184 | 185 | const event = parseEvent(data) 186 | if (!event) return 187 | 188 | handleEvent(event) 189 | }) 190 | } 191 | -------------------------------------------------------------------------------- /src/main/llm.ts: -------------------------------------------------------------------------------- 1 | import { dialog } from "electron" 2 | import { GoogleGenerativeAI } from "@google/generative-ai" 3 | import { configStore } from "./config" 4 | 5 | export async function postProcessTranscript(transcript: string) { 6 | const config = configStore.get() 7 | 8 | if ( 9 | !config.transcriptPostProcessingEnabled || 10 | !config.transcriptPostProcessingPrompt 11 | ) { 12 | return transcript 13 | } 14 | 15 | const prompt = config.transcriptPostProcessingPrompt.replace( 16 | "{transcript}", 17 | transcript, 18 | ) 19 | 20 | const chatProviderId = config.transcriptPostProcessingProviderId 21 | 22 | if (chatProviderId === "gemini") { 23 | if (!config.geminiApiKey) throw new Error("Gemini API key is required") 24 | 25 | const gai = new GoogleGenerativeAI(config.geminiApiKey) 26 | const gModel = gai.getGenerativeModel({ model: "gemini-1.5-flash-002" }) 27 | 28 | const result = await gModel.generateContent([prompt], { 29 | baseUrl: config.geminiBaseUrl, 30 | }) 31 | return result.response.text().trim() 32 | } 33 | 34 | const chatBaseUrl = 35 | chatProviderId === "groq" 36 | ? config.groqBaseUrl || "https://api.groq.com/openai/v1" 37 | : config.openaiBaseUrl || "https://api.openai.com/v1" 38 | 39 | const chatResponse = await fetch(`${chatBaseUrl}/chat/completions`, { 40 | method: "POST", 41 | headers: { 42 | Authorization: `Bearer ${chatProviderId === "groq" ? config.groqApiKey : config.openaiApiKey}`, 43 | "Content-Type": "application/json", 44 | }, 45 | body: JSON.stringify({ 46 | temperature: 0, 47 | model: 48 | chatProviderId === "groq" ? "llama-3.1-70b-versatile" : "gpt-4o-mini", 49 | messages: [ 50 | { 51 | role: "system", 52 | content: prompt, 53 | }, 54 | ], 55 | }), 56 | }) 57 | 58 | if (!chatResponse.ok) { 59 | const message = `${chatResponse.statusText} ${(await chatResponse.text()).slice(0, 300)}` 60 | 61 | throw new Error(message) 62 | } 63 | 64 | const chatJson = await chatResponse.json() 65 | console.log(chatJson) 66 | return chatJson.choices[0].message.content.trim() 67 | } 68 | -------------------------------------------------------------------------------- /src/main/menu.ts: -------------------------------------------------------------------------------- 1 | import { Menu, MenuItemConstructorOptions, shell } from "electron" 2 | 3 | const toMenu = ( 4 | items: Array, 5 | ) => { 6 | return items.filter(Boolean) as MenuItemConstructorOptions[] 7 | } 8 | 9 | export const createAppMenu = () => { 10 | const isMac = process.env.IS_MAC 11 | 12 | const template: Electron.MenuItemConstructorOptions[] = [ 13 | // { role: 'appMenu' } 14 | ...(isMac 15 | ? [ 16 | { 17 | label: process.env.PRODUCT_NAME, 18 | submenu: [ 19 | { role: "about" as const }, 20 | 21 | { type: "separator" as const }, 22 | { role: "services" as const }, 23 | { type: "separator" as const }, 24 | { role: "hide" as const }, 25 | { role: "hideOthers" as const }, 26 | { role: "unhide" as const }, 27 | { type: "separator" as const }, 28 | { role: "quit" as const }, 29 | ], 30 | }, 31 | ] 32 | : []), 33 | // { role: 'fileMenu' } 34 | { 35 | label: "File", 36 | submenu: [ 37 | isMac 38 | ? { 39 | label: "Close", 40 | accelerator: "CmdOrCtrl+W", 41 | click(_, window) { 42 | if (!window) return 43 | 44 | if (window.closable) { 45 | window.close() 46 | } else { 47 | window.hide() 48 | } 49 | }, 50 | } 51 | : { role: "quit" as const }, 52 | ], 53 | }, 54 | // { role: 'editMenu' } 55 | { 56 | label: "Edit", 57 | submenu: [ 58 | { role: "undo" as const }, 59 | { role: "redo" as const }, 60 | { type: "separator" as const }, 61 | { role: "cut" as const }, 62 | { role: "copy" as const }, 63 | { role: "paste" as const }, 64 | ...(isMac 65 | ? [ 66 | { role: "pasteAndMatchStyle" as const }, 67 | { role: "delete" as const }, 68 | { role: "selectAll" as const }, 69 | { type: "separator" as const }, 70 | { 71 | label: "Speech", 72 | submenu: [ 73 | { role: "startSpeaking" as const }, 74 | { role: "stopSpeaking" as const }, 75 | ], 76 | }, 77 | ] 78 | : [ 79 | { role: "delete" as const }, 80 | { type: "separator" as const }, 81 | { role: "selectAll" as const }, 82 | ]), 83 | ], 84 | }, 85 | // { role: 'viewMenu' } 86 | { 87 | label: "View", 88 | submenu: toMenu([ 89 | import.meta.env.DEV && { role: "toggleDevTools" }, 90 | import.meta.env.DEV && { type: "separator" }, 91 | import.meta.env.DEV && { role: "reload" }, 92 | { role: "resetZoom" }, 93 | { role: "zoomIn" }, 94 | { role: "zoomOut" }, 95 | { type: "separator" }, 96 | { role: "togglefullscreen" }, 97 | ]), 98 | }, 99 | // { role: 'windowMenu' } 100 | { 101 | label: "Window", 102 | submenu: [ 103 | { role: "minimize" as const }, 104 | { role: "zoom" as const }, 105 | ...(isMac 106 | ? [ 107 | { type: "separator" as const }, 108 | { role: "front" as const }, 109 | { type: "separator" as const }, 110 | { role: "window" as const }, 111 | ] 112 | : [{ role: "close" as const }]), 113 | ], 114 | }, 115 | { 116 | role: "help" as const, 117 | submenu: toMenu([ 118 | { 119 | label: "Send Feedback", 120 | click() { 121 | shell.openExternal("https://github.com/egoist/whispo/issues/new") 122 | }, 123 | }, 124 | ]), 125 | }, 126 | ] 127 | 128 | const menu = Menu.buildFromTemplate(template) 129 | 130 | return menu 131 | } 132 | -------------------------------------------------------------------------------- /src/main/renderer-handlers.ts: -------------------------------------------------------------------------------- 1 | import { UpdateDownloadedEvent } from "electron-updater" 2 | 3 | export type RendererHandlers = { 4 | startRecording: () => void 5 | finishRecording: () => void 6 | stopRecording: () => void 7 | startOrFinishRecording: () => void 8 | refreshRecordingHistory: () => void 9 | 10 | updateAvailable: (e: UpdateDownloadedEvent) => void 11 | navigate: (url: string) => void 12 | } 13 | -------------------------------------------------------------------------------- /src/main/serve.ts: -------------------------------------------------------------------------------- 1 | import { protocol, ProtocolRequest, ProtocolResponse } from "electron" 2 | import path from "path" 3 | import fs from "fs" 4 | import { recordingsFolder } from "./config" 5 | 6 | const rendererDir = path.join(__dirname, "../renderer") 7 | 8 | // See https://cs.chromium.org/chromium/src/net/base/net_error_list.h 9 | const FILE_NOT_FOUND = -6 10 | 11 | const getPath = async (path_: string) => { 12 | try { 13 | const result = await fs.promises.stat(path_) 14 | 15 | if (result.isFile()) { 16 | return path_ 17 | } 18 | 19 | if (result.isDirectory()) { 20 | return getPath(path.join(path_, "index.html")) 21 | } 22 | } catch (_) {} 23 | 24 | return null 25 | } 26 | 27 | const handleApp = async ( 28 | request: ProtocolRequest, 29 | callback: (response: string | ProtocolResponse) => void, 30 | ) => { 31 | const indexPath = path.join(rendererDir, "index.html") 32 | const filePath = path.join( 33 | rendererDir, 34 | decodeURIComponent(new URL(request.url).pathname), 35 | ) 36 | const resolvedPath = await getPath(filePath) 37 | const fileExtension = path.extname(filePath) 38 | 39 | if ( 40 | resolvedPath || 41 | !fileExtension || 42 | fileExtension === ".html" || 43 | fileExtension === ".asar" 44 | ) { 45 | callback({ 46 | path: resolvedPath || indexPath, 47 | }) 48 | } else { 49 | callback({ error: FILE_NOT_FOUND }) 50 | } 51 | } 52 | 53 | export function registerServeSchema() { 54 | protocol.registerSchemesAsPrivileged([ 55 | { 56 | scheme: "assets", 57 | privileges: { 58 | standard: true, 59 | secure: true, 60 | allowServiceWorkers: true, 61 | supportFetchAPI: true, 62 | corsEnabled: true, 63 | }, 64 | }, 65 | ]) 66 | } 67 | 68 | export function registerServeProtocol() { 69 | protocol.registerFileProtocol("assets", (request, callback) => { 70 | const { host, pathname, searchParams } = new URL(request.url) 71 | 72 | if (host === "recording") { 73 | const id = pathname.slice(1) 74 | const loc = path.join(recordingsFolder, `${id}.webm`) 75 | return callback({ path: loc }) 76 | } 77 | 78 | if (host === "file") { 79 | const filepath = searchParams.get("path") 80 | if (filepath) { 81 | return callback({ path: filepath }) 82 | } 83 | } 84 | 85 | if (host === "app") { 86 | return handleApp(request, callback) 87 | } 88 | 89 | callback({ error: FILE_NOT_FOUND }) 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /src/main/state.ts: -------------------------------------------------------------------------------- 1 | export const state = { 2 | isRecording: false 3 | } 4 | -------------------------------------------------------------------------------- /src/main/tipc.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import { getRendererHandlers, tipc } from "@egoist/tipc/main" 3 | import { showPanelWindow, WINDOWS } from "./window" 4 | import { 5 | app, 6 | clipboard, 7 | Menu, 8 | shell, 9 | systemPreferences, 10 | dialog, 11 | } from "electron" 12 | import path from "path" 13 | import { configStore, recordingsFolder } from "./config" 14 | import { Config, RecordingHistoryItem } from "../shared/types" 15 | import { RendererHandlers } from "./renderer-handlers" 16 | import { postProcessTranscript } from "./llm" 17 | import { state } from "./state" 18 | import { updateTrayIcon } from "./tray" 19 | import { isAccessibilityGranted } from "./utils" 20 | import { writeText } from "./keyboard" 21 | 22 | const t = tipc.create() 23 | 24 | const getRecordingHistory = () => { 25 | try { 26 | const history = JSON.parse( 27 | fs.readFileSync(path.join(recordingsFolder, "history.json"), "utf8"), 28 | ) as RecordingHistoryItem[] 29 | 30 | // sort desc by createdAt 31 | return history.sort((a, b) => b.createdAt - a.createdAt) 32 | } catch { 33 | return [] 34 | } 35 | } 36 | 37 | const saveRecordingsHitory = (history: RecordingHistoryItem[]) => { 38 | fs.writeFileSync( 39 | path.join(recordingsFolder, "history.json"), 40 | JSON.stringify(history), 41 | ) 42 | } 43 | 44 | export const router = { 45 | restartApp: t.procedure.action(async () => { 46 | app.relaunch() 47 | app.quit() 48 | }), 49 | 50 | getUpdateInfo: t.procedure.action(async () => { 51 | const { getUpdateInfo } = await import("./updater") 52 | return getUpdateInfo() 53 | }), 54 | 55 | quitAndInstall: t.procedure.action(async () => { 56 | const { quitAndInstall } = await import("./updater") 57 | 58 | quitAndInstall() 59 | }), 60 | 61 | checkForUpdatesAndDownload: t.procedure.action(async () => { 62 | const { checkForUpdatesAndDownload } = await import("./updater") 63 | 64 | return checkForUpdatesAndDownload() 65 | }), 66 | 67 | openMicrophoneInSystemPreferences: t.procedure.action(async () => { 68 | await shell.openExternal( 69 | "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", 70 | ) 71 | }), 72 | 73 | hidePanelWindow: t.procedure.action(async () => { 74 | const panel = WINDOWS.get("panel") 75 | 76 | panel?.hide() 77 | }), 78 | 79 | showContextMenu: t.procedure 80 | .input<{ x: number; y: number; selectedText?: string }>() 81 | .action(async ({ input, context }) => { 82 | const items: Electron.MenuItemConstructorOptions[] = [] 83 | 84 | if (input.selectedText) { 85 | items.push({ 86 | label: "Copy", 87 | click() { 88 | clipboard.writeText(input.selectedText || "") 89 | }, 90 | }) 91 | } 92 | 93 | if (import.meta.env.DEV) { 94 | items.push({ 95 | label: "Inspect Element", 96 | click() { 97 | context.sender.inspectElement(input.x, input.y) 98 | }, 99 | }) 100 | } 101 | 102 | const panelWindow = WINDOWS.get("panel") 103 | const isPanelWindow = panelWindow?.webContents.id === context.sender.id 104 | 105 | if (isPanelWindow) { 106 | items.push({ 107 | label: "Close", 108 | click() { 109 | panelWindow?.hide() 110 | }, 111 | }) 112 | } 113 | 114 | const menu = Menu.buildFromTemplate(items) 115 | menu.popup({ 116 | x: input.x, 117 | y: input.y, 118 | }) 119 | }), 120 | 121 | getMicrophoneStatus: t.procedure.action(async () => { 122 | return systemPreferences.getMediaAccessStatus("microphone") 123 | }), 124 | 125 | isAccessibilityGranted: t.procedure.action(async () => { 126 | return isAccessibilityGranted() 127 | }), 128 | 129 | requestAccesssbilityAccess: t.procedure.action(async () => { 130 | if (process.platform === "win32") return true 131 | 132 | return systemPreferences.isTrustedAccessibilityClient(true) 133 | }), 134 | 135 | requestMicrophoneAccess: t.procedure.action(async () => { 136 | return systemPreferences.askForMediaAccess("microphone") 137 | }), 138 | 139 | showPanelWindow: t.procedure.action(async () => { 140 | showPanelWindow() 141 | }), 142 | 143 | displayError: t.procedure 144 | .input<{ title?: string; message: string }>() 145 | .action(async ({ input }) => { 146 | dialog.showErrorBox(input.title || "Error", input.message) 147 | }), 148 | 149 | createRecording: t.procedure 150 | .input<{ 151 | recording: ArrayBuffer 152 | duration: number 153 | }>() 154 | .action(async ({ input }) => { 155 | fs.mkdirSync(recordingsFolder, { recursive: true }) 156 | 157 | const config = configStore.get() 158 | const form = new FormData() 159 | form.append( 160 | "file", 161 | new File([input.recording], "recording.webm", { type: "audio/webm" }), 162 | ) 163 | form.append( 164 | "model", 165 | config.sttProviderId === "groq" ? "whisper-large-v3" : "whisper-1", 166 | ) 167 | form.append("response_format", "json") 168 | 169 | const groqBaseUrl = config.groqBaseUrl || "https://api.groq.com/openai/v1" 170 | const openaiBaseUrl = config.openaiBaseUrl || "https://api.openai.com/v1" 171 | 172 | const transcriptResponse = await fetch( 173 | config.sttProviderId === "groq" 174 | ? `${groqBaseUrl}/audio/transcriptions` 175 | : `${openaiBaseUrl}/audio/transcriptions`, 176 | { 177 | method: "POST", 178 | headers: { 179 | Authorization: `Bearer ${config.sttProviderId === "groq" ? config.groqApiKey : config.openaiApiKey}`, 180 | }, 181 | body: form, 182 | }, 183 | ) 184 | 185 | if (!transcriptResponse.ok) { 186 | const message = `${transcriptResponse.statusText} ${(await transcriptResponse.text()).slice(0, 300)}` 187 | 188 | throw new Error(message) 189 | } 190 | 191 | const json: { text: string } = await transcriptResponse.json() 192 | const transcript = await postProcessTranscript(json.text) 193 | 194 | const history = getRecordingHistory() 195 | const item: RecordingHistoryItem = { 196 | id: Date.now().toString(), 197 | createdAt: Date.now(), 198 | duration: input.duration, 199 | transcript, 200 | } 201 | history.push(item) 202 | saveRecordingsHitory(history) 203 | 204 | fs.writeFileSync( 205 | path.join(recordingsFolder, `${item.id}.webm`), 206 | Buffer.from(input.recording), 207 | ) 208 | 209 | const main = WINDOWS.get("main") 210 | if (main) { 211 | getRendererHandlers( 212 | main.webContents, 213 | ).refreshRecordingHistory.send() 214 | } 215 | 216 | const panel = WINDOWS.get("panel") 217 | if (panel) { 218 | panel.hide() 219 | } 220 | 221 | // paste 222 | clipboard.writeText(transcript) 223 | if (isAccessibilityGranted()) { 224 | await writeText(transcript) 225 | } 226 | }), 227 | 228 | getRecordingHistory: t.procedure.action(async () => getRecordingHistory()), 229 | 230 | deleteRecordingItem: t.procedure 231 | .input<{ id: string }>() 232 | .action(async ({ input }) => { 233 | const recordings = getRecordingHistory().filter( 234 | (item) => item.id !== input.id, 235 | ) 236 | saveRecordingsHitory(recordings) 237 | fs.unlinkSync(path.join(recordingsFolder, `${input.id}.webm`)) 238 | }), 239 | 240 | deleteRecordingHistory: t.procedure.action(async () => { 241 | fs.rmSync(recordingsFolder, { force: true, recursive: true }) 242 | }), 243 | 244 | getConfig: t.procedure.action(async () => { 245 | return configStore.get() 246 | }), 247 | 248 | saveConfig: t.procedure 249 | .input<{ config: Config }>() 250 | .action(async ({ input }) => { 251 | configStore.save(input.config) 252 | }), 253 | 254 | recordEvent: t.procedure 255 | .input<{ type: "start" | "end" }>() 256 | .action(async ({ input }) => { 257 | if (input.type === "start") { 258 | state.isRecording = true 259 | } else { 260 | state.isRecording = false 261 | } 262 | updateTrayIcon() 263 | }), 264 | } 265 | 266 | export type Router = typeof router 267 | -------------------------------------------------------------------------------- /src/main/tray.ts: -------------------------------------------------------------------------------- 1 | import { Menu, Tray } from "electron" 2 | import path from "path" 3 | import { 4 | getWindowRendererHandlers, 5 | showMainWindow, 6 | showPanelWindowAndStartRecording, 7 | stopRecordingAndHidePanelWindow, 8 | } from "./window" 9 | import { state } from "./state" 10 | 11 | const defaultIcon = path.join(__dirname, `../../resources/${process.env.IS_MAC ? 'trayIconTemplate.png' : 'trayIcon.ico'}`) 12 | const stopIcon = path.join( 13 | __dirname, 14 | "../../resources/stopTrayIconTemplate.png", 15 | ) 16 | 17 | const buildMenu = (tray: Tray) => 18 | Menu.buildFromTemplate([ 19 | { 20 | label: state.isRecording ? "Cancel Recording" : "Start Recording", 21 | click() { 22 | if (state.isRecording) { 23 | state.isRecording = false 24 | tray.setImage(defaultIcon) 25 | stopRecordingAndHidePanelWindow() 26 | return 27 | } 28 | state.isRecording = true 29 | tray.setImage(stopIcon) 30 | showPanelWindowAndStartRecording() 31 | }, 32 | }, 33 | { 34 | label: "View History", 35 | click() { 36 | showMainWindow("/") 37 | }, 38 | }, 39 | { 40 | type: "separator", 41 | }, 42 | { 43 | label: "Settings", 44 | accelerator: "CmdOrCtrl+,", 45 | click() { 46 | showMainWindow("/settings") 47 | }, 48 | }, 49 | { 50 | type: "separator", 51 | }, 52 | { 53 | role: "quit", 54 | }, 55 | ]) 56 | 57 | let _tray: Tray | undefined 58 | 59 | export const updateTrayIcon = () => { 60 | if (!_tray) return 61 | 62 | _tray.setImage(state.isRecording ? stopIcon : defaultIcon) 63 | } 64 | 65 | export const initTray = () => { 66 | const tray = (_tray = new Tray(defaultIcon)) 67 | 68 | tray.on("click", () => { 69 | if (state.isRecording) { 70 | getWindowRendererHandlers("panel")?.finishRecording.send() 71 | return 72 | } 73 | 74 | tray.popUpContextMenu(buildMenu(tray)) 75 | }) 76 | 77 | tray.on("right-click", () => { 78 | tray.popUpContextMenu(buildMenu(tray)) 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /src/main/updater.ts: -------------------------------------------------------------------------------- 1 | import electronUpdater, { UpdateInfo } from "electron-updater" 2 | import { MenuItem, dialog } from "electron" 3 | import { makePanelWindowClosable, WINDOWS } from "./window" 4 | import { getRendererHandlers } from "@egoist/tipc/main" 5 | import { RendererHandlers } from "./renderer-handlers" 6 | 7 | electronUpdater.autoUpdater.fullChangelog = true 8 | electronUpdater.autoUpdater.autoDownload = false 9 | electronUpdater.autoUpdater.autoInstallOnAppQuit = true 10 | 11 | // Uncomment the following line to test auto-updates in development 12 | // electronUpdater.autoUpdater.forceDevUpdateConfig = import.meta.env.DEV 13 | 14 | if (import.meta.env.PROD) { 15 | electronUpdater.autoUpdater.setFeedURL({ 16 | provider: "github", 17 | host: "electron-releases.umida.co", 18 | owner: "egoist", 19 | repo: "whispo", 20 | }) 21 | } 22 | 23 | let updateInfo: UpdateInfo | null = null 24 | let downloadedUpdates: string[] | null = null 25 | let menuItem: MenuItem | null = null 26 | 27 | function enableMenuItem() { 28 | if (menuItem) { 29 | menuItem.enabled = true 30 | menuItem = null 31 | } 32 | } 33 | 34 | export function init() { 35 | electronUpdater.autoUpdater.addListener("update-downloaded", (e) => { 36 | const window = WINDOWS.get("main") 37 | if (window) { 38 | getRendererHandlers( 39 | window.webContents, 40 | ).updateAvailable.send(e) 41 | } 42 | // Menu.setApplicationMenu(createAppMenu('downloaded')) 43 | }) 44 | 45 | electronUpdater.autoUpdater.addListener("update-not-available", () => { 46 | updateInfo = null 47 | enableMenuItem() 48 | // const window = windows.get('updater') 49 | // window?.close() 50 | }) 51 | 52 | let hasSetDownloaing = false 53 | electronUpdater.autoUpdater.addListener("download-progress", (_info) => { 54 | // const window = windows.get('updater') 55 | // if (window) { 56 | // window.webContents.send('download-progress', info) 57 | // } 58 | 59 | if (!hasSetDownloaing) { 60 | hasSetDownloaing = true 61 | // Menu.setApplicationMenu(createAppMenu('downloading')) 62 | } 63 | }) 64 | } 65 | 66 | export function getUpdateInfo() { 67 | return updateInfo 68 | } 69 | 70 | export async function checkForUpdatesMenuItem(_menuItem: MenuItem) { 71 | menuItem = _menuItem 72 | menuItem.enabled = false 73 | 74 | const checkResult = await checkForUpdatesAndDownload().catch(() => null) 75 | 76 | if (checkResult && checkResult.updateInfo) { 77 | // nothing 78 | } else { 79 | await dialog.showMessageBox({ 80 | title: "No updates available", 81 | message: `You are already using the latest version of ${process.env.PRODUCT_NAME}.`, 82 | }) 83 | } 84 | } 85 | 86 | export async function checkForUpdatesAndDownload() { 87 | if (updateInfo && downloadedUpdates) return { downloadedUpdates, updateInfo } 88 | if (updateInfo) return { updateInfo, downloadedUpdates } 89 | 90 | const updates = await electronUpdater.autoUpdater.checkForUpdates() 91 | 92 | if ( 93 | updates && 94 | electronUpdater.autoUpdater.currentVersion.compare( 95 | updates.updateInfo.version, 96 | ) === -1 97 | ) { 98 | updateInfo = updates.updateInfo 99 | downloadedUpdates = await downloadUpdate() 100 | return { updateInfo, downloadedUpdates } 101 | } 102 | 103 | updateInfo = null 104 | downloadedUpdates = null 105 | return { updateInfo, downloadedUpdates } 106 | } 107 | 108 | export function quitAndInstall() { 109 | makePanelWindowClosable() 110 | setTimeout(() => { 111 | electronUpdater.autoUpdater.quitAndInstall() 112 | }) 113 | } 114 | 115 | let cancellationToken: electronUpdater.CancellationToken | null = null 116 | 117 | export async function downloadUpdate() { 118 | if (cancellationToken) { 119 | return null 120 | } 121 | 122 | cancellationToken = new electronUpdater.CancellationToken() 123 | const result = 124 | await electronUpdater.autoUpdater.downloadUpdate(cancellationToken) 125 | cancellationToken = null 126 | return result 127 | } 128 | 129 | export function cancelDownloadUpdate() { 130 | if (cancellationToken) { 131 | cancellationToken.cancel() 132 | cancellationToken = null 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/main/utils.ts: -------------------------------------------------------------------------------- 1 | import { systemPreferences } from "electron" 2 | 3 | export const isAccessibilityGranted = () => { 4 | if (process.platform === "win32") return true 5 | 6 | return systemPreferences.isTrustedAccessibilityClient(false) 7 | } 8 | -------------------------------------------------------------------------------- /src/main/window.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserWindow, 3 | BrowserWindowConstructorOptions, 4 | shell, 5 | screen, 6 | app, 7 | } from "electron" 8 | import path from "path" 9 | import { getRendererHandlers } from "@egoist/tipc/main" 10 | import { 11 | makeKeyWindow, 12 | makePanel, 13 | makeWindow, 14 | } from "@egoist/electron-panel-window" 15 | import { RendererHandlers } from "./renderer-handlers" 16 | import { configStore } from "./config" 17 | 18 | type WINDOW_ID = "main" | "panel" | "setup" 19 | 20 | export const WINDOWS = new Map() 21 | 22 | function createBaseWindow({ 23 | id, 24 | url, 25 | showWhenReady = true, 26 | windowOptions, 27 | }: { 28 | id: WINDOW_ID 29 | url?: string 30 | showWhenReady?: boolean 31 | windowOptions?: BrowserWindowConstructorOptions 32 | }) { 33 | // Create the browser window. 34 | const win = new BrowserWindow({ 35 | width: 900, 36 | height: 670, 37 | show: false, 38 | autoHideMenuBar: true, 39 | ...windowOptions, 40 | webPreferences: { 41 | preload: path.join(__dirname, "../preload/index.mjs"), 42 | sandbox: false, 43 | ...windowOptions?.webPreferences, 44 | }, 45 | }) 46 | 47 | WINDOWS.set(id, win) 48 | 49 | if (showWhenReady) { 50 | win.on("ready-to-show", () => { 51 | win.show() 52 | }) 53 | } 54 | 55 | win.on("close", () => { 56 | console.log("close", id) 57 | WINDOWS.delete(id) 58 | }) 59 | 60 | win.webContents.setWindowOpenHandler((details) => { 61 | shell.openExternal(details.url) 62 | return { action: "deny" } 63 | }) 64 | 65 | const baseUrl = import.meta.env.PROD 66 | ? "assets://app" 67 | : process.env["ELECTRON_RENDERER_URL"] 68 | 69 | win.loadURL(`${baseUrl}${url || ""}`) 70 | 71 | return win 72 | } 73 | 74 | export function createMainWindow({ url }: { url?: string } = {}) { 75 | const win = createBaseWindow({ 76 | id: "main", 77 | url, 78 | windowOptions: { 79 | titleBarStyle: "hiddenInset", 80 | }, 81 | }) 82 | 83 | if (process.env.IS_MAC) { 84 | win.on("close", () => { 85 | if (configStore.get().hideDockIcon) { 86 | app.setActivationPolicy("accessory") 87 | app.dock.hide() 88 | } 89 | }) 90 | 91 | win.on("show", () => { 92 | if (configStore.get().hideDockIcon && !app.dock.isVisible()) { 93 | app.dock.show() 94 | } 95 | }) 96 | } 97 | 98 | return win 99 | } 100 | 101 | export function createSetupWindow() { 102 | const win = createBaseWindow({ 103 | id: "setup", 104 | url: "/setup", 105 | windowOptions: { 106 | titleBarStyle: "hiddenInset", 107 | width: 800, 108 | height: 600, 109 | resizable: false, 110 | }, 111 | }) 112 | 113 | return win 114 | } 115 | 116 | export function showMainWindow(url?: string) { 117 | const win = WINDOWS.get("main") 118 | 119 | if (win) { 120 | win.show() 121 | if (url) { 122 | getRendererHandlers(win.webContents).navigate.send(url) 123 | } 124 | } else { 125 | createMainWindow({ url }) 126 | } 127 | } 128 | 129 | const panelWindowSize = { 130 | width: 260, 131 | height: 50, 132 | } 133 | 134 | const getPanelWindowPosition = () => { 135 | // position the window top right 136 | const currentScreen = screen.getDisplayNearestPoint( 137 | screen.getCursorScreenPoint(), 138 | ) 139 | const screenSize = currentScreen.workArea 140 | const position = { 141 | x: Math.floor( 142 | screenSize.x + (screenSize.width - panelWindowSize.width) - 10, 143 | ), 144 | y: screenSize.y + 10, 145 | } 146 | 147 | return position 148 | } 149 | 150 | export function createPanelWindow() { 151 | const position = getPanelWindowPosition() 152 | 153 | const win = createBaseWindow({ 154 | id: "panel", 155 | url: "/panel", 156 | showWhenReady: false, 157 | windowOptions: { 158 | hiddenInMissionControl: true, 159 | skipTaskbar: true, 160 | closable: false, 161 | maximizable: false, 162 | frame: false, 163 | // transparent: true, 164 | paintWhenInitiallyHidden: true, 165 | // hasShadow: false, 166 | width: panelWindowSize.width, 167 | height: panelWindowSize.height, 168 | maxWidth: panelWindowSize.width, 169 | maxHeight: panelWindowSize.height, 170 | minWidth: panelWindowSize.width, 171 | minHeight: panelWindowSize.height, 172 | visualEffectState: "active", 173 | vibrancy: "under-window", 174 | alwaysOnTop: true, 175 | x: position.x, 176 | y: position.y, 177 | }, 178 | }) 179 | 180 | win.on("hide", () => { 181 | getRendererHandlers(win.webContents).stopRecording.send() 182 | }) 183 | 184 | makePanel(win) 185 | 186 | return win 187 | } 188 | 189 | export function showPanelWindow() { 190 | const win = WINDOWS.get("panel") 191 | if (win) { 192 | const position = getPanelWindowPosition() 193 | win.setPosition(position.x, position.y) 194 | win.showInactive() 195 | makeKeyWindow(win) 196 | } 197 | } 198 | 199 | export function showPanelWindowAndStartRecording() { 200 | showPanelWindow() 201 | getWindowRendererHandlers("panel")?.startRecording.send() 202 | } 203 | 204 | export function makePanelWindowClosable() { 205 | const panel = WINDOWS.get("panel") 206 | if (panel && !panel.isClosable()) { 207 | makeWindow(panel) 208 | panel.setClosable(true) 209 | } 210 | } 211 | 212 | export const getWindowRendererHandlers = (id: WINDOW_ID) => { 213 | const win = WINDOWS.get(id) 214 | if (!win) return 215 | return getRendererHandlers(win.webContents) 216 | } 217 | 218 | export const stopRecordingAndHidePanelWindow = () => { 219 | const win = WINDOWS.get("panel") 220 | if (win) { 221 | getRendererHandlers(win.webContents).stopRecording.send() 222 | 223 | if (win.isVisible()) { 224 | win.hide() 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ElectronAPI } from '@electron-toolkit/preload' 2 | 3 | declare global { 4 | interface Window { 5 | electron: ElectronAPI 6 | api: unknown 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from "electron" 2 | import { electronAPI } from "@electron-toolkit/preload" 3 | 4 | // Custom APIs for renderer 5 | const api = {} 6 | 7 | // Use `contextBridge` APIs to expose Electron APIs to 8 | // renderer only if context isolation is enabled, otherwise 9 | // just add to the DOM global. 10 | if (process.contextIsolated) { 11 | try { 12 | contextBridge.exposeInMainWorld("electron", electronAPI) 13 | contextBridge.exposeInMainWorld("api", api) 14 | } catch (error) { 15 | console.error(error) 16 | } 17 | } else { 18 | // @ts-ignore (define in dts) 19 | window.electron = electronAPI 20 | // @ts-ignore (define in dts) 21 | window.api = api 22 | } 23 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Whispo 6 | 7 | 11 | 36 | 37 | 38 | 39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/renderer/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider } from "react-router-dom" 2 | import { router } from "./router" 3 | import { lazy, Suspense } from "react" 4 | 5 | const Updater = lazy(() => import("./components/updater")) 6 | 7 | function App(): JSX.Element { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | export default App 20 | -------------------------------------------------------------------------------- /src/renderer/src/assets/begin-record.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/whispo/1bebd3b1bc7f2636726edc014f60549e6a1f6e07/src/renderer/src/assets/begin-record.wav -------------------------------------------------------------------------------- /src/renderer/src/assets/end-record.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/whispo/1bebd3b1bc7f2636726edc014f60549e6a1f6e07/src/renderer/src/assets/end-record.wav -------------------------------------------------------------------------------- /src/renderer/src/components/app-layout.tsx: -------------------------------------------------------------------------------- 1 | import { rendererHandlers } from "@renderer/lib/tipc-client" 2 | import { cn } from "@renderer/lib/utils" 3 | import { useEffect } from "react" 4 | import { NavLink, Outlet, useNavigate } from "react-router-dom" 5 | 6 | export const Component = () => { 7 | const navigate = useNavigate() 8 | const navLinks = [ 9 | { 10 | text: "History", 11 | href: "/", 12 | icon: "i-mingcute-history-anticlockwise-line", 13 | }, 14 | { 15 | text: "Settings", 16 | href: "/settings", 17 | icon: "i-mingcute-settings-3-line", 18 | }, 19 | ] 20 | 21 | useEffect(() => { 22 | return rendererHandlers.navigate.listen((url) => { 23 | console.log("navigate", url) 24 | navigate(url) 25 | }) 26 | }, []) 27 | 28 | return ( 29 |
30 |
31 |
35 | 36 |
37 | {navLinks.map((link) => { 38 | return ( 39 | 45 | cn( 46 | "flex h-7 items-center gap-2 rounded-md px-2 font-medium transition-colors", 47 | isActive 48 | ? "bg-neutral-100 dark:bg-neutral-800 dark:text-white" 49 | : "hover:bg-neutral-50 dark:text-neutral-400 dark:hover:bg-neutral-800", 50 | ) 51 | } 52 | > 53 | 54 | {link.text} 55 | 56 | ) 57 | })} 58 |
59 |
60 |
61 | 62 |
63 |
64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /src/renderer/src/components/setup.tsx: -------------------------------------------------------------------------------- 1 | import { useMicrphoneStatusQuery } from "@renderer/lib/query-client" 2 | import { Button } from "./ui/button" 3 | import { tipcClient } from "@renderer/lib/tipc-client" 4 | import { useQuery } from "@tanstack/react-query" 5 | 6 | export function Setup() { 7 | const microphoneStatusQuery = useMicrphoneStatusQuery() 8 | const isAccessibilityGrantedQuery = useQuery({ 9 | queryKey: ["setup-isAccessibilityGranted"], 10 | queryFn: () => tipcClient.isAccessibilityGranted(), 11 | }) 12 | 13 | return ( 14 |
15 |
16 |

17 | Welcome to {process.env.PRODUCT_NAME} 18 |

19 |

20 | We need some system permissions before we can run the app 21 |

22 |
23 |
24 | {process.env.IS_MAC && ( 25 | { 30 | tipcClient.requestAccesssbilityAccess() 31 | }} 32 | enabled={isAccessibilityGrantedQuery.data} 33 | /> 34 | )} 35 | 36 | { 45 | const granted = await tipcClient.requestMicrophoneAccess() 46 | if (!granted) { 47 | tipcClient.openMicrophoneInSystemPreferences() 48 | } 49 | }} 50 | enabled={microphoneStatusQuery.data === "granted"} 51 | /> 52 |
53 |
54 | 55 |
56 | 66 |
67 |
68 |
69 | ) 70 | } 71 | 72 | const PermissionBlock = ({ 73 | title, 74 | description, 75 | actionHandler, 76 | actionText, 77 | enabled, 78 | }: { 79 | title: React.ReactNode 80 | description: React.ReactNode 81 | actionText: string 82 | actionHandler: () => void 83 | enabled?: boolean 84 | }) => { 85 | return ( 86 |
87 |
88 |
{title}
89 |
90 | {description} 91 |
92 |
93 |
94 | {enabled ? ( 95 |
96 | 97 | Granted 98 |
99 | ) : ( 100 | 103 | )} 104 |
105 |
106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { tv, type VariantProps } from "tailwind-variants" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | const buttonVariants = tv({ 8 | base: "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-ring disabled:pointer-events-none disabled:opacity-50", 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-input bg-background hover:bg-accent dark:hover:bg-neutral-900 hover:text-accent-foreground", 16 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 17 | ghost: 18 | "hover:bg-accent dark:hover:bg-neutral-800 hover:text-accent-foreground", 19 | link: "text-primary underline-offset-4 hover:underline", 20 | }, 21 | size: { 22 | default: "h-9 px-4 py-2", 23 | sm: "h-8 rounded-md px-3 text-xs", 24 | lg: "h-10 rounded-md px-8", 25 | icon: "h-8 w-8", 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: "default", 30 | size: "default", 31 | }, 32 | }) 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps { 37 | asChild?: boolean 38 | } 39 | 40 | const Button = React.forwardRef( 41 | ({ className, variant, size, asChild = false, ...props }, ref) => { 42 | const Comp = asChild ? Slot : "button" 43 | return ( 44 | 49 | ) 50 | }, 51 | ) 52 | Button.displayName = "Button" 53 | 54 | export { Button, buttonVariants } 55 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/control.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@renderer/lib/utils" 2 | import React from "react" 3 | 4 | export const Control = ({ 5 | label, 6 | children, 7 | className, 8 | }: { 9 | label: React.ReactNode 10 | children: React.ReactNode 11 | className?: string 12 | }) => { 13 | return ( 14 |
17 |
18 | {label} 19 |
20 |
21 | {children} 22 |
23 |
24 | ) 25 | } 26 | 27 | export const ControlGroup = ({ 28 | children, 29 | className, 30 | title, 31 | endDescription, 32 | }: { 33 | children: React.ReactNode 34 | className?: string 35 | title?: React.ReactNode 36 | endDescription?: React.ReactNode 37 | }) => { 38 | return ( 39 |
40 | {title && ( 41 |
42 | {title} 43 |
44 | )} 45 |
{children}
46 | {endDescription && ( 47 |
48 |
{endDescription}
49 |
50 | )} 51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { Cross2Icon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogTrigger, 114 | DialogClose, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "~/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef< 9 | HTMLInputElement, 10 | InputProps & { wrapperClassName?: string; endContent?: React.ReactNode } 11 | >(({ className, type, wrapperClassName, endContent, ...props }, ref) => { 12 | return ( 13 |
19 | 25 | 26 | {endContent} 27 |
28 | ) 29 | }) 30 | Input.displayName = "Input" 31 | 32 | export { Input } 33 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { 3 | CaretSortIcon, 4 | CheckIcon, 5 | ChevronDownIcon, 6 | ChevronUpIcon, 7 | } from "@radix-ui/react-icons" 8 | import * as SelectPrimitive from "@radix-ui/react-select" 9 | 10 | import { cn } from "~/lib/utils" 11 | 12 | const Select = SelectPrimitive.Root 13 | 14 | const SelectGroup = SelectPrimitive.Group 15 | 16 | const SelectValue = SelectPrimitive.Value 17 | 18 | const SelectTrigger = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, children, ...props }, ref) => ( 22 | span]:line-clamp-1", 26 | className, 27 | )} 28 | {...props} 29 | > 30 | {children} 31 | 32 | 33 | 34 | 35 | )) 36 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 37 | 38 | const SelectScrollUpButton = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | 51 | 52 | )) 53 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 54 | 55 | const SelectScrollDownButton = React.forwardRef< 56 | React.ElementRef, 57 | React.ComponentPropsWithoutRef 58 | >(({ className, ...props }, ref) => ( 59 | 67 | 68 | 69 | )) 70 | SelectScrollDownButton.displayName = 71 | SelectPrimitive.ScrollDownButton.displayName 72 | 73 | const SelectContent = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, children, position = "popper", ...props }, ref) => ( 77 | 78 | 89 | 90 | 97 | {children} 98 | 99 | 100 | 101 | 102 | )) 103 | SelectContent.displayName = SelectPrimitive.Content.displayName 104 | 105 | const SelectLabel = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )) 115 | SelectLabel.displayName = SelectPrimitive.Label.displayName 116 | 117 | const SelectItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )) 137 | SelectItem.displayName = SelectPrimitive.Item.displayName 138 | 139 | const SelectSeparator = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef 142 | >(({ className, ...props }, ref) => ( 143 | 148 | )) 149 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 150 | 151 | export { 152 | Select, 153 | SelectGroup, 154 | SelectValue, 155 | SelectTrigger, 156 | SelectContent, 157 | SelectLabel, 158 | SelectItem, 159 | SelectSeparator, 160 | SelectScrollUpButton, 161 | SelectScrollDownButton, 162 | } 163 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { cn } from "~/lib/utils" 3 | 4 | export const Spinner = ({ 5 | className, 6 | delay, 7 | }: { 8 | className?: string 9 | delay?: number 10 | }) => { 11 | const [show, setShow] = useState(!delay) 12 | 13 | useEffect(() => { 14 | const id = window.setTimeout(() => { 15 | setShow(true) 16 | }, delay) 17 | 18 | return () => { 19 | window.clearTimeout(id) 20 | } 21 | }, [delay]) 22 | 23 | if (!show) { 24 | return null 25 | } 26 | 27 | return ( 28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SwitchPrimitives from "@radix-ui/react-switch" 3 | 4 | import { cn } from "~/lib/utils" 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )) 25 | Switch.displayName = SwitchPrimitives.Root.displayName 26 | 27 | export { Switch } 28 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "~/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 | 214 |
215 | Use {"{transcript}"}{" "} 216 | placeholder to insert the original transcript 217 |
218 | 219 | 220 |
221 | 222 | 223 | )} 224 | 225 |
226 | ) 227 | } 228 | -------------------------------------------------------------------------------- /src/renderer/src/pages/settings-providers.tsx: -------------------------------------------------------------------------------- 1 | import { Control, ControlGroup } from "@renderer/components/ui/control" 2 | import { Input } from "@renderer/components/ui/input" 3 | import { 4 | useConfigQuery, 5 | useSaveConfigMutation, 6 | } from "@renderer/lib/query-client" 7 | import { Config } from "@shared/types" 8 | 9 | export function Component() { 10 | const configQuery = useConfigQuery() 11 | 12 | const saveConfigMutation = useSaveConfigMutation() 13 | 14 | const saveConfig = (config: Partial) => { 15 | saveConfigMutation.mutate({ 16 | config: { 17 | ...configQuery.data, 18 | ...config, 19 | }, 20 | }) 21 | } 22 | 23 | if (!configQuery.data) return null 24 | 25 | return ( 26 |
27 | 28 | 29 | { 33 | saveConfig({ 34 | openaiApiKey: e.currentTarget.value, 35 | }) 36 | }} 37 | /> 38 | 39 | 40 | 41 | { 46 | saveConfig({ 47 | openaiBaseUrl: e.currentTarget.value, 48 | }) 49 | }} 50 | /> 51 | 52 | 53 | 54 | 55 | 56 | { 60 | saveConfig({ 61 | groqApiKey: e.currentTarget.value, 62 | }) 63 | }} 64 | /> 65 | 66 | 67 | 68 | { 73 | saveConfig({ 74 | groqBaseUrl: e.currentTarget.value, 75 | }) 76 | }} 77 | /> 78 | 79 | 80 | 81 | 82 | 83 | { 87 | saveConfig({ 88 | geminiApiKey: e.currentTarget.value, 89 | }) 90 | }} 91 | /> 92 | 93 | 94 | 95 | { 100 | saveConfig({ 101 | geminiBaseUrl: e.currentTarget.value, 102 | }) 103 | }} 104 | /> 105 | 106 | 107 |
108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /src/renderer/src/pages/settings.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@renderer/lib/utils" 2 | import { NavLink, Outlet, useLocation } from "react-router-dom" 3 | 4 | export function Component() { 5 | const navLinks = [ 6 | { 7 | text: "General", 8 | href: "/settings", 9 | }, 10 | { 11 | text: "Providers", 12 | href: "/settings/providers", 13 | }, 14 | { 15 | text: "Data", 16 | href: "/settings/data", 17 | }, 18 | { 19 | text: "About", 20 | href: "/settings/about", 21 | }, 22 | ] 23 | 24 | const location = useLocation() 25 | 26 | const activeNavLink = navLinks.find((item) => item.href === location.pathname) 27 | 28 | return ( 29 |
30 |
31 |
32 | {navLinks.map((link) => { 33 | return ( 34 | 46 | {link.text} 47 | 48 | ) 49 | })} 50 |
51 |
52 |
53 |
54 |

{activeNavLink?.text}

55 |
56 | 57 | 58 |
59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/renderer/src/pages/setup.tsx: -------------------------------------------------------------------------------- 1 | import { useMicrphoneStatusQuery } from "@renderer/lib/query-client" 2 | import { Button } from "@renderer/components/ui/button" 3 | import { tipcClient } from "@renderer/lib/tipc-client" 4 | import { useQuery } from "@tanstack/react-query" 5 | 6 | export function Component() { 7 | const microphoneStatusQuery = useMicrphoneStatusQuery() 8 | const isAccessibilityGrantedQuery = useQuery({ 9 | queryKey: ["setup-isAccessibilityGranted"], 10 | queryFn: () => tipcClient.isAccessibilityGranted(), 11 | }) 12 | 13 | return ( 14 |
15 |
16 |

17 | Welcome to {process.env.PRODUCT_NAME} 18 |

19 |

20 | We need some system permissions before we can run the app 21 |

22 |
23 |
24 | {process.env.IS_MAC && ( 25 | { 30 | tipcClient.requestAccesssbilityAccess() 31 | }} 32 | enabled={isAccessibilityGrantedQuery.data} 33 | /> 34 | )} 35 | 36 | { 45 | const granted = await tipcClient.requestMicrophoneAccess() 46 | if (!granted) { 47 | tipcClient.openMicrophoneInSystemPreferences() 48 | } 49 | }} 50 | enabled={microphoneStatusQuery.data === "granted"} 51 | /> 52 |
53 |
54 | 55 |
56 | 66 |
67 |
68 |
69 | ) 70 | } 71 | 72 | const PermissionBlock = ({ 73 | title, 74 | description, 75 | actionHandler, 76 | actionText, 77 | enabled, 78 | }: { 79 | title: React.ReactNode 80 | description: React.ReactNode 81 | actionText: string 82 | actionHandler: () => void 83 | enabled?: boolean 84 | }) => { 85 | return ( 86 |
87 |
88 |
{title}
89 |
90 | {description} 91 |
92 |
93 |
94 | {enabled ? ( 95 |
96 | 97 | Granted 98 |
99 | ) : ( 100 | 103 | )} 104 |
105 |
106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /src/renderer/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from "react-router-dom" 2 | 3 | export const router: ReturnType = 4 | createBrowserRouter([ 5 | { 6 | path: "/", 7 | lazy: () => import("./components/app-layout"), 8 | children: [ 9 | { 10 | path: "settings", 11 | lazy: () => import("./pages/settings"), 12 | children: [ 13 | { 14 | path: "", 15 | lazy: () => import("./pages/settings-general"), 16 | }, 17 | { 18 | path: "about", 19 | lazy: () => import("./pages/settings-about"), 20 | }, 21 | { 22 | path: "providers", 23 | lazy: () => import("./pages/settings-providers"), 24 | }, 25 | { 26 | path: "data", 27 | lazy: () => import("./pages/settings-data"), 28 | }, 29 | ], 30 | }, 31 | { 32 | path: "", 33 | lazy: () => import("./pages/index"), 34 | }, 35 | ], 36 | }, 37 | { 38 | path: "/setup", 39 | lazy: () => import("./pages/setup"), 40 | }, 41 | { 42 | path: "/panel", 43 | lazy: () => import("./pages/panel"), 44 | }, 45 | ]) 46 | -------------------------------------------------------------------------------- /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | export const STT_PROVIDERS = [ 2 | { 3 | label: "OpenAI", 4 | value: "openai", 5 | }, 6 | { 7 | label: "Groq", 8 | value: "groq", 9 | }, 10 | ] as const 11 | 12 | export type STT_PROVIDER_ID = (typeof STT_PROVIDERS)[number]["value"] 13 | 14 | export const CHAT_PROVIDERS = [ 15 | { 16 | label: "OpenAI", 17 | value: "openai", 18 | }, 19 | { 20 | label: "Groq", 21 | value: "groq", 22 | }, 23 | { 24 | label: "Gemini", 25 | value: "gemini", 26 | }, 27 | ] as const 28 | 29 | export type CHAT_PROVIDER_ID = (typeof CHAT_PROVIDERS)[number]["value"] 30 | -------------------------------------------------------------------------------- /src/shared/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | APP_ID: string 4 | APP_VERSION: string 5 | PRODUCT_NAME: string 6 | IS_MAC: boolean 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import type { CHAT_PROVIDER_ID, STT_PROVIDER_ID } from "." 2 | 3 | export type RecordingHistoryItem = { 4 | id: string 5 | createdAt: number 6 | duration: number 7 | transcript: string 8 | } 9 | 10 | export type Config = { 11 | shortcut?: "hold-ctrl" | "ctrl-slash" 12 | hideDockIcon?: boolean 13 | 14 | sttProviderId?: STT_PROVIDER_ID 15 | 16 | openaiApiKey?: string 17 | openaiBaseUrl?: string 18 | 19 | groqApiKey?: string 20 | groqBaseUrl?: string 21 | 22 | geminiApiKey?: string 23 | geminiBaseUrl?: string 24 | 25 | transcriptPostProcessingEnabled?: boolean 26 | transcriptPostProcessingProviderId?: CHAT_PROVIDER_ID 27 | transcriptPostProcessingPrompt?: string 28 | } 29 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import animate from "tailwindcss-animate" 3 | import { iconsPlugin, getIconCollections } from "@egoist/tailwindcss-icons" 4 | 5 | /** @type {import('tailwindcss').Config} */ 6 | export default { 7 | darkMode: ["class"], 8 | content: ["./src/renderer/**/*.tsx"], 9 | theme: { 10 | extend: { 11 | borderRadius: { 12 | lg: "var(--radius)", 13 | md: "calc(var(--radius) - 2px)", 14 | sm: "calc(var(--radius) - 4px)", 15 | }, 16 | colors: { 17 | background: "hsl(var(--background))", 18 | foreground: "hsl(var(--foreground))", 19 | card: { 20 | DEFAULT: "hsl(var(--card))", 21 | foreground: "hsl(var(--card-foreground))", 22 | }, 23 | popover: { 24 | DEFAULT: "hsl(var(--popover))", 25 | foreground: "hsl(var(--popover-foreground))", 26 | }, 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | muted: { 36 | DEFAULT: "hsl(var(--muted))", 37 | foreground: "hsl(var(--muted-foreground))", 38 | }, 39 | accent: { 40 | DEFAULT: "hsl(var(--accent))", 41 | foreground: "hsl(var(--accent-foreground))", 42 | }, 43 | destructive: { 44 | DEFAULT: "hsl(var(--destructive))", 45 | foreground: "hsl(var(--destructive-foreground))", 46 | }, 47 | border: "hsl(var(--border))", 48 | input: "hsl(var(--input))", 49 | ring: "hsl(var(--ring))", 50 | chart: { 51 | 1: "hsl(var(--chart-1))", 52 | 2: "hsl(var(--chart-2))", 53 | 3: "hsl(var(--chart-3))", 54 | 4: "hsl(var(--chart-4))", 55 | 5: "hsl(var(--chart-5))", 56 | }, 57 | }, 58 | }, 59 | }, 60 | plugins: [ 61 | animate, 62 | iconsPlugin({ 63 | collections: getIconCollections(["mingcute"]), 64 | }), 65 | ], 66 | } 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "~/*": [ 8 | "src/renderer/src/*" 9 | ], 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", 3 | "include": ["electron.vite.config.*", "src/main/**/*", "src/shared/**/*","src/preload/**/*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["electron-vite/node"], 7 | "noUnusedLocals": false, 8 | "noUnusedParameters": false, 9 | "baseUrl": ".", 10 | "paths": { 11 | "@shared/*": [ 12 | "src/shared/*" 13 | ], 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", 3 | "include": [ 4 | "src/shared/**/*", 5 | "src/main/*.ts", 6 | "src/renderer/src/env.d.ts", 7 | "src/renderer/src/**/*", 8 | "src/renderer/src/**/*.tsx", 9 | "src/preload/*.d.ts" 10 | ], 11 | "compilerOptions": { 12 | "composite": true, 13 | "jsx": "react-jsx", 14 | "moduleResolution": "Bundler", 15 | "noUnusedLocals": false, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@renderer/*": [ 19 | "src/renderer/src/*" 20 | ], 21 | "~/*": [ 22 | "src/renderer/src/*" 23 | ], 24 | "@shared/*": [ 25 | "src/shared/*" 26 | ], 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /whispo-rs/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "bitflags" 7 | version = "1.3.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 10 | 11 | [[package]] 12 | name = "bitflags" 13 | version = "2.6.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 16 | 17 | [[package]] 18 | name = "block" 19 | version = "0.1.6" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" 22 | 23 | [[package]] 24 | name = "block2" 25 | version = "0.5.1" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" 28 | dependencies = [ 29 | "objc2", 30 | ] 31 | 32 | [[package]] 33 | name = "cocoa" 34 | version = "0.22.0" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "667fdc068627a2816b9ff831201dd9864249d6ee8d190b9532357f1fc0f61ea7" 37 | dependencies = [ 38 | "bitflags 1.3.2", 39 | "block", 40 | "core-foundation 0.9.4", 41 | "core-graphics 0.21.0", 42 | "foreign-types 0.3.2", 43 | "libc", 44 | "objc", 45 | ] 46 | 47 | [[package]] 48 | name = "core-foundation" 49 | version = "0.7.0" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" 52 | dependencies = [ 53 | "core-foundation-sys 0.7.0", 54 | "libc", 55 | ] 56 | 57 | [[package]] 58 | name = "core-foundation" 59 | version = "0.9.4" 60 | source = "registry+https://github.com/rust-lang/crates.io-index" 61 | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 62 | dependencies = [ 63 | "core-foundation-sys 0.8.7", 64 | "libc", 65 | ] 66 | 67 | [[package]] 68 | name = "core-foundation" 69 | version = "0.10.0" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" 72 | dependencies = [ 73 | "core-foundation-sys 0.8.7", 74 | "libc", 75 | ] 76 | 77 | [[package]] 78 | name = "core-foundation-sys" 79 | version = "0.7.0" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" 82 | 83 | [[package]] 84 | name = "core-foundation-sys" 85 | version = "0.8.7" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 88 | 89 | [[package]] 90 | name = "core-graphics" 91 | version = "0.19.2" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" 94 | dependencies = [ 95 | "bitflags 1.3.2", 96 | "core-foundation 0.7.0", 97 | "foreign-types 0.3.2", 98 | "libc", 99 | ] 100 | 101 | [[package]] 102 | name = "core-graphics" 103 | version = "0.21.0" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "52a67c4378cf203eace8fb6567847eb641fd6ff933c1145a115c6ee820ebb978" 106 | dependencies = [ 107 | "bitflags 1.3.2", 108 | "core-foundation 0.9.4", 109 | "foreign-types 0.3.2", 110 | "libc", 111 | ] 112 | 113 | [[package]] 114 | name = "core-graphics" 115 | version = "0.24.0" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" 118 | dependencies = [ 119 | "bitflags 2.6.0", 120 | "core-foundation 0.10.0", 121 | "core-graphics-types", 122 | "foreign-types 0.5.0", 123 | "libc", 124 | ] 125 | 126 | [[package]] 127 | name = "core-graphics-types" 128 | version = "0.2.0" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" 131 | dependencies = [ 132 | "bitflags 2.6.0", 133 | "core-foundation 0.10.0", 134 | "libc", 135 | ] 136 | 137 | [[package]] 138 | name = "enigo" 139 | version = "0.3.0" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6" 142 | dependencies = [ 143 | "core-foundation 0.10.0", 144 | "core-graphics 0.24.0", 145 | "foreign-types-shared 0.3.1", 146 | "libc", 147 | "log", 148 | "objc2", 149 | "objc2-app-kit", 150 | "objc2-foundation", 151 | "windows", 152 | "xkbcommon", 153 | "xkeysym", 154 | ] 155 | 156 | [[package]] 157 | name = "foreign-types" 158 | version = "0.3.2" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 161 | dependencies = [ 162 | "foreign-types-shared 0.1.1", 163 | ] 164 | 165 | [[package]] 166 | name = "foreign-types" 167 | version = "0.5.0" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" 170 | dependencies = [ 171 | "foreign-types-macros", 172 | "foreign-types-shared 0.3.1", 173 | ] 174 | 175 | [[package]] 176 | name = "foreign-types-macros" 177 | version = "0.2.3" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" 180 | dependencies = [ 181 | "proc-macro2", 182 | "quote", 183 | "syn", 184 | ] 185 | 186 | [[package]] 187 | name = "foreign-types-shared" 188 | version = "0.1.1" 189 | source = "registry+https://github.com/rust-lang/crates.io-index" 190 | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 191 | 192 | [[package]] 193 | name = "foreign-types-shared" 194 | version = "0.3.1" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" 197 | 198 | [[package]] 199 | name = "itoa" 200 | version = "1.0.13" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "540654e97a3f4470a492cd30ff187bc95d89557a903a2bbf112e2fae98104ef2" 203 | 204 | [[package]] 205 | name = "lazy_static" 206 | version = "1.5.0" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 209 | 210 | [[package]] 211 | name = "libc" 212 | version = "0.2.164" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" 215 | 216 | [[package]] 217 | name = "log" 218 | version = "0.4.22" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 221 | 222 | [[package]] 223 | name = "malloc_buf" 224 | version = "0.0.6" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" 227 | dependencies = [ 228 | "libc", 229 | ] 230 | 231 | [[package]] 232 | name = "memchr" 233 | version = "2.7.4" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 236 | 237 | [[package]] 238 | name = "memmap2" 239 | version = "0.9.5" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" 242 | dependencies = [ 243 | "libc", 244 | ] 245 | 246 | [[package]] 247 | name = "objc" 248 | version = "0.2.7" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" 251 | dependencies = [ 252 | "malloc_buf", 253 | ] 254 | 255 | [[package]] 256 | name = "objc-sys" 257 | version = "0.3.5" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" 260 | 261 | [[package]] 262 | name = "objc2" 263 | version = "0.5.2" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" 266 | dependencies = [ 267 | "objc-sys", 268 | "objc2-encode", 269 | ] 270 | 271 | [[package]] 272 | name = "objc2-app-kit" 273 | version = "0.2.2" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" 276 | dependencies = [ 277 | "bitflags 2.6.0", 278 | "block2", 279 | "libc", 280 | "objc2", 281 | "objc2-core-data", 282 | "objc2-core-image", 283 | "objc2-foundation", 284 | "objc2-quartz-core", 285 | ] 286 | 287 | [[package]] 288 | name = "objc2-core-data" 289 | version = "0.2.2" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" 292 | dependencies = [ 293 | "bitflags 2.6.0", 294 | "block2", 295 | "objc2", 296 | "objc2-foundation", 297 | ] 298 | 299 | [[package]] 300 | name = "objc2-core-image" 301 | version = "0.2.2" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" 304 | dependencies = [ 305 | "block2", 306 | "objc2", 307 | "objc2-foundation", 308 | "objc2-metal", 309 | ] 310 | 311 | [[package]] 312 | name = "objc2-encode" 313 | version = "4.0.3" 314 | source = "registry+https://github.com/rust-lang/crates.io-index" 315 | checksum = "7891e71393cd1f227313c9379a26a584ff3d7e6e7159e988851f0934c993f0f8" 316 | 317 | [[package]] 318 | name = "objc2-foundation" 319 | version = "0.2.2" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" 322 | dependencies = [ 323 | "bitflags 2.6.0", 324 | "block2", 325 | "libc", 326 | "objc2", 327 | ] 328 | 329 | [[package]] 330 | name = "objc2-metal" 331 | version = "0.2.2" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" 334 | dependencies = [ 335 | "bitflags 2.6.0", 336 | "block2", 337 | "objc2", 338 | "objc2-foundation", 339 | ] 340 | 341 | [[package]] 342 | name = "objc2-quartz-core" 343 | version = "0.2.2" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" 346 | dependencies = [ 347 | "bitflags 2.6.0", 348 | "block2", 349 | "objc2", 350 | "objc2-foundation", 351 | "objc2-metal", 352 | ] 353 | 354 | [[package]] 355 | name = "pkg-config" 356 | version = "0.3.31" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" 359 | 360 | [[package]] 361 | name = "proc-macro2" 362 | version = "1.0.91" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "307e3004becf10f5a6e0d59d20f3cd28231b0e0827a96cd3e0ce6d14bc1e4bb3" 365 | dependencies = [ 366 | "unicode-ident", 367 | ] 368 | 369 | [[package]] 370 | name = "quote" 371 | version = "1.0.37" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 374 | dependencies = [ 375 | "proc-macro2", 376 | ] 377 | 378 | [[package]] 379 | name = "rdev" 380 | version = "0.5.3" 381 | source = "registry+https://github.com/rust-lang/crates.io-index" 382 | checksum = "00552ca2dc2f93b84cd7b5581de49549411e4e41d89e1c691bcb93dc4be360c3" 383 | dependencies = [ 384 | "cocoa", 385 | "core-foundation 0.7.0", 386 | "core-foundation-sys 0.7.0", 387 | "core-graphics 0.19.2", 388 | "lazy_static", 389 | "libc", 390 | "winapi", 391 | "x11", 392 | ] 393 | 394 | [[package]] 395 | name = "ryu" 396 | version = "1.0.18" 397 | source = "registry+https://github.com/rust-lang/crates.io-index" 398 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 399 | 400 | [[package]] 401 | name = "serde" 402 | version = "1.0.215" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" 405 | dependencies = [ 406 | "serde_derive", 407 | ] 408 | 409 | [[package]] 410 | name = "serde_derive" 411 | version = "1.0.215" 412 | source = "registry+https://github.com/rust-lang/crates.io-index" 413 | checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" 414 | dependencies = [ 415 | "proc-macro2", 416 | "quote", 417 | "syn", 418 | ] 419 | 420 | [[package]] 421 | name = "serde_json" 422 | version = "1.0.133" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" 425 | dependencies = [ 426 | "itoa", 427 | "memchr", 428 | "ryu", 429 | "serde", 430 | ] 431 | 432 | [[package]] 433 | name = "syn" 434 | version = "2.0.89" 435 | source = "registry+https://github.com/rust-lang/crates.io-index" 436 | checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" 437 | dependencies = [ 438 | "proc-macro2", 439 | "quote", 440 | "unicode-ident", 441 | ] 442 | 443 | [[package]] 444 | name = "unicode-ident" 445 | version = "1.0.14" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" 448 | 449 | [[package]] 450 | name = "whispo-rs" 451 | version = "0.1.0" 452 | dependencies = [ 453 | "enigo", 454 | "rdev", 455 | "serde", 456 | "serde_json", 457 | ] 458 | 459 | [[package]] 460 | name = "winapi" 461 | version = "0.3.9" 462 | source = "registry+https://github.com/rust-lang/crates.io-index" 463 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 464 | dependencies = [ 465 | "winapi-i686-pc-windows-gnu", 466 | "winapi-x86_64-pc-windows-gnu", 467 | ] 468 | 469 | [[package]] 470 | name = "winapi-i686-pc-windows-gnu" 471 | version = "0.4.0" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 474 | 475 | [[package]] 476 | name = "winapi-x86_64-pc-windows-gnu" 477 | version = "0.4.0" 478 | source = "registry+https://github.com/rust-lang/crates.io-index" 479 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 480 | 481 | [[package]] 482 | name = "windows" 483 | version = "0.58.0" 484 | source = "registry+https://github.com/rust-lang/crates.io-index" 485 | checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" 486 | dependencies = [ 487 | "windows-core", 488 | "windows-targets", 489 | ] 490 | 491 | [[package]] 492 | name = "windows-core" 493 | version = "0.58.0" 494 | source = "registry+https://github.com/rust-lang/crates.io-index" 495 | checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" 496 | dependencies = [ 497 | "windows-implement", 498 | "windows-interface", 499 | "windows-result", 500 | "windows-strings", 501 | "windows-targets", 502 | ] 503 | 504 | [[package]] 505 | name = "windows-implement" 506 | version = "0.58.0" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" 509 | dependencies = [ 510 | "proc-macro2", 511 | "quote", 512 | "syn", 513 | ] 514 | 515 | [[package]] 516 | name = "windows-interface" 517 | version = "0.58.0" 518 | source = "registry+https://github.com/rust-lang/crates.io-index" 519 | checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" 520 | dependencies = [ 521 | "proc-macro2", 522 | "quote", 523 | "syn", 524 | ] 525 | 526 | [[package]] 527 | name = "windows-result" 528 | version = "0.2.0" 529 | source = "registry+https://github.com/rust-lang/crates.io-index" 530 | checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" 531 | dependencies = [ 532 | "windows-targets", 533 | ] 534 | 535 | [[package]] 536 | name = "windows-strings" 537 | version = "0.1.0" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" 540 | dependencies = [ 541 | "windows-result", 542 | "windows-targets", 543 | ] 544 | 545 | [[package]] 546 | name = "windows-targets" 547 | version = "0.52.6" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 550 | dependencies = [ 551 | "windows_aarch64_gnullvm", 552 | "windows_aarch64_msvc", 553 | "windows_i686_gnu", 554 | "windows_i686_gnullvm", 555 | "windows_i686_msvc", 556 | "windows_x86_64_gnu", 557 | "windows_x86_64_gnullvm", 558 | "windows_x86_64_msvc", 559 | ] 560 | 561 | [[package]] 562 | name = "windows_aarch64_gnullvm" 563 | version = "0.52.6" 564 | source = "registry+https://github.com/rust-lang/crates.io-index" 565 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 566 | 567 | [[package]] 568 | name = "windows_aarch64_msvc" 569 | version = "0.52.6" 570 | source = "registry+https://github.com/rust-lang/crates.io-index" 571 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 572 | 573 | [[package]] 574 | name = "windows_i686_gnu" 575 | version = "0.52.6" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 578 | 579 | [[package]] 580 | name = "windows_i686_gnullvm" 581 | version = "0.52.6" 582 | source = "registry+https://github.com/rust-lang/crates.io-index" 583 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 584 | 585 | [[package]] 586 | name = "windows_i686_msvc" 587 | version = "0.52.6" 588 | source = "registry+https://github.com/rust-lang/crates.io-index" 589 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 590 | 591 | [[package]] 592 | name = "windows_x86_64_gnu" 593 | version = "0.52.6" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 596 | 597 | [[package]] 598 | name = "windows_x86_64_gnullvm" 599 | version = "0.52.6" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 602 | 603 | [[package]] 604 | name = "windows_x86_64_msvc" 605 | version = "0.52.6" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 608 | 609 | [[package]] 610 | name = "x11" 611 | version = "2.21.0" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" 614 | dependencies = [ 615 | "libc", 616 | "pkg-config", 617 | ] 618 | 619 | [[package]] 620 | name = "xkbcommon" 621 | version = "0.8.0" 622 | source = "registry+https://github.com/rust-lang/crates.io-index" 623 | checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" 624 | dependencies = [ 625 | "libc", 626 | "memmap2", 627 | "xkeysym", 628 | ] 629 | 630 | [[package]] 631 | name = "xkeysym" 632 | version = "0.2.1" 633 | source = "registry+https://github.com/rust-lang/crates.io-index" 634 | checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" 635 | -------------------------------------------------------------------------------- /whispo-rs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "whispo-rs" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | rdev = "0.5.3" 8 | serde = { version = "1.0", features = ["derive"] } 9 | serde_json = "1.0" 10 | enigo = "0.3.0" 11 | 12 | [profile.release] 13 | strip = true 14 | -------------------------------------------------------------------------------- /whispo-rs/src/main.rs: -------------------------------------------------------------------------------- 1 | use rdev::{listen, Event, EventType}; 2 | use serde::Serialize; 3 | use serde_json::json; 4 | 5 | #[derive(Serialize)] 6 | struct RdevEvent { 7 | event_type: String, 8 | name: Option, 9 | time: std::time::SystemTime, 10 | data: String, 11 | } 12 | 13 | fn deal_event_to_json(event: Event) -> RdevEvent { 14 | let mut jsonify_event = RdevEvent { 15 | event_type: "".to_string(), 16 | name: event.name, 17 | time: event.time, 18 | data: "".to_string(), 19 | }; 20 | match event.event_type { 21 | EventType::KeyPress(key) => { 22 | jsonify_event.event_type = "KeyPress".to_string(); 23 | jsonify_event.data = json!({ 24 | "key": format!("{:?}", key) 25 | }) 26 | .to_string(); 27 | } 28 | EventType::KeyRelease(key) => { 29 | jsonify_event.event_type = "KeyRelease".to_string(); 30 | jsonify_event.data = json!({ 31 | "key": format!("{:?}", key) 32 | }) 33 | .to_string(); 34 | } 35 | EventType::MouseMove { x, y } => { 36 | jsonify_event.event_type = "MouseMove".to_string(); 37 | jsonify_event.data = json!({ 38 | "x": x, 39 | "y": y 40 | }) 41 | .to_string(); 42 | } 43 | EventType::ButtonPress(key) => { 44 | jsonify_event.event_type = "ButtonPress".to_string(); 45 | jsonify_event.data = json!({ 46 | "key": format!("{:?}", key) 47 | }) 48 | .to_string(); 49 | } 50 | EventType::ButtonRelease(key) => { 51 | jsonify_event.event_type = "ButtonRelease".to_string(); 52 | jsonify_event.data = json!({ 53 | "key": format!("{:?}", key) 54 | }) 55 | .to_string(); 56 | } 57 | EventType::Wheel { delta_x, delta_y } => { 58 | jsonify_event.event_type = "Wheel".to_string(); 59 | jsonify_event.data = json!({ 60 | "delta_x": delta_x, 61 | "delta_y": delta_y 62 | }) 63 | .to_string(); 64 | } 65 | } 66 | 67 | jsonify_event 68 | } 69 | 70 | fn write_text(text: &str) { 71 | use enigo::{Enigo, Keyboard, Settings}; 72 | 73 | let mut enigo = Enigo::new(&Settings::default()).unwrap(); 74 | 75 | // write text 76 | enigo.text(text).unwrap(); 77 | } 78 | 79 | fn main() { 80 | let args: Vec = std::env::args().collect(); 81 | 82 | if args.len() > 1 && args[1] == "listen" { 83 | if let Err(error) = listen(move |event| match event.event_type { 84 | EventType::KeyPress(_) | EventType::KeyRelease(_) => { 85 | let event = deal_event_to_json(event); 86 | println!("{}", serde_json::to_string(&event).unwrap()); 87 | } 88 | 89 | _ => {} 90 | }) { 91 | println!("!error: {:?}", error); 92 | } 93 | } 94 | 95 | if args.len() > 2 && args[1] == "write" { 96 | let text = args[2].clone(); 97 | write_text(text.as_str()); 98 | } 99 | } 100 | --------------------------------------------------------------------------------