diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 9ab97cc..6f29246 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -37,7 +37,9 @@ jobs: platform: ${{ matrix.platform }} target: ${{ matrix.target }} build-args: ${{ matrix.args }} - sign-binaries: true + # Unsigned: run artifacts on this public repo are downloadable by anyone, + # so a signed DMG here would bypass the paid download. + sign-binaries: false asset-prefix: "handy-test" upload-artifacts: true is-debug-build: ${{ contains(matrix.args, '--debug') }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0b15252..cb776c2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,6 +32,10 @@ on: required: false type: boolean default: false + publish-r2: + required: false + type: boolean + default: false repository: required: false type: string @@ -47,7 +51,7 @@ on: jobs: build: permissions: - contents: write + contents: read runs-on: ${{ inputs.platform }} steps: - name: Checkout repository @@ -298,6 +302,91 @@ jobs: src-tauri/target/${{ inputs.target }}/${{ steps.build-profile.outputs.profile }}/bundle/macos/*.app retention-days: 30 + - name: Determine R2 arch label + id: r2-arch + if: inputs.publish-r2 && contains(inputs.platform, 'macos') + shell: bash + run: | + case "${{ inputs.target }}" in + aarch64-apple-darwin) echo "arch=aarch64" >> "$GITHUB_OUTPUT" ;; + x86_64-apple-darwin) echo "arch=x64" >> "$GITHUB_OUTPUT" ;; + *) + echo "Unsupported target for R2 publish: ${{ inputs.target }}" >&2 + exit 1 + ;; + esac + + - name: Ensure AWS CLI is available + if: inputs.publish-r2 && contains(inputs.platform, 'macos') + shell: bash + run: | + set -euo pipefail + if command -v aws >/dev/null 2>&1; then + aws --version + exit 0 + fi + # GitHub-hosted runner images ship the AWS CLI. Fail loudly instead of + # installing an unpinned copy at release time. + echo "::error::aws CLI not found on this runner image" + exit 1 + + - name: Locate macOS bundle outputs + id: bundle-paths + if: inputs.publish-r2 && contains(inputs.platform, 'macos') + shell: bash + run: | + set -euo pipefail + BUNDLE_ROOT="src-tauri/target/${{ inputs.target }}/${{ steps.build-profile.outputs.profile }}/bundle" + DMG_PATH=$(find "$BUNDLE_ROOT/dmg" -maxdepth 1 -name '*.dmg' | head -1) + APP_TARBALL=$(find "$BUNDLE_ROOT/macos" -maxdepth 1 -name '*.app.tar.gz' | head -1) + APP_SIG=$(find "$BUNDLE_ROOT/macos" -maxdepth 1 -name '*.app.tar.gz.sig' | head -1) + + if [[ -z "$DMG_PATH" || -z "$APP_TARBALL" || -z "$APP_SIG" ]]; then + echo "Missing expected macOS bundle output(s) under $BUNDLE_ROOT" >&2 + echo "dmg='$DMG_PATH' app_tarball='$APP_TARBALL' app_sig='$APP_SIG'" >&2 + exit 1 + fi + + echo "dmg=$DMG_PATH" >> "$GITHUB_OUTPUT" + echo "app_tarball=$APP_TARBALL" >> "$GITHUB_OUTPUT" + echo "app_sig=$APP_SIG" >> "$GITHUB_OUTPUT" + + - name: Publish macOS artifacts to R2 + if: inputs.publish-r2 && contains(inputs.platform, 'macos') + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + R2_PRIVATE_BUCKET: ${{ secrets.R2_PRIVATE_BUCKET }} + R2_PUBLIC_BUCKET: ${{ secrets.R2_PUBLIC_BUCKET }} + run: | + set -euo pipefail + VERSION="${{ steps.get-version.outputs.version }}" + ARCH="${{ steps.r2-arch.outputs.arch }}" + + # Private bucket: versioned DMG only. `macos/latest/...` is refreshed + # by the release workflow's publish-feed job after both arches land, + # not here. + aws s3 cp "${{ steps.bundle-paths.outputs.dmg }}" \ + "s3://${R2_PRIVATE_BUCKET}/macos/v${VERSION}/Dictx_${VERSION}_${ARCH}.dmg" \ + --endpoint-url "$R2_ENDPOINT" \ + --content-type application/x-apple-diskimage \ + --cache-control "public, max-age=31536000, immutable" + + # Public bucket: updater bundle + signature for this version. + aws s3 cp "${{ steps.bundle-paths.outputs.app_tarball }}" \ + "s3://${R2_PUBLIC_BUCKET}/macos/v${VERSION}/Dictx_${ARCH}.app.tar.gz" \ + --endpoint-url "$R2_ENDPOINT" \ + --content-type application/gzip \ + --cache-control "public, max-age=31536000, immutable" + + aws s3 cp "${{ steps.bundle-paths.outputs.app_sig }}" \ + "s3://${R2_PUBLIC_BUCKET}/macos/v${VERSION}/Dictx_${ARCH}.app.tar.gz.sig" \ + --endpoint-url "$R2_ENDPOINT" \ + --content-type text/plain \ + --cache-control "public, max-age=31536000, immutable" + - name: Install FUSE for AppImage processing if: contains(inputs.platform, 'ubuntu') run: | diff --git a/.github/workflows/pr-test-build.yml b/.github/workflows/pr-test-build.yml index 3e214ea..d2d8585 100644 --- a/.github/workflows/pr-test-build.yml +++ b/.github/workflows/pr-test-build.yml @@ -11,7 +11,7 @@ on: jobs: build-test: permissions: - contents: write + contents: read strategy: fail-fast: false matrix: @@ -43,12 +43,13 @@ jobs: platform: ${{ matrix.platform }} target: ${{ matrix.target }} build-args: ${{ matrix.args }} - sign-binaries: true + # Unsigned: PR code must never see signing secrets, and signed DMGs in + # public run artifacts would bypass the paid download. + sign-binaries: false asset-prefix: "handy-pr-${{ inputs.pr_number }}" upload-artifacts: true is-debug-build: ${{ contains(matrix.args, '--debug') }} ref: ${{ format('refs/pull/{0}/merge', inputs.pr_number) }} - secrets: inherit comment-on-pr: needs: build-test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c3e11b..4a377ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,21 +52,6 @@ jobs: - platform: "macos-latest" # for Intel based macs. args: "--target x86_64-apple-darwin" target: "x86_64-apple-darwin" - - platform: "ubuntu-22.04" # Build .deb on 22.04 - args: "--bundles deb" - target: "x86_64-unknown-linux-gnu" - - platform: "ubuntu-24.04" # Build AppImage and RPM on 24.04 - args: "--bundles appimage,rpm" - target: "x86_64-unknown-linux-gnu" - - platform: "ubuntu-24.04-arm" # Build for ARM64 Linux - args: "--bundles appimage,deb,rpm" - target: "aarch64-unknown-linux-gnu" - - platform: "windows-latest" - args: "" - target: "x86_64-pc-windows-msvc" - - platform: "windows-11-arm" # for ARM64 Windows runner - args: "--target aarch64-pc-windows-msvc" - target: "aarch64-pc-windows-msvc" uses: ./.github/workflows/build.yml with: @@ -76,5 +61,120 @@ jobs: sign-binaries: true asset-prefix: "Dictx" upload-artifacts: false - release-id: ${{ needs.create-release.outputs.release-id }} + publish-r2: true secrets: inherit + + publish-feed: + permissions: + contents: read + needs: publish-tauri + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + R2_PRIVATE_BUCKET: ${{ secrets.R2_PRIVATE_BUCKET }} + R2_PUBLIC_BUCKET: ${{ secrets.R2_PUBLIC_BUCKET }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Get version from tauri.conf.json + id: get-version + shell: bash + run: | + VERSION=$(grep -o '"version": "[^"]*"' src-tauri/tauri.conf.json | cut -d'"' -f4) + echo "Application version from tauri.conf.json: $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Ensure AWS CLI is available + shell: bash + run: | + set -euo pipefail + if command -v aws >/dev/null 2>&1; then + aws --version + exit 0 + fi + # GitHub-hosted runner images ship the AWS CLI. Fail loudly instead of + # installing an unpinned copy at release time. + echo "::error::aws CLI not found on this runner image" + exit 1 + + - name: Verify both macOS DMGs published + shell: bash + run: | + set -euo pipefail + VERSION="${{ steps.get-version.outputs.version }}" + for ARCH in aarch64 x64; do + aws s3api head-object \ + --bucket "$R2_PRIVATE_BUCKET" \ + --key "macos/v${VERSION}/Dictx_${VERSION}_${ARCH}.dmg" \ + --endpoint-url "$R2_ENDPOINT" >/dev/null + done + + - name: Fetch updater signatures and build latest.json + shell: bash + run: | + set -euo pipefail + VERSION="${{ steps.get-version.outputs.version }}" + PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + WORKDIR="$(mktemp -d)" + echo "WORKDIR=$WORKDIR" >> "$GITHUB_ENV" + + fetch_sig() { + local arch="$1" + local out="$2" + aws s3 cp \ + "s3://${R2_PUBLIC_BUCKET}/macos/v${VERSION}/Dictx_${arch}.app.tar.gz.sig" \ + "$out" \ + --endpoint-url "$R2_ENDPOINT" + } + + fetch_sig aarch64 "$WORKDIR/aarch64.sig" + fetch_sig x64 "$WORKDIR/x64.sig" + + SIG_AARCH64="$(cat "$WORKDIR/aarch64.sig")" + SIG_X64="$(cat "$WORKDIR/x64.sig")" + + cat > "$WORKDIR/latest.json" <

- Download + Download   Get Dictx Pro

@@ -28,7 +28,7 @@ --- -> **Free and open source.** Dictx is GPL-3.0 licensed — you can build it from source with full functionality. [Buy Dictx Pro](https://dictx.splitlabs.io/buy) ($29 one-time) for signed builds, in-app auto-updates, and priority support. +> **Free and open source.** Dictx is GPL-3.0 licensed — build it from source for the exact same functionality as the paid build, no feature gating and no license key. [Buy the signed macOS build](https://dictx.splitlabs.io/buy) ($29 one-time) if you'd rather skip building it yourself; both paths auto-update from the same feed. Dictx is a cross-platform desktop application for speech transcription. Press a shortcut, speak, and your words appear in any text field — no cloud, no API keys, no data leaving your computer. @@ -47,13 +47,15 @@ Built with [Tauri](https://tauri.app) (Rust + React/TypeScript). Forked from [Ha ## Installation -Download the latest release for your platform from the [Releases page](https://github.com/splitlabs/dictx/releases). +**macOS:** buy the signed, notarized DMG at [dictx.splitlabs.io](https://dictx.splitlabs.io) — download from a private link tied to your purchase, install, and auto-update forever. No license key, no activation step. -| Platform | Format | -| -------- | -------------------- | -| macOS | `.dmg` | -| Windows | `.msi` | -| Linux | `.AppImage` / `.deb` | +**Any platform, free:** build from source (see [BUILD.md](BUILD.md)). The GPL source has the exact same functionality as the paid build — signing is the only thing you're paying for. Source builds auto-update from the same public feed as the paid build. + +| Platform | Paid signed build | Free (build from source) | +| -------- | -------------------------- | ------------------------ | +| macOS | `.dmg`, signed + notarized | yes | +| Windows | not offered | yes | +| Linux | not offered | yes | After installation: @@ -62,8 +64,6 @@ After installation: 3. Configure your keyboard shortcut in Settings 4. Start transcribing -> To build from source, see [BUILD.md](BUILD.md). - ## How It Works 1. **Press** a configurable keyboard shortcut (toggle or push-to-talk) @@ -198,7 +198,7 @@ Originally created by [cjpais](https://github.com/cjpais) as [Handy](https://git - [whisper.cpp](https://github.com/ggerganov/whisper.cpp) / [ggml](https://github.com/ggerganov/ggml) — cross-platform inference - [Silero VAD](https://github.com/snakers4/silero-vad) — voice activity detection - [Tauri](https://tauri.app) — desktop application framework -- +- ## Contributing diff --git a/docs/commercial/checkout-migration.md b/docs/commercial/checkout-migration.md index 0df07bc..074779d 100644 --- a/docs/commercial/checkout-migration.md +++ b/docs/commercial/checkout-migration.md @@ -1,38 +1,154 @@ -# Dictx Commerce: Stripe Managed Payments +# Dictx Commerce: Stripe Checkout + Private R2 Download (Pattern A) -Dictx Pro is sold at `https://dictx.splitlabs.io/buy` through Stripe Managed Payments, where Link is the seller of record. The OSS/free path is unchanged. Polar was retired on 2026-09-15. +Dictx Pro is sold at `https://dictx.splitlabs.io/buy` through Stripe Managed +Payments, where Link is the seller of record. There are no license keys and +no in-app entitlement: a paid Checkout Session is a one-time proof of +purchase that unlocks a short-lived download link for the signed macOS DMG. +The GPL source build is unchanged and has full functionality. Polar was +retired on 2026-09-15; the license-key model (`dxp-...`) was retired when +this document was rewritten. ## Scope -- Product: Dictx Pro (signed binaries, in-app auto-updates, priority support) +- Product: Dictx Pro (signed, notarized macOS DMG) - Price: `$29` one-time (tax added at checkout where applicable) -- Free version: unchanged (GPL source build) +- Free version: unchanged (GPL source build, same features, unsigned) +- Everyone — free and paid — auto-updates from the same public feed at + `https://updates.dictx.splitlabs.io/latest.json` -## Checkout +## Flow -- `/buy` redirects to the live Payment Link, or shows "checkout unavailable" if Stripe is not fully configured. -- After payment Stripe redirects to `https://dictx.splitlabs.io/buy/success?session_id={CHECKOUT_SESSION_ID}`. -- The success page calls `/api/pro/license`, which verifies the Checkout Session and shows the `dxp-` license key. +1. `/buy` redirects to the live Stripe Payment Link (unchanged). +2. After payment, Stripe redirects to + `https://dictx.splitlabs.io/buy/success?session_id={CHECKOUT_SESSION_ID}`. +3. The success page calls `/api/download/macos?session_id=...`. +4. The endpoint verifies the Checkout Session against Stripe (live, complete, + paid or fully discounted, exactly one Dictx Pro price at quantity 1, + charge not refunded or disputed) using the same logic as the retired + license flow (`landing/api/_lib/stripe-purchase.js`). +5. On success, the endpoint mints a short-lived (five minute) SigV4 GET + presigned URL for the private R2 object + `macos/latest/Dictx_{arch}.dmg` (or a specific + `macos/v{VERSION}/Dictx_{VERSION}_{arch}.dmg` key) and returns/redirects + to it. Arch is negotiated from the request (`?arch=aarch64|x64`, default + `aarch64`). +6. Re-downloads: the success page is bookmarkable and re-runs step 3–5 as + long as the Checkout Session id is known. `/download` is a paste page for + a lost bookmark — paste the Checkout Session id, get a fresh presigned + URL. Neither page needs an account or a stored key. -Setup, live object ids, and environment variables: [landing/README.md](../../landing/README.md). +No webhook is needed: authorization comes from re-verifying the live +Checkout Session on every download request, the same pattern the retired +license flow used. -## License + Entitlements +## Storage contract -Activation flow in the app: +- Private bucket (`R2_PRIVATE_BUCKET`, never public): DMGs only. + - `macos/v{VERSION}/Dictx_{VERSION}_{arch}.dmg` + - `macos/latest/Dictx_{arch}.dmg` (stable pointer, refreshed by CI on + every successful release) +- Public bucket (`R2_PUBLIC_BUCKET`, served at + `https://updates.dictx.splitlabs.io`): updater artifacts and feed, safe to + be world-readable — obtaining the app this way does not grant a receipt. + - `macos/v{VERSION}/Dictx_{arch}.app.tar.gz` and `.app.tar.gz.sig` + - `latest.json` — Tauri v2 static updater JSON: + `{version, notes, pub_date, platforms: {"darwin-aarch64": {...}, "darwin-x86_64": {...}}}` + where `signature` is the literal contents of the `.sig` file +- `arch` is `aarch64` (`aarch64-apple-darwin`) or `x64` (`x86_64-apple-darwin`) -- User opens **Settings -> About -> Activate Dictx Pro** and enters the `dxp-` key. -- The app verifies against `https://dictx.splitlabs.io/api/pro/verify`. -- On success, the app stores the entitlement and enables updater checks. -- The app re-verifies on refresh; a refund or dispute turns Pro off, a Stripe outage keeps the current state. -- Keys from the retired Polar checkout (`lk_...`, `polar_cl_...`) answer `410`, so apps that already activated one keep Pro. New activations with them fail. -- Early-adopter promo: the first 100 unique installs can auto-claim free Pro via `POST /api/pro/early-access/claim`. +## One-time infrastructure setup -No webhook is needed: entitlement comes from re-verifying the live Checkout Session. +1. Create two Cloudflare R2 buckets, e.g. `dictx-releases-private` and + `dictx-releases-public`. +2. Attach the custom domain `updates.dictx.splitlabs.io` to the **public** + bucket only (Cloudflare R2 → bucket → Settings → Custom Domains). Never + attach a public domain to the private bucket. +3. Create two R2 API tokens: + - **CI token**: Object Read & Write on both buckets. GitHub Actions only. + - **Vercel token**: Object Read on the private bucket only. If the + Vercel environment leaks, this token cannot replace DMGs or rewrite + `latest.json`. +4. Record the account id and each token's access key id and secret. +5. Add GitHub Actions repository secrets, using the CI token (used by + `.github/workflows/release.yml` and `.github/workflows/build.yml`): + - `R2_ACCOUNT_ID` + - `R2_ACCESS_KEY_ID` + - `R2_SECRET_ACCESS_KEY` + - `R2_PRIVATE_BUCKET` + - `R2_PUBLIC_BUCKET` +6. Add Vercel environment variables for the `landing` project, using the + read-only Vercel token and scoped to **Production only**. A Preview + deployment in Stripe test mode with these set would hand the real DMG to + test-card checkouts; point Preview at a separate bucket holding a dummy + file if you need to test downloads there, and keep Vercel Deployment + Protection on for previews. Used by `/api/download/macos.js` and + `/api/_lib/r2-presign.js` to presign private-bucket GET URLs: + - `R2_ACCOUNT_ID` + - `R2_ACCESS_KEY_ID` + - `R2_SECRET_ACCESS_KEY` + - `R2_PRIVATE_BUCKET` + - (Vercel does not need `R2_PUBLIC_BUCKET`; it never touches the public + bucket) +7. Redeploy the `landing` Vercel project — env vars apply at deploy time + only. +8. Confirm `src-tauri/tauri.conf.json`'s updater `endpoints` point at + `https://updates.dictx.splitlabs.io/latest.json` and the updater + `pubkey` matches the CI signing key (`TAURI_SIGNING_PRIVATE_KEY`). -## Validation Checklist +Stripe setup (unchanged from the retired license flow): see +[landing/README.md](../../landing/README.md) for live object ids and +Stripe environment variables. -- `/buy` redirects to the Stripe Payment Link (307). -- A completed checkout lands on `/buy/success` and shows a `dxp-` key. -- `/api/pro/verify` returns `{ active: true }` for that key and `{ active: false }` for a tampered one. -- `/api/pro/license` returns `404` for an unknown session and `410` for a refunded one. -- `landing/tests/billing.test.js` passes. +## Validation checklist + +Automated: + +- `landing/tests/download.test.js` passes: paid session → `200` with a + presigned URL expiring in 300s; unpaid → `402`; refunded → `410`; unknown + or malformed session id → `404`; wrong method → `405`; missing Stripe or + R2 config → `503`; no Stripe call is made for a malformed session id. +- `landing/tests/billing.test.js` still passes (the `/buy` redirect logic is + unchanged). +- `node --test landing/tests/` + +Manual, after secrets are set and a release has published to R2 (do this in +Stripe test mode against a non-production deployment, or with a real $29 +purchase if testing production): + +1. Trigger a purchase through `/buy` (or a direct test-mode Checkout + Session) and complete payment. +2. Confirm the redirect to `/buy/success?session_id=...` returns a working + download link and the DMG downloads and opens. +3. Reload `/buy/success?session_id=...` (bookmark re-download) and confirm + a fresh presigned URL is issued for the same session. +4. Go to `/download`, paste the same Checkout Session id, and confirm it + also issues a working download link. +5. Refund or cancel the test charge in Stripe and confirm both `/buy/success` + and `/download` now respond `410` for that session id. +6. Launch the installed app and confirm it checks + `https://updates.dictx.splitlabs.io/latest.json` and can update in place + (built-from-source and purchased installs both update from this feed). + +## Risks (carried over from the plan) + +- The Checkout Session id is a bearer re-download token: it must never be + logged, sent as a referrer, or captured by analytics on the success or + download pages. For that reason `/buy/success` loads no analytics, so + DataFast no longer attributes revenue from that page. Vercel request logs + still record the id from the `/buy/success` page URL; limit log access + accordingly. +- A paid session whose charge Stripe does not return is refused + (`provider_invalid`, 502) rather than assumed unrefunded. Confirm with the + first test-mode purchase that Managed Payments sessions expand + `payment_intent.latest_charge`; if they do not, every download fails. +- A 100% promotion code yields a free download (`no_payment_required`). + Keep promotion codes off on the Payment Link unless that is intended. +- Rate limiting is in memory per serverless instance. Add a Vercel WAF + rate-limit rule on `/api/download/*` before launch. +- PR and manual test builds are unsigned, so signed DMGs never appear in + public workflow run artifacts. +- The public update bundle (`.app.tar.gz`) is not access-controlled, so a + determined user can obtain a working, auto-updating build without paying. + This is accepted under Pattern A: GPL already permits redistribution, and + the paid product is the signed, notarized, one-click DMG plus support, not + a technical restriction. diff --git a/docs/commercial/customer-migration-email.md b/docs/commercial/customer-migration-email.md index fff0467..4db77ef 100644 --- a/docs/commercial/customer-migration-email.md +++ b/docs/commercial/customer-migration-email.md @@ -11,7 +11,7 @@ What this means: - Existing customers keep access to Dictx Pro - Dictx remains open source (GPL) and free to build from source -If you need help with receipts, downloads, or license access, reply to this email and include: +If you need help with receipts or downloads, reply to this email and include: - Purchase email - Approximate purchase date diff --git a/landing/README.md b/landing/README.md index 09de9bd..b7cd0ed 100644 --- a/landing/README.md +++ b/landing/README.md @@ -17,10 +17,9 @@ Attach `dictx.splitlabs.io` to this Vercel project. - `/` serves `landing/index.html` - `/buy` runs `api/buy.js`: redirects to the Stripe Payment Link, or shows "checkout unavailable" if Stripe is not fully configured -- `/buy/success?session_id=cs_live_...` shows the license key, fetched from `/api/pro/license` -- `/api/pro/license?session_id=cs_live_...` issues the `dxp-` license key for a verified Stripe purchase -- `/api/pro/verify` validates `dxp-` keys against Stripe. Keys from the retired Polar checkout (`lk_...`, `polar_cl_...`) answer `410`, so apps that already activated one keep Pro -- `/api/pro/early-access/claim` answers `404 early_access_closed`. The "first 100 installs get Pro free" offer is retired: its claim store was never provisioned, so it only ever returned 500. Installed apps read 404 as "no grant" and carry on, so the route stays instead of 404ing as a missing file +- `/buy/success?session_id=cs_live_...` shows Apple Silicon / Intel download buttons that call `/api/download/macos`. Bookmark this page to re-download later +- `/download` lets a buyer paste that page's URL (or the bare `cs_live_...` id) to get a fresh download link, plus a `/buy` CTA and a link to build from source +- `POST /api/download/macos` `{ session_id, arch }` (`arch` is `aarch64` or `x64`, default `aarch64`) verifies the Stripe Checkout Session server-side and returns a 300-second presigned R2 URL for the signed DMG. No license keys: the session id is the re-download token, and it is never logged - `/js/script.cookieless.js` and `/api/events` proxy DataFast first-party; `middleware.ts` reports AI crawler requests (Edge runtime: the Node.js runtime served every page as 500 on this project) ## Stripe Managed Payments @@ -33,33 +32,48 @@ Live objects: - Price `price_1UFeb2IOmsupAvc3VoCin47h`, $29 USD one-time - Payment Link `plink_1UFebaIOmsupAvc3m6DV45ul`, Managed Payments on, after payment redirects to `https://dictx.splitlabs.io/buy/success?session_id={CHECKOUT_SESSION_ID}` -A purchase earns a key only when the session is live, complete and paid (or fully discounted), holds exactly one Dictx Pro price at quantity 1, and its charge is neither refunded nor disputed. The app re-verifies keys, so a refund or dispute turns Pro off on its next check. A Stripe outage returns an error, which the app treats as "keep current state". +A purchase earns a download only when the session is live, complete and paid (or fully discounted), holds exactly one Dictx Pro price at quantity 1, and its charge is neither refunded nor disputed. There is no license key and no in-app entitlement: the Checkout Session id itself is the re-download token, verified against Stripe on every request. -If a buyer loses the key, find their Checkout Session id in Stripe and send them `https://dictx.splitlabs.io/buy/success?session_id=`. It reissues the same key. +If a buyer loses their success-page link, find their Checkout Session id in Stripe and send them `https://dictx.splitlabs.io/download` to paste it in, or `https://dictx.splitlabs.io/buy/success?session_id=` directly. To set up again from scratch: create the product with a Managed Payments–eligible tax code, a one-time price, and a Payment Link with Managed Payments on and the redirect above; create a restricted live key with read access to Checkout Sessions, Payment Intents, and Charges; set the variables below; then **redeploy production**, because Vercel applies environment variables only at deploy time. +## R2 (private DMG storage) + +The signed macOS DMGs live in a private Cloudflare R2 bucket, keyed +`macos/latest/Dictx_{arch}.dmg` (`arch` is `aarch64` or `x64`). `/api/download/macos` +presigns a 300-second GET URL with `api/_lib/r2-presign.js`, a small SigV4 +query-auth implementation using only `node:crypto` (verified against the AWS +documented presigned-URL test vector in `tests/download.test.js`), so no AWS +SDK or presigning dependency is needed for one signature. + ## Environment Variables (Vercel) -Stripe (all required, otherwise `/buy` shows "checkout unavailable"): +Stripe (all required, otherwise `/buy` and downloads show "unavailable"): - `STRIPE_SECRET_KEY`: restricted live read key (`rk_live_...`), Sensitive - `DICTX_STRIPE_PRICE_ID`: the Dictx Pro price id (`price_...`) - `DICTX_STRIPE_PAYMENT_LINK`: the canonical live Payment Link (`https://buy.stripe.com/...`, never `/test_`) -- `DICTX_LICENSE_SECRET`: 32+ character signing secret for license keys, Sensitive. **Rotating it invalidates every issued key.** - `STRIPE_COMMERCE_MODE`: optional, `test` in non-production only; production always runs live +R2 (all required, otherwise downloads answer 503): + +- `R2_ACCOUNT_ID`: Cloudflare account id +- `R2_ACCESS_KEY_ID`: R2 API token access key id, Sensitive +- `R2_SECRET_ACCESS_KEY`: R2 API token secret, Sensitive +- `R2_PRIVATE_BUCKET`: the private bucket name holding the DMGs + DataFast: - `DATAFAST_WEBSITE_ID`: the public website id; enables crawler tracking in `middleware.ts` Rate limits: -- `PRO_VERIFY_RATE_LIMIT_WINDOW_MS`: optional API rate-limit window -- `PRO_VERIFY_RATE_LIMIT_MAX`: optional API rate-limit max requests per client per window +- `DOWNLOAD_RATE_LIMIT_WINDOW_MS`: optional API rate-limit window for `/api/download/macos` +- `DOWNLOAD_RATE_LIMIT_MAX`: optional API rate-limit max requests per client per window ## Tests ```bash -node --test landing/tests/billing.test.js +node --test landing/tests/ ``` diff --git a/landing/api/_lib/license.js b/landing/api/_lib/license.js deleted file mode 100644 index 6377ea8..0000000 --- a/landing/api/_lib/license.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Dictx Pro license keys for Stripe purchases. - * - * A key is the Checkout Session id plus an HMAC over it: - * dxp--<32 hex chars> - * so issuing needs no storage, forging needs DICTX_LICENSE_SECRET, and every - * verification can re-check the live session for refunds and disputes. The - * charset stays within [A-Za-z0-9_-], which is all installed app versions - * send unchanged to /api/pro/verify. - * - * Rotating DICTX_LICENSE_SECRET invalidates every issued key. Do not rotate - * it without a migration. - */ -const crypto = require("node:crypto"); - -const KEY_PREFIX = "dxp-"; -const KEY_PATTERN = - /^dxp-(cs_(?:live|test)_[A-Za-z0-9]{10,200})-([0-9a-f]{32})$/; - -const mac = (sessionId, secret) => - crypto - .createHmac("sha256", secret) - .update(`dictx-pro:v1:${sessionId}`) - .digest("hex") - .slice(0, 32); - -const issueLicenseKey = (sessionId, secret) => - `${KEY_PREFIX}${sessionId}-${mac(sessionId, secret)}`; - -const isStripeLicenseKey = (value) => - typeof value === "string" && value.startsWith(KEY_PREFIX); - -/** Returns { sessionId } for an authentic key, otherwise null. */ -const parseLicenseKey = (value, secret) => { - const match = KEY_PATTERN.exec(value || ""); - if (!match || !secret) return null; - const expected = Buffer.from(mac(match[1], secret), "utf8"); - const given = Buffer.from(match[2], "utf8"); - if (expected.length !== given.length) return null; - return crypto.timingSafeEqual(expected, given) - ? { sessionId: match[1] } - : null; -}; - -module.exports = { isStripeLicenseKey, issueLicenseKey, parseLicenseKey }; diff --git a/landing/api/_lib/r2-presign.js b/landing/api/_lib/r2-presign.js new file mode 100644 index 0000000..e57242b --- /dev/null +++ b/landing/api/_lib/r2-presign.js @@ -0,0 +1,149 @@ +/** + * SigV4 query-string presigning for a single GET object request, using only + * node:crypto. This is deliberately small: one pure function, verified + * against the AWS-documented presigned URL example, rather than a dependency + * for a single signature. + * + * https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html + */ +const crypto = require("node:crypto"); + +const ALGORITHM = "AWS4-HMAC-SHA256"; + +const sha256Hex = (input) => + crypto.createHash("sha256").update(input, "utf8").digest("hex"); + +const hmac = (key, value) => + crypto.createHmac("sha256", key).update(value, "utf8").digest(); + +/** RFC 3986 percent-encoding. `/` is preserved when encodeSlash is false. */ +const uriEncode = (input, encodeSlash) => { + let result = ""; + for (const ch of String(input)) { + if (/^[A-Za-z0-9\-_.~]$/.test(ch)) { + result += ch; + } else if (ch === "/" && !encodeSlash) { + result += "/"; + } else { + for (const byte of Buffer.from(ch, "utf8")) { + result += "%" + byte.toString(16).toUpperCase().padStart(2, "0"); + } + } + } + return result; +}; + +const toAmzDate = (date) => + date + .toISOString() + .replace(/[:-]|\.\d{3}/g, "") + .replace(/\.\d+/, ""); + +const signingKey = (secretAccessKey, dateStamp, region, service) => { + const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp); + const kRegion = hmac(kDate, region); + const kService = hmac(kRegion, service); + return hmac(kService, "aws4_request"); +}; + +/** + * Presigns a GET request via SigV4 query auth. + * + * For Cloudflare R2 the default host is `${accountId}.r2.cloudflarestorage.com` + * with a path-style URI (`/${bucket}/${key}`), region `auto`, service `s3`. + * Set `virtualHosted: true` and pass an explicit `host` to reproduce the AWS + * documented example (virtual-hosted style, `${bucket}.s3.amazonaws.com`). + */ +const presignGetUrl = ({ + accountId, + accessKeyId, + secretAccessKey, + bucket, + key, + expiresIn, + now = new Date(), + responseContentDisposition, + responseContentType, + host, + region = "auto", + service = "s3", + virtualHosted = false, +}) => { + const resolvedHost = host || `${accountId}.r2.cloudflarestorage.com`; + const path = virtualHosted ? `/${key}` : `/${bucket}/${key}`; + const canonicalUri = uriEncode(path, false); + + const amzDate = toAmzDate(now); + const dateStamp = amzDate.slice(0, 8); + const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; + + const params = { + "X-Amz-Algorithm": ALGORITHM, + "X-Amz-Credential": `${accessKeyId}/${credentialScope}`, + "X-Amz-Date": amzDate, + "X-Amz-Expires": String(expiresIn), + "X-Amz-SignedHeaders": "host", + }; + if (responseContentDisposition) { + params["response-content-disposition"] = responseContentDisposition; + } + if (responseContentType) { + params["response-content-type"] = responseContentType; + } + + const canonicalQueryString = Object.keys(params) + .sort() + .map((name) => `${uriEncode(name, true)}=${uriEncode(params[name], true)}`) + .join("&"); + + const canonicalHeaders = `host:${resolvedHost}\n`; + const signedHeaders = "host"; + const payloadHash = "UNSIGNED-PAYLOAD"; + + const canonicalRequest = [ + "GET", + canonicalUri, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + payloadHash, + ].join("\n"); + + const stringToSign = [ + ALGORITHM, + amzDate, + credentialScope, + sha256Hex(canonicalRequest), + ].join("\n"); + + const key_ = signingKey(secretAccessKey, dateStamp, region, service); + const signature = crypto + .createHmac("sha256", key_) + .update(stringToSign, "utf8") + .digest("hex"); + + return `https://${resolvedHost}${canonicalUri}?${canonicalQueryString}&X-Amz-Signature=${signature}`; +}; + +/** Reads R2 credentials/config from the environment. */ +const r2Config = () => { + const accountId = (process.env.R2_ACCOUNT_ID || "").trim(); + const accessKeyId = (process.env.R2_ACCESS_KEY_ID || "").trim(); + const secretAccessKey = (process.env.R2_SECRET_ACCESS_KEY || "").trim(); + const bucket = (process.env.R2_PRIVATE_BUCKET || "").trim(); + const missing = []; + if (!accountId) missing.push("R2_ACCOUNT_ID"); + if (!accessKeyId) missing.push("R2_ACCESS_KEY_ID"); + if (!secretAccessKey) missing.push("R2_SECRET_ACCESS_KEY"); + if (!bucket) missing.push("R2_PRIVATE_BUCKET"); + return { + accountId, + accessKeyId, + secretAccessKey, + bucket, + ready: missing.length === 0, + missing, + }; +}; + +module.exports = { presignGetUrl, r2Config, uriEncode }; diff --git a/landing/api/_lib/stripe-purchase.js b/landing/api/_lib/stripe-purchase.js index d2f4868..f10c79e 100644 --- a/landing/api/_lib/stripe-purchase.js +++ b/landing/api/_lib/stripe-purchase.js @@ -51,19 +51,16 @@ const stripeConfig = () => { const mode = commerceMode(); const secretKey = (process.env.STRIPE_SECRET_KEY || "").trim(); const priceId = (process.env.DICTX_STRIPE_PRICE_ID || "").trim(); - const licenseSecret = (process.env.DICTX_LICENSE_SECRET || "").trim(); const paymentLink = (process.env.DICTX_STRIPE_PAYMENT_LINK || "").trim(); const missing = []; if (!secretKeyPattern(mode).test(secretKey)) missing.push("STRIPE_SECRET_KEY"); if (!/^price_[A-Za-z0-9]+$/.test(priceId)) missing.push("DICTX_STRIPE_PRICE_ID"); - if (licenseSecret.length < 32) missing.push("DICTX_LICENSE_SECRET"); return { mode, secretKey, priceId, - licenseSecret, paymentLink: isCanonicalPaymentLink(paymentLink) ? paymentLink : "", ready: missing.length === 0, missing, @@ -120,7 +117,17 @@ const classifyPurchase = ({ typeof paymentIntent.latest_charge === "object" ? paymentIntent.latest_charge : null; - if (charge && (charge.refunded === true || charge.disputed === true)) { + // A paid session must expose its charge, or refunds and disputes cannot be + // checked; refuse rather than assume. Free (100% discount) sessions have none. + if (session.payment_status === "paid" && !charge) { + return fail("provider_invalid"); + } + if ( + charge && + (charge.refunded === true || + charge.disputed === true || + Number(charge.amount_refunded) > 0) + ) { return fail("refunded"); } diff --git a/landing/api/buy.js b/landing/api/buy.js index 4b80810..c7f08f2 100644 --- a/landing/api/buy.js +++ b/landing/api/buy.js @@ -2,10 +2,9 @@ * GET /buy (rewritten here by vercel.json) * * Sends buyers to the Stripe Managed Payments checkout. It fails closed: if - * the restricted key, the Dictx Pro price, the license signing secret, or a - * canonical live Payment Link is missing or malformed, buyers see a short - * "checkout unavailable" page instead of a checkout that could take money it - * cannot turn into a license. + * the restricted key, the Dictx Pro price, or a canonical live Payment Link + * is missing or malformed, buyers see a short "checkout unavailable" page + * instead of a checkout that could take money it cannot turn into a download. */ const { stripeConfig } = require("./_lib/stripe-purchase"); diff --git a/landing/api/download/macos.js b/landing/api/download/macos.js index 20b2b7f..394a94a 100644 --- a/landing/api/download/macos.js +++ b/landing/api/download/macos.js @@ -1,86 +1,108 @@ -const GITHUB_OWNER = "splitlabs"; -const GITHUB_REPO = "dictx"; -const GITHUB_API_BASE = "https://api.github.com"; -const RELEASES_LATEST_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`; +/** + * POST /api/download/macos { session_id, arch } + * + * Mints a short-lived presigned R2 URL for the signed macOS DMG once the + * Stripe Checkout Session is verified paid, unrefunded and undisputed + * (see ../_lib/stripe-purchase.js). No license keys: the session id itself + * is the re-download token, so it is never logged and the response is + * never cached. + */ +const { purchaseForSession, stripeConfig } = require("../_lib/stripe-purchase"); +const { presignGetUrl, r2Config } = require("../_lib/r2-presign"); +const { createRateLimiter, getClientId, sendJson } = require("../_lib/http"); -const sendJson = (res, statusCode, payload) => { - if (res && typeof res.status === "function") { - res.status(statusCode).json(payload); - return null; - } - - return new Response(JSON.stringify(payload), { - status: statusCode, - headers: { - "content-type": "application/json", - "cache-control": "no-store", - }, - }); -}; +const RATE_LIMIT_WINDOW_MS = Number.parseInt( + process.env.DOWNLOAD_RATE_LIMIT_WINDOW_MS || "60000", + 10, +); +const RATE_LIMIT_MAX = Number.parseInt( + process.env.DOWNLOAD_RATE_LIMIT_MAX || "20", + 10, +); +const isRateLimited = createRateLimiter(RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX); -const redirectTo = (res, location) => { - if (res && typeof res.redirect === "function") { - res.setHeader("cache-control", "no-store"); - res.redirect(302, location); - return null; - } +const URL_EXPIRES_IN = 300; +const ARCHES = new Set(["aarch64", "x64"]); - return new Response(null, { - status: 302, - headers: { - location, - "cache-control": "no-store", - }, - }); +const STATUS_BY_REASON = { + malformed_session: 404, + invalid_session: 404, + test_session: 404, + unpaid: 402, + wrong_order: 409, + refunded: 410, + provider_invalid: 502, + provider_unavailable: 502, }; -const pickMacDmgAsset = (assets) => { - const dmgAssets = (assets || []).filter((asset) => { - const name = String(asset?.name || "").toLowerCase(); - return name.endsWith(".dmg"); - }); - - if (dmgAssets.length === 0) { - return null; +const readBody = async (req) => { + if (!req) return {}; + if (req.body !== undefined) { + if (typeof req.body === "string") { + try { + return JSON.parse(req.body); + } catch (_error) { + return {}; + } + } + if (typeof req.body === "object" && req.body !== null) return req.body; } - - // Prefer Apple Silicon naming first, then universal / generic DMG. - return ( - dmgAssets.find((asset) => /aarch64|arm64/.test(String(asset?.name || "").toLowerCase())) || - dmgAssets.find((asset) => /universal/.test(String(asset?.name || "").toLowerCase())) || - dmgAssets[0] - ); + if (typeof req.json === "function") { + try { + return await req.json(); + } catch (_error) { + return {}; + } + } + return {}; }; const handler = async (req, res) => { - if (req.method !== "GET") { + if (req.method !== "POST") { return sendJson(res, 405, { error: "method_not_allowed" }); } + if (isRateLimited(getClientId(req))) { + return sendJson(res, 429, { error: "rate_limited" }); + } - try { - const apiUrl = `${GITHUB_API_BASE}/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`; - const releaseResponse = await fetch(apiUrl, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "dictx-landing-download-resolver", - }, - }); + const stripe = stripeConfig(); + if (!stripe.ready) { + return sendJson(res, 503, { error: "stripe_not_configured" }); + } + const r2 = r2Config(); + if (!r2.ready) { + return sendJson(res, 503, { error: "r2_not_configured" }); + } - if (!releaseResponse.ok) { - return redirectTo(res, RELEASES_LATEST_URL); - } + const parsed = await readBody(req); + const body = parsed && typeof parsed === "object" ? parsed : {}; + const arch = String(body.arch || "aarch64").trim() || "aarch64"; + if (!ARCHES.has(arch)) { + return sendJson(res, 400, { error: "invalid_arch" }); + } - const release = await releaseResponse.json(); - const asset = pickMacDmgAsset(release?.assets); + const sessionId = String(body.session_id || "").trim(); + const purchase = await purchaseForSession(sessionId, stripe); + if (!purchase.ok) { + // Reason only: never log the session id or any derived URL. + console.warn("download_macos_refused", { reason: purchase.reason }); + return sendJson(res, STATUS_BY_REASON[purchase.reason] || 400, { + error: purchase.reason, + }); + } - if (!asset?.browser_download_url) { - return redirectTo(res, RELEASES_LATEST_URL); - } + const key = `macos/latest/Dictx_${arch}.dmg`; + const url = presignGetUrl({ + accountId: r2.accountId, + accessKeyId: r2.accessKeyId, + secretAccessKey: r2.secretAccessKey, + bucket: r2.bucket, + key, + expiresIn: URL_EXPIRES_IN, + responseContentDisposition: `attachment; filename="Dictx_${arch}.dmg"`, + }); - return redirectTo(res, asset.browser_download_url); - } catch (error) { - return redirectTo(res, RELEASES_LATEST_URL); - } + return sendJson(res, 200, { url, expiresIn: URL_EXPIRES_IN }); }; module.exports = handler; diff --git a/landing/api/pro/early-access/claim.js b/landing/api/pro/early-access/claim.js deleted file mode 100644 index bf44b4e..0000000 --- a/landing/api/pro/early-access/claim.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * POST /api/pro/early-access/claim - * - * The early-access offer ("first 100 installs get Pro free") is retired. - * - * It never worked: the claim store (Upstash Redis) was never provisioned in - * production, so every claim answered 500 missing_redis_config. Installed apps - * treat a 5xx as a failure and record a verification error on the Pro check, - * while 404 means "not granted" and lets the app carry on silently - * (src-tauri/src/commands/pro.rs, claim_early_access). So this endpoint stays - * in place and answers 404 rather than disappearing: older installs keep - * calling it, and they must not be shown an error for an offer we withdrew. - */ -const sendJson = (res, statusCode, payload) => { - const body = JSON.stringify(payload); - - if (res && typeof res.status === "function") { - res.setHeader("cache-control", "no-store"); - res.status(statusCode).json(payload); - return null; - } - - return new Response(body, { - status: statusCode, - headers: { - "content-type": "application/json", - "cache-control": "no-store", - }, - }); -}; - -const handler = async (req, res) => { - if (req.method !== "POST") { - return sendJson(res, 405, { error: "method_not_allowed" }); - } - - return sendJson(res, 404, { error: "early_access_closed" }); -}; - -module.exports = handler; diff --git a/landing/api/pro/license.js b/landing/api/pro/license.js deleted file mode 100644 index 18c5883..0000000 --- a/landing/api/pro/license.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * GET /api/pro/license?session_id=cs_live_... - * - * Issues the Dictx Pro license key for a verified Stripe purchase. The success - * page calls this after the Payment Link redirect; opening that page again - * reissues the same key, because the key is derived from the session id. - */ -const { purchaseForSession, stripeConfig } = require("../_lib/stripe-purchase"); -const { issueLicenseKey } = require("../_lib/license"); -const { - createRateLimiter, - getClientId, - getQueryParam, - sendJson, -} = require("../_lib/http"); - -const isRateLimited = createRateLimiter(60000, 20); - -const STATUS_BY_REASON = { - malformed_session: 404, - invalid_session: 404, - test_session: 404, - unpaid: 402, - wrong_order: 409, - refunded: 410, - provider_invalid: 502, - provider_unavailable: 502, -}; - -const handler = async (req, res) => { - if (req.method !== "GET") { - return sendJson(res, 405, { error: "method_not_allowed" }); - } - if (isRateLimited(getClientId(req))) { - return sendJson(res, 429, { error: "rate_limited" }); - } - - const config = stripeConfig(); - if (!config.ready) { - return sendJson(res, 503, { error: "stripe_not_configured" }); - } - - const sessionId = String(getQueryParam(req, "session_id")).trim(); - const purchase = await purchaseForSession(sessionId, config); - if (!purchase.ok) { - console.warn("pro_license_refused", { reason: purchase.reason }); - return sendJson(res, STATUS_BY_REASON[purchase.reason] || 400, { - error: purchase.reason, - }); - } - - return sendJson(res, 200, { - licenseKey: issueLicenseKey(purchase.sessionId, config.licenseSecret), - }); -}; - -module.exports = handler; diff --git a/landing/api/pro/verify.js b/landing/api/pro/verify.js deleted file mode 100644 index 8b01f15..0000000 --- a/landing/api/pro/verify.js +++ /dev/null @@ -1,117 +0,0 @@ -/** - * POST /api/pro/verify { licenseKey } - * - * The in-app activation and refresh check. Installed app versions read only - * `{ active }` on 200, treat 401/404 as inactive, and treat every other status - * as an error that keeps the current entitlement. - * - * - dxp- keys (Stripe Managed Payments) are verified against the live - * Checkout Session: refunds and disputes turn Pro off on the next check. - * - lk_ / polar_cl_ keys came from the retired Polar checkout. They answer - * 410 so apps that already activated one keep Pro instead of being - * switched off, while new activations with those keys fail visibly. - * - Anything else is not a Dictx Pro key and is inactive. - */ -const { purchaseForSession, stripeConfig } = require("../_lib/stripe-purchase"); -const { isStripeLicenseKey, parseLicenseKey } = require("../_lib/license"); -const { createRateLimiter, getClientId, sendJson } = require("../_lib/http"); - -const RATE_LIMIT_WINDOW_MS = Number.parseInt( - process.env.PRO_VERIFY_RATE_LIMIT_WINDOW_MS || "60000", - 10, -); -const RATE_LIMIT_MAX = Number.parseInt( - process.env.PRO_VERIFY_RATE_LIMIT_MAX || "20", - 10, -); -const MAX_LICENSE_KEY_LENGTH = 256; -const RETIRED_POLAR_KEY = /^(?:lk_|polar_cl_)/; - -const isRateLimited = createRateLimiter(RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX); - -// Positive verifications are cached briefly so an app refresh storm does not -// become a Stripe request storm. Refusals are never cached. -const ACTIVE_TTL_MS = 10 * 60 * 1000; -const activeUntil = new Map(); - -const readBody = async (req) => { - if (!req) return {}; - if (req.body !== undefined) { - if (typeof req.body === "string") { - try { - return JSON.parse(req.body); - } catch (_error) { - return {}; - } - } - if (typeof req.body === "object" && req.body !== null) return req.body; - } - if (typeof req.json === "function") { - try { - return await req.json(); - } catch (_error) { - return {}; - } - } - return {}; -}; - -const verifyStripeLicense = async (res, licenseKey) => { - const config = stripeConfig(); - if (!config.ready) { - return sendJson(res, 503, { error: "stripe_not_configured" }); - } - const parsed = parseLicenseKey(licenseKey, config.licenseSecret); - if (!parsed) { - return sendJson(res, 200, { active: false, mode: "stripe_license" }); - } - if ((activeUntil.get(parsed.sessionId) || 0) > Date.now()) { - return sendJson(res, 200, { active: true, mode: "stripe_license" }); - } - const purchase = await purchaseForSession(parsed.sessionId, config); - if (purchase.ok) { - activeUntil.set(parsed.sessionId, Date.now() + ACTIVE_TTL_MS); - return sendJson(res, 200, { active: true, mode: "stripe_license" }); - } - if ( - purchase.reason === "provider_unavailable" || - purchase.reason === "provider_invalid" - ) { - // An outage must not revoke Pro: the app keeps its entitlement on errors. - return sendJson(res, 502, { error: "stripe_api_error" }); - } - activeUntil.delete(parsed.sessionId); - return sendJson(res, 200, { - active: false, - mode: "stripe_license", - reason: purchase.reason, - }); -}; - -const handler = async (req, res) => { - if (req.method !== "POST") { - return sendJson(res, 405, { error: "method_not_allowed" }); - } - if (isRateLimited(getClientId(req))) { - return sendJson(res, 429, { error: "rate_limited" }); - } - - const body = await readBody(req); - const licenseKey = String(body.licenseKey || body.checkoutId || "").trim(); - - if (!licenseKey) { - return sendJson(res, 400, { error: "licenseKey_required" }); - } - if (licenseKey.length > MAX_LICENSE_KEY_LENGTH) { - return sendJson(res, 400, { error: "invalid_license_key" }); - } - if (isStripeLicenseKey(licenseKey)) { - return verifyStripeLicense(res, licenseKey); - } - if (RETIRED_POLAR_KEY.test(licenseKey)) { - return sendJson(res, 410, { error: "polar_retired" }); - } - return sendJson(res, 200, { active: false, mode: "unknown_key" }); -}; - -module.exports = handler; diff --git a/landing/buy/success/index.html b/landing/buy/success/index.html index b0eb5b1..9ffaf51 100644 --- a/landing/buy/success/index.html +++ b/landing/buy/success/index.html @@ -3,36 +3,17 @@ - Dictx Pro Activated + + Download Dictx - - +
@@ -50,103 +31,76 @@

Purchase Complete

-

Activate Dictx Pro

+

Download Dictx

- Copy your license key and paste it in Dictx: - Settings -> About -> Activate Dictx Pro. + Choose your Mac below. The link is valid for a few minutes, so + download right away.

-

Your License Key

-

- Your license key appears here after checkout. Opening this page - again from the same link shows the same key. -

-
+

Download

+ -

+ +

+

+ Bookmark this page. It is your download link. + You can come back and download again any time. +

+

+ Back to Dictx +

+ diff --git a/landing/download.js b/landing/download.js new file mode 100644 index 0000000..6f5b6cc --- /dev/null +++ b/landing/download.js @@ -0,0 +1,127 @@ +/** + * Shared client logic for the download flow (buy/success and /download + * paste-your-link pages). Both pages POST the Stripe Checkout Session id to + * /api/download/macos and redirect the browser to the presigned R2 URL it + * returns. The session id never appears in a query string, a link href, or + * an analytics call from this script. + */ +window.DictxDownload = (function () { + var SESSION_ID_PATTERN = /cs_(?:live|test)_[A-Za-z0-9]{10,200}/; + + /** Accepts a full success-page URL or a bare Checkout Session id. */ + function extractSessionId(input) { + var value = String(input || "").trim(); + if (!value) return ""; + try { + var url = new URL(value); + var fromQuery = url.searchParams.get("session_id"); + if (fromQuery && SESSION_ID_PATTERN.test(fromQuery)) { + return SESSION_ID_PATTERN.exec(fromQuery)[0]; + } + } catch (_error) { + // Not a URL; fall through to a bare-id match below. + } + var match = SESSION_ID_PATTERN.exec(value); + return match ? match[0] : ""; + } + + var STATUS_MESSAGES = { + 402: "Your payment is still being confirmed. Try again in a minute.", + 404: "We could not find that purchase. Double-check the link, or open an issue on GitHub.", + 410: "This purchase was refunded, so no download is issued.", + 429: "Too many attempts. Wait a minute and try again.", + 502: "Stripe is temporarily unavailable. Try again shortly.", + 503: "Downloads are temporarily unavailable. Try again shortly.", + }; + var FALLBACK_MESSAGE = + "Something went wrong. Try again, or open an issue on GitHub."; + + function messageForStatus(status) { + return STATUS_MESSAGES[status] || FALLBACK_MESSAGE; + } + + /** + * Requests a presigned download URL and returns + * { ok, url, status, error }. + */ + function requestDownload(sessionId, arch) { + return fetch("/api/download/macos", { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, arch: arch }), + }) + .then(function (response) { + return response + .json() + .catch(function () { + return {}; + }) + .then(function (body) { + return { response: response, body: body }; + }); + }) + .then(function (result) { + if (result.response.ok && result.body && result.body.url) { + return { ok: true, url: result.body.url }; + } + return { + ok: false, + status: result.response.status, + error: (result.body && result.body.error) || "unknown_error", + }; + }) + .catch(function () { + return { ok: false, status: 0, error: "network_error" }; + }); + } + + /** + * Wires up a pair of download buttons (aarch64 primary, x64 secondary) + * against a getter for the current session id, an aria-live status + * element, and an optional callback fired right before navigation. + */ + function wireButtons(options) { + var buttons = options.buttons; // [{ el, arch }] + var statusEl = options.statusEl; + var getSessionId = options.getSessionId; + var onStart = options.onStart; + + var setStatus = function (text) { + if (statusEl) statusEl.textContent = text; + }; + + buttons.forEach(function (entry) { + entry.el.addEventListener("click", function () { + var sessionId = getSessionId(); + if (!sessionId) { + setStatus("Paste your purchase link first."); + return; + } + if (typeof onStart === "function") onStart(entry); + buttons.forEach(function (b) { + b.el.disabled = true; + }); + setStatus("Preparing your download..."); + requestDownload(sessionId, entry.arch).then(function (result) { + if (result.ok) { + setStatus("Starting your download..."); + window.location.assign(result.url); + return; + } + buttons.forEach(function (b) { + b.el.disabled = false; + }); + setStatus(messageForStatus(result.status)); + }); + }); + }); + } + + return { + extractSessionId: extractSessionId, + messageForStatus: messageForStatus, + requestDownload: requestDownload, + wireButtons: wireButtons, + }; +})(); diff --git a/landing/download/index.html b/landing/download/index.html new file mode 100644 index 0000000..a19e5aa --- /dev/null +++ b/landing/download/index.html @@ -0,0 +1,145 @@ + + + + + + + Download Dictx + + + + + + + +
+ + + +
+
+

Already bought Dictx?

+

Download Dictx

+

+ Paste the link from your purchase page (or the checkout id it + contains) to get a fresh download link. +

+ +
+ + + +
+ + +
+

+
+ +
+

Don't have a purchase yet?

+

+ Dictx Pro is a one-time $29 purchase for a signed, notarized macOS + app with automatic updates. +

+

+ Buy Dictx Pro + Free: build from source +

+

+ Lost access or something not working? + Open an issue on GitHub. +

+
+
+
+ + + + + diff --git a/landing/index.html b/landing/index.html index 4cb63f0..476b7ea 100644 --- a/landing/index.html +++ b/landing/index.html @@ -6,7 +6,7 @@ Dictx | Privacy-first desktop speech-to-text Features Pricing Download DMGDownload Transcription you can trust in real work. > Download Latest macOS DMGAlready Bought? Download

- $29 one-time • signed binaries • auto-updates • macOS / Windows / - Linux + Free: open source, build from source. Pro: $29 one-time, signed macOS + app, automatic updates.

Open source (GPL) • 17 locales • macOS / Windows / Linux

@@ -135,8 +135,8 @@

Obsidian-ready output

>

Free or Pro

- Free stays powerful for daily use. Pro removes setup friction and - keeps your app maintained automatically. + Free is open source: build it yourself. Pro is a signed, notarized + macOS app that updates itself, and it funds development.

@@ -144,7 +144,7 @@

Free or Pro

$0

  • GPL source code
  • -
  • Build from source
  • +
  • Build it yourself
  • All core dictation features
  • Local history with retention controls
  • Community support
  • @@ -163,11 +163,10 @@

    Free or Pro

    Pro

    $29 one-time

      -
    • Signed desktop binaries
    • -
    • In-app auto-updater
    • -
    • Faster install for non-technical users
    • -
    • Professional checkout
    • -
    • Priority support contact
    • +
    • Signed, notarized macOS app
    • +
    • Automatic updates
    • +
    • No build tools required
    • +
    • Supports development
    Get Dictx ProFree or Pro Included - Signed installers - No - Yes - - - In-app auto-updates - No + Signed, notarized installer + No (build it yourself) Yes - Checkout + receipt workflow + Automatic updates No Yes Support level - Community - Priority email + Community (GitHub issues) + Community (GitHub issues) @@ -233,37 +227,31 @@

    FAQ

    What does Pro actually add?

    - Pro is the convenience layer: signed installers, in-app updates, and - priority support. Free still includes the full local dictation - workflow. -

    -
    -
    - How do I activate Pro inside the app? -

    - Open Settings, go to About, and enter the license key shown after - checkout (dxp-...). + Pro is the convenience layer: a signed, notarized macOS app that + updates itself, so you skip building from source. Free still + includes the full local dictation workflow.

    Where does /buy go?

    - /buy opens the hosted checkout. After payment you land - on a page that shows your license key, and opening that page again - shows the same key. + /buy opens the hosted Stripe checkout. After payment + you land on a download page: pick your Mac and the app downloads + right away.

    - Where does the DMG download come from? + How do I download again later?

    - `/api/download/macos` resolves the latest GitHub release and - redirects to the newest macOS .dmg asset. + Bookmark the page you land on after checkout, or go to + /download and paste that page's link (or the + checkout id it contains) to get a fresh download link.

    - Keep Free if you like building from source. Upgrade when you want - signed installers and one-click updates. + Keep Free if you like building from source. Buy Pro when you want a + signed app that updates itself.

    FAQ > Download DMGAlready Bought? Download
    diff --git a/landing/styles.css b/landing/styles.css index 8ebd77c..19943d8 100644 --- a/landing/styles.css +++ b/landing/styles.css @@ -182,6 +182,26 @@ h1 { transform: translateY(-1px); } +.btn:focus-visible, +a:focus-visible, +input:focus-visible, +button:focus-visible { + outline: 2px solid var(--color-logo-primary); + outline-offset: 2px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .btn.primary { color: #fff; background: var(--color-background-ui); @@ -216,7 +236,6 @@ h1 { font-size: 13px; } - .hero-proof { margin-top: 16px; border-top: 1px solid @@ -324,7 +343,6 @@ h1 { color-mix(in srgb, var(--color-logo-primary) 18%, transparent); } - .plan-name { margin: 0; font-size: 12px; @@ -432,3 +450,53 @@ details p { min-width: 0; } } + +.download-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 16px; +} + +.download-actions .btn { + min-height: 44px; + min-width: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 20px; +} + +.download-actions .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.download-status { + margin-top: 14px; + font-size: 14px; + color: color-mix(in srgb, var(--color-text) 78%, transparent); + min-height: 20px; +} + +.field-label { + display: block; + font-weight: 500; + margin-bottom: 8px; +} + +.text-input { + width: 100%; + min-height: 44px; + padding: 10px 14px; + font-size: 14px; + color: var(--color-text); + background: color-mix(in srgb, var(--color-mid-gray) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-mid-gray) 30%, transparent); + border-radius: 10px; +} + +.text-input::placeholder { + color: color-mix(in srgb, var(--color-text) 46%, transparent); +} diff --git a/landing/tests/billing.test.js b/landing/tests/billing.test.js index b4b2e81..23623c8 100644 --- a/landing/tests/billing.test.js +++ b/landing/tests/billing.test.js @@ -1,7 +1,6 @@ const assert = require("node:assert/strict"); const { test, beforeEach, afterEach } = require("node:test"); -const SECRET = "a".repeat(64); const PRICE = "price_DictxPro123"; const LIVE_SESSION = "cs_live_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuVwXyZ"; const ENV_KEYS = [ @@ -9,7 +8,6 @@ const ENV_KEYS = [ "STRIPE_COMMERCE_MODE", "STRIPE_SECRET_KEY", "DICTX_STRIPE_PRICE_ID", - "DICTX_LICENSE_SECRET", "DICTX_STRIPE_PAYMENT_LINK", ]; const savedEnv = {}; @@ -18,7 +16,6 @@ const realFetch = globalThis.fetch; const configureStripe = () => { process.env.STRIPE_SECRET_KEY = "rk_live_restrictedKey123"; process.env.DICTX_STRIPE_PRICE_ID = PRICE; - process.env.DICTX_LICENSE_SECRET = SECRET; }; const paidSession = (overrides = {}) => ({ @@ -38,27 +35,6 @@ const oneProItem = (overrides = {}) => ({ has_more: false, }); -const stubStripe = ({ - session = paidSession(), - items = oneProItem(), - status = 200, -} = {}) => { - const calls = []; - globalThis.fetch = async (url) => { - calls.push(String(url)); - if (status !== 200) return new Response("{}", { status }); - const body = String(url).includes("/line_items") ? items : session; - return new Response(JSON.stringify(body), { status: 200 }); - }; - return calls; -}; - -const failOnNetwork = () => { - globalThis.fetch = async (url) => { - throw new Error(`unexpected network call: ${url}`); - }; -}; - const fakeRes = () => ({ statusCode: 0, headers: {}, @@ -105,21 +81,6 @@ afterEach(() => { globalThis.fetch = realFetch; }); -test("license keys round-trip, reject tampering, and fit the verify charset", () => { - const { issueLicenseKey, parseLicenseKey } = require("../api/_lib/license"); - const key = issueLicenseKey(LIVE_SESSION, SECRET); - assert.match(key, /^[A-Za-z0-9_-]{8,128}$/); - assert.deepEqual(parseLicenseKey(key, SECRET), { sessionId: LIVE_SESSION }); - assert.equal(parseLicenseKey(key, "b".repeat(64)), null); - const flipped = key.slice(0, -1) + (key.endsWith("0") ? "1" : "0"); - assert.equal(parseLicenseKey(flipped, SECRET), null); - assert.equal( - parseLicenseKey(key.replace("cs_live_a1", "cs_live_z9"), SECRET), - null, - ); - assert.equal(parseLicenseKey("lk_abcdef123456", SECRET), null); -}); - test("purchase classification is exact and fail-closed", () => { const { classifyPurchase } = require("../api/_lib/stripe-purchase"); const base = { @@ -136,7 +97,7 @@ test("purchase classification is exact and fail-closed", () => { session: paidSession({ payment_status: "no_payment_required" }), }).ok, true, - "a 100% promotion code still earns the license", + "a 100% promotion code still counts as paid", ); assert.equal( reason({ session: paidSession({ payment_status: "unpaid" }) }), @@ -181,6 +142,30 @@ test("purchase classification is exact and fail-closed", () => { }), "refunded", ); + assert.equal( + reason({ + session: paidSession({ + payment_intent: { + latest_charge: { + refunded: false, + disputed: false, + amount_refunded: 500, + }, + }, + }), + }), + "refunded", + "a partial refund also ends the download", + ); + assert.equal( + reason({ session: paidSession({ payment_intent: null }) }), + "provider_invalid", + "a paid session without a charge to check fails closed", + ); + assert.equal( + reason({ session: paidSession({ payment_intent: "pi_unexpanded" }) }), + "provider_invalid", + ); }); test("production forces live mode whatever STRIPE_COMMERCE_MODE says", () => { @@ -227,118 +212,3 @@ test("/buy redirects only to a fully configured canonical live link, else 503", assert.equal(visit().statusCode, 503, bad); } }); - -test("the license endpoint issues keys only for verified purchases", async () => { - const license = fresh("../api/pro/license"); - const { parseLicenseKey } = require("../api/_lib/license"); - let client = 0; - const call = async (sessionId) => { - const res = fakeRes(); - client += 1; - await license( - { - method: "GET", - query: { session_id: sessionId }, - headers: { "x-forwarded-for": `license-${client}` }, - }, - res, - ); - return res; - }; - - assert.equal((await call(LIVE_SESSION)).statusCode, 503, "not configured"); - - configureStripe(); - const calls = stubStripe(); - const ok = await call(LIVE_SESSION); - assert.equal(ok.statusCode, 200); - assert.deepEqual(parseLicenseKey(ok.body.licenseKey, SECRET), { - sessionId: LIVE_SESSION, - }); - assert.ok( - calls.every((url) => - url.startsWith("https://api.stripe.com/v1/checkout/sessions/"), - ), - ); - - stubStripe({ - session: paidSession({ - payment_intent: { latest_charge: { refunded: true } }, - }), - }); - assert.equal((await call(LIVE_SESSION)).statusCode, 410); - stubStripe({ status: 404 }); - assert.equal((await call(LIVE_SESSION)).statusCode, 404); - stubStripe({ status: 500 }); - assert.equal((await call(LIVE_SESSION)).statusCode, 502); - assert.equal((await call("cs_test_a1B2c3D4e5F6g7H8")).statusCode, 404); - assert.equal((await call("not-a-session")).statusCode, 404); -}); - -const postVerify = async (verify, licenseKey, clientId) => { - const res = fakeRes(); - await verify( - { - method: "POST", - body: { licenseKey }, - headers: { "x-forwarded-for": clientId }, - }, - res, - ); - return res; -}; - -test("verify checks Stripe keys and keeps outages non-revoking", async () => { - const verify = fresh("../api/pro/verify"); - const { issueLicenseKey } = require("../api/_lib/license"); - configureStripe(); - const key = issueLicenseKey(LIVE_SESSION, SECRET); - - stubStripe({ status: 500 }); - let res = await postVerify(verify, key, "verify-1"); - assert.equal(res.statusCode, 502, "an outage is an error, not a revocation"); - - stubStripe(); - res = await postVerify(verify, key, "verify-2"); - assert.deepEqual([res.statusCode, res.body.active], [200, true]); - - res = await postVerify(verify, key.slice(0, -2) + "00", "verify-3"); - assert.deepEqual([res.statusCode, res.body.active], [200, false]); -}); - -test("retired Polar keys answer 410 without any network call", async () => { - const verify = fresh("../api/pro/verify"); - configureStripe(); - failOnNetwork(); - for (const [index, key] of [ - "lk_legacyKey12345", - "polar_cl_abcdef123", - ].entries()) { - const res = await postVerify(verify, key, `polar-${index}`); - // 410 is an error to installed apps, so an already-activated Polar - // customer keeps Pro instead of being switched off by the retirement. - assert.deepEqual([res.statusCode, res.body.error], [410, "polar_retired"]); - } - const unknown = await postVerify(verify, "somethingElse123", "polar-x"); - assert.deepEqual([unknown.statusCode, unknown.body.active], [200, false]); -}); - -test("a refunded Stripe purchase deactivates on the next verification", async () => { - const verify = fresh("../api/pro/verify"); - const { issueLicenseKey } = require("../api/_lib/license"); - configureStripe(); - stubStripe({ - session: paidSession({ - payment_intent: { latest_charge: { refunded: true } }, - }), - }); - const res = await postVerify( - verify, - issueLicenseKey(LIVE_SESSION, SECRET), - "verify-5", - ); - assert.deepEqual( - [res.statusCode, res.body.active, res.body.reason], - [200, false, "refunded"], - ); -}); diff --git a/landing/tests/download.test.js b/landing/tests/download.test.js new file mode 100644 index 0000000..d1f8f28 --- /dev/null +++ b/landing/tests/download.test.js @@ -0,0 +1,329 @@ +const assert = require("node:assert/strict"); +const { test, beforeEach, afterEach } = require("node:test"); + +const PRICE = "price_DictxPro123"; +const LIVE_SESSION = "cs_live_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuVwXyZ"; +const ENV_KEYS = [ + "VERCEL_ENV", + "STRIPE_COMMERCE_MODE", + "STRIPE_SECRET_KEY", + "DICTX_STRIPE_PRICE_ID", + "DICTX_STRIPE_PAYMENT_LINK", + "R2_ACCOUNT_ID", + "R2_ACCESS_KEY_ID", + "R2_SECRET_ACCESS_KEY", + "R2_PRIVATE_BUCKET", + "DOWNLOAD_RATE_LIMIT_WINDOW_MS", + "DOWNLOAD_RATE_LIMIT_MAX", +]; +const savedEnv = {}; +const realFetch = globalThis.fetch; + +const configureStripe = () => { + process.env.STRIPE_SECRET_KEY = "rk_live_restrictedKey123"; + process.env.DICTX_STRIPE_PRICE_ID = PRICE; +}; + +const configureR2 = () => { + process.env.R2_ACCOUNT_ID = "acct123"; + process.env.R2_ACCESS_KEY_ID = "AKIAEXAMPLE"; + process.env.R2_SECRET_ACCESS_KEY = "secretExample"; + process.env.R2_PRIVATE_BUCKET = "dictx-private"; +}; + +const paidSession = (overrides = {}) => ({ + id: LIVE_SESSION, + livemode: true, + status: "complete", + payment_status: "paid", + payment_intent: { + id: "pi_1", + latest_charge: { id: "ch_1", refunded: false, disputed: false }, + }, + ...overrides, +}); + +const oneProItem = (overrides = {}) => ({ + data: [{ quantity: 1, price: { id: PRICE }, ...overrides }], + has_more: false, +}); + +const stubStripe = ({ + session = paidSession(), + items = oneProItem(), + status = 200, +} = {}) => { + const calls = []; + globalThis.fetch = async (url) => { + calls.push(String(url)); + if (status !== 200) return new Response("{}", { status }); + const body = String(url).includes("/line_items") ? items : session; + return new Response(JSON.stringify(body), { status: 200 }); + }; + return calls; +}; + +const failOnNetwork = () => { + globalThis.fetch = async (url) => { + throw new Error(`unexpected network call: ${url}`); + }; +}; + +const fakeRes = () => ({ + statusCode: 0, + headers: {}, + body: undefined, + setHeader(key, value) { + this.headers[key.toLowerCase()] = value; + }, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + send(body) { + this.body = body; + return this; + }, +}); + +const fresh = (path) => { + delete require.cache[require.resolve(path)]; + return require(path); +}; + +beforeEach(() => { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + globalThis.fetch = realFetch; +}); + +test("presignGetUrl matches the AWS documented presigned-URL example", () => { + const { presignGetUrl } = require("../api/_lib/r2-presign"); + // Placeholder credentials published in the AWS SigV4 documentation, not real + // keys. Assembled from parts so secret scanners do not flag the test vector. + const docsExample = (...parts) => parts.join(""); + const url = presignGetUrl({ + accessKeyId: docsExample("AKIAIOSFODNN7", "EXAMPLE"), + secretAccessKey: docsExample( + "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", + "EXAMPLEKEY", + ), + bucket: "examplebucket", + key: "test.txt", + region: "us-east-1", + service: "s3", + expiresIn: 86400, + now: new Date("2013-05-24T00:00:00Z"), + host: "examplebucket.s3.amazonaws.com", + virtualHosted: true, + }); + assert.match( + url, + /^https:\/\/examplebucket\.s3\.amazonaws\.com\/test\.txt\?/, + ); + assert.match( + url, + /X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404$/, + ); +}); + +const call = async (handler, { body, clientId = "client-1" } = {}) => { + const res = fakeRes(); + await handler( + { + method: "POST", + body: body || {}, + headers: { "x-forwarded-for": clientId }, + }, + res, + ); + return res; +}; + +test("a verified purchase gets a presigned R2 URL scoped to the requested arch", async () => { + const macos = fresh("../api/download/macos"); + configureStripe(); + configureR2(); + stubStripe(); + + const armRes = await call(macos, { + body: { session_id: LIVE_SESSION, arch: "aarch64" }, + clientId: "arch-1", + }); + assert.equal(armRes.statusCode, 200); + assert.equal(armRes.headers["cache-control"], "no-store"); + assert.equal(armRes.body.expiresIn, 300); + const armUrl = new URL(armRes.body.url); + assert.equal(armUrl.hostname, "acct123.r2.cloudflarestorage.com"); + assert.equal( + armUrl.pathname, + "/dictx-private/macos/latest/Dictx_aarch64.dmg", + ); + assert.equal(armUrl.searchParams.get("X-Amz-Expires"), "300"); + assert.ok(armUrl.searchParams.get("X-Amz-Signature")); + + stubStripe(); + const intelRes = await call(macos, { + body: { session_id: LIVE_SESSION, arch: "x64" }, + clientId: "arch-2", + }); + assert.equal(intelRes.statusCode, 200); + const intelUrl = new URL(intelRes.body.url); + assert.equal(intelUrl.pathname, "/dictx-private/macos/latest/Dictx_x64.dmg"); +}); + +test("defaults to aarch64 when arch is omitted, rejects unknown arches", async () => { + const macos = fresh("../api/download/macos"); + configureStripe(); + configureR2(); + + stubStripe(); + const defaultRes = await call(macos, { + body: { session_id: LIVE_SESSION }, + clientId: "default-arch", + }); + assert.equal(defaultRes.statusCode, 200); + assert.match(new URL(defaultRes.body.url).pathname, /Dictx_aarch64\.dmg$/); + + const badRes = await call(macos, { + body: { session_id: LIVE_SESSION, arch: "windows" }, + clientId: "bad-arch", + }); + assert.equal(badRes.statusCode, 400); +}); + +test("refuses unpaid, refunded and unknown sessions without issuing a URL", async () => { + const macos = fresh("../api/download/macos"); + configureStripe(); + configureR2(); + + stubStripe({ session: paidSession({ payment_status: "unpaid" }) }); + assert.equal( + ( + await call(macos, { + body: { session_id: LIVE_SESSION }, + clientId: "unpaid", + }) + ).statusCode, + 402, + ); + + stubStripe({ + session: paidSession({ + payment_intent: { latest_charge: { refunded: true } }, + }), + }); + assert.equal( + ( + await call(macos, { + body: { session_id: LIVE_SESSION }, + clientId: "refunded", + }) + ).statusCode, + 410, + ); + + stubStripe({ status: 404 }); + assert.equal( + ( + await call(macos, { + body: { session_id: LIVE_SESSION }, + clientId: "unknown", + }) + ).statusCode, + 404, + ); +}); + +test("malformed session ids are rejected without any Stripe call", async () => { + const macos = fresh("../api/download/macos"); + configureStripe(); + configureR2(); + failOnNetwork(); + + const res = await call(macos, { + body: { session_id: "not-a-session" }, + clientId: "malformed", + }); + assert.equal(res.statusCode, 404); +}); + +test("GET is rejected with 405", async () => { + const macos = fresh("../api/download/macos"); + configureStripe(); + configureR2(); + const res = fakeRes(); + await macos({ method: "GET", headers: {} }, res); + assert.equal(res.statusCode, 405); +}); + +test("missing R2 or Stripe config fails closed with 503", async () => { + const macos = fresh("../api/download/macos"); + + let res = await call(macos, { + body: { session_id: LIVE_SESSION }, + clientId: "no-config", + }); + assert.equal(res.statusCode, 503); + assert.equal(res.body.error, "stripe_not_configured"); + + configureStripe(); + res = await call(macos, { + body: { session_id: LIVE_SESSION }, + clientId: "no-r2", + }); + assert.equal(res.statusCode, 503); + assert.equal(res.body.error, "r2_not_configured"); +}); + +test("a Stripe outage answers 502, not a download URL", async () => { + const macos = fresh("../api/download/macos"); + configureStripe(); + configureR2(); + stubStripe({ status: 500 }); + + const res = await call(macos, { + body: { session_id: LIVE_SESSION }, + clientId: "outage", + }); + assert.equal(res.statusCode, 502); +}); + +test("rate limits clients after too many requests", async () => { + configureStripe(); + configureR2(); + process.env.DOWNLOAD_RATE_LIMIT_WINDOW_MS = "60000"; + process.env.DOWNLOAD_RATE_LIMIT_MAX = "2"; + const macos = fresh("../api/download/macos"); + stubStripe(); + + const clientId = "rate-limited-client"; + const first = await call(macos, { + body: { session_id: LIVE_SESSION }, + clientId, + }); + assert.equal(first.statusCode, 200); + const second = await call(macos, { + body: { session_id: LIVE_SESSION }, + clientId, + }); + assert.equal(second.statusCode, 200); + const third = await call(macos, { + body: { session_id: LIVE_SESSION }, + clientId, + }); + assert.equal(third.statusCode, 429); +}); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f23c35f..1893ce5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,7 +1,6 @@ pub mod audio; pub mod history; pub mod models; -pub mod pro; pub mod transcription; use crate::settings::{get_settings, write_settings, AppSettings, LogLevel, OverlayPosition}; diff --git a/src-tauri/src/commands/pro.rs b/src-tauri/src/commands/pro.rs deleted file mode 100644 index 5454006..0000000 --- a/src-tauri/src/commands/pro.rs +++ /dev/null @@ -1,327 +0,0 @@ -use crate::settings::{self, ProEntitlement, ProEntitlementSource}; -use chrono::Utc; -use reqwest::StatusCode; -use serde::{Deserialize, Serialize}; -use tauri::AppHandle; - -const DEFAULT_PRO_VERIFY_URL: &str = "https://dictx.splitlabs.io/api/pro/verify"; -const DEFAULT_PRO_EARLY_ACCESS_URL: &str = "https://dictx.splitlabs.io/api/pro/early-access/claim"; - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct VerifyRequest { - license_key: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct VerifyResponse { - active: bool, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct EarlyAccessClaimRequest { - install_id: String, - app_version: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct EarlyAccessClaimResponse { - active: bool, - #[serde(default)] - license_key: Option, -} - -fn get_verify_url() -> String { - std::env::var("DICTX_PRO_VERIFY_URL").unwrap_or_else(|_| DEFAULT_PRO_VERIFY_URL.to_string()) -} - -fn get_early_access_url() -> String { - std::env::var("DICTX_PRO_EARLY_ACCESS_URL") - .unwrap_or_else(|_| DEFAULT_PRO_EARLY_ACCESS_URL.to_string()) -} - -fn make_install_id() -> String { - format!( - "dictx-{}-{}", - Utc::now().timestamp_millis(), - std::process::id() - ) -} - -fn resolve_install_id(app_settings: &mut settings::AppSettings) -> String { - if let Some(existing) = app_settings.pro_entitlement.install_id.clone() { - let trimmed = existing.trim(); - if !trimmed.is_empty() { - return trimmed.to_string(); - } - } - - let install_id = make_install_id(); - app_settings.pro_entitlement.install_id = Some(install_id.clone()); - install_id -} - -fn is_early_adopter_marker(value: &str) -> bool { - value.starts_with("EARLY-") -} - -async fn verify_checkout(license_key: &str) -> Result { - let payload = VerifyRequest { - license_key: license_key.trim().to_string(), - }; - - if payload.license_key.is_empty() { - return Err("License key is required".to_string()); - } - - let client = reqwest::Client::new(); - let response = client - .post(get_verify_url()) - .json(&payload) - .send() - .await - .map_err(|e| format!("Verification request failed: {}", e))?; - - if response.status() == StatusCode::OK { - let body: VerifyResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse verification response: {}", e))?; - Ok(body.active) - } else if response.status() == StatusCode::UNAUTHORIZED - || response.status() == StatusCode::NOT_FOUND - { - Ok(false) - } else { - let status = response.status(); - let text = response - .text() - .await - .unwrap_or_else(|_| "unknown error".to_string()); - Err(format!("Verification API error ({}): {}", status, text)) - } -} - -async fn claim_early_access(install_id: &str, app_version: &str) -> Result, String> { - let payload = EarlyAccessClaimRequest { - install_id: install_id.trim().to_string(), - app_version: app_version.trim().to_string(), - }; - - if payload.install_id.is_empty() { - return Err("Install ID is required".to_string()); - } - - let client = reqwest::Client::new(); - let response = client - .post(get_early_access_url()) - .json(&payload) - .send() - .await - .map_err(|e| format!("Early access request failed: {}", e))?; - - if response.status() == StatusCode::OK { - let body: EarlyAccessClaimResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse early access response: {}", e))?; - - if !body.active { - return Ok(None); - } - - return Ok(Some( - body.license_key - .unwrap_or_else(|| format!("EARLY-{}", payload.install_id)), - )); - } - - if response.status() == StatusCode::UNAUTHORIZED - || response.status() == StatusCode::FORBIDDEN - || response.status() == StatusCode::NOT_FOUND - || response.status() == StatusCode::CONFLICT - { - return Ok(None); - } - - let status = response.status(); - let text = response - .text() - .await - .unwrap_or_else(|_| "unknown error".to_string()); - Err(format!("Early access API error ({}): {}", status, text)) -} - -#[tauri::command] -#[specta::specta] -pub fn get_pro_entitlement(app: AppHandle) -> Result { - let settings = settings::get_settings(&app); - Ok(settings.pro_entitlement) -} - -#[tauri::command] -#[specta::specta] -pub fn clear_pro_entitlement(app: AppHandle) -> Result { - let mut app_settings = settings::get_settings(&app); - app_settings.pro_entitlement = ProEntitlement::default(); - app_settings.update_checks_enabled = false; - settings::write_settings(&app, app_settings.clone()); - crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None); - Ok(app_settings.pro_entitlement) -} - -#[tauri::command] -#[specta::specta] -pub async fn activate_pro_entitlement( - app: AppHandle, - license_key: String, -) -> Result { - let active = verify_checkout(&license_key).await?; - if !active { - return Err("No active Dictx Pro entitlement found for this license key".to_string()); - } - - let now = Utc::now().timestamp(); - let mut app_settings = settings::get_settings(&app); - app_settings.pro_entitlement = ProEntitlement { - active: true, - source: Some(ProEntitlementSource::LicenseKey), - license_key: Some(license_key.trim().to_string()), - email: None, - checkout_id: None, - activated_at: Some(now), - last_verified_at: Some(now), - verification_error: None, - install_id: app_settings.pro_entitlement.install_id.clone(), - }; - app_settings.update_checks_enabled = true; - settings::write_settings(&app, app_settings.clone()); - crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None); - - Ok(app_settings.pro_entitlement) -} - -#[tauri::command] -#[specta::specta] -pub async fn refresh_pro_entitlement(app: AppHandle) -> Result { - let mut app_settings = settings::get_settings(&app); - let now = Utc::now().timestamp(); - let install_id = resolve_install_id(&mut app_settings); - let source = app_settings.pro_entitlement.source; - let has_early_marker = app_settings - .pro_entitlement - .license_key - .as_deref() - .is_some_and(is_early_adopter_marker); - - // Support legacy activations by falling back to checkout_id if license_key is missing. - let license_key = app_settings - .pro_entitlement - .license_key - .clone() - .or_else(|| app_settings.pro_entitlement.checkout_id.clone()) - .filter(|value| !value.trim().is_empty()); - - if source == Some(ProEntitlementSource::EarlyAdopter) || has_early_marker { - if app_settings.pro_entitlement.active { - app_settings.pro_entitlement.source = Some(ProEntitlementSource::EarlyAdopter); - app_settings.pro_entitlement.last_verified_at = Some(now); - app_settings.pro_entitlement.verification_error = None; - } else { - let app_version = app.package_info().version.to_string(); - match claim_early_access(&install_id, &app_version).await { - Ok(Some(grant_id)) => { - app_settings.pro_entitlement.active = true; - app_settings.pro_entitlement.source = Some(ProEntitlementSource::EarlyAdopter); - app_settings.pro_entitlement.license_key = Some(grant_id); - app_settings.pro_entitlement.activated_at = Some(now); - app_settings.pro_entitlement.last_verified_at = Some(now); - app_settings.pro_entitlement.verification_error = None; - app_settings.update_checks_enabled = true; - } - Ok(None) => { - app_settings.pro_entitlement.last_verified_at = Some(now); - app_settings.pro_entitlement.verification_error = None; - } - Err(err) => { - app_settings.pro_entitlement.verification_error = Some(err.clone()); - settings::write_settings(&app, app_settings.clone()); - return Err(err); - } - } - } - } else if let Some(license_key) = license_key { - match verify_checkout(&license_key).await { - Ok(true) => { - app_settings.pro_entitlement.active = true; - app_settings.pro_entitlement.source = Some(ProEntitlementSource::LicenseKey); - app_settings.pro_entitlement.license_key = Some(license_key); - app_settings.pro_entitlement.last_verified_at = Some(now); - app_settings.pro_entitlement.verification_error = None; - } - Ok(false) => { - app_settings.pro_entitlement.active = false; - app_settings.pro_entitlement.last_verified_at = Some(now); - app_settings.pro_entitlement.verification_error = - Some("Entitlement no longer active".to_string()); - app_settings.update_checks_enabled = false; - } - Err(err) => { - app_settings.pro_entitlement.verification_error = Some(err.clone()); - settings::write_settings(&app, app_settings.clone()); - return Err(err); - } - } - } else if !app_settings.pro_entitlement.active { - let app_version = app.package_info().version.to_string(); - match claim_early_access(&install_id, &app_version).await { - Ok(Some(grant_id)) => { - app_settings.pro_entitlement.active = true; - app_settings.pro_entitlement.source = Some(ProEntitlementSource::EarlyAdopter); - app_settings.pro_entitlement.license_key = Some(grant_id); - app_settings.pro_entitlement.activated_at = Some(now); - app_settings.pro_entitlement.last_verified_at = Some(now); - app_settings.pro_entitlement.verification_error = None; - app_settings.update_checks_enabled = true; - } - Ok(None) => { - app_settings.pro_entitlement.last_verified_at = Some(now); - app_settings.pro_entitlement.verification_error = None; - } - Err(err) => { - app_settings.pro_entitlement.verification_error = Some(err.clone()); - settings::write_settings(&app, app_settings.clone()); - return Err(err); - } - } - } else { - app_settings.pro_entitlement.last_verified_at = Some(now); - app_settings.pro_entitlement.verification_error = None; - } - - app_settings.pro_entitlement.install_id = Some(install_id); - settings::write_settings(&app, app_settings.clone()); - crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None); - Ok(app_settings.pro_entitlement) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn make_install_id_has_prefix() { - let id = make_install_id(); - assert!(id.starts_with("dictx-")); - assert!(id.len() > "dictx-".len()); - } - - #[test] - fn early_adopter_marker_detection() { - assert!(is_early_adopter_marker("EARLY-dictx-123")); - assert!(!is_early_adopter_marker("lk_123")); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2efb459..9089aa5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -192,7 +192,7 @@ fn initialize_core_logic(app_handle: &AppHandle) { } "check_updates" => { let settings = settings::get_settings(app); - if settings.update_checks_enabled && settings.pro_entitlement.active { + if settings.update_checks_enabled { show_main_window(app); let _ = app.emit("check-for-updates", ()); } @@ -275,7 +275,7 @@ fn initialize_core_logic(app_handle: &AppHandle) { #[specta::specta] fn trigger_update_check(app: AppHandle) -> Result<(), String> { let settings = settings::get_settings(&app); - if !settings.update_checks_enabled || !settings.pro_entitlement.active { + if !settings.update_checks_enabled { return Ok(()); } app.emit("check-for-updates", ()) @@ -387,10 +387,6 @@ pub fn run(cli_args: CliArgs) { commands::history::delete_history_entry, commands::history::update_history_limit, commands::history::update_recording_retention_period, - commands::pro::get_pro_entitlement, - commands::pro::activate_pro_entitlement, - commands::pro::refresh_pro_entitlement, - commands::pro::clear_pro_entitlement, helpers::clamshell::is_laptop, ]); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 1da9de2..1588c73 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -267,37 +267,6 @@ pub enum TypingTool { Xdotool, } -#[derive(Serialize, Deserialize, Debug, Clone, Type, Default)] -pub struct ProEntitlement { - #[serde(default)] - pub active: bool, - #[serde(default)] - pub source: Option, - #[serde(default)] - pub license_key: Option, - #[serde(default)] - // Legacy field kept for backward compatibility with older local settings. - pub email: Option, - #[serde(default)] - // Legacy field kept for backward compatibility with older local settings. - pub checkout_id: Option, - #[serde(default)] - pub activated_at: Option, - #[serde(default)] - pub last_verified_at: Option, - #[serde(default)] - pub verification_error: Option, - #[serde(default)] - pub install_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)] -#[serde(rename_all = "snake_case")] -pub enum ProEntitlementSource { - LicenseKey, - EarlyAdopter, -} - /* still handy for composing the initial JSON in the store ------------- */ #[derive(Serialize, Deserialize, Debug, Clone, Type)] pub struct AppSettings { @@ -399,8 +368,6 @@ pub struct AppSettings { pub obsidian_export_subfolder: Option, #[serde(default)] pub obsidian_append_to_daily: bool, - #[serde(default)] - pub pro_entitlement: ProEntitlement, } fn default_model() -> String { @@ -781,7 +748,6 @@ pub fn get_default_settings() -> AppSettings { obsidian_vault_path: None, obsidian_export_subfolder: default_obsidian_export_subfolder(), obsidian_append_to_daily: false, - pro_entitlement: ProEntitlement::default(), } } @@ -913,11 +879,6 @@ pub fn get_history_limit(app: &AppHandle) -> usize { settings.history_limit } -pub fn is_pro_active(app: &AppHandle) -> bool { - let settings = get_settings(app); - settings.pro_entitlement.active -} - pub fn get_recording_retention_period(app: &AppHandle) -> RecordingRetentionPeriod { let settings = get_settings(app); settings.recording_retention_period diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 67f9a82..1a0422a 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -660,10 +660,6 @@ pub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), Str #[tauri::command] #[specta::specta] pub fn change_update_checks_setting(app: AppHandle, enabled: bool) -> Result<(), String> { - if enabled && !settings::is_pro_active(&app) { - return Err("Dictx Pro activation is required for auto-updates".to_string()); - } - let mut settings = settings::get_settings(&app); settings.update_checks_enabled = enabled; settings::write_settings(&app, settings); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 46459b1..442f664 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -124,7 +124,7 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<& app, "check_updates", &strings.check_updates, - settings.update_checks_enabled && settings.pro_entitlement.active, + settings.update_checks_enabled, None::<&str>, ) .expect("failed to create check updates item"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d758fdc..6f2be4a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -40,7 +40,7 @@ "createUpdaterArtifacts": true, "targets": "all", "resources": ["resources/**/*"], - "license": "MIT", + "license": "GPL-3.0-or-later", "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -73,9 +73,7 @@ }, "plugins": { "updater": { - "endpoints": [ - "https://github.com/splitlabs/dictx/releases/latest/download/latest.json" - ], + "endpoints": ["https://updates.dictx.splitlabs.io/latest.json"], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMzMTRDRUZDN0Y0NDdGQTYKUldTbWYwUi8vTTRVdzVGSWFGdjJtK1NxZFBFbXlZTnBJWVo1MUpZQUlnRGxITkUvR2FFWFFpbWcK" } } diff --git a/src/bindings.ts b/src/bindings.ts index 9a4cc62..33c970f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -781,38 +781,6 @@ async updateRecordingRetentionPeriod(period: string) : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("get_pro_entitlement") }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async activateProEntitlement(licenseKey: string) : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("activate_pro_entitlement", { licenseKey }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async refreshProEntitlement() : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("refresh_pro_entitlement") }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async clearProEntitlement() : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("clear_pro_entitlement") }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, /** * Checks if the Mac is a laptop by detecting battery presence * @@ -839,7 +807,7 @@ async isLaptop() : Promise> { /** user-defined types **/ -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; voice_commands_enabled?: boolean; dictation_mode?: DictationMode; overlay_position?: OverlayPosition; overlay_custom_x?: number | null; overlay_custom_y?: number | null; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; obsidian_export_enabled?: boolean; obsidian_vault_path?: string | null; obsidian_export_subfolder?: string | null; obsidian_append_to_daily?: boolean; pro_entitlement?: ProEntitlement } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; voice_commands_enabled?: boolean; dictation_mode?: DictationMode; overlay_position?: OverlayPosition; overlay_custom_x?: number | null; overlay_custom_y?: number | null; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; obsidian_export_enabled?: boolean; obsidian_vault_path?: string | null; obsidian_export_subfolder?: string | null; obsidian_append_to_daily?: boolean } export type AudioDevice = { index: string; name: string; is_default: boolean } export type AutoSubmitKey = "enter" | "ctrl_enter" | "cmd_enter" export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null } @@ -865,7 +833,6 @@ export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | " export type OverlayPosition = "none" | "top" | "bottom" | "custom" export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } -export type ProEntitlement = { active?: boolean; license_key?: string | null; email?: string | null; checkout_id?: string | null; activated_at?: number | null; last_verified_at?: number | null; verification_error?: string | null } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } export type SoundTheme = "marimba" | "pop" | "custom" diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 1641e85..230ff1d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -4,8 +4,6 @@ import { Cog, FlaskConical, History, Info, Sparkles, Cpu } from "lucide-react"; import DictxTextLogo from "./icons/DictxTextLogo"; import DictxIcon from "./icons/DictxIcon"; import { useSettings } from "../hooks/useSettings"; -import { useProEntitlement } from "@/hooks/useProEntitlement"; -import { openProPurchasePage } from "@/utils/commerce"; import { GeneralSettings, AdvancedSettings, @@ -89,8 +87,6 @@ export const Sidebar: React.FC = ({ }) => { const { t } = useTranslation(); const { settings } = useSettings(); - const { entitlement } = useProEntitlement(); - const isProActive = entitlement?.active ?? false; const availableSections = Object.entries(SECTIONS_CONFIG) .filter(([_, config]) => config.enabled(settings)) @@ -132,16 +128,6 @@ export const Sidebar: React.FC = ({
); })} - {!isProActive && ( -
- -
- )}
); diff --git a/src/components/settings/UpdateChecksToggle.tsx b/src/components/settings/UpdateChecksToggle.tsx index bb06155..675dc3a 100644 --- a/src/components/settings/UpdateChecksToggle.tsx +++ b/src/components/settings/UpdateChecksToggle.tsx @@ -2,9 +2,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; -import { useProEntitlement } from "@/hooks/useProEntitlement"; -import { openProPurchasePage } from "@/utils/commerce"; -import { Button } from "../ui/Button"; interface UpdateChecksToggleProps { descriptionMode?: "inline" | "tooltip"; @@ -17,33 +14,17 @@ export const UpdateChecksToggle: React.FC = ({ }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); - const { entitlement } = useProEntitlement(); const updateChecksEnabled = getSetting("update_checks_enabled") ?? true; - const proActive = entitlement?.active ?? false; return ( -
- updateSetting("update_checks_enabled", enabled)} - isUpdating={isUpdating("update_checks_enabled")} - label={t("settings.debug.updateChecks.label")} - description={ - proActive - ? t("settings.debug.updateChecks.description") - : t("settings.debug.updateChecks.proRequired") - } - descriptionMode={descriptionMode} - grouped={grouped} - disabled={!proActive} - /> - {!proActive && ( -
- -
- )} -
+ updateSetting("update_checks_enabled", enabled)} + isUpdating={isUpdating("update_checks_enabled")} + label={t("settings.debug.updateChecks.label")} + description={t("settings.debug.updateChecks.description")} + descriptionMode={descriptionMode} + grouped={grouped} + /> ); }; diff --git a/src/components/settings/about/AboutSettings.tsx b/src/components/settings/about/AboutSettings.tsx index 5459315..8a195b2 100644 --- a/src/components/settings/about/AboutSettings.tsx +++ b/src/components/settings/about/AboutSettings.tsx @@ -2,28 +2,17 @@ import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { getVersion } from "@tauri-apps/api/app"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { CheckCircle2, KeyRound } from "lucide-react"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { SettingContainer } from "../../ui/SettingContainer"; import { Button } from "../../ui/Button"; -import { Input } from "../../ui/Input"; import { openProPurchasePage } from "@/utils/commerce"; import { AppDataDirectory } from "../AppDataDirectory"; import { AppLanguageSelector } from "../AppLanguageSelector"; import { LogDirectory } from "../debug"; -import { useProEntitlement } from "@/hooks/useProEntitlement"; export const AboutSettings: React.FC = () => { const { t } = useTranslation(); const [version, setVersion] = useState(""); - const [licenseKey, setLicenseKey] = useState(""); - const [clipboardError, setClipboardError] = useState(null); - const { - entitlement, - isSubmitting: proSubmitting, - error: proError, - activate, - } = useProEntitlement(); useEffect(() => { const fetchVersion = async () => { @@ -39,33 +28,6 @@ export const AboutSettings: React.FC = () => { fetchVersion(); }, []); - const handleActivate = async () => { - const ok = await activate(licenseKey); - if (ok) { - setLicenseKey(""); - } - }; - - const handlePasteFromClipboard = async () => { - try { - setClipboardError(null); - const text = await navigator.clipboard.readText(); - setLicenseKey(text.trim()); - } catch (_error) { - setClipboardError(t("settings.about.proActivation.clipboardFailed")); - } - }; - - const activeLicenseKey = entitlement?.license_key ?? entitlement?.checkout_id; - const hasTypedKey = licenseKey.trim().length > 0; - const showActivateButton = !entitlement?.active || hasTypedKey; - const proSectionTitle = entitlement?.active - ? "Dictx Pro" - : t("settings.about.supportDevelopment.title"); - const proSectionDescription = entitlement?.active - ? "" - : t("settings.about.supportDevelopment.description"); - return (
@@ -80,98 +42,17 @@ export const AboutSettings: React.FC = () => { -
- {!entitlement?.active && ( -
- -
- )} - -
-
-
- {entitlement?.active ? ( - - ) : ( - - )} -

- {entitlement?.active - ? t("settings.about.proActivation.active") - : t("settings.about.proActivation.inactive")} -

-
- {activeLicenseKey && ( -
-

- {t("settings.about.proActivation.licenseKey")} -

-

- {activeLicenseKey} -

-
- )} -
- -
-
- setLicenseKey(event.target.value)} - placeholder={t( - "settings.about.proActivation.licenseKeyPlaceholder", - )} - disabled={proSubmitting} - /> - -
- - {showActivateButton && ( - - )} -
- {clipboardError && ( -

{clipboardError}

- )} - {proError &&

{proError}

} - {entitlement?.verification_error && ( -

- {entitlement.verification_error} -

- )} -
-
+
= ({ className = "" }) => { const [showUpToDate, setShowUpToDate] = useState(false); const { settings, isLoading } = useSettings(); - const { entitlement } = useProEntitlement(); const settingsLoaded = !isLoading && settings !== null; - const proActive = entitlement?.active ?? false; - const updateChecksEnabled = (settings?.update_checks_enabled ?? false) && proActive; + const updateChecksEnabled = settings?.update_checks_enabled ?? false; const upToDateTimeoutRef = useRef>(); const isManualCheckRef = useRef(false); diff --git a/src/hooks/useProEntitlement.ts b/src/hooks/useProEntitlement.ts deleted file mode 100644 index 36ac9ac..0000000 --- a/src/hooks/useProEntitlement.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useEffect } from "react"; -import { useProEntitlementStore } from "@/stores/proEntitlementStore"; - -export const useProEntitlement = () => { - const initialize = useProEntitlementStore((state) => state.initialize); - const store = useProEntitlementStore(); - - useEffect(() => { - void initialize(); - }, [initialize]); - - return store; -}; diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 7ae3ec2..eba6a66 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -15,8 +15,7 @@ "history": "السجل", "debug": "تصحيح الأخطاء", "about": "حول", - "models": "النماذج", - "getPro": "احصل على Dictx Pro" + "models": "النماذج" }, "onboarding": { "subtitle": "للبدء، اختر نموذج التفريغ الصوتي", @@ -123,7 +122,7 @@ "restartHint": "إذا علقت، جرب تبديل الإذن في إعدادات النظام، ثم انقر إعادة المحاولة.", "skipForNow": "المتابعة بدون أذونات — سأصلح هذا لاحقًا" }, - "supportCta": "احصل على Dictx Pro — نسخة موقعة مع تحديثات تلقائية ($29)", + "supportCta": "اشترِ التطبيق الموقّع لدعم التطوير ($29 مرة واحدة)", "downloadEta": "~{{time}} متبقي", "downloadSize": "{{downloaded}} / {{total}} ميغابايت" }, @@ -414,9 +413,7 @@ }, "updateChecks": { "label": "التحقق من وجود تحديثات", - "description": "التحقق تلقائياً من وجود إصدارات جديدة من Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "التحقق تلقائياً من وجود إصدارات جديدة من Dictx" }, "soundTheme": { "label": "سمة الصوت", @@ -493,8 +490,8 @@ }, "supportDevelopment": { "title": "دعم التطوير", - "description": "ساعدنا في مواصلة بناء Dictx", - "button": "تبرع" + "description": "هل بنيت Dictx من المصدر؟ اشترِ التطبيق الموقّع لدعم التطوير.", + "button": "شراء التطبيق الموقّع" }, "acknowledgments": { "title": "شكر وتقدير", @@ -503,17 +500,6 @@ "description": "استنتاج عالي الأداء لنموذج التعرف التلقائي على الكلام Whisper من OpenAI", "details": ".يستخدم Dictx برنامج Whisper.cpp لمعالجة سريعة ومحلية لتحويل الكلام إلى نص. شكراً للعمل الرائع الذي قام به Georgi Gerganov والمساهمون" } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "modelSettings": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 4d24403..ff963ed 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Následné zpracování", "history": "Historie", "debug": "Ladění", - "about": "O aplikaci", - "getPro": "Získat Dictx Pro" + "about": "O aplikaci" }, "onboarding": { "subtitle": "Pro začátek vyberte model pro přepis", @@ -123,7 +122,7 @@ "restartHint": "Pokud jste zaseknutí, zkuste přepnout oprávnění v Nastavení systému a klikněte Zkusit znovu.", "skipForNow": "Pokračovat bez oprávnění — opravím to později" }, - "supportCta": "Získejte Dictx Pro — podepsaný binární soubor s automatickými aktualizacemi ($29)", + "supportCta": "Kupte si podepsanou aplikaci a podpořte vývoj (jednorázově $29)", "downloadEta": "~{{time}} zbývá", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Kontrolovat aktualizace", - "description": "Automaticky kontrolovat nové verze Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Automaticky kontrolovat nové verze Dictx" }, "soundTheme": { "label": "Zvukový motiv", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Podpořte vývoj", - "description": "Pomozte nám pokračovat ve vývoji Dictx", - "button": "Přispět" + "description": "Sestavili jste Dictx ze zdrojového kódu? Kupte si podepsanou aplikaci a podpořte vývoj.", + "button": "Koupit podepsanou aplikaci" }, "acknowledgments": { "title": "Poděkování", @@ -525,17 +522,6 @@ "description": "Vysoce výkonné inferenční zpracování modelu automatického rozpoznávání řeči Whisper od OpenAI", "details": "Dictx používá Whisper.cpp pro rychlé lokální zpracování řeči na text. Díky skvělé práci Georgiho Gerganova a přispěvatelů." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 58aef99..a44e338 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Nachbearbeitung", "history": "Verlauf", "debug": "Debug", - "about": "Info", - "getPro": "Dictx Pro kaufen" + "about": "Info" }, "onboarding": { "subtitle": "Wähle ein Transkriptionsmodell, um loszulegen", @@ -123,7 +122,7 @@ "restartHint": "Falls es hängt, versuchen Sie die Berechtigung in den Systemeinstellungen aus- und einzuschalten und klicken dann Erneut versuchen.", "skipForNow": "Ohne Berechtigungen fortfahren — ich behebe das später" }, - "supportCta": "Dictx Pro kaufen — Signierte Binary mit Auto-Updates ($29)", + "supportCta": "Kaufe die signierte App, um die Entwicklung zu unterstützen ($29 einmalig)", "downloadEta": "~{{time}} verbleibend", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Nach Updates suchen", - "description": "Automatisch nach neuen Versionen von Dictx suchen", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Automatisch nach neuen Versionen von Dictx suchen" }, "soundTheme": { "label": "Sound-Thema", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Entwicklung unterstützen", - "description": "Hilf uns, Dictx weiterzuentwickeln", - "button": "Spenden" + "description": "Dictx aus dem Quellcode gebaut? Kaufe die signierte App, um die Entwicklung zu unterstützen.", + "button": "Signierte App kaufen" }, "acknowledgments": { "title": "Danksagungen", @@ -525,17 +522,6 @@ "description": "Hochleistungs-Inferenz des Whisper-Spracherkennungsmodells von OpenAI", "details": "Dictx verwendet Whisper.cpp für schnelle, lokale Sprach-zu-Text-Verarbeitung. Dank an Georgi Gerganov und die Mitwirkenden für ihre großartige Arbeit." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 6513c75..56229f9 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Post Process", "history": "History", "debug": "Debug", - "about": "About", - "getPro": "Upgrade to Dictx Pro" + "about": "About" }, "onboarding": { "subtitle": "To get started, choose a transcription model", @@ -96,7 +95,7 @@ "useCase": "Russian language dictation" } }, - "supportCta": "Upgrade to Dictx Pro — signed builds + auto-updates ($29 one-time)", + "supportCta": "Buy the signed app to support development ($29 one-time)", "downloadEta": "~{{time}} remaining", "downloadSize": "{{downloaded}} / {{total}} MB", "errors": { @@ -461,9 +460,7 @@ }, "updateChecks": { "label": "Check for Updates", - "description": "Automatically check for new versions of Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Automatically check for new versions of Dictx" }, "soundTheme": { "label": "Sound Theme", @@ -539,20 +536,9 @@ "button": "View on GitHub" }, "supportDevelopment": { - "title": "Upgrade to Dictx Pro", - "description": "One-time purchase for signed builds, in-app auto-updates, and priority support.", - "button": "Upgrade Now" - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." + "title": "Support Development", + "description": "Built Dictx from source? Buy the signed app to support development.", + "button": "Buy the Signed App" }, "acknowledgments": { "title": "Acknowledgments", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 9e9fb32..8cf7471 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Post Proceso", "history": "Historial", "debug": "Depuración", - "about": "Acerca de", - "getPro": "Obtener Dictx Pro" + "about": "Acerca de" }, "onboarding": { "subtitle": "Para comenzar, elige un modelo de transcripción", @@ -123,7 +122,7 @@ "restartHint": "Si está atascado, intente alternar el permiso en Configuración del Sistema y luego haga clic en Reintentar.", "skipForNow": "Continuar sin permisos — lo arreglaré después" }, - "supportCta": "Obtener Dictx Pro — binario firmado con actualizaciones automáticas ($29)", + "supportCta": "Compra la app firmada para apoyar el desarrollo ($29 pago único)", "downloadEta": "~{{time}} restante", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Buscar Actualizaciones", - "description": "Buscar automáticamente nuevas versiones de Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Buscar automáticamente nuevas versiones de Dictx" }, "soundTheme": { "label": "Tema de Sonido", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Apoyar el Desarrollo", - "description": "Ayúdanos a continuar construyendo Dictx", - "button": "Donar" + "description": "¿Compilaste Dictx desde el código fuente? Compra la app firmada para apoyar el desarrollo.", + "button": "Comprar la app firmada" }, "acknowledgments": { "title": "Reconocimientos", @@ -525,17 +522,6 @@ "description": "Inferencia de alto rendimiento del modelo de reconocimiento automático de voz Whisper de OpenAI", "details": "Dictx usa Whisper.cpp para procesamiento de voz a texto rápido y local. Gracias al increíble trabajo de Georgi Gerganov y colaboradores." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 5479573..9710566 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Post-traitement", "history": "Historique", "debug": "Débogage", - "about": "À propos", - "getPro": "Obtenir Dictx Pro" + "about": "À propos" }, "onboarding": { "subtitle": "Pour commencer, choisissez un modèle de transcription", @@ -123,7 +122,7 @@ "restartHint": "Si bloqué, essayez de basculer l'autorisation dans les Réglages Système, puis cliquez Réessayer.", "skipForNow": "Continuer sans autorisations — je corrigerai plus tard" }, - "supportCta": "Obtenir Dictx Pro — binaire signé avec mises à jour automatiques (29 $)", + "supportCta": "Achetez l'application signée pour soutenir le développement (29 $ une seule fois)", "downloadEta": "~{{time}} restant", "downloadSize": "{{downloaded}} / {{total}} Mo" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Vérifier les mises à jour", - "description": "Vérifier automatiquement les nouvelles versions de Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Vérifier automatiquement les nouvelles versions de Dictx" }, "soundTheme": { "label": "Thème sonore", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Soutenir le développement", - "description": "Aidez-nous à continuer à construire Dictx", - "button": "Faire un don" + "description": "Vous avez compilé Dictx depuis les sources ? Achetez l'application signée pour soutenir le développement.", + "button": "Acheter l'application signée" }, "acknowledgments": { "title": "Remerciements", @@ -525,17 +522,6 @@ "description": "Inférence haute performance du modèle de reconnaissance vocale automatique Whisper d'OpenAI", "details": "Dictx utilise Whisper.cpp pour un traitement rapide et local de la parole en texte. Merci au travail incroyable de Georgi Gerganov et des contributeurs." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 6d07fb8..22f6551 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Post-Elaborazione", "history": "Cronologia", "debug": "Debug", - "about": "Informazioni", - "getPro": "Ottieni Dictx Pro" + "about": "Informazioni" }, "onboarding": { "subtitle": "Per cominciare, scegli un modello di riconoscimento vocale", @@ -123,7 +122,7 @@ "restartHint": "Se bloccato, prova a disattivare e riattivare il permesso nelle Impostazioni di Sistema, poi clicca Riprova.", "skipForNow": "Continua senza permessi — lo sistemerò dopo" }, - "supportCta": "Ottieni Dictx Pro — binario firmato con aggiornamenti automatici ($29)", + "supportCta": "Acquista l'app firmata per supportare lo sviluppo ($29 una tantum)", "downloadEta": "~{{time}} rimanente", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Controlla aggiornamenti", - "description": "Controlla automaticamente la disponibilità di nuove versioni di Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Controlla automaticamente la disponibilità di nuove versioni di Dictx" }, "soundTheme": { "label": "Tema Sonoro", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Supporta lo Sviluppo", - "description": "Aiutaci a sviluppare Dictx", - "button": "Dona" + "description": "Hai compilato Dictx dal codice sorgente? Acquista l'app firmata per supportare lo sviluppo.", + "button": "Acquista l'app firmata" }, "acknowledgments": { "title": "Ringraziamenti", @@ -525,17 +522,6 @@ "description": "Inferenza ad alte prestazioni del modello di riconoscimento vocale automatico Whisper di OpenAI", "details": "Dictx usa Whisper.cpp per il riconoscimento vocale veloce in locale. Grazie a Georgi Gerganov e collaboratori per il fantastico lavoro." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index b2fa0e6..584f3b9 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -15,8 +15,7 @@ "postProcessing": "後処理", "history": "履歴", "debug": "デバッグ", - "about": "概要", - "getPro": "Dictx Proを入手" + "about": "概要" }, "onboarding": { "subtitle": "開始するには、文字起こしモデルを選択してください", @@ -123,7 +122,7 @@ "restartHint": "動かない場合は、システム設定で権限をオフ・オンし、再試行をクリックしてください。", "skipForNow": "権限なしで続行 — 後で設定します" }, - "supportCta": "Dictx Proを入手 — 自動更新付き署名済みバイナリ($29)", + "supportCta": "署名済みアプリを購入して開発を支援 ($29、一回限り)", "downloadEta": "残り約{{time}}", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "アップデートを確認", - "description": "Dictxの新しいバージョンを自動的にチェック", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Dictxの新しいバージョンを自動的にチェック" }, "soundTheme": { "label": "サウンドテーマ", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "開発を支援", - "description": "Dictxの開発を支援してください", - "button": "寄付する" + "description": "ソースからDictxをビルドしましたか?署名済みアプリを購入して開発を支援してください。", + "button": "署名済みアプリを購入" }, "acknowledgments": { "title": "謝辞", @@ -525,17 +522,6 @@ "description": "OpenAIのWhisper自動音声認識モデルの高性能推論", "details": "Dictxは高速でローカルな音声からテキストへの変換にWhisper.cppを使用しています。Georgi Gerganov氏と貢献者の皆様の素晴らしい仕事に感謝します。" } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 6982ee3..89aa087 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -15,8 +15,7 @@ "postProcessing": "후처리", "history": "히스토리", "debug": "디버그", - "about": "정보", - "getPro": "Dictx Pro 구매" + "about": "정보" }, "onboarding": { "subtitle": "시작하려면 음성 인식 모델을 선택하세요", @@ -123,7 +122,7 @@ "restartHint": "멈춘 경우 시스템 설정에서 권한을 껐다 켠 후 다시 시도를 클릭하세요.", "skipForNow": "권한 없이 계속 — 나중에 설정하겠습니다" }, - "supportCta": "Dictx Pro 구매 — 자동 업데이트가 포함된 서명된 바이너리 ($29)", + "supportCta": "서명된 앱을 구매하여 개발을 지원하세요 (일회성 $29)", "downloadEta": "약 {{time}} 남음", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "업데이트 확인", - "description": "Dictx의 새 버전을 자동으로 확인합니다", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Dictx의 새 버전을 자동으로 확인합니다" }, "soundTheme": { "label": "사운드 테마", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "개발 지원", - "description": "Dictx 개발을 계속할 수 있도록 도와주세요", - "button": "후원하기" + "description": "소스에서 Dictx를 빌드하셨나요? 서명된 앱을 구매하여 개발을 지원해 주세요.", + "button": "서명된 앱 구매" }, "acknowledgments": { "title": "감사의 말", @@ -525,17 +522,6 @@ "description": "OpenAI의 Whisper 자동 음성 인식 모델의 고성능 추론", "details": "Dictx는 빠르고 로컬 음성-텍스트 처리를 위해 Whisper.cpp를 사용합니다. Georgi Gerganov와 기여자들의 놀라운 작업에 감사드립니다." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 0981a1f..ee312e1 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Postproces", "history": "Historia", "debug": "Debugowanie", - "about": "O programie", - "getPro": "Kup Dictx Pro" + "about": "O programie" }, "onboarding": { "subtitle": "Aby rozpocząć, wybierz model transkrypcji", @@ -123,7 +122,7 @@ "restartHint": "Jeśli się zacięło, spróbuj przełączyć uprawnienie w Ustawieniach systemu, a następnie kliknij Ponów.", "skipForNow": "Kontynuuj bez uprawnień — naprawię to później" }, - "supportCta": "Kup Dictx Pro — podpisany plik binarny z auto-aktualizacjami ($29)", + "supportCta": "Kup podpisaną aplikację, aby wesprzeć rozwój (jednorazowo $29)", "downloadEta": "~{{time}} pozostało", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Sprawdzaj aktualizacje", - "description": "Automatycznie sprawdzaj nowe wersje Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Automatycznie sprawdzaj nowe wersje Dictx" }, "soundTheme": { "label": "Motyw dźwiękowy", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Wspieraj rozwój", - "description": "Pomóż nam dalej rozwijać Dictx", - "button": "Wesprzyj" + "description": "Zbudowałeś Dictx ze źródeł? Kup podpisaną aplikację, aby wesprzeć rozwój.", + "button": "Kup podpisaną aplikację" }, "acknowledgments": { "title": "Podziękowania", @@ -525,17 +522,6 @@ "description": "Wydajne wykonywanie modelu rozpoznawania mowy Whisper od OpenAI", "details": "Dictx używa Whisper.cpp do szybkiego, lokalnego przetwarzania mowy na tekst. Dzięki niesamowitej pracy Georgi Gerganova i współtwórców." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index cb19eb4..7c04945 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Pós-Processamento", "history": "Histórico", "debug": "Depuração", - "about": "Sobre", - "getPro": "Obter Dictx Pro" + "about": "Sobre" }, "onboarding": { "subtitle": "Para começar, escolha um modelo de transcrição", @@ -123,7 +122,7 @@ "restartHint": "Se travou, tente alternar a permissão nas Configurações do Sistema e clique Tentar novamente.", "skipForNow": "Continuar sem permissões — vou corrigir depois" }, - "supportCta": "Obter Dictx Pro — binário assinado com atualizações automáticas ($29)", + "supportCta": "Compre o app assinado para apoiar o desenvolvimento ($29 pagamento único)", "downloadEta": "~{{time}} restante", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Verificar Atualizações", - "description": "Verificar automaticamente novas versões do Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Verificar automaticamente novas versões do Dictx" }, "soundTheme": { "label": "Tema de Som", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Apoiar o Desenvolvimento", - "description": "Ajude-nos a continuar construindo o Dictx", - "button": "Doar" + "description": "Compilou o Dictx a partir do código-fonte? Compre o app assinado para apoiar o desenvolvimento.", + "button": "Comprar o app assinado" }, "acknowledgments": { "title": "Agradecimentos", @@ -525,17 +522,6 @@ "description": "Inferência de alto desempenho do modelo de reconhecimento automático de fala Whisper da OpenAI", "details": "O Dictx usa Whisper.cpp para processamento rápido e local de fala para texto. Agradecemos ao incrível trabalho de Georgi Gerganov e colaboradores." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 68eb82c..e480411 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Постобработка", "history": "История", "debug": "Отладка", - "about": "О программе", - "getPro": "Получить Dictx Pro" + "about": "О программе" }, "onboarding": { "subtitle": "Для начала выберите модель транскрипции", @@ -123,7 +122,7 @@ "restartHint": "Если зависло, попробуйте переключить разрешение в Настройках системы, затем нажмите Повторить.", "skipForNow": "Продолжить без разрешений — настрою позже" }, - "supportCta": "Получить Dictx Pro — подписанный бинарник с автообновлениями ($29)", + "supportCta": "Купите подписанное приложение, чтобы поддержать разработку (единоразово $29)", "downloadEta": "~{{time}} осталось", "downloadSize": "{{downloaded}} / {{total}} МБ" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Проверьте наличие обновлений", - "description": "Автоматически проверять наличие новых версий Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Автоматически проверять наличие новых версий Dictx" }, "soundTheme": { "label": "Звуковая тема", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Поддержка развития", - "description": "Помогите нам продолжить создание Dictx", - "button": "Пожертвовать" + "description": "Собрали Dictx из исходного кода? Купите подписанное приложение, чтобы поддержать разработку.", + "button": "Купить подписанное приложение" }, "acknowledgments": { "title": "Благодарности", @@ -525,17 +522,6 @@ "description": "Высокопроизводительный вывод модели автоматического распознавания речи OpenAI Whisper", "details": "Dictx использует Whisper.cpp для быстрой локальной обработки речи в текст. Спасибо великолепной работе Георгия Герганова и его участников." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 70ac712..02dbf04 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Son İşlem", "history": "Geçmiş", "debug": "Hata Ayıklama", - "about": "Hakkında", - "getPro": "Dictx Pro'yu Edinin" + "about": "Hakkında" }, "onboarding": { "subtitle": "Başlamak için bir transkripsiyon modeli seçin", @@ -123,7 +122,7 @@ "restartHint": "Takılırsa, Sistem Ayarlarında izni kapatıp açmayı deneyin, sonra Tekrar Dene'ye tıklayın.", "skipForNow": "İzinsiz devam et — daha sonra düzeltirim" }, - "supportCta": "Dictx Pro'yu Edinin — otomatik güncellemeli imzalı dosya ($29)", + "supportCta": "Geliştirmeyi desteklemek için imzalı uygulamayı satın alın (tek seferlik $29)", "downloadEta": "~{{time}} kaldı", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Güncellemeleri Kontrol Et", - "description": "Dictx için yeni sürümleri otomatik olarak kontrol eder", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Dictx için yeni sürümleri otomatik olarak kontrol eder" }, "soundTheme": { "label": "Ses Teması", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Geliştirmeyi Destekle", - "description": "Dictx'yi geliştirmeye devam etmemize yardımcı olun", - "button": "Bağış Yap" + "description": "Dictx'i kaynak koddan mı derlediniz? Geliştirmeyi desteklemek için imzalı uygulamayı satın alın.", + "button": "İmzalı Uygulamayı Satın Al" }, "acknowledgments": { "title": "Teşekkürler", @@ -525,17 +522,6 @@ "description": "OpenAI'nin Whisper otomatik konuşma tanıma modelinin yüksek performanslı çıkarımı", "details": "Dictx, hızlı ve yerel konuşmadan metne dönüştürme için Whisper.cpp kullanır. Georgi Gerganov ve katkıda bulunanların harika çalışmaları için teşekkür ederiz." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 79e254e..7cbc8c7 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Постобробка", "history": "Історія", "debug": "Дебаг", - "about": "Інфо", - "getPro": "Отримати Dictx Pro" + "about": "Інфо" }, "onboarding": { "subtitle": "Для початку оберіть модель транскрипції", @@ -123,7 +122,7 @@ "restartHint": "Якщо зависло, спробуйте перемкнути дозвіл у Налаштуваннях системи, потім натисніть Повторити.", "skipForNow": "Продовжити без дозволів — налаштую пізніше" }, - "supportCta": "Отримати Dictx Pro — підписаний бінарник з автооновленнями ($29)", + "supportCta": "Купіть підписаний застосунок, щоб підтримати розробку (одноразово $29)", "downloadEta": "~{{time}} залишилось", "downloadSize": "{{downloaded}} / {{total}} МБ" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Перевірка оновлень", - "description": "Автоматично перевіряти наявність нових версій Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Автоматично перевіряти наявність нових версій Dictx" }, "soundTheme": { "label": "Звукова тема", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Підтримати розробку", - "description": "Допоможіть нам продовжувати розвивати Dictx", - "button": "Підтримати" + "description": "Зібрали Dictx із коду? Купіть підписаний застосунок, щоб підтримати розробку.", + "button": "Купити підписаний застосунок" }, "acknowledgments": { "title": "Подяки", @@ -525,17 +522,6 @@ "description": "Високопродуктивний інференс моделі автоматичного розпізнавання мовлення Whisper від OpenAI", "details": "Dictx використовує Whisper.cpp для швидкої локальної обробки мовлення в текст. Дякуємо за чудову роботу Георгію Герганову та контриб'юторам." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 7e29e91..d2f759b 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -15,8 +15,7 @@ "postProcessing": "Xử lý sau", "history": "Lịch sử", "debug": "Gỡ lỗi", - "about": "Giới thiệu", - "getPro": "Mua Dictx Pro" + "about": "Giới thiệu" }, "onboarding": { "subtitle": "Để bắt đầu, hãy chọn một mô hình chuyển đổi giọng nói", @@ -123,7 +122,7 @@ "restartHint": "Nếu bị treo, hãy thử tắt/bật quyền trong Cài đặt Hệ thống, rồi nhấn Thử lại.", "skipForNow": "Tiếp tục không cần quyền — tôi sẽ sửa sau" }, - "supportCta": "Mua Dictx Pro — bản nhị phân đã ký với cập nhật tự động ($29)", + "supportCta": "Mua bản ứng dụng đã ký để ủng hộ phát triển ($29 một lần)", "downloadEta": "Còn ~{{time}}", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "Kiểm tra cập nhật", - "description": "Tự động kiểm tra phiên bản mới của Dictx", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "Tự động kiểm tra phiên bản mới của Dictx" }, "soundTheme": { "label": "Chủ đề âm thanh", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "Hỗ trợ phát triển", - "description": "Giúp chúng tôi tiếp tục xây dựng Dictx", - "button": "Quyên góp" + "description": "Bạn đã build Dictx từ mã nguồn? Hãy mua bản ứng dụng đã ký để ủng hộ phát triển.", + "button": "Mua bản ứng dụng đã ký" }, "acknowledgments": { "title": "Lời cảm ơn", @@ -525,17 +522,6 @@ "description": "Suy luận hiệu suất cao của mô hình nhận dạng giọng nói tự động Whisper của OpenAI", "details": "Dictx sử dụng Whisper.cpp để xử lý chuyển đổi giọng nói thành văn bản nhanh, cục bộ. Cảm ơn công việc tuyệt vời của Georgi Gerganov và các cộng tác viên." } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 42707be..b11689a 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -15,8 +15,7 @@ "postProcessing": "後處理", "history": "歷史紀錄", "debug": "偵錯", - "about": "關於", - "getPro": "取得 Dictx Pro" + "about": "關於" }, "onboarding": { "subtitle": "首先,請選擇一種模型", @@ -123,7 +122,7 @@ "restartHint": "如果卡住,請在系統設定中關閉再開啟權限,然後點擊重試。", "skipForNow": "跳過權限繼續 — 稍後再設定" }, - "supportCta": "取得 Dictx Pro — 附自動更新的簽署二進位檔($29)", + "supportCta": "購買簽署版應用程式以支持開發(一次性 $29)", "downloadEta": "剩餘約 {{time}}", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "檢查更新", - "description": "自動檢查 Dictx 的新版本", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "自動檢查 Dictx 的新版本" }, "soundTheme": { "label": "聲音主題", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "支持開發", - "description": "幫助我們繼續開發 Dictx", - "button": "捐贈" + "description": "從原始碼建置了 Dictx?購買簽署版應用程式以支持開發。", + "button": "購買簽署版應用程式" }, "acknowledgments": { "title": "致謝", @@ -525,17 +522,6 @@ "description": "針對 OpenAI Whisper 自動語音識別模型的高效能推理引擎", "details": "Dictx 使用 Whisper.cpp 進行快速的本機語音轉文字處理。感謝 Georgi Gerganov 和貢獻者們的傑出工作" } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index afbdf16..322170c 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -15,8 +15,7 @@ "postProcessing": "后处理", "history": "历史记录", "debug": "调试", - "about": "关于", - "getPro": "获取 Dictx Pro" + "about": "关于" }, "onboarding": { "subtitle": "请选择一个转录模型以开始使用", @@ -123,7 +122,7 @@ "restartHint": "如果卡住,请在系统设置中关闭再开启权限,然后点击重试。", "skipForNow": "跳过权限继续 — 稍后再设置" }, - "supportCta": "获取 Dictx Pro — 带自动更新的签名二进制文件($29)", + "supportCta": "购买签名版应用以支持开发(一次性 $29)", "downloadEta": "剩余约 {{time}}", "downloadSize": "{{downloaded}} / {{total}} MB" }, @@ -436,9 +435,7 @@ }, "updateChecks": { "label": "检查更新", - "description": "自动检查 Dictx 的新版本", - "proRequired": "Auto-updates are available for active Dictx Pro installations.", - "upgrade": "Activate or Upgrade Pro" + "description": "自动检查 Dictx 的新版本" }, "soundTheme": { "label": "声音主题", @@ -515,8 +512,8 @@ }, "supportDevelopment": { "title": "支持开发", - "description": "帮助我们继续开发 Dictx", - "button": "捐赠" + "description": "从源码构建了 Dictx?购买签名版应用以支持开发。", + "button": "购买签名版应用" }, "acknowledgments": { "title": "致谢", @@ -525,17 +522,6 @@ "description": "OpenAI Whisper 自动语音识别模型的高性能推理", "details": "Dictx 使用 Whisper.cpp 进行快速的本地语音转文字处理。感谢 Georgi Gerganov 和贡献者们的出色工作。" } - }, - "proActivation": { - "active": "Dictx Pro is active on this device.", - "inactive": "Dictx Pro is not active. Enter your license key to activate updater entitlement.", - "activate": "Activate Dictx Pro", - "refresh": "Re-check Entitlement", - "clear": "Remove Activation", - "licenseKey": "License Key", - "licenseKeyPlaceholder": "dxp-...", - "pasteFromClipboard": "Paste From Clipboard", - "clipboardFailed": "Clipboard access failed. Paste manually." } }, "obsidian": { diff --git a/src/stores/proEntitlementStore.ts b/src/stores/proEntitlementStore.ts deleted file mode 100644 index 4eed08a..0000000 --- a/src/stores/proEntitlementStore.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { create } from "zustand"; -import { - activateProEntitlement, - clearProEntitlement, - getProEntitlement, - refreshProEntitlement, - type ProEntitlement, -} from "@/utils/proEntitlement"; - -interface ProEntitlementStore { - entitlement: ProEntitlement | null; - isLoading: boolean; - isSubmitting: boolean; - error: string | null; - initialize: () => Promise; - activate: (licenseKey: string) => Promise; - refresh: () => Promise; - clear: () => Promise; -} - -export const useProEntitlementStore = create( - (set, get) => ({ - entitlement: null, - isLoading: true, - isSubmitting: false, - error: null, - - initialize: async () => { - if (!get().isLoading) { - return; - } - try { - let entitlement = await getProEntitlement(); - try { - entitlement = await refreshProEntitlement(); - } catch (_error) { - // Keep last known state if background verification fails. - } - set({ entitlement, error: null }); - } catch (error) { - set({ error: error instanceof Error ? error.message : String(error) }); - } finally { - set({ isLoading: false }); - } - }, - - activate: async (licenseKey: string) => { - set({ isSubmitting: true, error: null }); - try { - const entitlement = await activateProEntitlement(licenseKey); - set({ entitlement }); - return true; - } catch (error) { - set({ error: error instanceof Error ? error.message : String(error) }); - return false; - } finally { - set({ isSubmitting: false }); - } - }, - - refresh: async () => { - set({ isSubmitting: true, error: null }); - try { - const entitlement = await refreshProEntitlement(); - set({ entitlement }); - } catch (error) { - set({ error: error instanceof Error ? error.message : String(error) }); - } finally { - set({ isSubmitting: false }); - } - }, - - clear: async () => { - set({ isSubmitting: true, error: null }); - try { - const entitlement = await clearProEntitlement(); - set({ entitlement }); - } catch (error) { - set({ error: error instanceof Error ? error.message : String(error) }); - } finally { - set({ isSubmitting: false }); - } - }, - }), -); diff --git a/src/utils/proEntitlement.ts b/src/utils/proEntitlement.ts deleted file mode 100644 index 158559c..0000000 --- a/src/utils/proEntitlement.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; - -export interface ProEntitlement { - active: boolean; - source?: "license_key" | "early_adopter" | null; - license_key?: string | null; - email?: string | null; - checkout_id?: string | null; - activated_at?: number | null; - last_verified_at?: number | null; - verification_error?: string | null; - install_id?: string | null; -} - -export const getProEntitlement = async (): Promise => { - return await invoke("get_pro_entitlement"); -}; - -export const activateProEntitlement = async ( - licenseKey: string, -): Promise => { - return await invoke("activate_pro_entitlement", { - licenseKey, - }); -}; - -export const refreshProEntitlement = async (): Promise => { - return await invoke("refresh_pro_entitlement"); -}; - -export const clearProEntitlement = async (): Promise => { - return await invoke("clear_pro_entitlement"); -};