Claude/review pricing strategy e x80c #4787
44 changed files+4599−49
Addedbilling/.env.example+28−0View fileUnifiedSplit
@@ -0,0 +1,28 @@
1# Voxlen billing service — required environment variables.
2#
3# Copy to .env for local dev; set these in your hosting provider's
4# secret manager for production.
5
6# Stripe (Dashboard → Developers → API keys / Webhooks)
7STRIPE_SECRET_KEY=sk_live_...
8STRIPE_WEBHOOK_SECRET=whsec_...
9
10# Ed25519 private key seed (32-byte hex). Generate once with:
11# npm run keygen
12# Embed the matching public key into the desktop build via
13# VOXLEN_LICENSE_PUBKEY at `cargo tauri build` time.
14VOXLEN_LICENSE_PRIVKEY=
15
16# Transactional email (resend.com). Swap for Postmark/SES with small
17# edits in server.ts if you prefer another provider.
18RESEND_API_KEY=re_...
19RESEND_FROM=Voxlen <licenses@voxlen.ai>
20
21# Stripe Price IDs for each tier. Create Products + Prices in the
22# Stripe Dashboard, then paste the resulting price_... IDs here.
23PRICE_ID_PRO=price_...
24PRICE_ID_PROFESSIONAL=price_...
25PRICE_ID_LIFETIME=price_...
26
27# HTTP listen port (most serverless hosts set this automatically)
28PORT=8080
Addedbilling/README.md+68−0View fileUnifiedSplit
@@ -0,0 +1,68 @@
1# Voxlen billing
2
3Stripe webhook + Ed25519 license signing. Deployable to any Node 20+ host (Vercel, Railway, Fly, AWS Lambda, bare VM, Cloudflare Workers with minor tweaks).
4
5## How it works
6
71. Customer clicks **Buy Pro / Professional / Lifetime** on the landing page.
82. They complete checkout on Stripe-hosted checkout.
93. Stripe fires `checkout.session.completed` → this service.
104. This service generates an Ed25519-signed license payload and emails it to the customer via Resend.
115. Customer pastes the license into Voxlen → Settings → License. Done.
126. On subscription renewal (`invoice.paid`), a fresh license is issued automatically.
13
14**No user audio, transcripts, or document content ever touches this service.** Only the customer's email and the purchased tier — both of which Stripe already holds.
15
16## First-time setup
17
18```bash
19npm install
20
21# Generate your signing keypair. Run ONCE — if you lose it, all existing
22# licenses become unverifiable.
23npm run keygen
24```
25
26Copy the output:
27
28- **Private key** → your host's secret manager as `VOXLEN_LICENSE_PRIVKEY`. Never leave this directory otherwise.
29- **Public key** → embed into the desktop build:
30 ```bash
31 VOXLEN_LICENSE_PUBKEY=<hex> cargo tauri build
32 ```
33
34Copy `.env.example` to `.env` and fill in:
35
36- `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` from the Stripe Dashboard
37- `RESEND_API_KEY` from resend.com
38- `PRICE_ID_PRO`, `PRICE_ID_PROFESSIONAL`, `PRICE_ID_LIFETIME` — Price IDs of the three Products you created in Stripe
39
40## Run
41
42```bash
43npm run dev # local (needs ngrok or a Stripe CLI forward for webhook testing)
44npm run build
45npm start # production
46```
47
48Point your Stripe webhook endpoint at `https://yourhost/stripe/webhook`.
49
50## Manual license issuance
51
52For team licenses, founder comps, or replacing a lost key without going through Stripe:
53
54```bash
55VOXLEN_LICENSE_PRIVKEY=<hex> \
56 npm run sign -- --tier pro --email user@example.com --expires 2026-12-31
57
58VOXLEN_LICENSE_PRIVKEY=<hex> \
59 npm run sign -- --tier lifetime --email founder@example.com
60```
61
62## Why Ed25519 offline licenses (not a licensing server)?
63
64- No infra dependency from the desktop app — customers can use Voxlen on flights, in secure facilities, anywhere.
65- Customer privacy — the app never phones home.
66- Cheap — no per-activation cost or rate-limiting needed.
67- Forgery-resistant — the signing key stays on our server; the desktop app only holds the public half.
68- The tradeoff: a determined attacker with one valid license could share the key. Mitigations in future versions: per-seat HWID binding, online periodic revalidation for Professional tier.
Addedbilling/package-lock.json+770−0View fileUnifiedSplit
@@ -0,0 +1,770 @@
1{
2 "name": "voxlen-billing",
3 "version": "0.1.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "voxlen-billing",
9 "version": "0.1.0",
10 "dependencies": {
11 "resend": "^4.0.0",
12 "stripe": "^17.0.0"
13 },
14 "devDependencies": {
15 "@types/node": "^22.0.0",
16 "ts-node": "^10.9.2",
17 "typescript": "^5.6.0"
18 }
19 },
20 "node_modules/@cspotcode/source-map-support": {
21 "version": "0.8.1",
22 "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
23 "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
24 "dev": true,
25 "license": "MIT",
26 "dependencies": {
27 "@jridgewell/trace-mapping": "0.3.9"
28 },
29 "engines": {
30 "node": ">=12"
31 }
32 },
33 "node_modules/@jridgewell/resolve-uri": {
34 "version": "3.1.2",
35 "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
36 "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
37 "dev": true,
38 "license": "MIT",
39 "engines": {
40 "node": ">=6.0.0"
41 }
42 },
43 "node_modules/@jridgewell/sourcemap-codec": {
44 "version": "1.5.5",
45 "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
46 "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
47 "dev": true,
48 "license": "MIT"
49 },
50 "node_modules/@jridgewell/trace-mapping": {
51 "version": "0.3.9",
52 "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
53 "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
54 "dev": true,
55 "license": "MIT",
56 "dependencies": {
57 "@jridgewell/resolve-uri": "^3.0.3",
58 "@jridgewell/sourcemap-codec": "^1.4.10"
59 }
60 },
61 "node_modules/@react-email/render": {
62 "version": "1.1.2",
63 "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz",
64 "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==",
65 "license": "MIT",
66 "dependencies": {
67 "html-to-text": "^9.0.5",
68 "prettier": "^3.5.3",
69 "react-promise-suspense": "^0.3.4"
70 },
71 "engines": {
72 "node": ">=18.0.0"
73 },
74 "peerDependencies": {
75 "react": "^18.0 || ^19.0 || ^19.0.0-rc",
76 "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
77 }
78 },
79 "node_modules/@selderee/plugin-htmlparser2": {
80 "version": "0.11.0",
81 "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
82 "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
83 "license": "MIT",
84 "dependencies": {
85 "domhandler": "^5.0.3",
86 "selderee": "^0.11.0"
87 },
88 "funding": {
89 "url": "https://ko-fi.com/killymxi"
90 }
91 },
92 "node_modules/@tsconfig/node10": {
93 "version": "1.0.12",
94 "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
95 "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
96 "dev": true,
97 "license": "MIT"
98 },
99 "node_modules/@tsconfig/node12": {
100 "version": "1.0.11",
101 "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
102 "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
103 "dev": true,
104 "license": "MIT"
105 },
106 "node_modules/@tsconfig/node14": {
107 "version": "1.0.3",
108 "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
109 "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
110 "dev": true,
111 "license": "MIT"
112 },
113 "node_modules/@tsconfig/node16": {
114 "version": "1.0.4",
115 "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
116 "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
117 "dev": true,
118 "license": "MIT"
119 },
120 "node_modules/@types/node": {
121 "version": "22.19.17",
122 "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
123 "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
124 "license": "MIT",
125 "dependencies": {
126 "undici-types": "~6.21.0"
127 }
128 },
129 "node_modules/acorn": {
130 "version": "8.16.0",
131 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
132 "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
133 "dev": true,
134 "license": "MIT",
135 "bin": {
136 "acorn": "bin/acorn"
137 },
138 "engines": {
139 "node": ">=0.4.0"
140 }
141 },
142 "node_modules/acorn-walk": {
143 "version": "8.3.5",
144 "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
145 "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
146 "dev": true,
147 "license": "MIT",
148 "dependencies": {
149 "acorn": "^8.11.0"
150 },
151 "engines": {
152 "node": ">=0.4.0"
153 }
154 },
155 "node_modules/arg": {
156 "version": "4.1.3",
157 "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
158 "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
159 "dev": true,
160 "license": "MIT"
161 },
162 "node_modules/call-bind-apply-helpers": {
163 "version": "1.0.2",
164 "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
165 "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
166 "license": "MIT",
167 "dependencies": {
168 "es-errors": "^1.3.0",
169 "function-bind": "^1.1.2"
170 },
171 "engines": {
172 "node": ">= 0.4"
173 }
174 },
175 "node_modules/call-bound": {
176 "version": "1.0.4",
177 "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
178 "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
179 "license": "MIT",
180 "dependencies": {
181 "call-bind-apply-helpers": "^1.0.2",
182 "get-intrinsic": "^1.3.0"
183 },
184 "engines": {
185 "node": ">= 0.4"
186 },
187 "funding": {
188 "url": "https://github.com/sponsors/ljharb"
189 }
190 },
191 "node_modules/create-require": {
192 "version": "1.1.1",
193 "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
194 "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
195 "dev": true,
196 "license": "MIT"
197 },
198 "node_modules/deepmerge": {
199 "version": "4.3.1",
200 "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
201 "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
202 "license": "MIT",
203 "engines": {
204 "node": ">=0.10.0"
205 }
206 },
207 "node_modules/diff": {
208 "version": "4.0.4",
209 "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
210 "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
211 "dev": true,
212 "license": "BSD-3-Clause",
213 "engines": {
214 "node": ">=0.3.1"
215 }
216 },
217 "node_modules/dom-serializer": {
218 "version": "2.0.0",
219 "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
220 "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
221 "license": "MIT",
222 "dependencies": {
223 "domelementtype": "^2.3.0",
224 "domhandler": "^5.0.2",
225 "entities": "^4.2.0"
226 },
227 "funding": {
228 "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
229 }
230 },
231 "node_modules/domelementtype": {
232 "version": "2.3.0",
233 "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
234 "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
235 "funding": [
236 {
237 "type": "github",
238 "url": "https://github.com/sponsors/fb55"
239 }
240 ],
241 "license": "BSD-2-Clause"
242 },
243 "node_modules/domhandler": {
244 "version": "5.0.3",
245 "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
246 "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
247 "license": "BSD-2-Clause",
248 "dependencies": {
249 "domelementtype": "^2.3.0"
250 },
251 "engines": {
252 "node": ">= 4"
253 },
254 "funding": {
255 "url": "https://github.com/fb55/domhandler?sponsor=1"
256 }
257 },
258 "node_modules/domutils": {
259 "version": "3.2.2",
260 "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
261 "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
262 "license": "BSD-2-Clause",
263 "dependencies": {
264 "dom-serializer": "^2.0.0",
265 "domelementtype": "^2.3.0",
266 "domhandler": "^5.0.3"
267 },
268 "funding": {
269 "url": "https://github.com/fb55/domutils?sponsor=1"
270 }
271 },
272 "node_modules/dunder-proto": {
273 "version": "1.0.1",
274 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
275 "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
276 "license": "MIT",
277 "dependencies": {
278 "call-bind-apply-helpers": "^1.0.1",
279 "es-errors": "^1.3.0",
280 "gopd": "^1.2.0"
281 },
282 "engines": {
283 "node": ">= 0.4"
284 }
285 },
286 "node_modules/entities": {
287 "version": "4.5.0",
288 "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
289 "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
290 "license": "BSD-2-Clause",
291 "engines": {
292 "node": ">=0.12"
293 },
294 "funding": {
295 "url": "https://github.com/fb55/entities?sponsor=1"
296 }
297 },
298 "node_modules/es-define-property": {
299 "version": "1.0.1",
300 "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
301 "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
302 "license": "MIT",
303 "engines": {
304 "node": ">= 0.4"
305 }
306 },
307 "node_modules/es-errors": {
308 "version": "1.3.0",
309 "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
310 "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
311 "license": "MIT",
312 "engines": {
313 "node": ">= 0.4"
314 }
315 },
316 "node_modules/es-object-atoms": {
317 "version": "1.1.1",
318 "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
319 "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
320 "license": "MIT",
321 "dependencies": {
322 "es-errors": "^1.3.0"
323 },
324 "engines": {
325 "node": ">= 0.4"
326 }
327 },
328 "node_modules/fast-deep-equal": {
329 "version": "2.0.1",
330 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
331 "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
332 "license": "MIT"
333 },
334 "node_modules/function-bind": {
335 "version": "1.1.2",
336 "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
337 "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
338 "license": "MIT",
339 "funding": {
340 "url": "https://github.com/sponsors/ljharb"
341 }
342 },
343 "node_modules/get-intrinsic": {
344 "version": "1.3.0",
345 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
346 "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
347 "license": "MIT",
348 "dependencies": {
349 "call-bind-apply-helpers": "^1.0.2",
350 "es-define-property": "^1.0.1",
351 "es-errors": "^1.3.0",
352 "es-object-atoms": "^1.1.1",
353 "function-bind": "^1.1.2",
354 "get-proto": "^1.0.1",
355 "gopd": "^1.2.0",
356 "has-symbols": "^1.1.0",
357 "hasown": "^2.0.2",
358 "math-intrinsics": "^1.1.0"
359 },
360 "engines": {
361 "node": ">= 0.4"
362 },
363 "funding": {
364 "url": "https://github.com/sponsors/ljharb"
365 }
366 },
367 "node_modules/get-proto": {
368 "version": "1.0.1",
369 "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
370 "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
371 "license": "MIT",
372 "dependencies": {
373 "dunder-proto": "^1.0.1",
374 "es-object-atoms": "^1.0.0"
375 },
376 "engines": {
377 "node": ">= 0.4"
378 }
379 },
380 "node_modules/gopd": {
381 "version": "1.2.0",
382 "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
383 "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
384 "license": "MIT",
385 "engines": {
386 "node": ">= 0.4"
387 },
388 "funding": {
389 "url": "https://github.com/sponsors/ljharb"
390 }
391 },
392 "node_modules/has-symbols": {
393 "version": "1.1.0",
394 "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
395 "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
396 "license": "MIT",
397 "engines": {
398 "node": ">= 0.4"
399 },
400 "funding": {
401 "url": "https://github.com/sponsors/ljharb"
402 }
403 },
404 "node_modules/hasown": {
405 "version": "2.0.2",
406 "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
407 "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
408 "license": "MIT",
409 "dependencies": {
410 "function-bind": "^1.1.2"
411 },
412 "engines": {
413 "node": ">= 0.4"
414 }
415 },
416 "node_modules/html-to-text": {
417 "version": "9.0.5",
418 "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
419 "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
420 "license": "MIT",
421 "dependencies": {
422 "@selderee/plugin-htmlparser2": "^0.11.0",
423 "deepmerge": "^4.3.1",
424 "dom-serializer": "^2.0.0",
425 "htmlparser2": "^8.0.2",
426 "selderee": "^0.11.0"
427 },
428 "engines": {
429 "node": ">=14"
430 }
431 },
432 "node_modules/htmlparser2": {
433 "version": "8.0.2",
434 "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
435 "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
436 "funding": [
437 "https://github.com/fb55/htmlparser2?sponsor=1",
438 {
439 "type": "github",
440 "url": "https://github.com/sponsors/fb55"
441 }
442 ],
443 "license": "MIT",
444 "dependencies": {
445 "domelementtype": "^2.3.0",
446 "domhandler": "^5.0.3",
447 "domutils": "^3.0.1",
448 "entities": "^4.4.0"
449 }
450 },
451 "node_modules/leac": {
452 "version": "0.6.0",
453 "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
454 "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
455 "license": "MIT",
456 "funding": {
457 "url": "https://ko-fi.com/killymxi"
458 }
459 },
460 "node_modules/make-error": {
461 "version": "1.3.6",
462 "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
463 "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
464 "dev": true,
465 "license": "ISC"
466 },
467 "node_modules/math-intrinsics": {
468 "version": "1.1.0",
469 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
470 "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
471 "license": "MIT",
472 "engines": {
473 "node": ">= 0.4"
474 }
475 },
476 "node_modules/object-inspect": {
477 "version": "1.13.4",
478 "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
479 "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
480 "license": "MIT",
481 "engines": {
482 "node": ">= 0.4"
483 },
484 "funding": {
485 "url": "https://github.com/sponsors/ljharb"
486 }
487 },
488 "node_modules/parseley": {
489 "version": "0.12.1",
490 "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
491 "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
492 "license": "MIT",
493 "dependencies": {
494 "leac": "^0.6.0",
495 "peberminta": "^0.9.0"
496 },
497 "funding": {
498 "url": "https://ko-fi.com/killymxi"
499 }
500 },
501 "node_modules/peberminta": {
502 "version": "0.9.0",
503 "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
504 "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
505 "license": "MIT",
506 "funding": {
507 "url": "https://ko-fi.com/killymxi"
508 }
509 },
510 "node_modules/prettier": {
511 "version": "3.8.3",
512 "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
513 "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
514 "license": "MIT",
515 "bin": {
516 "prettier": "bin/prettier.cjs"
517 },
518 "engines": {
519 "node": ">=14"
520 },
521 "funding": {
522 "url": "https://github.com/prettier/prettier?sponsor=1"
523 }
524 },
525 "node_modules/qs": {
526 "version": "6.15.1",
527 "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
528 "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
529 "license": "BSD-3-Clause",
530 "dependencies": {
531 "side-channel": "^1.1.0"
532 },
533 "engines": {
534 "node": ">=0.6"
535 },
536 "funding": {
537 "url": "https://github.com/sponsors/ljharb"
538 }
539 },
540 "node_modules/react": {
541 "version": "19.2.5",
542 "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
543 "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
544 "license": "MIT",
545 "peer": true,
546 "engines": {
547 "node": ">=0.10.0"
548 }
549 },
550 "node_modules/react-dom": {
551 "version": "19.2.5",
552 "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
553 "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
554 "license": "MIT",
555 "peer": true,
556 "dependencies": {
557 "scheduler": "^0.27.0"
558 },
559 "peerDependencies": {
560 "react": "^19.2.5"
561 }
562 },
563 "node_modules/react-promise-suspense": {
564 "version": "0.3.4",
565 "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
566 "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==",
567 "license": "MIT",
568 "dependencies": {
569 "fast-deep-equal": "^2.0.1"
570 }
571 },
572 "node_modules/resend": {
573 "version": "4.8.0",
574 "resolved": "https://registry.npmjs.org/resend/-/resend-4.8.0.tgz",
575 "integrity": "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==",
576 "license": "MIT",
577 "dependencies": {
578 "@react-email/render": "1.1.2"
579 },
580 "engines": {
581 "node": ">=18"
582 }
583 },
584 "node_modules/scheduler": {
585 "version": "0.27.0",
586 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
587 "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
588 "license": "MIT",
589 "peer": true
590 },
591 "node_modules/selderee": {
592 "version": "0.11.0",
593 "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
594 "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
595 "license": "MIT",
596 "dependencies": {
597 "parseley": "^0.12.0"
598 },
599 "funding": {
600 "url": "https://ko-fi.com/killymxi"
601 }
602 },
603 "node_modules/side-channel": {
604 "version": "1.1.0",
605 "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
606 "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
607 "license": "MIT",
608 "dependencies": {
609 "es-errors": "^1.3.0",
610 "object-inspect": "^1.13.3",
611 "side-channel-list": "^1.0.0",
612 "side-channel-map": "^1.0.1",
613 "side-channel-weakmap": "^1.0.2"
614 },
615 "engines": {
616 "node": ">= 0.4"
617 },
618 "funding": {
619 "url": "https://github.com/sponsors/ljharb"
620 }
621 },
622 "node_modules/side-channel-list": {
623 "version": "1.0.1",
624 "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
625 "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
626 "license": "MIT",
627 "dependencies": {
628 "es-errors": "^1.3.0",
629 "object-inspect": "^1.13.4"
630 },
631 "engines": {
632 "node": ">= 0.4"
633 },
634 "funding": {
635 "url": "https://github.com/sponsors/ljharb"
636 }
637 },
638 "node_modules/side-channel-map": {
639 "version": "1.0.1",
640 "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
641 "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
642 "license": "MIT",
643 "dependencies": {
644 "call-bound": "^1.0.2",
645 "es-errors": "^1.3.0",
646 "get-intrinsic": "^1.2.5",
647 "object-inspect": "^1.13.3"
648 },
649 "engines": {
650 "node": ">= 0.4"
651 },
652 "funding": {
653 "url": "https://github.com/sponsors/ljharb"
654 }
655 },
656 "node_modules/side-channel-weakmap": {
657 "version": "1.0.2",
658 "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
659 "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
660 "license": "MIT",
661 "dependencies": {
662 "call-bound": "^1.0.2",
663 "es-errors": "^1.3.0",
664 "get-intrinsic": "^1.2.5",
665 "object-inspect": "^1.13.3",
666 "side-channel-map": "^1.0.1"
667 },
668 "engines": {
669 "node": ">= 0.4"
670 },
671 "funding": {
672 "url": "https://github.com/sponsors/ljharb"
673 }
674 },
675 "node_modules/stripe": {
676 "version": "17.7.0",
677 "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz",
678 "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==",
679 "license": "MIT",
680 "dependencies": {
681 "@types/node": ">=8.1.0",
682 "qs": "^6.11.0"
683 },
684 "engines": {
685 "node": ">=12.*"
686 }
687 },
688 "node_modules/ts-node": {
689 "version": "10.9.2",
690 "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
691 "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
692 "dev": true,
693 "license": "MIT",
694 "dependencies": {
695 "@cspotcode/source-map-support": "^0.8.0",
696 "@tsconfig/node10": "^1.0.7",
697 "@tsconfig/node12": "^1.0.7",
698 "@tsconfig/node14": "^1.0.0",
699 "@tsconfig/node16": "^1.0.2",
700 "acorn": "^8.4.1",
701 "acorn-walk": "^8.1.1",
702 "arg": "^4.1.0",
703 "create-require": "^1.1.0",
704 "diff": "^4.0.1",
705 "make-error": "^1.1.1",
706 "v8-compile-cache-lib": "^3.0.1",
707 "yn": "3.1.1"
708 },
709 "bin": {
710 "ts-node": "dist/bin.js",
711 "ts-node-cwd": "dist/bin-cwd.js",
712 "ts-node-esm": "dist/bin-esm.js",
713 "ts-node-script": "dist/bin-script.js",
714 "ts-node-transpile-only": "dist/bin-transpile.js",
715 "ts-script": "dist/bin-script-deprecated.js"
716 },
717 "peerDependencies": {
718 "@swc/core": ">=1.2.50",
719 "@swc/wasm": ">=1.2.50",
720 "@types/node": "*",
721 "typescript": ">=2.7"
722 },
723 "peerDependenciesMeta": {
724 "@swc/core": {
725 "optional": true
726 },
727 "@swc/wasm": {
728 "optional": true
729 }
730 }
731 },
732 "node_modules/typescript": {
733 "version": "5.9.3",
734 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
735 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
736 "dev": true,
737 "license": "Apache-2.0",
738 "bin": {
739 "tsc": "bin/tsc",
740 "tsserver": "bin/tsserver"
741 },
742 "engines": {
743 "node": ">=14.17"
744 }
745 },
746 "node_modules/undici-types": {
747 "version": "6.21.0",
748 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
749 "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
750 "license": "MIT"
751 },
752 "node_modules/v8-compile-cache-lib": {
753 "version": "3.0.1",
754 "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
755 "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
756 "dev": true,
757 "license": "MIT"
758 },
759 "node_modules/yn": {
760 "version": "3.1.1",
761 "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
762 "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
763 "dev": true,
764 "license": "MIT",
765 "engines": {
766 "node": ">=6"
767 }
768 }
769 }
770}
Addedbilling/package.json+25−0View fileUnifiedSplit
@@ -0,0 +1,25 @@
1{
2 "name": "voxlen-billing",
3 "version": "0.1.0",
4 "description": "Stripe webhook + Ed25519 license signing for Voxlen. Issues signed licenses to customers after successful checkout.",
5 "private": true,
6 "type": "module",
7 "scripts": {
8 "dev": "tsx src/server.ts",
9 "build": "tsc",
10 "start": "node dist/server.js",
11 "keygen": "tsx src/keygen.ts",
12 "sign": "tsx src/sign.ts",
13 "interop": "tsx src/interop_test.ts",
14 "typecheck": "tsc --noEmit"
15 },
16 "dependencies": {
17 "stripe": "^17.0.0",
18 "resend": "^4.0.0"
19 },
20 "devDependencies": {
21 "@types/node": "^22.0.0",
22 "tsx": "^4.19.0",
23 "typescript": "^5.6.0"
24 }
25}
Addedbilling/src/interop_test.ts+83−0View fileUnifiedSplit
@@ -0,0 +1,83 @@
1// Interop self-check. Runs in pure Node — no Rust required — and also
2// writes a fixture file that a Rust-side integration test can load to
3// confirm byte-for-byte compatibility with src-tauri/src/license.rs.
4
5import {
6 verify as cryptoVerify,
7 createPublicKey,
8} from "node:crypto";
9import { writeFileSync, mkdirSync } from "node:fs";
10import { dirname, join } from "node:path";
11import { fileURLToPath } from "node:url";
12import { createLicense, generateKeypair, signLicense } from "./license.js";
13
14function pubKeyFromRawHex(hex: string) {
15 const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex");
16 const full = Buffer.concat([spkiPrefix, Buffer.from(hex, "hex")]);
17 return createPublicKey({ key: full, format: "der", type: "spki" });
18}
19
20function verify(license: string, publicKeyHex: string): boolean {
21 if (!license.startsWith("VOXLEN-")) return false;
22 const body = license.slice("VOXLEN-".length);
23 const dot = body.indexOf(".");
24 if (dot < 0) return false;
25 const payload = Buffer.from(body.slice(0, dot), "base64url");
26 const signature = Buffer.from(body.slice(dot + 1), "base64url");
27 const key = pubKeyFromRawHex(publicKeyHex);
28 return cryptoVerify(null, payload, key, signature);
29}
30
31function main() {
32 console.log("== Voxlen license interop self-check ==");
33
34 const { privateKeyHex, publicKeyHex } = generateKeypair();
35 console.log(`pub : ${publicKeyHex}`);
36
37 const { payload, key } = createLicense(
38 { tier: "pro", email: "ceo@example.com", expires: null },
39 privateKeyHex,
40 );
41 console.log(`key : ${key}`);
42 console.log(`len : ${key.length} bytes`);
43
44 const ok = verify(key, publicKeyHex);
45 if (!ok) {
46 console.error("FAIL: Node-side self-verify failed");
47 process.exit(1);
48 }
49 console.log("Node-side self-verify: OK");
50
51 // Tamper with payload → must fail.
52 const tampered = key.replace("pro", "pro").replace(/.$/, (c) => (c === "A" ? "B" : "A"));
53 if (verify(tampered, publicKeyHex)) {
54 console.error("FAIL: tampered license verified as valid");
55 process.exit(1);
56 }
57 console.log("Tamper detection: OK");
58
59 // Write a fixture file the Rust test can read.
60 const here = dirname(fileURLToPath(import.meta.url));
61 const fixtureDir = join(here, "..", "..", "src-tauri", "tests", "fixtures");
62 mkdirSync(fixtureDir, { recursive: true });
63 const fixturePath = join(fixtureDir, "license_fixture.json");
64 writeFileSync(
65 fixturePath,
66 JSON.stringify({ publicKeyHex, license: key, payload }, null, 2),
67 );
68 console.log(`Wrote fixture: ${fixturePath}`);
69
70 // Also a bad signature so the Rust side can confirm it rejects forgeries.
71 const forgedKey = signLicense(
72 { ...payload, tier: "lifetime" },
73 generateKeypair().privateKeyHex, // different key
74 );
75 writeFileSync(
76 join(fixtureDir, "license_forged_fixture.json"),
77 JSON.stringify({ publicKeyHex, license: forgedKey }, null, 2),
78 );
79 console.log("Wrote forged-license fixture.");
80 console.log("All checks passed.");
81}
82
83main();
Addedbilling/src/keygen.ts+29−0View fileUnifiedSplit
@@ -0,0 +1,29 @@
1// Generate a fresh Ed25519 keypair for Voxlen license signing.
2//
3// Run once. Store the private key in your billing host's secret manager
4// as VOXLEN_LICENSE_PRIVKEY. Embed the public key into the desktop build
5// via VOXLEN_LICENSE_PUBKEY at `cargo tauri build` time.
6//
7// npm run keygen
8
9import { generateKeypair } from "./license.js";
10
11const { privateKeyHex, publicKeyHex } = generateKeypair();
12
13console.log("Voxlen license keypair generated.");
14console.log();
15console.log("Private key (store in billing host secrets as VOXLEN_LICENSE_PRIVKEY):");
16console.log(` ${privateKeyHex}`);
17console.log();
18console.log("Public key (embed in desktop build via VOXLEN_LICENSE_PUBKEY):");
19console.log(` ${publicKeyHex}`);
20console.log();
21console.log(
22 "Copy the public key into the VOXLEN_LICENSE_PUBKEY env var used by",
23);
24console.log(
25 "`cargo tauri build`. The private key MUST stay on your server only —",
26);
27console.log(
28 "anyone who obtains it can mint licenses.",
29);
Addedbilling/src/license.ts+113−0View fileUnifiedSplit
@@ -0,0 +1,113 @@
1// Ed25519 license signing for Voxlen.
2//
3// Payload format matches src-tauri/src/license.rs::LicensePayload exactly.
4// Any drift between these files will cause the desktop app to reject
5// licenses.
6
7import { createPrivateKey, sign as cryptoSign, generateKeyPairSync } from "node:crypto";
8import { randomUUID } from "node:crypto";
9
10export type Tier = "free" | "pro" | "professional" | "lifetime";
11
12export interface LicensePayload {
13 tier: Tier;
14 email: string;
15 issued: number;
16 expires: number | null;
17 id: string;
18}
19
20const PREFIX = "VOXLEN-";
21
22function b64url(buf: Buffer): string {
23 return buf.toString("base64url");
24}
25
26/** Decode a 32-byte hex string into raw Ed25519 seed bytes. */
27function seedFromHex(hex: string): Buffer {
28 const trimmed = hex.trim().replace(/^0x/, "");
29 if (trimmed.length !== 64) {
30 throw new Error(
31 `Ed25519 private key seed must be 32 bytes (64 hex chars); got ${trimmed.length} chars`,
32 );
33 }
34 return Buffer.from(trimmed, "hex");
35}
36
37/**
38 * Wrap a 32-byte Ed25519 seed into a PKCS#8 DER structure that Node's
39 * crypto module will accept. Saves us a dependency on `tweetnacl` or
40 * similar when the Node runtime already supports Ed25519.
41 */
42function pkcs8FromSeed(seed: Buffer): Buffer {
43 // PKCS#8 PrivateKeyInfo for Ed25519:
44 // 30 2e SEQUENCE, len 46
45 // 02 01 00 INTEGER 0 (version)
46 // 30 05 SEQUENCE, len 5 (AlgorithmIdentifier)
47 // 06 03 2b 65 70 OID 1.3.101.112 (Ed25519)
48 // 04 22 OCTET STRING, len 34
49 // 04 20 <32-byte-seed> OCTET STRING, len 32, seed
50 if (seed.length !== 32) throw new Error("seed must be 32 bytes");
51 const prefix = Buffer.from(
52 "302e020100300506032b657004220420",
53 "hex",
54 );
55 return Buffer.concat([prefix, seed]);
56}
57
58/** Sign a LicensePayload and return a `VOXLEN-<b64>.<b64>` string. */
59export function signLicense(payload: LicensePayload, privateKeyHex: string): string {
60 const json = Buffer.from(JSON.stringify(payload), "utf8");
61 const seed = seedFromHex(privateKeyHex);
62 const key = createPrivateKey({
63 key: pkcs8FromSeed(seed),
64 format: "der",
65 type: "pkcs8",
66 });
67 // Node's crypto.sign expects null algorithm for Ed25519.
68 const signature = cryptoSign(null, json, key);
69 return `${PREFIX}${b64url(json)}.${b64url(signature)}`;
70}
71
72export interface CreateLicenseOpts {
73 tier: Exclude<Tier, "free">;
74 email: string;
75 /** ISO string or Date for expiry. Omit for perpetual (Lifetime). */
76 expires?: Date | string | null;
77}
78
79/** High-level helper: stamp issued/id and sign. */
80export function createLicense(opts: CreateLicenseOpts, privateKeyHex: string): {
81 payload: LicensePayload;
82 key: string;
83} {
84 const issued = Math.floor(Date.now() / 1000);
85 let expires: number | null = null;
86 if (opts.expires != null) {
87 const d = typeof opts.expires === "string" ? new Date(opts.expires) : opts.expires;
88 if (Number.isNaN(d.getTime())) throw new Error("invalid expires date");
89 expires = Math.floor(d.getTime() / 1000);
90 }
91 const payload: LicensePayload = {
92 tier: opts.tier,
93 email: opts.email,
94 issued,
95 expires,
96 id: randomUUID(),
97 };
98 return { payload, key: signLicense(payload, privateKeyHex) };
99}
100
101/** Generate a fresh Ed25519 keypair and return hex encodings. */
102export function generateKeypair(): { privateKeyHex: string; publicKeyHex: string } {
103 const { privateKey, publicKey } = generateKeyPairSync("ed25519");
104 // DER SubjectPublicKeyInfo for Ed25519 ends with the 32-byte raw key.
105 const pubDer = publicKey.export({ format: "der", type: "spki" });
106 const pubRaw = pubDer.subarray(pubDer.length - 32);
107 const privDer = privateKey.export({ format: "der", type: "pkcs8" });
108 const privRaw = privDer.subarray(privDer.length - 32);
109 return {
110 privateKeyHex: privRaw.toString("hex"),
111 publicKeyHex: pubRaw.toString("hex"),
112 };
113}
Addedbilling/src/server.ts+198−0View fileUnifiedSplit
@@ -0,0 +1,198 @@
1// Voxlen billing webhook.
2//
3// Listens for Stripe `checkout.session.completed` events, issues a
4// signed Ed25519 license, and emails it to the customer via Resend.
5// Deployable anywhere that runs Node 20+ (Vercel function, Railway,
6// Fly, Cloudflare Workers with a small tweak, bare VM, etc.).
7//
8// No customer audio, no transcripts, no user content touches this
9// server. It only sees the email address and the purchased tier —
10// both of which Stripe already has.
11
12import http from "node:http";
13import { Buffer } from "node:buffer";
14import Stripe from "stripe";
15import { Resend } from "resend";
16import { createLicense, type Tier } from "./license.js";
17
18const {
19 STRIPE_SECRET_KEY,
20 STRIPE_WEBHOOK_SECRET,
21 VOXLEN_LICENSE_PRIVKEY,
22 RESEND_API_KEY,
23 RESEND_FROM = "Voxlen <licenses@voxlen.ai>",
24 PORT = "8080",
25 // Product → tier mapping. Paste the Stripe Price IDs you created.
26 PRICE_ID_PRO,
27 PRICE_ID_PROFESSIONAL,
28 PRICE_ID_LIFETIME,
29} = process.env;
30
31for (const [k, v] of Object.entries({
32 STRIPE_SECRET_KEY,
33 STRIPE_WEBHOOK_SECRET,
34 VOXLEN_LICENSE_PRIVKEY,
35 RESEND_API_KEY,
36})) {
37 if (!v) {
38 console.error(`Missing required env var: ${k}`);
39 process.exit(1);
40 }
41}
42
43const stripe = new Stripe(STRIPE_SECRET_KEY!);
44const resend = new Resend(RESEND_API_KEY!);
45
46function priceToTier(priceId: string | undefined | null): Tier | null {
47 if (!priceId) return null;
48 if (priceId === PRICE_ID_PRO) return "pro";
49 if (priceId === PRICE_ID_PROFESSIONAL) return "professional";
50 if (priceId === PRICE_ID_LIFETIME) return "lifetime";
51 return null;
52}
53
54function tierExpiry(tier: Tier): Date | null {
55 // Pro / Professional are monthly subscriptions. Stripe renews them
56 // automatically — we mint a fresh license on every successful
57 // invoice.payment_succeeded event (wired below) with a rolling
58 // 35-day expiry so a missed renewal disables the app promptly but
59 // ordinary network hiccups don't break paying customers. Lifetime
60 // never expires.
61 if (tier === "lifetime") return null;
62 const d = new Date();
63 d.setDate(d.getDate() + 35);
64 return d;
65}
66
67async function issueAndEmail(tier: Exclude<Tier, "free">, email: string) {
68 const { payload, key } = createLicense(
69 { tier, email, expires: tierExpiry(tier) },
70 VOXLEN_LICENSE_PRIVKEY!,
71 );
72 console.log(`Issued ${tier} license ${payload.id} for ${email}`);
73
74 const expiresLine =
75 payload.expires === null
76 ? "This is a lifetime license — it never expires."
77 : `This license is valid until ${new Date(payload.expires * 1000).toUTCString()}. Subscriptions auto-renew; we'll email you a fresh license each billing period.`;
78
79 await resend.emails.send({
80 from: RESEND_FROM!,
81 to: email,
82 subject: `Your Voxlen ${tier[0].toUpperCase() + tier.slice(1)} license`,
83 text: [
84 `Welcome to Voxlen.`,
85 ``,
86 `Your ${tier} license key:`,
87 ``,
88 key,
89 ``,
90 `To activate: open Voxlen, go to Settings → License, and paste the key above.`,
91 `${expiresLine}`,
92 ``,
93 `Download the latest version at https://voxlen.ai/#download`,
94 ``,
95 `Questions? Reply to this email.`,
96 ``,
97 `— The Voxlen team`,
98 ].join("\n"),
99 });
100}
101
102async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
103 const email = session.customer_email || session.customer_details?.email;
104 if (!email) {
105 console.warn(`Session ${session.id} completed with no email; skipping.`);
106 return;
107 }
108
109 // Pull line items so we can map price → tier.
110 const items = await stripe.checkout.sessions.listLineItems(session.id, {
111 limit: 10,
112 });
113 for (const item of items.data) {
114 const tier = priceToTier(item.price?.id);
115 if (tier && tier !== "free") {
116 await issueAndEmail(tier, email);
117 return;
118 }
119 }
120 console.warn(`Session ${session.id}: no matching tier for purchased items.`);
121}
122
123async function handleInvoicePaid(invoice: Stripe.Invoice) {
124 const email = invoice.customer_email;
125 if (!email) return;
126 for (const item of invoice.lines.data) {
127 const tier = priceToTier(item.price?.id);
128 if (tier && tier !== "free") {
129 await issueAndEmail(tier, email);
130 return;
131 }
132 }
133}
134
135function readBody(req: http.IncomingMessage): Promise<Buffer> {
136 return new Promise((resolve, reject) => {
137 const chunks: Buffer[] = [];
138 req.on("data", (c) => chunks.push(c));
139 req.on("end", () => resolve(Buffer.concat(chunks)));
140 req.on("error", reject);
141 });
142}
143
144const server = http.createServer(async (req, res) => {
145 if (req.method === "GET" && req.url === "/health") {
146 res.writeHead(200, { "content-type": "text/plain" });
147 res.end("ok");
148 return;
149 }
150 if (req.method !== "POST" || req.url !== "/stripe/webhook") {
151 res.writeHead(404);
152 res.end();
153 return;
154 }
155
156 const sig = req.headers["stripe-signature"];
157 if (typeof sig !== "string") {
158 res.writeHead(400);
159 res.end("missing signature");
160 return;
161 }
162
163 let event: Stripe.Event;
164 try {
165 const body = await readBody(req);
166 event = stripe.webhooks.constructEvent(body, sig, STRIPE_WEBHOOK_SECRET!);
167 } catch (e) {
168 console.error("Webhook signature verification failed:", e);
169 res.writeHead(400);
170 res.end("bad signature");
171 return;
172 }
173
174 try {
175 switch (event.type) {
176 case "checkout.session.completed":
177 await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
178 break;
179 case "invoice.paid":
180 await handleInvoicePaid(event.data.object as Stripe.Invoice);
181 break;
182 default:
183 // ignore — the Stripe dashboard shows the rest.
184 break;
185 }
186 res.writeHead(200, { "content-type": "application/json" });
187 res.end(JSON.stringify({ received: true }));
188 } catch (e) {
189 console.error("Handler error:", e);
190 res.writeHead(500);
191 res.end("handler error");
192 }
193});
194
195const port = Number(PORT);
196server.listen(port, () => {
197 console.log(`Voxlen billing webhook listening on :${port}`);
198});
Addedbilling/src/sign.ts+55−0View fileUnifiedSplit
@@ -0,0 +1,55 @@
1// Manually sign a license — useful for founder comps, test customers,
2// team licenses, or replacing a lost key without Stripe.
3//
4// Usage:
5// VOXLEN_LICENSE_PRIVKEY=<hex> \
6// npm run sign -- --tier pro --email user@example.com --expires 2026-12-31
7//
8// VOXLEN_LICENSE_PRIVKEY=<hex> \
9// npm run sign -- --tier lifetime --email user@example.com
10
11import { createLicense, type Tier } from "./license.js";
12
13function arg(flag: string): string | null {
14 const i = process.argv.indexOf(flag);
15 if (i === -1) return null;
16 const v = process.argv[i + 1];
17 return v ?? null;
18}
19
20const tier = arg("--tier") as Tier | null;
21const email = arg("--email");
22const expiresStr = arg("--expires");
23
24if (!tier || !email) {
25 console.error("Usage: npm run sign -- --tier <pro|professional|lifetime> --email <addr> [--expires YYYY-MM-DD]");
26 process.exit(1);
27}
28
29if (tier === "free") {
30 console.error("Cannot sign a Free-tier license — Free is the default.");
31 process.exit(1);
32}
33
34const privateKeyHex = process.env.VOXLEN_LICENSE_PRIVKEY;
35if (!privateKeyHex) {
36 console.error("VOXLEN_LICENSE_PRIVKEY env var is required.");
37 process.exit(1);
38}
39
40const expires: Date | null = expiresStr ? new Date(expiresStr) : null;
41if (expires && Number.isNaN(expires.getTime())) {
42 console.error(`Invalid --expires value: ${expiresStr}`);
43 process.exit(1);
44}
45
46const { payload, key } = createLicense(
47 { tier, email, expires },
48 privateKeyHex,
49);
50
51console.log("Issued license:");
52console.log(JSON.stringify(payload, null, 2));
53console.log();
54console.log("License key (give this to the customer):");
55console.log(key);
Addedbilling/tsconfig.json+16−0View fileUnifiedSplit
@@ -0,0 +1,16 @@
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "ESNext",
5 "moduleResolution": "bundler",
6 "esModuleInterop": true,
7 "strict": true,
8 "skipLibCheck": true,
9 "resolveJsonModule": true,
10 "isolatedModules": true,
11 "outDir": "dist",
12 "rootDir": "src",
13 "lib": ["ES2022"]
14 },
15 "include": ["src"]
16}
Addedlanding/.env.example+26−0View fileUnifiedSplit
@@ -0,0 +1,26 @@
1# Voxlen landing page — build-time configuration.
2#
3# Copy this file to `.env` (or set these in your hosting provider) and
4# point each variable at a Stripe-hosted checkout URL for the matching
5# tier.
6#
7# The fastest path: in the Stripe Dashboard, create three Products ("Pro",
8# "Professional", "Lifetime"), create a Price on each, then on each Price
9# click "Payment links" → create a payment link → paste the resulting
10# `https://buy.stripe.com/...` URL below. Payment links give you Apple
11# Pay, Google Pay, and card support out of the box with zero server work.
12#
13# When any variable is unset, the corresponding button falls back to
14# #download so the UI never shows a broken link.
15#
16# After a customer pays, your Stripe webhook handler must:
17# 1. Generate an Ed25519-signed license payload matching
18# src-tauri/src/license.rs::LicensePayload.
19# 2. Email the license string (format: VOXLEN-<base64(payload)>.<base64(sig)>)
20# to the customer.
21# 3. Embed the matching public key in the desktop build via the
22# VOXLEN_LICENSE_PUBKEY env var at `cargo build` time.
23
24VITE_STRIPE_CHECKOUT_PRO=
25VITE_STRIPE_CHECKOUT_PROFESSIONAL=
26VITE_STRIPE_CHECKOUT_LIFETIME=
Modifiedlanding/index.html+27−17View fileUnifiedSplit
@@ -3,28 +3,31 @@
33 <head>
44 <meta charset="UTF-8" />
55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Marco Reid Voice | Speak. It is done.</title>
7 <meta name="description" content="The most advanced AI dictation tool. Real-time voice-to-text, AI grammar correction, and universal text injection. Works everywhere, never interrupts." />
8 <link rel="canonical" href="https://marcoreid.com/voice/" />
9 <meta property="og:title" content="Marco Reid Voice - AI Voice Dictation" />
10 <meta property="og:description" content="Speak naturally. AI transcribes, polishes, and injects text into any app. 90+ languages. Offline mode. Fraction of the cost of alternatives." />
6 <title>Marco Reid Voice | Premium AI dictation for professionals</title>
7 <meta name="description" content="The most advanced AI dictation tool — built for lawyers, accountants, and other professionals. Real-time voice-to-text, AI grammar correction, universal text injection, and zero-retention privacy on every platform." />
8 <link rel="canonical" href="https://voxlen.ai/" />
9 <meta property="og:title" content="Marco Reid Voice — Premium AI dictation for professionals" />
10 <meta property="og:description" content="Zero-retention voice dictation with AI grammar correction. Built for privileged information. macOS, Windows, Linux, iOS, Android, and web." />
1111 <meta property="og:type" content="website" />
12 <meta property="og:url" content="https://marcoreid.com/voice/" />
12 <meta property="og:url" content="https://voxlen.ai/" />
1313 <meta property="og:site_name" content="Marco Reid Voice" />
14 <meta property="og:image" content="https://marcoreid.com/og-voice.png" />
14 <meta property="og:image" content="https://voxlen.ai/og-image.png" />
15 <meta property="og:image:width" content="1200" />
16 <meta property="og:image:height" content="630" />
17 <meta property="og:image:alt" content="Marco Reid Voice — Speak. It is done." />
1518 <meta name="twitter:card" content="summary_large_image" />
16 <meta name="twitter:title" content="Marco Reid Voice - AI Voice Dictation" />
17 <meta name="twitter:description" content="Speak naturally. AI transcribes, polishes, and injects text into any app. 90+ languages. Offline mode." />
18 <meta name="twitter:image" content="https://marcoreid.com/og-voice.png" />
19 <meta name="twitter:title" content="Marco Reid Voice — Premium AI dictation for professionals" />
20 <meta name="twitter:description" content="Zero-retention voice dictation with AI grammar correction. Built for privileged information. Every platform." />
21 <meta name="twitter:image" content="https://voxlen.ai/og-image.png" />
1922 <link rel="icon" type="image/svg+xml" href="/vox-icon.svg" />
2023 <script type="application/ld+json">
2124 {
2225 "@context": "https://schema.org",
2326 "@type": "SoftwareApplication",
24 "name": "Voxlen",
27 "name": "Marco Reid Voice",
2528 "applicationCategory": "ProductivityApplication",
26 "operatingSystem": "macOS, Windows, Linux, iOS",
27 "description": "AI-powered voice dictation with real-time grammar correction and universal text injection. Designed for professionals including lawyers and accountants.",
29 "operatingSystem": "macOS, Windows, Linux, iOS, Android, Web",
30 "description": "Premium AI-powered voice dictation with real-time grammar correction, universal text injection, and zero-retention privacy. Built for lawyers, accountants, and other professionals who handle privileged information.",
2831 "url": "https://voxlen.ai/",
2932 "offers": [
3033 {
@@ -36,14 +39,21 @@
3639 {
3740 "@type": "Offer",
3841 "name": "Pro",
39 "price": "5.99",
42 "price": "29",
43 "priceCurrency": "USD",
44 "billingIncrement": "P1M"
45 },
46 {
47 "@type": "Offer",
48 "name": "Professional",
49 "price": "79",
4050 "priceCurrency": "USD",
4151 "billingIncrement": "P1M"
4252 },
4353 {
4454 "@type": "Offer",
4555 "name": "Lifetime",
46 "price": "149",
56 "price": "599",
4757 "priceCurrency": "USD"
4858 }
4959 ],
@@ -52,8 +62,8 @@
5262 "AI grammar correction",
5363 "Universal text injection",
5464 "20+ languages",
55 "Offline mode",
56 "Cross-platform (macOS, Windows, Linux, iOS)"
65 "Zero-retention privacy",
66 "Cross-platform (macOS, Windows, Linux, iOS, Android, Web)"
5767 ],
5868 "screenshot": "https://voxlen.ai/og-image.png"
5969 }
Modifiedlanding/package-lock.json+229−0View fileUnifiedSplit
@@ -14,6 +14,7 @@
1414 "react-dom": "^18.3.1"
1515 },
1616 "devDependencies": {
17 "@resvg/resvg-js": "^2.6.2",
1718 "@types/react": "^18.3.18",
1819 "@types/react-dom": "^18.3.5",
1920 "@vitejs/plugin-react": "^4.3.4",
@@ -849,6 +850,234 @@
849850 "node": ">= 8"
850851 }
851852 },
853 "node_modules/@resvg/resvg-js": {
854 "version": "2.6.2",
855 "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
856 "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
857 "dev": true,
858 "license": "MPL-2.0",
859 "engines": {
860 "node": ">= 10"
861 },
862 "optionalDependencies": {
863 "@resvg/resvg-js-android-arm-eabi": "2.6.2",
864 "@resvg/resvg-js-android-arm64": "2.6.2",
865 "@resvg/resvg-js-darwin-arm64": "2.6.2",
866 "@resvg/resvg-js-darwin-x64": "2.6.2",
867 "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
868 "@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
869 "@resvg/resvg-js-linux-arm64-musl": "2.6.2",
870 "@resvg/resvg-js-linux-x64-gnu": "2.6.2",
871 "@resvg/resvg-js-linux-x64-musl": "2.6.2",
872 "@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
873 "@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
874 "@resvg/resvg-js-win32-x64-msvc": "2.6.2"
875 }
876 },
877 "node_modules/@resvg/resvg-js-android-arm-eabi": {
878 "version": "2.6.2",
879 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
880 "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
881 "cpu": [
882 "arm"
883 ],
884 "dev": true,
885 "license": "MPL-2.0",
886 "optional": true,
887 "os": [
888 "android"
889 ],
890 "engines": {
891 "node": ">= 10"
892 }
893 },
894 "node_modules/@resvg/resvg-js-android-arm64": {
895 "version": "2.6.2",
896 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
897 "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
898 "cpu": [
899 "arm64"
900 ],
901 "dev": true,
902 "license": "MPL-2.0",
903 "optional": true,
904 "os": [
905 "android"
906 ],
907 "engines": {
908 "node": ">= 10"
909 }
910 },
911 "node_modules/@resvg/resvg-js-darwin-arm64": {
912 "version": "2.6.2",
913 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
914 "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
915 "cpu": [
916 "arm64"
917 ],
918 "dev": true,
919 "license": "MPL-2.0",
920 "optional": true,
921 "os": [
922 "darwin"
923 ],
924 "engines": {
925 "node": ">= 10"
926 }
927 },
928 "node_modules/@resvg/resvg-js-darwin-x64": {
929 "version": "2.6.2",
930 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
931 "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
932 "cpu": [
933 "x64"
934 ],
935 "dev": true,
936 "license": "MPL-2.0",
937 "optional": true,
938 "os": [
939 "darwin"
940 ],
941 "engines": {
942 "node": ">= 10"
943 }
944 },
945 "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
946 "version": "2.6.2",
947 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
948 "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
949 "cpu": [
950 "arm"
951 ],
952 "dev": true,
953 "license": "MPL-2.0",
954 "optional": true,
955 "os": [
956 "linux"
957 ],
958 "engines": {
959 "node": ">= 10"
960 }
961 },
962 "node_modules/@resvg/resvg-js-linux-arm64-gnu": {
963 "version": "2.6.2",
964 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
965 "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
966 "cpu": [
967 "arm64"
968 ],
969 "dev": true,
970 "license": "MPL-2.0",
971 "optional": true,
972 "os": [
973 "linux"
974 ],
975 "engines": {
976 "node": ">= 10"
977 }
978 },
979 "node_modules/@resvg/resvg-js-linux-arm64-musl": {
980 "version": "2.6.2",
981 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
982 "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
983 "cpu": [
984 "arm64"
985 ],
986 "dev": true,
987 "license": "MPL-2.0",
988 "optional": true,
989 "os": [
990 "linux"
991 ],
992 "engines": {
993 "node": ">= 10"
994 }
995 },
996 "node_modules/@resvg/resvg-js-linux-x64-gnu": {
997 "version": "2.6.2",
998 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
999 "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
1000 "cpu": [
1001 "x64"
1002 ],
1003 "dev": true,
1004 "license": "MPL-2.0",
1005 "optional": true,
1006 "os": [
1007 "linux"
1008 ],
1009 "engines": {
1010 "node": ">= 10"
1011 }
1012 },
1013 "node_modules/@resvg/resvg-js-linux-x64-musl": {
1014 "version": "2.6.2",
1015 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
1016 "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
1017 "cpu": [
1018 "x64"
1019 ],
1020 "dev": true,
1021 "license": "MPL-2.0",
1022 "optional": true,
1023 "os": [
1024 "linux"
1025 ],
1026 "engines": {
1027 "node": ">= 10"
1028 }
1029 },
1030 "node_modules/@resvg/resvg-js-win32-arm64-msvc": {
1031 "version": "2.6.2",
1032 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
1033 "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
1034 "cpu": [
1035 "arm64"
1036 ],
1037 "dev": true,
1038 "license": "MPL-2.0",
1039 "optional": true,
1040 "os": [
1041 "win32"
1042 ],
1043 "engines": {
1044 "node": ">= 10"
1045 }
1046 },
1047 "node_modules/@resvg/resvg-js-win32-ia32-msvc": {
1048 "version": "2.6.2",
1049 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
1050 "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
1051 "cpu": [
1052 "ia32"
1053 ],
1054 "dev": true,
1055 "license": "MPL-2.0",
1056 "optional": true,
1057 "os": [
1058 "win32"
1059 ],
1060 "engines": {
1061 "node": ">= 10"
1062 }
1063 },
1064 "node_modules/@resvg/resvg-js-win32-x64-msvc": {
1065 "version": "2.6.2",
1066 "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
1067 "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
1068 "cpu": [
1069 "x64"
1070 ],
1071 "dev": true,
1072 "license": "MPL-2.0",
1073 "optional": true,
1074 "os": [
1075 "win32"
1076 ],
1077 "engines": {
1078 "node": ">= 10"
1079 }
1080 },
8521081 "node_modules/@rolldown/pluginutils": {
8531082 "version": "1.0.0-beta.27",
8541083 "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
Modifiedlanding/package.json+3−1View fileUnifiedSplit
@@ -5,7 +5,8 @@
55 "type": "module",
66 "scripts": {
77 "dev": "vite",
8 "build": "tsc && vite build",
8 "build": "npm run og && tsc && vite build",
9 "og": "node scripts/generate-og.mjs",
910 "preview": "vite preview"
1011 },
1112 "dependencies": {
@@ -15,6 +16,7 @@
1516 "react-dom": "^18.3.1"
1617 },
1718 "devDependencies": {
19 "@resvg/resvg-js": "^2.6.2",
1820 "@types/react": "^18.3.18",
1921 "@types/react-dom": "^18.3.5",
2022 "@vitejs/plugin-react": "^4.3.4",
Addedlanding/public/og-image.svg+71−0View fileUnifiedSplit
@@ -0,0 +1,71 @@
1
2<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
3 <defs>
4 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
5 <stop offset="0" stop-color="#09090b"/>
6 <stop offset="1" stop-color="#1b1b20"/>
7 </linearGradient>
8 <linearGradient id="brass" x1="0" y1="0" x2="1" y2="0">
9 <stop offset="0" stop-color="#d4a24c"/>
10 <stop offset="1" stop-color="#b5863a"/>
11 </linearGradient>
12 <radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
13 <stop offset="0" stop-color="#d4a24c" stop-opacity="0.18"/>
14 <stop offset="1" stop-color="#d4a24c" stop-opacity="0"/>
15 </radialGradient>
16 <filter id="blur"><feGaussianBlur stdDeviation="40"/></filter>
17 </defs>
18
19 <rect width="1200" height="630" fill="url(#bg)"/>
20
21 <!-- Soft brass glow behind the mark -->
22 <circle cx="260" cy="320" r="220" fill="url(#glow)" filter="url(#blur)"/>
23
24 <!-- Mic mark -->
25 <g transform="translate(180, 210)">
26 <rect x="40" y="0" width="80" height="130" rx="40" fill="none" stroke="url(#brass)" stroke-width="8"/>
27 <path d="M 15 100 Q 15 190, 80 190 Q 145 190, 145 100" fill="none" stroke="url(#brass)" stroke-width="8" stroke-linecap="round"/>
28 <line x1="80" y1="190" x2="80" y2="220" stroke="url(#brass)" stroke-width="8" stroke-linecap="round"/>
29 <line x1="50" y1="220" x2="110" y2="220" stroke="url(#brass)" stroke-width="8" stroke-linecap="round"/>
30 </g>
31
32 <!-- Thin brass divider under the wordmark -->
33 <line x1="440" y1="240" x2="540" y2="240" stroke="url(#brass)" stroke-width="2"/>
34
35 <!-- Headline -->
36 <text x="440" y="215" font-family="Georgia, 'Times New Roman', serif" font-size="72" font-weight="500" fill="#f8f6f2" letter-spacing="-2">
37 Marco Reid Voice
38 </text>
39 <text x="440" y="296" font-family="Georgia, 'Times New Roman', serif" font-style="italic" font-size="34" fill="#d4a24c">
40 Speak. It is done.
41 </text>
42
43 <!-- Subhead -->
44 <text x="440" y="370" font-family="Inter, Helvetica, sans-serif" font-size="26" font-weight="500" fill="#e7e3db">
45 Premium AI dictation for lawyers, accountants,
46 </text>
47 <text x="440" y="404" font-family="Inter, Helvetica, sans-serif" font-size="26" font-weight="500" fill="#e7e3db">
48 and professionals who demand precision.
49 </text>
50
51 <!-- Pills -->
52 <g font-family="Inter, Helvetica, sans-serif" font-size="16" font-weight="600" letter-spacing="1.2">
53 <g transform="translate(440, 460)">
54 <rect x="0" y="0" width="210" height="44" rx="22" fill="none" stroke="#3a3a42" stroke-width="1.5"/>
55 <text x="22" y="28" fill="#e7e3db">ZERO-RETENTION</text>
56 </g>
57 <g transform="translate(670, 460)">
58 <rect x="0" y="0" width="180" height="44" rx="22" fill="none" stroke="#3a3a42" stroke-width="1.5"/>
59 <text x="22" y="28" fill="#e7e3db">REAL-TIME</text>
60 </g>
61 <g transform="translate(870, 460)">
62 <rect x="0" y="0" width="230" height="44" rx="22" fill="none" stroke="url(#brass)" stroke-width="1.5"/>
63 <text x="22" y="28" fill="#d4a24c">PRIVILEGED-SAFE</text>
64 </g>
65 </g>
66
67 <!-- Footer URL -->
68 <text x="440" y="562" font-family="'Courier New', monospace" font-size="18" fill="#8a8892" letter-spacing="2">
69 VOXLEN.AI
70 </text>
71</svg>
Addedlanding/scripts/generate-og.mjs+26−0View fileUnifiedSplit
@@ -0,0 +1,26 @@
1
2// Rasterise public/og-image.svg to public/og-image.png at 1200x630.
3// Run via `npm run og`. Runs again automatically on `npm run build`.
4// Intentionally zero-config: the SVG is the source of truth; the PNG is a
5// generated artefact so LinkedIn/Twitter/Facebook (which don't accept SVG
6// og:image) get a proper preview.
7
8import { readFileSync, writeFileSync } from "node:fs";
9import { dirname, resolve } from "node:path";
10import { fileURLToPath } from "node:url";
11import { Resvg } from "@resvg/resvg-js";
12
13const here = dirname(fileURLToPath(import.meta.url));
14const root = resolve(here, "..");
15const svgPath = resolve(root, "public", "og-image.svg");
16const pngPath = resolve(root, "public", "og-image.png");
17
18const svg = readFileSync(svgPath);
19const resvg = new Resvg(svg, {
20 fitTo: { mode: "width", value: 1200 },
21 background: "#09090b",
22 font: { loadSystemFonts: true },
23});
24const png = resvg.render().asPng();
25writeFileSync(pngPath, png);
26console.log(`Wrote ${pngPath} (${png.length.toLocaleString()} bytes).`);
Modifiedlanding/src/App.tsx+123−8View fileUnifiedSplit
@@ -46,6 +46,87 @@ export default function App() {
4646 {legalModal && (
4747 <LegalModal type={legalModal} onClose={() => setLegalModal(null)} />
4848 )}
49 <CookieBanner onOpenPrivacy={() => setLegalModal("privacy")} />
50 </div>
51 );
52}
53
54/**
55 * GDPR cookie banner. The landing site only sets first-party preference
56 * cookies today, but reserves the right to enable analytics with consent.
57 * The banner stores the decision in localStorage under `voxlen_cookie_consent`
58 * so returning visitors aren't nagged.
59 */
60function CookieBanner({ onOpenPrivacy }: { onOpenPrivacy: () => void }) {
61 const [show, setShow] = useState(false);
62
63 useEffect(() => {
64 try {
65 const saved = localStorage.getItem("voxlen_cookie_consent");
66 if (!saved) setShow(true);
67 } catch {
68 // Private-mode or disabled storage — skip banner rather than spam.
69 }
70 }, []);
71
72 const decide = (decision: "accepted" | "rejected") => {
73 try {
74 localStorage.setItem(
75 "voxlen_cookie_consent",
76 JSON.stringify({ decision, decidedAt: new Date().toISOString(), version: 1 }),
77 );
78 } catch {
79 // Non-fatal — honour the click either way.
80 }
81 setShow(false);
82 };
83
84 if (!show) return null;
85
86 return (
87 <div className="fixed bottom-0 inset-x-0 z-50 p-4 md:p-6 pointer-events-none">
88 <div
89 role="dialog"
90 aria-labelledby="cookie-banner-title"
91 className="pointer-events-auto max-w-4xl mx-auto rounded-xl border border-white/10 bg-[#0c0c0f]/95 backdrop-blur-xl shadow-2xl shadow-black/50 p-5 md:p-6"
92 >
93 <div className="flex flex-col md:flex-row md:items-center gap-4">
94 <div className="flex-1">
95 <h3 id="cookie-banner-title" className="text-sm font-semibold text-white mb-1">
96 Cookies & preferences
97 </h3>
98 <p className="text-xs text-zinc-400 leading-relaxed">
99 We use strictly-necessary cookies to run this site. We'd also like your consent
100 to set analytics cookies that help us improve the product. You can change your
101 mind any time. See our{" "}
102 <button
103 type="button"
104 onClick={onOpenPrivacy}
105 className="underline text-zinc-200 hover:text-white"
106 >
107 Privacy Policy
108 </button>
109 .
110 </p>
111 </div>
112 <div className="flex items-center gap-2 shrink-0">
113 <button
114 type="button"
115 onClick={() => decide("rejected")}
116 className="h-9 px-4 rounded-lg border border-white/10 text-sm text-zinc-300 hover:text-white hover:border-white/20 transition-colors"
117 >
118 Reject
119 </button>
120 <button
121 type="button"
122 onClick={() => decide("accepted")}
123 className="h-9 px-4 rounded-lg bg-marcoreid-600 text-white text-sm font-medium hover:bg-marcoreid-700 transition-colors"
124 >
125 Accept
126 </button>
127 </div>
128 </div>
129 </div>
49130 </div>
50131 );
51132}
@@ -299,9 +380,9 @@ function Features() {
299380 bg: "bg-orange-400/10",
300381 },
301382 {
302 icon: Cpu,
303 title: "Offline Mode",
304 description: "Run Whisper locally for complete privacy. Your audio never leaves your device. Perfect for sensitive documents.",
383 icon: Shield,
384 title: "Zero-Retention Privacy",
385 description: "Your audio streams device-to-provider on zero-retention endpoints — never routed through or stored by Voxlen. Designed for privileged information.",
305386 color: "text-emerald-400",
306387 bg: "bg-emerald-400/10",
307388 },
@@ -426,7 +507,7 @@ function Comparison() {
426507 { name: "Grammarly", price: "$12/mo", realtime: false, neverInterrupts: false, grammar: true, anyApp: false, offline: false, extMic: false },
427508 { name: "Dragon", price: "$700", realtime: true, neverInterrupts: false, grammar: false, anyApp: true, offline: true, extMic: false },
428509 { name: "Wispr Flow", price: "$12/mo", realtime: true, neverInterrupts: true, grammar: false, anyApp: true, offline: false, extMic: false },
429 { name: "Marco Reid Voice", price: "$29/mo", realtime: true, neverInterrupts: true, grammar: true, anyApp: true, offline: true, extMic: true, highlight: true },
510 { name: "Marco Reid Voice", price: "$29/mo", realtime: true, neverInterrupts: true, grammar: true, anyApp: true, offline: false, extMic: true, highlight: true },
430511 ];
431512
432513 return (
@@ -496,9 +577,36 @@ function Comparison() {
496577 );
497578}
498579
580// Checkout URLs are resolved from Vite env vars at build time so we can swap
581// Stripe accounts or price IDs without touching code. Supply either a
582// fully-resolved Stripe-hosted checkout page (https://buy.stripe.com/...)
583// or a path on our own site that mounts Stripe Checkout.
584// When unset, the button falls back to #download so we never ship a
585// broken "Buy" link.
586const CHECKOUT_URLS = {
587 pro: import.meta.env.VITE_STRIPE_CHECKOUT_PRO || "#download",
588 professional: import.meta.env.VITE_STRIPE_CHECKOUT_PROFESSIONAL || "#download",
589 lifetime: import.meta.env.VITE_STRIPE_CHECKOUT_LIFETIME || "#download",
590};
591
592type TierKey = "free" | "pro" | "professional" | "lifetime";
593
499594function Pricing() {
500 const tiers = [
595 const tiers: Array<{
596 key: TierKey;
597 name: string;
598 tagline: string;
599 price: string;
600 period: string;
601 features: string[];
602 cta: string;
603 ctaStyle: "primary" | "secondary";
604 highlight: boolean;
605 badge?: string;
606 href: string;
607 }> = [
501608 {
609 key: "free",
502610 name: "Free",
503611 tagline: "Try it out",
504612 price: "$0",
@@ -511,8 +619,10 @@ function Pricing() {
511619 cta: "Download Free",
512620 ctaStyle: "secondary",
513621 highlight: false,
622 href: "#download",
514623 },
515624 {
625 key: "pro",
516626 name: "Pro",
517627 tagline: "For daily dictation",
518628 price: "$29",
@@ -532,8 +642,10 @@ function Pricing() {
532642 ctaStyle: "primary",
533643 highlight: true,
534644 badge: "Most Popular",
645 href: CHECKOUT_URLS.pro,
535646 },
536647 {
648 key: "professional",
537649 name: "Professional",
538650 tagline: "For lawyers, accountants & firms",
539651 price: "$79",
@@ -551,8 +663,10 @@ function Pricing() {
551663 ctaStyle: "secondary",
552664 highlight: false,
553665 badge: "Recommended for Pros",
666 href: CHECKOUT_URLS.professional,
554667 },
555668 {
669 key: "lifetime",
556670 name: "Lifetime",
557671 tagline: "Pay once, own forever",
558672 price: "$599",
@@ -567,6 +681,7 @@ function Pricing() {
567681 cta: "Get Lifetime",
568682 ctaStyle: "secondary",
569683 highlight: false,
684 href: CHECKOUT_URLS.lifetime,
570685 },
571686 ];
572687
@@ -642,7 +757,7 @@ function Pricing() {
642757 ))}
643758 </ul>
644759 <a
645 href="#download"
760 href={t.href}
646761 className={`block text-center h-11 leading-[44px] rounded-xl text-sm font-semibold transition-colors ${
647762 t.ctaStyle === "primary"
648763 ? "bg-marcoreid-600 text-white hover:bg-marcoreid-700 shadow-lg shadow-marcoreid-600/25"
@@ -686,7 +801,7 @@ function FAQ() {
686801 },
687802 {
688803 q: "Do I need an internet connection?",
689 a: "Not always. Marco Reid Voice includes a fully offline mode that runs entirely on your device — ideal for flights or sensitive work. Our cloud models give higher accuracy and lower latency, but you always have the choice.",
804 a: "Yes. Marco Reid Voice currently uses cloud speech models (Deepgram Nova-2 and OpenAI Whisper) for best-in-class accuracy and latency. Audio streams directly from your device to the provider on zero-retention endpoints — never through Voxlen servers. A fully on-device mode is on the roadmap.",
690805 },
691806 {
692807 q: "Does it work with my external USB microphone?",
@@ -1056,7 +1171,7 @@ function PrivacyContent() {
10561171 your device and the underlying AI providers — never through Voxlen-operated servers:
10571172 </p>
10581173 <ul className="text-zinc-400 space-y-2 list-disc pl-5">
1059 <li><strong className="text-zinc-200">Speech-to-Text:</strong> Audio streams directly from your device to the speech-to-text provider on zero-retention endpoints. In offline mode, audio never leaves your device.</li>
1174 <li><strong className="text-zinc-200">Speech-to-Text:</strong> Audio streams directly from your device to the speech-to-text provider on zero-retention endpoints. Never routed through Voxlen servers.</li>
10601175 <li><strong className="text-zinc-200">Grammar Correction:</strong> Text is sent directly from your device to the grammar AI provider (Anthropic or OpenAI) on zero-retention endpoints. We have no intermediary server.</li>
10611176 <li><strong className="text-zinc-200">Text Injection:</strong> All text injection happens locally via OS-level APIs. No network transmission involved.</li>
10621177 <li><strong className="text-zinc-200">API credentials:</strong> Voxlen provisions provider credentials as part of your subscription, but credentials are issued to your device and used only for direct device-to-provider traffic.</li>
Addedlanding/src/vite-env.d.ts+11−0View fileUnifiedSplit
@@ -0,0 +1,11 @@
1/// <reference types="vite/client" />
2
3interface ImportMetaEnv {
4 readonly VITE_STRIPE_CHECKOUT_PRO?: string;
5 readonly VITE_STRIPE_CHECKOUT_PROFESSIONAL?: string;
6 readonly VITE_STRIPE_CHECKOUT_LIFETIME?: string;
7}
8
9interface ImportMeta {
10 readonly env: ImportMetaEnv;
11}
Modifiedpackage-lock.json+917−1View fileUnifiedSplit
Large file (1,114 lines). Load full file
Modifiedpackage.json+9−4View fileUnifiedSplit
@@ -8,7 +8,9 @@
88 "dev": "vite",
99 "build": "tsc && vite build",
1010 "preview": "vite preview",
11 "tauri": "tauri"
11 "tauri": "tauri",
12 "test": "vitest run",
13 "test:watch": "vitest"
1214 },
1315 "dependencies": {
1416 "@radix-ui/react-dialog": "^1.1.4",
@@ -20,10 +22,10 @@
2022 "@radix-ui/react-tabs": "^1.1.2",
2123 "@radix-ui/react-tooltip": "^1.1.6",
2224 "@tauri-apps/api": "^2.2.0",
23 "@tauri-apps/plugin-global-shortcut": "^2.2.0",
24 "@tauri-apps/plugin-notification": "^2.2.0",
2525 "@tauri-apps/plugin-dialog": "^2.2.0",
2626 "@tauri-apps/plugin-fs": "^2.2.0",
27 "@tauri-apps/plugin-global-shortcut": "^2.2.0",
28 "@tauri-apps/plugin-notification": "^2.2.0",
2729 "@tauri-apps/plugin-store": "^2.2.0",
2830 "@tauri-apps/plugin-updater": "^2.2.0",
2931 "clsx": "^2.1.1",
@@ -39,10 +41,13 @@
3941 "@types/react": "^18.3.18",
4042 "@types/react-dom": "^18.3.5",
4143 "@vitejs/plugin-react": "^4.3.4",
44 "@vitest/expect": "^4.1.4",
4245 "autoprefixer": "^10.4.20",
46 "jsdom": "^29.0.2",
4347 "postcss": "^8.4.49",
4448 "tailwindcss": "^3.4.17",
4549 "typescript": "^5.7.3",
46 "vite": "^6.0.7"
50 "vite": "^6.0.7",
51 "vitest": "^4.1.4"
4752 }
4853}
Modifiedsrc-tauri/Cargo.lock+258−8View fileUnifiedSplit
@@ -285,6 +285,17 @@ version = "1.1.2"
285285source = "registry+https://github.com/rust-lang/crates.io-index"
286286checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
287287
288[[package]]
289name = "auto-launch"
290version = "0.5.0"
291source = "registry+https://github.com/rust-lang/crates.io-index"
292checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
293dependencies = [
294 "dirs 4.0.0",
295 "thiserror 1.0.69",
296 "winreg 0.10.1",
297]
298
288299[[package]]
289300name = "autocfg"
290301version = "1.5.0"
@@ -303,6 +314,12 @@ version = "0.22.1"
303314source = "registry+https://github.com/rust-lang/crates.io-index"
304315checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
305316
317[[package]]
318name = "base64ct"
319version = "1.8.3"
320source = "registry+https://github.com/rust-lang/crates.io-index"
321checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
322
306323[[package]]
307324name = "bindgen"
308325version = "0.72.1"
@@ -656,6 +673,12 @@ dependencies = [
656673 "crossbeam-utils",
657674]
658675
676[[package]]
677name = "const-oid"
678version = "0.9.6"
679source = "registry+https://github.com/rust-lang/crates.io-index"
680checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
681
659682[[package]]
660683name = "convert_case"
661684version = "0.4.0"
@@ -871,6 +894,33 @@ dependencies = [
871894 "syn 2.0.117",
872895]
873896
897[[package]]
898name = "curve25519-dalek"
899version = "4.1.3"
900source = "registry+https://github.com/rust-lang/crates.io-index"
901checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
902dependencies = [
903 "cfg-if",
904 "cpufeatures",
905 "curve25519-dalek-derive",
906 "digest",
907 "fiat-crypto",
908 "rustc_version",
909 "subtle",
910 "zeroize",
911]
912
913[[package]]
914name = "curve25519-dalek-derive"
915version = "0.1.1"
916source = "registry+https://github.com/rust-lang/crates.io-index"
917checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
918dependencies = [
919 "proc-macro2",
920 "quote",
921 "syn 2.0.117",
922]
923
874924[[package]]
875925name = "darling"
876926version = "0.23.0"
@@ -917,6 +967,37 @@ version = "2.10.0"
917967source = "registry+https://github.com/rust-lang/crates.io-index"
918968checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
919969
970[[package]]
971name = "dbus"
972version = "0.9.10"
973source = "registry+https://github.com/rust-lang/crates.io-index"
974checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
975dependencies = [
976 "libc",
977 "libdbus-sys",
978 "windows-sys 0.59.0",
979]
980
981[[package]]
982name = "dbus-secret-service"
983version = "4.1.0"
984source = "registry+https://github.com/rust-lang/crates.io-index"
985checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
986dependencies = [
987 "dbus",
988 "zeroize",
989]
990
991[[package]]
992name = "der"
993version = "0.7.10"
994source = "registry+https://github.com/rust-lang/crates.io-index"
995checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
996dependencies = [
997 "const-oid",
998 "zeroize",
999]
1000
9201001[[package]]
9211002name = "deranged"
9221003version = "0.5.8"
@@ -971,13 +1052,33 @@ dependencies = [
9711052 "crypto-common",
9721053]
9731054
1055[[package]]
1056name = "dirs"
1057version = "4.0.0"
1058source = "registry+https://github.com/rust-lang/crates.io-index"
1059checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
1060dependencies = [
1061 "dirs-sys 0.3.7",
1062]
1063
9741064[[package]]
9751065name = "dirs"
9761066version = "6.0.0"
9771067source = "registry+https://github.com/rust-lang/crates.io-index"
9781068checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
9791069dependencies = [
980 "dirs-sys",
1070 "dirs-sys 0.5.0",
1071]
1072
1073[[package]]
1074name = "dirs-sys"
1075version = "0.3.7"
1076source = "registry+https://github.com/rust-lang/crates.io-index"
1077checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
1078dependencies = [
1079 "libc",
1080 "redox_users 0.4.6",
1081 "winapi",
9811082]
9821083
9831084[[package]]
@@ -988,7 +1089,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
9881089dependencies = [
9891090 "libc",
9901091 "option-ext",
991 "redox_users",
1092 "redox_users 0.5.2",
9921093 "windows-sys 0.61.2",
9931094]
9941095
@@ -1089,6 +1190,31 @@ version = "1.0.20"
10891190source = "registry+https://github.com/rust-lang/crates.io-index"
10901191checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
10911192
1193[[package]]
1194name = "ed25519"
1195version = "2.2.3"
1196source = "registry+https://github.com/rust-lang/crates.io-index"
1197checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
1198dependencies = [
1199 "pkcs8",
1200 "signature",
1201]
1202
1203[[package]]
1204name = "ed25519-dalek"
1205version = "2.2.0"
1206source = "registry+https://github.com/rust-lang/crates.io-index"
1207checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
1208dependencies = [
1209 "curve25519-dalek",
1210 "ed25519",
1211 "rand_core 0.6.4",
1212 "serde",
1213 "sha2",
1214 "subtle",
1215 "zeroize",
1216]
1217
10921218[[package]]
10931219name = "either"
10941220version = "1.15.0"
@@ -1106,7 +1232,7 @@ dependencies = [
11061232 "rustc_version",
11071233 "toml 0.9.12+spec-1.1.0",
11081234 "vswhom",
1109 "winreg",
1235 "winreg 0.55.0",
11101236]
11111237
11121238[[package]]
@@ -1237,6 +1363,12 @@ dependencies = [
12371363 "simd-adler32",
12381364]
12391365
1366[[package]]
1367name = "fiat-crypto"
1368version = "0.2.9"
1369source = "registry+https://github.com/rust-lang/crates.io-index"
1370checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
1371
12401372[[package]]
12411373name = "field-offset"
12421374version = "0.3.6"
@@ -2358,6 +2490,21 @@ dependencies = [
23582490 "unicode-segmentation",
23592491]
23602492
2493[[package]]
2494name = "keyring"
2495version = "3.6.3"
2496source = "registry+https://github.com/rust-lang/crates.io-index"
2497checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
2498dependencies = [
2499 "byteorder",
2500 "dbus-secret-service",
2501 "log",
2502 "security-framework 2.11.1",
2503 "security-framework 3.7.0",
2504 "windows-sys 0.60.2",
2505 "zeroize",
2506]
2507
23612508[[package]]
23622509name = "kuchikiki"
23632510version = "0.8.8-speedreader"
@@ -2423,6 +2570,15 @@ version = "0.2.185"
24232570source = "registry+https://github.com/rust-lang/crates.io-index"
24242571checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
24252572
2573[[package]]
2574name = "libdbus-sys"
2575version = "0.2.7"
2576source = "registry+https://github.com/rust-lang/crates.io-index"
2577checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
2578dependencies = [
2579 "pkg-config",
2580]
2581
24262582[[package]]
24272583name = "libloading"
24282584version = "0.7.4"
@@ -2528,9 +2684,12 @@ dependencies = [
25282684 "core-graphics 0.24.0",
25292685 "cpal",
25302686 "crossbeam-channel",
2687 "ed25519-dalek",
25312688 "env_logger",
25322689 "futures-util",
2690 "hex",
25332691 "hound",
2692 "keyring",
25342693 "log",
25352694 "objc",
25362695 "parking_lot",
@@ -2542,6 +2701,7 @@ dependencies = [
25422701 "serde_json",
25432702 "tauri",
25442703 "tauri-build",
2704 "tauri-plugin-autostart",
25452705 "tauri-plugin-dialog",
25462706 "tauri-plugin-fs",
25472707 "tauri-plugin-global-shortcut",
@@ -2688,7 +2848,7 @@ dependencies = [
26882848 "openssl-probe",
26892849 "openssl-sys",
26902850 "schannel",
2691 "security-framework",
2851 "security-framework 3.7.0",
26922852 "security-framework-sys",
26932853 "tempfile",
26942854]
@@ -3377,6 +3537,16 @@ dependencies = [
33773537 "futures-io",
33783538]
33793539
3540[[package]]
3541name = "pkcs8"
3542version = "0.10.2"
3543source = "registry+https://github.com/rust-lang/crates.io-index"
3544checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
3545dependencies = [
3546 "der",
3547 "spki",
3548]
3549
33803550[[package]]
33813551name = "pkg-config"
33823552version = "0.3.33"
@@ -3728,6 +3898,17 @@ dependencies = [
37283898 "bitflags 2.11.1",
37293899]
37303900
3901[[package]]
3902name = "redox_users"
3903version = "0.4.6"
3904source = "registry+https://github.com/rust-lang/crates.io-index"
3905checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
3906dependencies = [
3907 "getrandom 0.2.17",
3908 "libredox",
3909 "thiserror 1.0.69",
3910]
3911
37313912[[package]]
37323913name = "redox_users"
37333914version = "0.5.2"
@@ -4102,6 +4283,19 @@ version = "1.2.0"
41024283source = "registry+https://github.com/rust-lang/crates.io-index"
41034284checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
41044285
4286[[package]]
4287name = "security-framework"
4288version = "2.11.1"
4289source = "registry+https://github.com/rust-lang/crates.io-index"
4290checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
4291dependencies = [
4292 "bitflags 2.11.1",
4293 "core-foundation 0.9.4",
4294 "core-foundation-sys",
4295 "libc",
4296 "security-framework-sys",
4297]
4298
41054299[[package]]
41064300name = "security-framework"
41074301version = "3.7.0"
@@ -4421,6 +4615,15 @@ dependencies = [
44214615 "libc",
44224616]
44234617
4618[[package]]
4619name = "signature"
4620version = "2.2.0"
4621source = "registry+https://github.com/rust-lang/crates.io-index"
4622checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
4623dependencies = [
4624 "rand_core 0.6.4",
4625]
4626
44244627[[package]]
44254628name = "simd-adler32"
44264629version = "0.3.9"
@@ -4509,6 +4712,16 @@ dependencies = [
45094712 "system-deps",
45104713]
45114714
4715[[package]]
4716name = "spki"
4717version = "0.7.3"
4718source = "registry+https://github.com/rust-lang/crates.io-index"
4719checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
4720dependencies = [
4721 "base64ct",
4722 "der",
4723]
4724
45124725[[package]]
45134726name = "stable_deref_trait"
45144727version = "1.2.1"
@@ -4782,7 +4995,7 @@ dependencies = [
47824995 "anyhow",
47834996 "bytes",
47844997 "cookie",
4785 "dirs",
4998 "dirs 6.0.0",
47864999 "dunce",
47875000 "embed_plist",
47885001 "getrandom 0.3.4",
@@ -4832,7 +5045,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
48325045dependencies = [
48335046 "anyhow",
48345047 "cargo_toml",
4835 "dirs",
5048 "dirs 6.0.0",
48365049 "glob",
48375050 "heck 0.5.0",
48385051 "json-patch",
@@ -4904,6 +5117,20 @@ dependencies = [
49045117 "walkdir",
49055118]
49065119
5120[[package]]
5121name = "tauri-plugin-autostart"
5122version = "2.5.1"
5123source = "registry+https://github.com/rust-lang/crates.io-index"
5124checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
5125dependencies = [
5126 "auto-launch",
5127 "serde",
5128 "serde_json",
5129 "tauri",
5130 "tauri-plugin",
5131 "thiserror 2.0.18",
5132]
5133
49075134[[package]]
49085135name = "tauri-plugin-dialog"
49095136version = "2.7.0"
@@ -5532,7 +5759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
55325759checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
55335760dependencies = [
55345761 "crossbeam-channel",
5535 "dirs",
5762 "dirs 6.0.0",
55365763 "libappindicator",
55375764 "muda",
55385765 "objc2",
@@ -6590,6 +6817,15 @@ dependencies = [
65906817 "memchr",
65916818]
65926819
6820[[package]]
6821name = "winreg"
6822version = "0.10.1"
6823source = "registry+https://github.com/rust-lang/crates.io-index"
6824checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
6825dependencies = [
6826 "winapi",
6827]
6828
65936829[[package]]
65946830name = "winreg"
65956831version = "0.55.0"
@@ -6704,7 +6940,7 @@ dependencies = [
67046940 "block2",
67056941 "cookie",
67066942 "crossbeam-channel",
6707 "dirs",
6943 "dirs 6.0.0",
67086944 "dom_query",
67096945 "dpi",
67106946 "dunce",
@@ -6912,6 +7148,20 @@ name = "zeroize"
69127148version = "1.8.2"
69137149source = "registry+https://github.com/rust-lang/crates.io-index"
69147150checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
7151dependencies = [
7152 "zeroize_derive",
7153]
7154
7155[[package]]
7156name = "zeroize_derive"
7157version = "1.4.3"
7158source = "registry+https://github.com/rust-lang/crates.io-index"
7159checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
7160dependencies = [
7161 "proc-macro2",
7162 "quote",
7163 "syn 2.0.117",
7164]
69157165
69167166[[package]]
69177167name = "zerotrie"
Modifiedsrc-tauri/Cargo.toml+3−0View fileUnifiedSplit
@@ -43,6 +43,9 @@ crossbeam-channel = "0.5"
4343bytemuck = { version = "1", features = ["derive"] }
4444futures-util = "0.3"
4545rodio = "0.20"
46ed25519-dalek = { version = "2", features = ["rand_core"] }
47hex = "0.4"
48keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
4649
4750[target.'cfg(target_os = "macos")'.dependencies]
4851cocoa = "0.26"
Modifiedsrc-tauri/src/commands/grammar.rs+9−0View fileUnifiedSplit
@@ -66,6 +66,15 @@ pub async fn correct_grammar(
6666 text: String,
6767 custom_vocabulary: Option<Vec<String>>,
6868) -> Result<GrammarResult, String> {
69 if !super::license::current_tier().is_paid() {
70 return Err(
71 "AI grammar correction requires a Pro, Professional, or Lifetime license. \
72 Activate your license in Settings → License, or start a trial at \
73 voxlen.ai/pricing."
74 .to_string(),
75 );
76 }
77
6978 let config = get_config_store().read().clone();
7079
7180 if !config.enabled {
Addedsrc-tauri/src/commands/license.rs+34−0View fileUnifiedSplit
@@ -0,0 +1,34 @@
1//! License-management Tauri commands.
2//!
3//! Licenses are verified entirely on-device using an embedded Ed25519
4//! public key. No user data leaves the machine as part of activation.
5
6use tauri::AppHandle;
7
8use super::settings;
9use crate::license::{self, LicenseStatus, Tier};
10
11
12pub fn get_license_status() -> LicenseStatus {
13 license::status(settings::current_license_key().as_deref())
14}
15
16
17pub fn activate_license(app: AppHandle, key: String) -> Result<LicenseStatus, String> {
18 let trimmed = key.trim().to_string();
19 license::verify(&trimmed).map_err(|e| e.to_string())?;
20 settings::set_license_key(&app, Some(trimmed.clone()))?;
21 Ok(license::status(Some(&trimmed)))
22}
23
24
25pub fn clear_license(app: AppHandle) -> Result<LicenseStatus, String> {
26 settings::set_license_key(&app, None)?;
27 Ok(LicenseStatus::free())
28}
29
30/// Internal helper: returns the user's current tier. Free if no valid
31/// license is installed. Used by gated commands (dictation, grammar).
32pub fn current_tier() -> Tier {
33 license::status(settings::current_license_key().as_deref()).tier
34}
Modifiedsrc-tauri/src/commands/mod.rs+1−0View fileUnifiedSplit
@@ -2,6 +2,7 @@ pub mod audio;
22pub mod dictation;
33pub mod grammar;
44pub mod history;
5pub mod license;
56pub mod permissions;
67pub mod settings;
78pub mod stt;
Modifiedsrc-tauri/src/commands/settings.rs+88−2View fileUnifiedSplit
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
22use tauri::AppHandle;
33use tauri_plugin_store::StoreExt;
44
5use crate::secrets::{self, Secret};
6
57
68pub struct AppSettings {
79 // Audio
@@ -47,6 +49,10 @@ pub struct AppSettings {
4749 // Privacy
4850 pub telemetry_enabled: bool,
4951 pub save_transcripts: bool,
52
53 // Licensing
54
55 pub license_key: Option<String>,
5056}
5157
5258impl Default for AppSettings {
@@ -87,8 +93,70 @@ impl Default for AppSettings {
8793
8894 telemetry_enabled: false,
8995 save_transcripts: true,
96
97 license_key: None,
98 }
99 }
100}
101
102/// Read the currently-persisted license key (if any) from the in-memory
103/// settings cache. Used by gated commands and by the `license` command
104/// module. Backed by the OS keyring via the `secrets` module.
105pub fn current_license_key() -> Option<String> {
106 get_settings_store().read().license_key.clone()
107}
108
109/// Set (or clear) the license key. Persists to the OS keyring (not to
110/// disk) and refreshes the in-memory cache.
111pub fn set_license_key(_app: &AppHandle, key: Option<String>) -> Result<(), String> {
112 secrets::write(Secret::LicenseKey, key.as_deref())?;
113 get_settings_store().write().license_key = key;
114 Ok(())
115}
116
117/// Fields that live in the OS keyring rather than the plain-text store.
118/// Stripped from the struct before it is persisted to disk and re-hydrated
119/// on load.
120fn strip_secrets(settings: &mut AppSettings) {
121 settings.license_key = None;
122 settings.stt_api_key = None;
123 settings.grammar_api_key = None;
124}
125
126/// Hydrate secrets from the OS keyring into an in-memory settings snapshot.
127fn hydrate_secrets(settings: &mut AppSettings) {
128 settings.license_key = secrets::read(Secret::LicenseKey);
129 settings.stt_api_key = secrets::read(Secret::SttApiKey);
130 settings.grammar_api_key = secrets::read(Secret::GrammarApiKey);
131}
132
133/// Migrate secrets that were previously written to the plain-text store
134/// into the OS keyring, then wipe them from the on-disk snapshot. Safe to
135/// call on every startup; becomes a no-op after the first successful run.
136fn migrate_plaintext_secrets(app: &AppHandle, loaded: &mut AppSettings) -> Result<(), String> {
137 let mut migrated = false;
138 if let Some(v) = loaded.license_key.clone().filter(|s| !s.is_empty()) {
139 if secrets::write(Secret::LicenseKey, Some(&v)).is_ok() {
140 migrated = true;
141 }
142 }
143 if let Some(v) = loaded.stt_api_key.clone().filter(|s| !s.is_empty()) {
144 if secrets::write(Secret::SttApiKey, Some(&v)).is_ok() {
145 migrated = true;
90146 }
91147 }
148 if let Some(v) = loaded.grammar_api_key.clone().filter(|s| !s.is_empty()) {
149 if secrets::write(Secret::GrammarApiKey, Some(&v)).is_ok() {
150 migrated = true;
151 }
152 }
153 if migrated {
154 let mut stripped = loaded.clone();
155 strip_secrets(&mut stripped);
156 persist_settings(app, &stripped)?;
157 log::info!("Migrated plain-text secrets to OS keyring.");
158 }
159 Ok(())
92160}
93161
94162const SETTINGS_STORE_FILE: &str = "settings.json";
@@ -105,7 +173,10 @@ fn persist_settings(app: &AppHandle, settings: &AppSettings) -> Result<(), Strin
105173 let store = app
106174 .store(SETTINGS_STORE_FILE)
107175 .map_err(|e| e.to_string())?;
108 let value = serde_json::to_value(settings).map_err(|e| e.to_string())?;
176 // Never write secrets to the on-disk store; they live in the keyring.
177 let mut sanitised = settings.clone();
178 strip_secrets(&mut sanitised);
179 let value = serde_json::to_value(&sanitised).map_err(|e| e.to_string())?;
109180 store.set(SETTINGS_KEY, value);
110181 store.save().map_err(|e| e.to_string())?;
111182 Ok(())
@@ -118,6 +189,10 @@ pub fn get_settings() -> Result<AppSettings, String> {
118189
119190
120191pub fn update_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
192 // Route secrets to the OS keyring; everything else to the on-disk store.
193 secrets::write(Secret::LicenseKey, settings.license_key.as_deref())?;
194 secrets::write(Secret::SttApiKey, settings.stt_api_key.as_deref())?;
195 secrets::write(Secret::GrammarApiKey, settings.grammar_api_key.as_deref())?;
121196 *get_settings_store().write() = settings.clone();
122197 persist_settings(&app, &settings)?;
123198 Ok(())
@@ -125,6 +200,10 @@ pub fn update_settings(app: AppHandle, settings: AppSettings) -> Result<(), Stri
125200
126201
127202pub fn reset_settings(app: AppHandle) -> Result<AppSettings, String> {
203 // Clear keyring-backed secrets as part of the reset.
204 let _ = secrets::write(Secret::LicenseKey, None);
205 let _ = secrets::write(Secret::SttApiKey, None);
206 let _ = secrets::write(Secret::GrammarApiKey, None);
128207 let defaults = AppSettings::default();
129208 *get_settings_store().write() = defaults.clone();
130209 persist_settings(&app, &defaults)?;
@@ -140,7 +219,7 @@ pub fn load_settings_from_disk(app: AppHandle) -> Result<AppSettings, String> {
140219 .store(SETTINGS_STORE_FILE)
141220 .map_err(|e| e.to_string())?;
142221
143 let loaded = match store.get(SETTINGS_KEY) {
222 let mut loaded = match store.get(SETTINGS_KEY) {
144223 Some(value) => match serde_json::from_value::<AppSettings>(value) {
145224 Ok(s) => s,
146225 Err(e) => {
@@ -164,6 +243,13 @@ pub fn load_settings_from_disk(app: AppHandle) -> Result<AppSettings, String> {
164243 }
165244 };
166245
246 // Move any plain-text secrets into the keyring before hydrating from it.
247 // This is a one-shot migration for installs upgrading from pre-keyring builds.
248 if let Err(e) = migrate_plaintext_secrets(&app, &mut loaded) {
249 log::warn!("Secret migration failed: {}", e);
250 }
251 hydrate_secrets(&mut loaded);
252
167253 *get_settings_store().write() = loaded.clone();
168254 Ok(loaded)
169255}
Modifiedsrc-tauri/src/lib.rs+6−0View fileUnifiedSplit
@@ -1,5 +1,7 @@
11mod audio;
22mod commands;
3mod license;
4mod secrets;
35mod stt;
46mod text_injection;
57
@@ -160,6 +162,10 @@ pub fn run() {
160162 // Permission commands
161163 commands::permissions::check_permissions,
162164 commands::permissions::request_admin_permissions,
165 // License commands
166 commands::license::get_license_status,
167 commands::license::activate_license,
168 commands::license::clear_license,
163169 ])
164170 .on_window_event(|window, event| {
165171 if let tauri::WindowEvent::CloseRequested { api, .. } = event {
Addedsrc-tauri/src/license.rs+242−0View fileUnifiedSplit
@@ -0,0 +1,242 @@
1//! License verification for Marco Reid Voice.
2//!
3//! Licenses are Ed25519-signed JSON payloads. The signing key lives on the
4//! Voxlen billing server (invoked by the Stripe webhook); the verification
5//! key is embedded in the binary and is safe to publish. A valid license
6//! string looks like:
7//!
8//! ```text
9//! VOXLEN-<base64url(payload_json)>.<base64url(signature)>
10//! ```
11//!
12//! The customer pastes this into the app once; verification is entirely
13//! offline thereafter. No user content, license data, or identity ever
14//! leaves the device as part of verification.
15
16use anyhow::{anyhow, Result};
17use base64::Engine;
18use ed25519_dalek::{Signature, Verifier, VerifyingKey};
19use serde::{Deserialize, Serialize};
20
21/// Ed25519 verification key (32 bytes, hex-encoded).
22///
23/// Set at build time via the `VOXLEN_LICENSE_PUBKEY` env var. When unset, a
24/// placeholder dev key is used — dev licenses will verify, but no production
25/// license will. Before shipping paid builds, set the env var to the hex
26/// of the public half of the Ed25519 keypair whose private half lives on
27/// the billing server.
28const EMBEDDED_PUBKEY_HEX: &str = match option_env!("VOXLEN_LICENSE_PUBKEY") {
29 Some(k) => k,
30 None => "0000000000000000000000000000000000000000000000000000000000000000",
31};
32
33const LICENSE_PREFIX: &str = "VOXLEN-";
34
35
36
37pub enum Tier {
38 Free,
39 Pro,
40 Professional,
41 Lifetime,
42}
43
44impl Tier {
45 pub fn is_paid(self) -> bool {
46 !matches!(self, Tier::Free)
47 }
48}
49
50
51pub struct LicensePayload {
52 pub tier: Tier,
53 pub email: String,
54 /// Unix timestamp (seconds) when the license was issued.
55 pub issued: i64,
56 /// Unix timestamp (seconds) when the license expires. `None` = perpetual.
57 pub expires: Option<i64>,
58 /// License ID — used for revocation checks (future).
59 pub id: String,
60}
61
62
63pub struct LicenseStatus {
64 pub tier: Tier,
65 pub email: Option<String>,
66 pub expires: Option<i64>,
67 pub valid: bool,
68 pub reason: Option<String>,
69}
70
71impl LicenseStatus {
72 pub fn free() -> Self {
73 Self {
74 tier: Tier::Free,
75 email: None,
76 expires: None,
77 valid: true,
78 reason: None,
79 }
80 }
81
82 pub fn invalid(reason: impl Into<String>) -> Self {
83 Self {
84 tier: Tier::Free,
85 email: None,
86 expires: None,
87 valid: false,
88 reason: Some(reason.into()),
89 }
90 }
91}
92
93/// Verify a license string and return the parsed payload if valid.
94pub fn verify(license: &str) -> Result<LicensePayload> {
95 let body = license
96 .trim()
97 .strip_prefix(LICENSE_PREFIX)
98 .ok_or_else(|| anyhow!("license must start with {}", LICENSE_PREFIX))?;
99
100 let (payload_b64, sig_b64) = body
101 .split_once('.')
102 .ok_or_else(|| anyhow!("license format invalid: missing signature"))?;
103
104 let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
105 let payload_bytes = engine
106 .decode(payload_b64)
107 .map_err(|_| anyhow!("license payload is not valid base64"))?;
108 let sig_bytes = engine
109 .decode(sig_b64)
110 .map_err(|_| anyhow!("license signature is not valid base64"))?;
111
112 let pubkey_bytes = hex::decode(EMBEDDED_PUBKEY_HEX)
113 .map_err(|_| anyhow!("embedded license key is malformed"))?;
114 let pubkey_arr: [u8; 32] = pubkey_bytes
115 .try_into()
116 .map_err(|_| anyhow!("embedded license key must be 32 bytes"))?;
117 let verifying_key = VerifyingKey::from_bytes(&pubkey_arr)
118 .map_err(|_| anyhow!("embedded license key is not a valid Ed25519 key"))?;
119
120 let sig_arr: [u8; 64] = sig_bytes
121 .as_slice()
122 .try_into()
123 .map_err(|_| anyhow!("license signature must be 64 bytes"))?;
124 let signature = Signature::from_bytes(&sig_arr);
125
126 verifying_key
127 .verify(&payload_bytes, &signature)
128 .map_err(|_| anyhow!("license signature does not verify — license may be forged or corrupted"))?;
129
130 let payload: LicensePayload = serde_json::from_slice(&payload_bytes)
131 .map_err(|e| anyhow!("license payload is not valid JSON: {e}"))?;
132
133 if let Some(exp) = payload.expires {
134 let now = chrono::Utc::now().timestamp();
135 if now > exp {
136 return Err(anyhow!("license expired"));
137 }
138 }
139
140 Ok(payload)
141}
142
143/// Check a stored license string and produce a status suitable for the
144/// frontend entitlement store. Never errors — an invalid license simply
145/// downgrades the user to Free.
146pub fn status(license: Option<&str>) -> LicenseStatus {
147 match license {
148 None => LicenseStatus::free(),
149 Some(s) if s.trim().is_empty() => LicenseStatus::free(),
150 Some(s) => match verify(s) {
151 Ok(payload) => LicenseStatus {
152 tier: payload.tier,
153 email: Some(payload.email),
154 expires: payload.expires,
155 valid: true,
156 reason: None,
157 },
158 Err(e) => LicenseStatus::invalid(e.to_string()),
159 },
160 }
161}
162
163
164mod tests {
165 use super::*;
166
167
168 fn rejects_empty_license() {
169 assert!(verify("").is_err());
170 }
171
172
173 fn rejects_missing_prefix() {
174 assert!(verify("notalicense").is_err());
175 }
176
177
178 fn rejects_missing_signature() {
179 assert!(verify("VOXLEN-abcdef").is_err());
180 }
181
182
183 fn rejects_bad_base64_payload() {
184 // Payload half contains chars that are not in the URL_SAFE alphabet.
185 assert!(verify("VOXLEN-!!!!.AAAA").is_err());
186 }
187
188
189 fn rejects_bad_base64_signature() {
190 // Valid base64url payload, junk signature.
191 assert!(verify("VOXLEN-eyJ0aWVyIjoicHJvIn0.!!!!").is_err());
192 }
193
194
195 fn status_of_none_is_free() {
196 let s = status(None);
197 assert!(s.valid);
198 assert_eq!(s.tier, Tier::Free);
199 }
200
201
202 fn status_of_empty_string_is_free() {
203 let s = status(Some(""));
204 assert!(s.valid);
205 assert_eq!(s.tier, Tier::Free);
206 }
207
208
209 fn status_of_whitespace_is_free() {
210 let s = status(Some(" \t\n"));
211 assert!(s.valid);
212 assert_eq!(s.tier, Tier::Free);
213 }
214
215
216 fn status_of_garbage_is_marked_invalid() {
217 let s = status(Some("VOXLEN-garbage"));
218 assert!(!s.valid);
219 assert_eq!(s.tier, Tier::Free);
220 assert!(s.reason.is_some());
221 }
222
223
224 fn tier_paid_discrimination() {
225 assert!(!Tier::Free.is_paid());
226 assert!(Tier::Pro.is_paid());
227 assert!(Tier::Professional.is_paid());
228 assert!(Tier::Lifetime.is_paid());
229 }
230
231
232 fn tier_serde_roundtrip() {
233 // Payload is serialised with lowercase variants; make sure the wire
234 // format stays stable so the Node signer keeps working.
235 assert_eq!(serde_json::to_string(&Tier::Pro).unwrap(), "\"pro\"");
236 assert_eq!(serde_json::to_string(&Tier::Professional).unwrap(), "\"professional\"");
237 assert_eq!(serde_json::to_string(&Tier::Lifetime).unwrap(), "\"lifetime\"");
238 assert_eq!(serde_json::to_string(&Tier::Free).unwrap(), "\"free\"");
239 let parsed: Tier = serde_json::from_str("\"pro\"").unwrap();
240 assert_eq!(parsed, Tier::Pro);
241 }
242}
Addedsrc-tauri/src/secrets.rs+72−0View fileUnifiedSplit
@@ -0,0 +1,72 @@
1//! OS-native secret storage.
2//!
3//! Wraps the `keyring` crate so API keys and license strings live in
4//! Keychain / Credential Manager / libsecret instead of the plain-text
5//! settings store. Callers never see platform-specific error types —
6//! failures downgrade to `Ok(None)` on read and return a descriptive
7//! string on write.
8//!
9//! Entries are namespaced under the service `ai.voxlen.marco-reid-voice`
10//! so they are easy to spot in system keychain viewers.
11
12use keyring::Entry;
13
14const SERVICE: &str = "ai.voxlen.marco-reid-voice";
15
16/// Names of the secrets managed by this module. Kept as an enum so
17/// callers can't typo the account name.
18
19pub enum Secret {
20 LicenseKey,
21 SttApiKey,
22 GrammarApiKey,
23}
24
25impl Secret {
26 fn account(self) -> &'static str {
27 match self {
28 Secret::LicenseKey => "license_key",
29 Secret::SttApiKey => "stt_api_key",
30 Secret::GrammarApiKey => "grammar_api_key",
31 }
32 }
33}
34
35fn entry(secret: Secret) -> Result<Entry, String> {
36 Entry::new(SERVICE, secret.account()).map_err(|e| format!("keyring unavailable: {e}"))
37}
38
39/// Read a secret. Returns `Ok(None)` if the entry does not exist or the
40/// platform keychain is unavailable (e.g. headless Linux CI). Never
41/// returns `Err` so callers can treat absent secrets as "free tier" or
42/// "not configured" without extra plumbing.
43pub fn read(secret: Secret) -> Option<String> {
44 let entry = entry(secret).ok()?;
45 match entry.get_password() {
46 Ok(v) => Some(v),
47 Err(keyring::Error::NoEntry) => None,
48 Err(e) => {
49 log::debug!("keyring read for {:?} failed: {}", secret, e);
50 None
51 }
52 }
53}
54
55/// Store a secret. Passing an empty string deletes the entry so callers
56/// can use a single code path for "set to X or clear".
57pub fn write(secret: Secret, value: Option<&str>) -> Result<(), String> {
58 let entry = entry(secret)?;
59 match value {
60 Some(v) if !v.is_empty() => entry
61 .set_password(v)
62 .map_err(|e| format!("failed to store {} in keyring: {}", secret.account(), e)),
63 _ => match entry.delete_credential() {
64 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
65 Err(e) => Err(format!(
66 "failed to clear {} from keyring: {}",
67 secret.account(),
68 e
69 )),
70 },
71 }
72}
Addedsrc-tauri/tests/fixtures/license_fixture.json+11−0View fileUnifiedSplit
@@ -0,0 +1,11 @@
1{
2 "publicKeyHex": "f73e2a693b66614da297a97afd8b47eb3a774f2cb0310cf4fd3a7584b2dc2ad3",
3 "license": "VOXLEN-eyJ0aWVyIjoicHJvIiwiZW1haWwiOiJjZW9AZXhhbXBsZS5jb20iLCJpc3N1ZWQiOjE3NzYzODI2OTAsImV4cGlyZXMiOm51bGwsImlkIjoiZDE0MDE4YWMtYWJiZS00ZWQ1LWJmMmItODAzODc5MDkwOWVjIn0.3z45vc46kJnHEtjkzRodsDKUykdQkPyTZiVBpFZFihjppXxyj0_AB0fXvlrfjHbDcGPrkkdrc4bFJO41qkDvAg",
4 "payload": {
5 "tier": "pro",
6 "email": "ceo@example.com",
7 "issued": 1776382690,
8 "expires": null,
9 "id": "d14018ac-abbe-4ed5-bf2b-8038790909ec"
10 }
11}
\ No newline at end of file
Addedsrc-tauri/tests/fixtures/license_forged_fixture.json+4−0View fileUnifiedSplit
@@ -0,0 +1,4 @@
1{
2 "publicKeyHex": "f73e2a693b66614da297a97afd8b47eb3a774f2cb0310cf4fd3a7584b2dc2ad3",
3 "license": "VOXLEN-eyJ0aWVyIjoibGlmZXRpbWUiLCJlbWFpbCI6ImNlb0BleGFtcGxlLmNvbSIsImlzc3VlZCI6MTc3NjM4MjY5MCwiZXhwaXJlcyI6bnVsbCwiaWQiOiJkMTQwMThhYy1hYmJlLTRlZDUtYmYyYi04MDM4NzkwOTA5ZWMifQ.GzjUGa8aYQtNNGj5TDp7vabLDhrr3d_NOcz8QToEKII1vV3jHmgm8e86_osNxzOn-NPc7ImU_ZsKJ2KsfC9xBQ"
4}
\ No newline at end of file
Addedsrc-tauri/tests/license_interop.rs+81−0View fileUnifiedSplit
@@ -0,0 +1,81 @@
1//! Interop test: confirms the Rust license verifier accepts licenses
2//! signed by the Node/TypeScript billing service.
3//!
4//! Fixtures are generated by `billing/src/interop_test.ts`. If the
5//! payload format or signature scheme drifts between the two sides,
6//! this test fails immediately.
7//!
8//! Because the embedded Rust pubkey is a compile-time constant (not
9//! known until deploy), this test reconstructs verification using the
10//! fixture's own public key rather than calling `license::verify`
11//! directly.
12
13use base64::Engine;
14use ed25519_dalek::{Signature, Verifier, VerifyingKey};
15use serde_json::Value;
16use std::fs;
17
18const FIXTURE_PATH: &str = "tests/fixtures/license_fixture.json";
19const FORGED_FIXTURE_PATH: &str = "tests/fixtures/license_forged_fixture.json";
20const LICENSE_PREFIX: &str = "VOXLEN-";
21
22fn load_fixture(path: &str) -> Value {
23 let bytes = fs::read(path)
24 .unwrap_or_else(|_| panic!("missing fixture {path}: run `npm run interop` in billing/"));
25 serde_json::from_slice(&bytes).expect("fixture is not valid JSON")
26}
27
28fn split_license(license: &str) -> (Vec<u8>, Vec<u8>) {
29 let body = license
30 .strip_prefix(LICENSE_PREFIX)
31 .expect("license must have VOXLEN- prefix");
32 let (payload_b64, sig_b64) = body.split_once('.').expect("license missing signature");
33 let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
34 let payload = engine.decode(payload_b64).expect("payload not base64url");
35 let signature = engine.decode(sig_b64).expect("signature not base64url");
36 (payload, signature)
37}
38
39fn pubkey_from_hex(hex: &str) -> VerifyingKey {
40 let bytes = hex::decode(hex).expect("pubkey not hex");
41 let arr: [u8; 32] = bytes.try_into().expect("pubkey not 32 bytes");
42 VerifyingKey::from_bytes(&arr).expect("invalid Ed25519 public key")
43}
44
45
46fn node_signed_license_verifies_in_rust() {
47 let fixture = load_fixture(FIXTURE_PATH);
48 let pubkey_hex = fixture["publicKeyHex"].as_str().unwrap();
49 let license = fixture["license"].as_str().unwrap();
50
51 let (payload, signature) = split_license(license);
52 let key = pubkey_from_hex(pubkey_hex);
53 let sig_arr: [u8; 64] = signature.as_slice().try_into().expect("signature not 64 bytes");
54 let sig = Signature::from_bytes(&sig_arr);
55
56 key.verify(&payload, &sig)
57 .expect("Node-signed license must verify against its public key");
58
59 // And the payload round-trips the same shape.
60 let parsed: Value = serde_json::from_slice(&payload).expect("payload is JSON");
61 assert_eq!(parsed["tier"].as_str(), fixture["payload"]["tier"].as_str());
62 assert_eq!(parsed["email"].as_str(), fixture["payload"]["email"].as_str());
63 assert_eq!(parsed["id"].as_str(), fixture["payload"]["id"].as_str());
64}
65
66
67fn forged_license_is_rejected() {
68 let fixture = load_fixture(FORGED_FIXTURE_PATH);
69 let pubkey_hex = fixture["publicKeyHex"].as_str().unwrap();
70 let license = fixture["license"].as_str().unwrap();
71
72 let (payload, signature) = split_license(license);
73 let key = pubkey_from_hex(pubkey_hex);
74 let sig_arr: [u8; 64] = signature.as_slice().try_into().expect("signature not 64 bytes");
75 let sig = Signature::from_bytes(&sig_arr);
76
77 assert!(
78 key.verify(&payload, &sig).is_err(),
79 "license signed by a different keypair must NOT verify"
80 );
81}
Modifiedsrc/App.tsx+9−0View fileUnifiedSplit
@@ -15,6 +15,8 @@ import { usePersistedSettings, saveSettings } from "@/hooks/usePersistedSettings
1515import { useTauriEvents } from "@/hooks/useTauriEvents";
1616import { useGlobalShortcuts } from "@/hooks/useGlobalShortcuts";
1717import { loadFlywheel } from "@/stores/flywheel";
18import { useEntitlementStore } from "@/stores/entitlement";
19import { useUsageStore } from "@/stores/usage";
1820
1921type View = "dictation" | "grammar" | "history" | "settings" | "admin";
2022
@@ -43,6 +45,13 @@ export default function App() {
4345 loadFlywheel();
4446 }, []);
4547
48 // Load license / entitlement status on startup so gated features know
49 // which tier the user is on before any command is invoked.
50 useEffect(() => {
51 useEntitlementStore.getState().refresh();
52 useUsageStore.getState().load();
53 }, []);
54
4655 // Wire Tauri events (audio-level, waveform-samples, transcription, etc.).
4756 // Hook handles its own cleanup and is safe outside Tauri.
4857 useTauriEvents();
Modifiedsrc/components/dictation/DictationPanel.tsx+91−2View fileUnifiedSplit
@@ -23,6 +23,9 @@ import { useSettingsStore } from "@/stores/settings";
2323import { formatDuration } from "@/lib/utils";
2424import { useHistoryStore } from "@/stores/history";
2525import { useFlywheelStore } from "@/stores/flywheel";
26import { useEntitlementStore } from "@/stores/entitlement";
27import { useUsageStore } from "@/stores/usage";
28import { FREE_WEEKLY_WORD_CAP } from "@/stores/entitlement";
2629
2730export function DictationPanel() {
2831 const status = useDictationStore((s) => s.status);
@@ -38,6 +41,12 @@ export function DictationPanel() {
3841 const devices = useAudioStore((s) => s.devices);
3942 const shortcutToggle = useSettingsStore((s) => s.shortcutToggle);
4043 const showWaveform = useSettingsStore((s) => s.showWaveform);
44 const tier = useEntitlementStore((s) => s.tier);
45 const weeklyWordsUsed = useUsageStore((s) => s.wordsUsed);
46 const recordWeeklyWords = useUsageStore((s) => s.recordWords);
47 const isFree = tier === "free";
48 const weeklyRemaining = Math.max(0, FREE_WEEKLY_WORD_CAP - weeklyWordsUsed);
49 const overFreeCap = isFree && weeklyRemaining === 0;
4150
4251 const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
4352 const sessionStartRef = useRef<Date | null>(null);
@@ -66,6 +75,10 @@ export function DictationPanel() {
6675
6776 const handleToggleDictation = useCallback(async () => {
6877 if (status === "idle" || status === "paused") {
78 if (overFreeCap) {
79 // Free tier has exhausted the weekly cap. Don't start a session.
80 return;
81 }
6982 sessionStartRef.current = new Date();
7083 try {
7184 const { invoke } = await import("@tauri-apps/api/core");
@@ -102,10 +115,13 @@ export function DictationPanel() {
102115
103116 const engine = useSettingsStore.getState().sttEngine;
104117 useFlywheelStore.getState().recordSession(wc, currentDuration, engine);
118 // Weekly meter is only enforced for Free, but we record for all tiers
119 // so the UI is accurate the moment a license is revoked or expires.
120 recordWeeklyWords(wc);
105121 }
106122 setStatus("idle");
107123 }
108 }, [status, setStatus]);
124 }, [status, setStatus, overFreeCap, recordWeeklyWords]);
109125
110126 const handlePause = useCallback(async () => {
111127 if (status === "listening") {
@@ -195,6 +211,14 @@ export function DictationPanel() {
195211
196212 return (
197213 <div className="flex flex-col h-full">
214 {/* Free-tier weekly word meter. Hidden for paid tiers. */}
215 {isFree && (
216 <FreeTierMeter
217 used={weeklyWordsUsed}
218 cap={FREE_WEEKLY_WORD_CAP}
219 remaining={weeklyRemaining}
220 />
221 )}
198222 {/* Main dictation area */}
199223 <div className="flex-1 flex flex-col p-8 gap-7 overflow-hidden">
200224 {/* Mic control + waveform */}
@@ -206,11 +230,15 @@ export function DictationPanel() {
206230 )}
207231 <button
208232 onClick={handleToggleDictation}
233 disabled={overFreeCap && !isActive}
234 title={overFreeCap && !isActive ? "Free tier cap reached — upgrade to continue" : undefined}
209235 className={cn(
210236 "relative z-10 flex items-center justify-center w-[84px] h-[84px] rounded-full transition-all duration-300 shadow-inset-hairline",
211237 isActive
212238 ? "bg-gradient-to-br from-marcoreid-700 to-marcoreid-900 text-brass-300 shadow-elevation-lg scale-105"
213 : "bg-gradient-to-br from-surface-100 to-surface-200 text-surface-700 hover:from-surface-200 hover:to-surface-300 hover:text-surface-900 shadow-elevation"
239 : overFreeCap
240 ? "bg-gradient-to-br from-surface-100 to-surface-200 text-surface-500 opacity-50 cursor-not-allowed shadow-elevation"
241 : "bg-gradient-to-br from-surface-100 to-surface-200 text-surface-700 hover:from-surface-200 hover:to-surface-300 hover:text-surface-900 shadow-elevation"
214242 )}
215243 >
216244 {isActive ? (
@@ -363,3 +391,64 @@ export function DictationPanel() {
363391 </div>
364392 );
365393}
394
395interface FreeTierMeterProps {
396 used: number;
397 cap: number;
398 remaining: number;
399}
400
401function FreeTierMeter({ used, cap, remaining }: FreeTierMeterProps) {
402 const pct = Math.min(100, Math.round((used / cap) * 100));
403 const depleted = remaining === 0;
404 const warning = !depleted && remaining <= cap * 0.2;
405 const barColour = depleted
406 ? "bg-red-500"
407 : warning
408 ? "bg-amber-500"
409 : "bg-brass-500";
410 const copy = depleted
411 ? "Weekly free cap reached — upgrade to keep dictating"
412 : warning
413 ? `${remaining} words left this week`
414 : `${used} / ${cap} words used this week`;
415
416 return (
417 <div className="px-6 py-2 border-b border-surface-300/50 bg-surface-50/70">
418 <div className="flex items-center justify-between gap-4">
419 <div className="flex-1 min-w-0">
420 <div className="flex items-center justify-between mb-1">
421 <span className="label-caps">Free tier</span>
422 <span
423 className={cn(
424 "font-mono text-[11px] tabular-nums",
425 depleted ? "text-red-600" : warning ? "text-amber-600" : "text-surface-700"
426 )}
427 >
428 {copy}
429 </span>
430 </div>
431 <div className="h-1.5 w-full rounded-full bg-surface-300/60 overflow-hidden">
432 <div
433 className={cn("h-full transition-all duration-300", barColour)}
434 style={{ width: `${pct}%` }}
435 />
436 </div>
437 </div>
438 <a
439 href="https://voxlen.ai/pricing"
440 target="_blank"
441 rel="noopener noreferrer"
442 className={cn(
443 "inline-flex items-center gap-1 px-3 py-1.5 rounded-md border text-[11px] font-medium tracking-tight whitespace-nowrap transition-colors",
444 depleted
445 ? "border-brass-500 bg-brass-500 text-marcoreid-900 hover:bg-brass-400"
446 : "border-surface-300 text-surface-800 hover:border-brass-500 hover:text-marcoreid-900"
447 )}
448 >
449 Upgrade
450 </a>
451 </div>
452 </div>
453 );
454}
Addedsrc/components/settings/FlywheelSettings.tsx+193−0View fileUnifiedSplit
@@ -0,0 +1,193 @@
1import { useMemo, useState } from "react";
2import { BookOpen, Sparkles, Trash2, TrendingUp, X } from "lucide-react";
3import { Button } from "@/components/ui/Button";
4import { Input } from "@/components/ui/Input";
5import { useFlywheelStore } from "@/stores/flywheel";
6
7/**
8 * Flywheel settings panel.
9 *
10 * The flywheel captures local, privacy-safe learning signal — vocabulary
11 * the user uses, correction patterns the grammar AI applies, and session
12 * metrics — and stores it entirely on-device. This panel is the user's
13 * window into that data so they can audit, curate, or wipe it.
14 *
15 * No network calls. No telemetry. Just transparency.
16 */
17export function FlywheelSettings() {
18 const vocabulary = useFlywheelStore((s) => s.vocabulary);
19 const corrections = useFlywheelStore((s) => s.corrections);
20 const metrics = useFlywheelStore((s) => s.metrics);
21 const removeVocabulary = useFlywheelStore((s) => s.removeVocabulary);
22 const getTopCorrectionPatterns = useFlywheelStore((s) => s.getTopCorrectionPatterns);
23 const clearAll = useFlywheelStore((s) => s.clearAll);
24
25 const [query, setQuery] = useState("");
26
27 const filteredVocab = useMemo(() => {
28 const q = query.trim().toLowerCase();
29 const sorted = [...vocabulary].sort((a, b) => b.frequency - a.frequency);
30 if (!q) return sorted.slice(0, 200);
31 return sorted.filter((v) => v.word.toLowerCase().includes(q)).slice(0, 200);
32 }, [vocabulary, query]);
33
34 const topCorrections = useMemo(
35 () => getTopCorrectionPatterns(10),
36 [corrections, getTopCorrectionPatterns], // eslint-disable-line react-hooks/exhaustive-deps
37 );
38
39 const handleClearAll = () => {
40 if (!confirm("Erase all on-device learning data? Vocabulary, correction patterns, and metrics will be wiped.")) {
41 return;
42 }
43 clearAll();
44 };
45
46 const wpm = metrics.avgWordsPerMinute || 0;
47 const hours = Math.round((metrics.totalDuration / 3600) * 10) / 10;
48
49 return (
50 <div className="max-w-3xl">
51 <div className="mb-6">
52 <h2 className="text-xl font-semibold text-surface-950 mb-1 flex items-center gap-2">
53 <Sparkles className="h-5 w-5 text-brass-500" />
54 Learning
55 </h2>
56 <p className="text-sm text-surface-700">
57 Marco Reid Voice learns your vocabulary and correction patterns locally to get better
58 over time. Nothing leaves your device — audit or wipe anything below.
59 </p>
60 </div>
61
62 {/* Metrics tiles */}
63 <div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
64 <MetricTile label="Sessions" value={metrics.totalSessions.toLocaleString()} />
65 <MetricTile label="Words" value={metrics.totalWords.toLocaleString()} />
66 <MetricTile label="Hours" value={hours.toLocaleString()} />
67 <MetricTile label="Words / min" value={wpm.toLocaleString()} />
68 </div>
69
70 {/* Correction patterns */}
71 <section className="mb-8">
72 <header className="flex items-center gap-2 mb-3">
73 <TrendingUp className="h-4 w-4 text-brass-500" />
74 <h3 className="text-sm font-semibold text-surface-950 tracking-tight">
75 Top correction patterns
76 </h3>
77 </header>
78 {topCorrections.length === 0 ? (
79 <EmptyState>Dictate a few sessions and accept AI corrections — patterns will appear here.</EmptyState>
80 ) : (
81 <div className="rounded-md border border-surface-300/60 overflow-hidden">
82 {topCorrections.map((c, i) => (
83 <div
84 key={`${c.original}-${c.corrected}-${i}`}
85 className="flex items-center justify-between px-4 py-2.5 border-b border-surface-300/30 last:border-0 bg-surface-50/40"
86 >
87 <div className="flex items-baseline gap-3 min-w-0">
88 <span className="font-mono text-[12px] text-red-700 line-through truncate">
89 {c.original}
90 </span>
91 <span className="text-surface-500">→</span>
92 <span className="font-mono text-[12px] text-green-700 truncate">
93 {c.corrected}
94 </span>
95 <span className="label-caps">{c.category}</span>
96 </div>
97 <span className="text-[11px] text-surface-600 font-mono tabular-nums shrink-0">
98 ×{c.occurrences}
99 </span>
100 </div>
101 ))}
102 </div>
103 )}
104 </section>
105
106 {/* Vocabulary */}
107 <section className="mb-8">
108 <header className="flex items-center justify-between mb-3 gap-3">
109 <div className="flex items-center gap-2">
110 <BookOpen className="h-4 w-4 text-brass-500" />
111 <h3 className="text-sm font-semibold text-surface-950 tracking-tight">
112 Learned vocabulary
113 </h3>
114 <span className="text-[11px] text-surface-600 font-mono">
115 {vocabulary.length.toLocaleString()} terms
116 </span>
117 </div>
118 <Input
119 type="text"
120 placeholder="Filter…"
121 value={query}
122 onChange={(e) => setQuery(e.target.value)}
123 className="max-w-[200px] text-xs"
124 />
125 </header>
126 {filteredVocab.length === 0 ? (
127 <EmptyState>
128 {vocabulary.length === 0
129 ? "No vocabulary captured yet. Terms you dictate repeatedly will show up here."
130 : "No matches."}
131 </EmptyState>
132 ) : (
133 <div className="flex flex-wrap gap-1.5">
134 {filteredVocab.map((v) => (
135 <span
136 key={v.word}
137 className="inline-flex items-center gap-1.5 pl-2.5 pr-1 py-1 rounded-full border border-surface-300/60 bg-surface-50/60 text-[11px] text-surface-800"
138 title={`Seen ${v.frequency} time${v.frequency === 1 ? "" : "s"} · ${v.source}`}
139 >
140 {v.word}
141 <span className="font-mono text-[10px] text-surface-500 tabular-nums">
142 ×{v.frequency}
143 </span>
144 <button
145 type="button"
146 onClick={() => removeVocabulary(v.word)}
147 className="ml-0.5 p-0.5 rounded-full text-surface-500 hover:text-red-700 hover:bg-red-100 transition-colors"
148 aria-label={`Remove ${v.word}`}
149 >
150 <X className="h-3 w-3" />
151 </button>
152 </span>
153 ))}
154 </div>
155 )}
156 </section>
157
158 {/* Danger zone */}
159 <section className="pt-4 border-t border-surface-300/50">
160 <h3 className="text-sm font-semibold text-surface-950 tracking-tight mb-2">
161 Reset learning data
162 </h3>
163 <p className="text-xs text-surface-700 mb-3 max-w-md">
164 Wipes vocabulary, correction patterns, and usage metrics from this device. Your
165 dictation history and settings are unaffected.
166 </p>
167 <Button variant="danger" size="sm" onClick={handleClearAll}>
168 <Trash2 className="h-3.5 w-3.5" />
169 Clear learning data
170 </Button>
171 </section>
172 </div>
173 );
174}
175
176function MetricTile({ label, value }: { label: string; value: string }) {
177 return (
178 <div className="rounded-md border border-surface-300/60 bg-surface-50/60 px-4 py-3">
179 <div className="label-caps">{label}</div>
180 <div className="font-display text-[22px] text-surface-950 font-medium tabular-nums mt-0.5">
181 {value}
182 </div>
183 </div>
184 );
185}
186
187function EmptyState({ children }: { children: React.ReactNode }) {
188 return (
189 <div className="rounded-md border border-dashed border-surface-300/60 px-4 py-6 text-center text-[12px] text-surface-600">
190 {children}
191 </div>
192 );
193}
Addedsrc/components/settings/LicenseSettings.tsx+190−0View fileUnifiedSplit
@@ -0,0 +1,190 @@
1import { useEffect, useState } from "react";
2import { KeyRound, CheckCircle2, AlertCircle, ExternalLink } from "lucide-react";
3import { Button } from "@/components/ui/Button";
4import { Input } from "@/components/ui/Input";
5import { useEntitlementStore, Tier } from "@/stores/entitlement";
6
7const TIER_LABELS: Record<Tier, string> = {
8 free: "Free",
9 pro: "Pro",
10 professional: "Professional",
11 lifetime: "Lifetime",
12};
13
14const TIER_DESCRIPTIONS: Record<Tier, string> = {
15 free: "Limited to 500 words/week. AI grammar and premium engines locked.",
16 pro: "Unlimited dictation. AI grammar. All speech engines. All devices.",
17 professional: "Everything in Pro plus zero-retention, per-matter vocabulary, audit logs, and SSO.",
18 lifetime: "Everything in Pro. Lifetime updates. Direct founder support.",
19};
20
21function formatExpiry(ts: number | null): string | null {
22 if (ts === null) return null;
23 try {
24 return new Date(ts * 1000).toLocaleDateString(undefined, {
25 year: "numeric",
26 month: "long",
27 day: "numeric",
28 });
29 } catch {
30 return null;
31 }
32}
33
34export function LicenseSettings() {
35 const { tier, email, expires, valid, reason, isLoaded, refresh, activate, clear } =
36 useEntitlementStore();
37
38 const [keyInput, setKeyInput] = useState("");
39 const [activating, setActivating] = useState(false);
40 const [error, setError] = useState<string | null>(null);
41 const [success, setSuccess] = useState(false);
42
43 useEffect(() => {
44 if (!isLoaded) refresh();
45 }, [isLoaded, refresh]);
46
47 const handleActivate = async () => {
48 setError(null);
49 setSuccess(false);
50 setActivating(true);
51 const err = await activate(keyInput);
52 setActivating(false);
53 if (err) {
54 setError(err);
55 return;
56 }
57 setKeyInput("");
58 setSuccess(true);
59 setTimeout(() => setSuccess(false), 3000);
60 };
61
62 const handleClear = async () => {
63 if (!confirm("Remove your license and drop back to the Free tier?")) return;
64 await clear();
65 setSuccess(false);
66 setError(null);
67 };
68
69 const expiryLabel = formatExpiry(expires);
70 const badCurrentLicense = !valid && reason !== null;
71
72 return (
73 <div className="max-w-2xl">
74 <div className="mb-6">
75 <h2 className="text-xl font-semibold text-surface-950 mb-1">License</h2>
76 <p className="text-sm text-surface-700">
77 Activate Pro, Professional, or Lifetime to unlock unlimited dictation and AI grammar.
78 </p>
79 </div>
80
81 {/* Current tier card */}
82 <div className="mb-6 p-5 rounded-lg border border-surface-300/60 bg-surface-50/50">
83 <div className="flex items-start gap-3">
84 <div
85 className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
86 tier === "free"
87 ? "bg-surface-200 text-surface-700"
88 : "bg-brass-100 text-brass-700"
89 }`}
90 >
91 <KeyRound className="h-5 w-5" />
92 </div>
93 <div className="flex-1 min-w-0">
94 <div className="flex items-baseline gap-2 flex-wrap">
95 <span className="text-base font-semibold text-surface-950">
96 {TIER_LABELS[tier]}
97 </span>
98 {tier !== "free" && valid && (
99 <span className="inline-flex items-center gap-1 text-xs text-green-700">
100 <CheckCircle2 className="h-3.5 w-3.5" />
101 Active
102 </span>
103 )}
104 </div>
105 <p className="text-sm text-surface-700 mt-0.5">{TIER_DESCRIPTIONS[tier]}</p>
106 {email && (
107 <p className="text-xs text-surface-600 mt-2 font-mono">Licensed to {email}</p>
108 )}
109 {expiryLabel && (
110 <p className="text-xs text-surface-600 mt-1">Renews {expiryLabel}</p>
111 )}
112 {badCurrentLicense && (
113 <p className="text-xs text-red-700 mt-2 flex items-start gap-1.5">
114 <AlertCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
115 <span>
116 Installed license is invalid: {reason}. Paste a new key below or contact support.
117 </span>
118 </p>
119 )}
120 </div>
121 {tier !== "free" && (
122 <Button variant="ghost" size="sm" onClick={handleClear}>
123 Remove
124 </Button>
125 )}
126 </div>
127 </div>
128
129 {/* Activation form */}
130 <div className="p-5 rounded-lg border border-surface-300/60">
131 <label className="block text-sm font-medium text-surface-950 mb-2">
132 Activate a license key
133 </label>
134 <p className="text-xs text-surface-700 mb-3">
135 Paste the key you received after purchasing. Activation happens entirely on your device
136 — nothing is sent to our servers.
137 </p>
138 <div className="flex gap-2">
139 <Input
140 type="text"
141 placeholder="VOXLEN-..."
142 value={keyInput}
143 onChange={(e) => setKeyInput(e.target.value)}
144 disabled={activating}
145 className="font-mono text-xs flex-1"
146 />
147 <Button
148 onClick={handleActivate}
149 disabled={activating || keyInput.trim().length === 0}
150 >
151 {activating ? "Activating…" : "Activate"}
152 </Button>
153 </div>
154
155 {error && (
156 <p className="mt-3 text-xs text-red-700 flex items-start gap-1.5">
157 <AlertCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
158 {error}
159 </p>
160 )}
161 {success && (
162 <p className="mt-3 text-xs text-green-700 flex items-start gap-1.5">
163 <CheckCircle2 className="h-3.5 w-3.5 shrink-0 mt-0.5" />
164 License activated.
165 </p>
166 )}
167 </div>
168
169 {/* Purchase links */}
170 {tier === "free" && (
171 <div className="mt-6 p-5 rounded-lg bg-surface-50/50 border border-surface-300/60">
172 <p className="text-sm text-surface-900 font-medium mb-2">Need a license?</p>
173 <p className="text-xs text-surface-700 mb-3">
174 Pro ($29/month), Professional for legal and accounting teams ($79/month), or Lifetime
175 ($599 one-time). All AI infrastructure included — no API keys required.
176 </p>
177 <a
178 href="https://voxlen.ai/#pricing"
179 target="_blank"
180 rel="noopener noreferrer"
181 className="inline-flex items-center gap-1.5 text-sm text-brass-700 hover:text-brass-800 font-medium"
182 >
183 View pricing
184 <ExternalLink className="h-3.5 w-3.5" />
185 </a>
186 </div>
187 )}
188 </div>
189 );
190}
Modifiedsrc/components/settings/SettingsPanel.tsx+16−6View fileUnifiedSplit
@@ -7,7 +7,11 @@ import {
77 Palette,
88 Shield,
99 Zap,
10 KeyRound,
11 Sparkles,
1012} from "lucide-react";
13import { LicenseSettings } from "./LicenseSettings";
14import { FlywheelSettings } from "./FlywheelSettings";
1115import { cn } from "@/lib/utils";
1216import { Button } from "@/components/ui/Button";
1317import { Switch } from "@/components/ui/Switch";
@@ -22,10 +26,12 @@ const tabs = [
2226 { id: "audio", label: "Audio", icon: Mic },
2327 { id: "stt", label: "Speech Engine", icon: Cpu },
2428 { id: "grammar", label: "Grammar AI", icon: SpellCheck },
29 { id: "learning", label: "Learning", icon: Sparkles },
2530 { id: "shortcuts", label: "Shortcuts", icon: Keyboard },
2631 { id: "appearance", label: "Appearance", icon: Palette },
2732 { id: "advanced", label: "Advanced", icon: Zap },
2833 { id: "privacy", label: "Privacy", icon: Shield },
34 { id: "license", label: "License", icon: KeyRound },
2935];
3036
3137// Persist settings whenever they change
@@ -172,6 +178,8 @@ export function SettingsPanel() {
172178 return <SttSettings />;
173179 case "grammar":
174180 return <GrammarSettings />;
181 case "learning":
182 return <FlywheelSettings />;
175183 case "shortcuts":
176184 return <ShortcutSettings />;
177185 case "appearance":
@@ -180,6 +188,8 @@ export function SettingsPanel() {
180188 return <AdvancedSettings />;
181189 case "privacy":
182190 return <PrivacySettings />;
191 case "license":
192 return <LicenseSettings />;
183193 default:
184194 return <AudioSettings />;
185195 }
@@ -827,7 +837,7 @@ function PrivacySettings() {
827837 <div className="space-y-6 max-w-lg">
828838 <SectionHeader
829839 title="Privacy"
830 description="Your data, your control. Marco Reid Voice can work fully offline with local models."
840 description="Your data, your control. Audio streams device-to-provider on zero-retention endpoints — never routed through Voxlen servers."
831841 />
832842
833843 <SettingRow>
@@ -855,23 +865,23 @@ function PrivacySettings() {
855865 <ul className="space-y-1.5 text-[12px] text-surface-700 leading-relaxed">
856866 <li className="flex items-start gap-2">
857867 <span className="text-brass-500 mt-0.5">—</span>
858 Audio is never stored on our servers.
868 Audio never passes through Voxlen servers. It streams direct to the speech provider on zero-retention endpoints.
859869 </li>
860870 <li className="flex items-start gap-2">
861871 <span className="text-brass-500 mt-0.5">—</span>
862 Use Whisper Local for fully offline operation.
872 Grammar text goes direct to Anthropic or OpenAI on zero-retention endpoints.
863873 </li>
864874 <li className="flex items-start gap-2">
865875 <span className="text-brass-500 mt-0.5">—</span>
866 API keys are stored locally on your device.
876 API keys and your licence live in your OS keychain (Keychain / Credential Manager / Secret Service).
867877 </li>
868878 <li className="flex items-start gap-2">
869879 <span className="text-brass-500 mt-0.5">—</span>
870 No data is shared with third parties.
880 Learned vocabulary and correction patterns stay on this device and never leave it.
871881 </li>
872882 <li className="flex items-start gap-2">
873883 <span className="text-brass-500 mt-0.5">—</span>
874 You can delete all local data at any time.
884 You can delete all local data any time — transcripts, learning data, or the whole settings store.
875885 </li>
876886 </ul>
877887 </div>
Addedsrc/stores/entitlement.test.ts+105−0View fileUnifiedSplit
@@ -0,0 +1,105 @@
1import { beforeEach, describe, expect, it, vi } from "vitest";
2
3// invoke() is the only external dependency — mock it to simulate Rust-side
4// license-status responses without spinning up a Tauri webview.
5const invokeMock = vi.fn();
6vi.mock("@tauri-apps/api/core", () => ({
7 invoke: (...args: unknown[]) => invokeMock(...args),
8}));
9
10async function freshStore() {
11 vi.resetModules();
12 const mod = await import("./entitlement");
13 return mod;
14}
15
16describe("useEntitlementStore", () => {
17 beforeEach(() => {
18 invokeMock.mockReset();
19 });
20
21 it("defaults to free before refresh()", async () => {
22 const { useEntitlementStore } = await freshStore();
23 const s = useEntitlementStore.getState();
24 expect(s.tier).toBe("free");
25 expect(s.isLoaded).toBe(false);
26 });
27
28 it("applies a paid status from the Rust side", async () => {
29 invokeMock.mockResolvedValueOnce({
30 tier: "professional",
31 email: "counsel@example.com",
32 expires: 9_999_999_999,
33 valid: true,
34 reason: null,
35 });
36 const { useEntitlementStore } = await freshStore();
37 await useEntitlementStore.getState().refresh();
38 const s = useEntitlementStore.getState();
39 expect(s.tier).toBe("professional");
40 expect(s.email).toBe("counsel@example.com");
41 expect(s.valid).toBe(true);
42 expect(s.isLoaded).toBe(true);
43 expect(invokeMock).toHaveBeenCalledWith("get_license_status");
44 });
45
46 it("falls back to free when the Rust side errors", async () => {
47 invokeMock.mockRejectedValueOnce(new Error("bridge down"));
48 const { useEntitlementStore } = await freshStore();
49 await useEntitlementStore.getState().refresh();
50 expect(useEntitlementStore.getState().tier).toBe("free");
51 expect(useEntitlementStore.getState().isLoaded).toBe(true);
52 });
53
54 it("activate() returns null on success and updates the tier", async () => {
55 invokeMock.mockResolvedValueOnce({
56 tier: "pro",
57 email: "founder@example.com",
58 expires: null,
59 valid: true,
60 reason: null,
61 });
62 const { useEntitlementStore } = await freshStore();
63 const err = await useEntitlementStore.getState().activate("VOXLEN-foo.bar");
64 expect(err).toBeNull();
65 expect(useEntitlementStore.getState().tier).toBe("pro");
66 expect(invokeMock).toHaveBeenCalledWith("activate_license", {
67 key: "VOXLEN-foo.bar",
68 });
69 });
70
71 it("activate() surfaces a string error from the Rust command", async () => {
72 invokeMock.mockRejectedValueOnce("license expired");
73 const { useEntitlementStore } = await freshStore();
74 const err = await useEntitlementStore.getState().activate("VOXLEN-bad");
75 expect(err).toBe("license expired");
76 expect(useEntitlementStore.getState().tier).toBe("free");
77 });
78
79 it("clear() resets the store to free even if the Rust command errors", async () => {
80 // First, seed with a paid tier.
81 invokeMock.mockResolvedValueOnce({
82 tier: "lifetime",
83 email: "x@y.z",
84 expires: null,
85 valid: true,
86 reason: null,
87 });
88 const { useEntitlementStore } = await freshStore();
89 await useEntitlementStore.getState().refresh();
90 expect(useEntitlementStore.getState().tier).toBe("lifetime");
91
92 // Then clear with a failing bridge.
93 invokeMock.mockRejectedValueOnce(new Error("kaboom"));
94 await useEntitlementStore.getState().clear();
95 expect(useEntitlementStore.getState().tier).toBe("free");
96 });
97
98 it("isPaid() matches the server-side Tier::is_paid() semantics", async () => {
99 const { isPaid } = await freshStore();
100 expect(isPaid("free")).toBe(false);
101 expect(isPaid("pro")).toBe(true);
102 expect(isPaid("professional")).toBe(true);
103 expect(isPaid("lifetime")).toBe(true);
104 });
105});
Addedsrc/stores/entitlement.ts+93−0View fileUnifiedSplit
@@ -0,0 +1,93 @@
1import { create } from "zustand";
2import { invoke } from "@tauri-apps/api/core";
3
4export type Tier = "free" | "pro" | "professional" | "lifetime";
5
6export interface LicenseStatus {
7 tier: Tier;
8 email: string | null;
9 expires: number | null;
10 valid: boolean;
11 reason: string | null;
12}
13
14export interface EntitlementState {
15 tier: Tier;
16 email: string | null;
17 expires: number | null;
18 valid: boolean;
19 reason: string | null;
20 isLoaded: boolean;
21
22 /** Fetch the current license status from the Rust side. Called on app start. */
23 refresh: () => Promise<void>;
24 /** Attempt to activate a pasted license key. Returns an error message on failure. */
25 activate: (key: string) => Promise<string | null>;
26 /** Remove the installed license and drop back to Free. */
27 clear: () => Promise<void>;
28}
29
30const FREE_STATUS: LicenseStatus = {
31 tier: "free",
32 email: null,
33 expires: null,
34 valid: true,
35 reason: null,
36};
37
38function apply(set: (p: Partial<EntitlementState>) => void, s: LicenseStatus) {
39 set({
40 tier: s.tier,
41 email: s.email,
42 expires: s.expires,
43 valid: s.valid,
44 reason: s.reason,
45 isLoaded: true,
46 });
47}
48
49export const useEntitlementStore = create<EntitlementState>((set) => ({
50 tier: "free",
51 email: null,
52 expires: null,
53 valid: true,
54 reason: null,
55 isLoaded: false,
56
57 refresh: async () => {
58 try {
59 const status = await invoke<LicenseStatus>("get_license_status");
60 apply(set, status);
61 } catch (e) {
62 console.error("Failed to load license status:", e);
63 apply(set, FREE_STATUS);
64 }
65 },
66
67 activate: async (key: string) => {
68 try {
69 const status = await invoke<LicenseStatus>("activate_license", { key });
70 apply(set, status);
71 return null;
72 } catch (e) {
73 const msg = typeof e === "string" ? e : e instanceof Error ? e.message : "License activation failed";
74 return msg;
75 }
76 },
77
78 clear: async () => {
79 try {
80 const status = await invoke<LicenseStatus>("clear_license");
81 apply(set, status);
82 } catch (e) {
83 console.error("Failed to clear license:", e);
84 apply(set, FREE_STATUS);
85 }
86 },
87}));
88
89/** Convenience selector: true if the user has any paid tier. */
90export const isPaid = (tier: Tier) => tier !== "free";
91
92/** Free-tier weekly word cap. Keep in sync with landing page copy. */
93export const FREE_WEEKLY_WORD_CAP = 500;
Addedsrc/stores/usage.test.ts+94−0View fileUnifiedSplit
@@ -0,0 +1,94 @@
1import { beforeEach, describe, expect, it, vi } from "vitest";
2import { FREE_WEEKLY_WORD_CAP } from "./entitlement";
3import { endOfWeekMs } from "./usage";
4
5// The store caches module-level state (zustand). We re-import per test so each
6// case gets a fresh store and we can also mock the tauri plugin to avoid
7// touching the filesystem.
8vi.mock("@tauri-apps/plugin-store", () => ({
9 load: () => {
10 throw new Error("tauri store not available in tests");
11 },
12}));
13
14async function freshStore() {
15 vi.resetModules();
16 localStorage.clear();
17 const mod = await import("./usage");
18 return mod.useUsageStore;
19}
20
21describe("useUsageStore", () => {
22 beforeEach(() => {
23 localStorage.clear();
24 });
25
26 it("starts at zero words for a new week", async () => {
27 const store = await freshStore();
28 const { wordsUsed, weekStartMs } = store.getState();
29 expect(wordsUsed).toBe(0);
30 expect(weekStartMs).toBeGreaterThan(0);
31 });
32
33 it("remaining defaults to the free cap", async () => {
34 const store = await freshStore();
35 expect(store.getState().remaining()).toBe(FREE_WEEKLY_WORD_CAP);
36 });
37
38 it("records words and reflects them in `remaining`", async () => {
39 const store = await freshStore();
40 store.getState().recordWords(100);
41 expect(store.getState().wordsUsed).toBe(100);
42 expect(store.getState().remaining()).toBe(FREE_WEEKLY_WORD_CAP - 100);
43 });
44
45 it("isOverCap flips true once the cap is reached", async () => {
46 const store = await freshStore();
47 expect(store.getState().isOverCap()).toBe(false);
48 store.getState().recordWords(FREE_WEEKLY_WORD_CAP);
49 expect(store.getState().isOverCap()).toBe(true);
50 // Remaining never goes negative.
51 expect(store.getState().remaining()).toBe(0);
52 });
53
54 it("ignores zero and negative counts", async () => {
55 const store = await freshStore();
56 store.getState().recordWords(0);
57 store.getState().recordWords(-5);
58 store.getState().recordWords(NaN);
59 expect(store.getState().wordsUsed).toBe(0);
60 });
61
62 it("floors fractional word counts", async () => {
63 const store = await freshStore();
64 store.getState().recordWords(10.9);
65 expect(store.getState().wordsUsed).toBe(10);
66 });
67
68 it("resets to a new week on demand", async () => {
69 const store = await freshStore();
70 store.getState().recordWords(42);
71 store.getState().reset();
72 expect(store.getState().wordsUsed).toBe(0);
73 });
74
75 it("rolls over when a new week begins", async () => {
76 const store = await freshStore();
77 store.getState().recordWords(100);
78 // Force the stored weekStart to be last week. Next recordWords() should
79 // roll the meter back to zero before adding.
80 store.setState({
81 ...store.getState(),
82 weekStartMs: store.getState().weekStartMs - 14 * 86_400_000,
83 wordsUsed: 300,
84 });
85 store.getState().recordWords(50);
86 expect(store.getState().wordsUsed).toBe(50);
87 });
88
89 it("endOfWeekMs lands exactly seven days after weekStart", async () => {
90 const store = await freshStore();
91 const start = store.getState().weekStartMs;
92 expect(endOfWeekMs(start) - start).toBe(7 * 86_400_000);
93 });
94});
Addedsrc/stores/usage.ts+148−0View fileUnifiedSplit
@@ -0,0 +1,148 @@
1import { create } from "zustand";
2import { FREE_WEEKLY_WORD_CAP } from "./entitlement";
3
4/**
5 * Weekly word-count meter for Free tier enforcement.
6 *
7 * Storage is local-only: tauri-plugin-store when available, localStorage as a
8 * fallback for non-Tauri dev. Aligned with the privacy stance in CLAUDE.md —
9 * usage counters never leave the device.
10 *
11 * The "week" rolls over every Monday 00:00 in the user's local timezone. That's
12 * slightly messier than UTC but matches how humans think about "this week".
13 */
14
15const STORE_FILE = "usage.json";
16const LOCAL_KEY = "voxlen_usage";
17const DATA_KEY = "usage";
18const ONE_DAY_MS = 86_400_000;
19
20export interface WeeklyUsage {
21 /** Timestamp (ms since epoch) of this week's start — Monday 00:00 local. */
22 weekStartMs: number;
23 /** Total words transcribed during the current week. */
24 wordsUsed: number;
25}
26
27export interface UsageState extends WeeklyUsage {
28 isLoaded: boolean;
29
30 /** Hydrate from persistent storage. Call on app start. */
31 load: () => Promise<void>;
32 /** Record N words against the weekly meter. Rolls the week over if stale. */
33 recordWords: (count: number) => void;
34 /** Zero the meter and start a new week at the current moment. */
35 reset: () => void;
36 /** Remaining words under the cap (never negative). */
37 remaining: (cap?: number) => number;
38 /** True if the user has used at least `cap` words this week. */
39 isOverCap: (cap?: number) => boolean;
40}
41
42/** Returns the epoch ms for the most recent Monday 00:00 local-time. */
43function startOfWeek(now: Date = new Date()): number {
44 const d = new Date(now);
45 d.setHours(0, 0, 0, 0);
46 // getDay: 0=Sun, 1=Mon, ..., 6=Sat. Treat Monday as start.
47 const daysSinceMonday = (d.getDay() + 6) % 7;
48 d.setDate(d.getDate() - daysSinceMonday);
49 return d.getTime();
50}
51
52function freshWeek(): WeeklyUsage {
53 return { weekStartMs: startOfWeek(), wordsUsed: 0 };
54}
55
56function rollIfStale(u: WeeklyUsage): WeeklyUsage {
57 const current = startOfWeek();
58 return u.weekStartMs === current ? u : { weekStartMs: current, wordsUsed: 0 };
59}
60
61let persistTimer: ReturnType<typeof setTimeout> | null = null;
62function schedulePersist() {
63 if (persistTimer) clearTimeout(persistTimer);
64 persistTimer = setTimeout(() => persistUsage(), 500);
65}
66
67export const useUsageStore = create<UsageState>((set, get) => ({
68 ...freshWeek(),
69 isLoaded: false,
70
71 load: async () => {
72 const loaded = await loadUsage();
73 set({ ...rollIfStale(loaded), isLoaded: true });
74 },
75
76 recordWords: (count) => {
77 if (!Number.isFinite(count) || count <= 0) return;
78 set((state) => {
79 const rolled = rollIfStale(state);
80 return { ...rolled, wordsUsed: rolled.wordsUsed + Math.floor(count) };
81 });
82 schedulePersist();
83 },
84
85 reset: () => {
86 set({ ...freshWeek() });
87 schedulePersist();
88 },
89
90 remaining: (cap = FREE_WEEKLY_WORD_CAP) => {
91 const { wordsUsed } = rollIfStale(get());
92 return Math.max(0, cap - wordsUsed);
93 },
94
95 isOverCap: (cap = FREE_WEEKLY_WORD_CAP) => {
96 const { wordsUsed } = rollIfStale(get());
97 return wordsUsed >= cap;
98 },
99}));
100
101async function loadUsage(): Promise<WeeklyUsage> {
102 try {
103 const { load } = await import("@tauri-apps/plugin-store");
104 const store = await load(STORE_FILE);
105 const v = (await store.get(DATA_KEY)) as WeeklyUsage | null;
106 if (v && typeof v.weekStartMs === "number" && typeof v.wordsUsed === "number") {
107 return v;
108 }
109 } catch {
110 // Fall through to localStorage.
111 }
112 try {
113 const raw = localStorage.getItem(LOCAL_KEY);
114 if (raw) {
115 const parsed = JSON.parse(raw) as WeeklyUsage;
116 if (typeof parsed?.weekStartMs === "number" && typeof parsed?.wordsUsed === "number") {
117 return parsed;
118 }
119 }
120 } catch {
121 // Ignore.
122 }
123 return freshWeek();
124}
125
126async function persistUsage(): Promise<void> {
127 const { weekStartMs, wordsUsed } = useUsageStore.getState();
128 const data: WeeklyUsage = { weekStartMs, wordsUsed };
129 try {
130 const { load } = await import("@tauri-apps/plugin-store");
131 const store = await load(STORE_FILE);
132 await store.set(DATA_KEY, data);
133 await store.save();
134 return;
135 } catch {
136 // Fall through.
137 }
138 try {
139 localStorage.setItem(LOCAL_KEY, JSON.stringify(data));
140 } catch {
141 // Non-fatal.
142 }
143}
144
145/** End-of-week in ms (Monday 00:00 + 7 days). */
146export function endOfWeekMs(weekStartMs: number): number {
147 return weekStartMs + 7 * ONE_DAY_MS;
148}
Addedvitest.config.ts+20−0View fileUnifiedSplit
@@ -0,0 +1,20 @@
1import { defineConfig } from "vitest/config";
2import react from "@vitejs/plugin-react";
3import path from "path";
4
5// Keep test config separate from `vite.config.ts` so the Tauri dev server
6// never pulls in test-only plugins. Aliases match src/tsconfig.
7export default defineConfig({
8 plugins: [react()],
9 resolve: {
10 alias: {
11 "@": path.resolve(__dirname, "./src"),
12 },
13 },
14 test: {
15 environment: "jsdom",
16 globals: true,
17 include: ["src/**/*.test.{ts,tsx}"],
18 restoreMocks: true,
19 },
20});
021
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts