├── .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 |