diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd6e584..7401e38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -183,7 +183,14 @@ jobs: Automated release for ${{ env.RELEASE_TAG }}. This release publishes native Tauri bundles built on macOS, Windows, and Linux runners. - Windows/Linux builds are unsigned in this workflow. + + ### Первый запуск / First launch + + Сборки пока без платной подписи кода — это **не вирус** (исходники открыты, AGPL-3.0). Если ОС блокирует запуск: + The builds are not code-signed yet — this is **not malware** (open source, AGPL-3.0). If your OS blocks it: + + - **macOS** — «не удалось проверить» / "could not verify": нажмите **Готово/Done**, затем **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть** (System Settings → Privacy & Security → Open Anyway). Или: `xattr -dr com.apple.quarantine /Applications/Talkis.app` + - **Windows** — SmartScreen «защитил ПК» / "protected your PC": **Подробнее → Выполнить в любом случае** (More info → Run anyway). draft: false prerelease: ${{ contains(env.RELEASE_TAG, '-') }} files: | diff --git a/README.md b/README.md index b3a5bfa..18a55fb 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,27 @@ Download the latest build from GitHub Releases: - [Windows x64 installer](https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-windows-x64-setup.exe) - [Linux x64 AppImage](https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-linux-x64.AppImage) +### If your OS blocks the first launch + +Talkis is currently distributed **without paid code signing**, so Gatekeeper (macOS) and SmartScreen (Windows) show an "unverified app" warning. This is **not malware**: the source is open (AGPL-3.0) and the builds are produced by the public GitHub Actions workflow in this repo. How to open it: + +**macOS** — "Apple could not verify Talkis is free of malware": + +1. In the dialog click **Done** (not "Move to Trash"). +2. Open **System Settings → Privacy & Security**, scroll down to *"Talkis was blocked…"* and click **Open Anyway**. +3. Confirm with your password or Touch ID and click **Open** again. + +Or, in one command in Terminal (after moving it to Applications): + +```bash +xattr -dr com.apple.quarantine /Applications/Talkis.app +``` + +**Windows** — the blue *"Windows protected your PC"* dialog: + +1. Click **More info**. +2. Click **Run anyway**. + On first launch, grant the permissions Talkis needs: - Microphone access for recording. diff --git a/README.ru.md b/README.ru.md index b6e23d1..68b384d 100644 --- a/README.ru.md +++ b/README.ru.md @@ -123,6 +123,27 @@ Talkis сделан для повседневной работы: IDE, чаты, - [Windows x64 installer](https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-windows-x64-setup.exe) - [Linux x64 AppImage](https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-linux-x64.AppImage) +### Если ОС блокирует первый запуск + +Talkis пока распространяется **без платной подписи кода**, поэтому Gatekeeper (macOS) и SmartScreen (Windows) показывают предупреждение о «непроверённом» приложении. Это **не вирус**: исходный код открыт (AGPL-3.0), сборки собираются публичным GitHub Actions из этого репозитория. Как открыть: + +**macOS** — «Не удалось проверить, что приложение Talkis не содержит вредоносного ПО»: + +1. В диалоге нажмите **«Готово»** (не «Переместить в Корзину»). +2. Откройте **Системные настройки → Конфиденциальность и безопасность**, пролистайте вниз до строки *«Talkis заблокирован…»* и нажмите **«Всё равно открыть»**. +3. Подтвердите паролем или Touch ID и ещё раз нажмите **«Открыть»**. + +Либо одной командой в Терминале (после переноса в «Программы»): + +```bash +xattr -dr com.apple.quarantine /Applications/Talkis.app +``` + +**Windows** — синее окно *«Система Windows защитила ваш компьютер»*: + +1. Нажмите **«Подробнее»**. +2. Нажмите **«Выполнить в любом случае»**. + При первом запуске выдайте нужные разрешения: - доступ к микрофону для записи; diff --git a/docs/release/review-v0.3.2.md b/docs/release/review-v0.3.2.md new file mode 100644 index 0000000..4f9cd4c --- /dev/null +++ b/docs/release/review-v0.3.2.md @@ -0,0 +1,43 @@ +# Release Review + +## Release + +- Version: 0.3.2 +- Release branch: release/v0.3.2 +- Target tag: v0.3.2 +- Reviewer: Codex +- Date: 2026-06-30 + +## Scope + +- Key changes included in this release: single-instance focus fix for Windows/Linux, auth flow cancellation guard, subscription/account card cleanup, main-page stats glass card with rotating help/support hint, settings navigation order update, first-launch signing guidance in README/site/release notes. +- User-facing changes: widget should no longer duplicate on repeated launch on Windows/Linux; logout should not immediately re-authenticate from stale auth flow; subscription CTA/status states are clearer; the settings menu puts general settings at the bottom. +- Risky areas: cloud auth deep-link/polling flow, settings model tab conditional rendering, release artifact workflow text, UI layout around the main stats panel. + +## Checks run + +- `git diff --check`: passed +- `bun run check:release`: passed +- `TAURI_SIGNING_PRIVATE_KEY_PATH=~/.tauri/talkis-updater.key bun run build:release:macos`: failed at DMG bundling in this sandboxed environment after `.app` build; `hdiutil` reports `Cannot start hdiejectd because app is sandboxed` / `Device not configured`. +- Native/GitHub Windows build: expected via GitHub Actions after tag +- Native/GitHub Linux build: expected via GitHub Actions after tag +- Additional manual checks: release diff reviewed statically; no browser/Playwright pass requested. + +## Manual review + +- Hotkey flow: no hotkey registration code changed; main screen still renders the configured hotkey on the first hint slide. +- Onboarding permissions: no permission/onboarding flow changes in this release. +- Widget position and notice behavior: single-instance handler now unminimizes/shows/focuses the existing widget on Windows/Linux. +- Transcription quality and short-utterance handling: no transcription pipeline or hallucination-filter changes in this release. +- README refreshed: README.md and README.ru.md include first-launch instructions for unsigned builds. + +## Findings + +- Blockers: local macOS DMG packaging could not be completed in the current sandboxed environment. +- Non-blocking issues: Windows/Linux release artifacts are validated by GitHub Actions runners, not locally on this macOS machine. +- Follow-ups after release: continue paid code-signing/certification work to reduce first-launch OS warnings. + +## Decision + +- Ready for `main` merge: no, blocked until DMG build is validated outside this sandbox or by CI. +- Ready for tag publish: no, blocked until release artifacts are validated. diff --git a/docs/release/trust-and-signing.md b/docs/release/trust-and-signing.md new file mode 100644 index 0000000..89f247e --- /dev/null +++ b/docs/release/trust-and-signing.md @@ -0,0 +1,87 @@ +# Доверие ОС, подпись кода и нотаризация + +Runbook по проблеме «после релиза все платформы расценивают Talkis как вредоносное ПО». + +## Диагноз (корень проблемы) + +Сборки Talkis **не подписаны платными сертификатами и не нотаризованы** ни под одну платформу. +ОС реагируют на это предупреждениями о «непроверённом» приложении — это **не** реальная +детекция вируса и **не** регрессия конкретного релиза. + +- Единственная «подпись» в пайплайне — ключ апдейтера `TAURI_SIGNING_PRIVATE_KEY` (minisign). + Он нужен только авто-апдейтеру для проверки подлинности обновлений и **не влияет на доверие ОС**. +- **macOS** (`scripts/postprocess-macos-release.sh`): `codesign --force --deep --sign -` — это + **ad-hoc** подпись без Apple Developer ID и **без нотаризации**. Скачанный `.dmg` получает + атрибут `com.apple.quarantine`, и Gatekeeper показывает «Не удалось проверить, что приложение + не содержит вредоносного ПО». +- **Windows** (`.github/workflows/release.yml`): NSIS `.exe` **полностью без подписи** → SmartScreen + «Система Windows защитила ваш компьютер». Бандл из нативных бинарей (`talkis-llm` = llama.cpp, + `talkis-ffmpeg`, whisper-STT, открывают локальные порты 8011/18200) повышает шанс эвристического + флага у Defender/сторонних AV. +- **Linux**: AppImage/deb без подписи; обычно «вредоносом» не зовётся. + +**Почему «после нового релиза».** Пайплайн никогда не подписывал нормально (v0.3.0 был таким же). +Каждая новая неподписанная сборка — это новый неизвестный хеш: репутация в SmartScreen/Defender +обнуляется, а релиз пошёл к реальным пользователям, которые скачивают файл (с quarantine-битом), +а не собирают локально. + +## Фаза 1 — бесплатные меры (сделано / делается) + +Не убирают предупреждение полностью, но снимают трение и страх у пользователей. + +- [x] Инструкция «как открыть» в `README.ru.md` / `README.md` (раздел «Если ОС блокирует первый запуск»). +- [x] Та же инструкция в теле GitHub-релиза (`release.yml`, шаг *Publish GitHub release assets*). +- [x] Блок «Первый запуск» на статической странице сайта (`site/index.html`) рядом со ссылками на загрузку. +- [ ] Проверить сборку на [VirusTotal](https://www.virustotal.com/) — убедиться, что нет **реальных** + детекций (а только репутационные предупреждения). Если конкретный движок флагит — отправить + false-positive репорт (см. ниже). + +### Инструкции для пользователей + +**macOS** («не удалось проверить»): +1. В диалоге — **«Готово»** (не «Переместить в Корзину»). +2. **Системные настройки → Конфиденциальность и безопасность → «Всё равно открыть»**. +3. Подтвердить паролем/Touch ID, ещё раз **«Открыть»**. + Терминал-альтернатива: `xattr -dr com.apple.quarantine /Applications/Talkis.app` + +> На macOS 15 Sequoia правый клик → «Открыть» больше не обходит Gatekeeper — нужен путь через +> «Системные настройки». На Sonoma и раньше правый клик → «Открыть» ещё работает. + +**Windows** (SmartScreen): **«Подробнее» → «Выполнить в любом случае»**. +Либо ПКМ по `.exe` → Свойства → галочка **«Разблокировать»** → Применить. + +### False-positive репорты (если есть реальная детекция) + +- Microsoft Defender: https://www.microsoft.com/en-us/wdsi/filesubmission (как разработчик ПО). +- VirusTotal: открыть отчёт по файлу, у флагнувших движков использовать форму «contact / false positive». + +## Фаза 2 — настоящая подпись (когда будет бюджет) + +Полностью убирает предупреждения. Здесь же — конкретные шаги, чтобы не вспоминать заново. + +### macOS — Developer ID + нотаризация (~$99/год) + +1. Apple Developer Program → сертификат **Developer ID Application**. +2. В CI (`release.yml`, шаг сборки macOS) задать Tauri-переменные: + - `APPLE_CERTIFICATE` (base64 .p12), `APPLE_CERTIFICATE_PASSWORD` + - `APPLE_SIGNING_IDENTITY` (`Developer ID Application: … (TEAMID)`) + - `APPLE_ID`, `APPLE_PASSWORD` (app-specific), `APPLE_TEAM_ID` + Tauri тогда сам подпишет Developer ID и **нотаризует** бандл. +3. Убрать/заменить ad-hoc `postprocess-macos-release.sh`. Если оставляем ручную подпись — + **не использовать `--deep`** (deprecated, ломает подпись бандла с сайдкарами): подписывать + вложенные бинарники по отдельности (inside-out), приложение — последним, с + `--options runtime --timestamp --entitlements entitlements.plist`, затем `xcrun notarytool submit` + и `xcrun stapler staple`. + +### Windows — подпись installer'а + +- Варианты: **EV-сертификат** (мгновенная репутация SmartScreen, дорого), обычный **OV-сертификат** + (дешевле, но репутация копится), либо **Azure Trusted Signing** (~$10/мес, нужна верифицированная + организация/личность). +- В Tauri NSIS прописать `signCommand` (signtool / Azure) — подписывать `.exe`/`.msi`. + +### Линки + +- Tauri code signing: https://v2.tauri.app/distribute/sign/ +- Apple notarization: https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution +- Azure Trusted Signing: https://learn.microsoft.com/azure/trusted-signing/ diff --git a/package.json b/package.json index 3399091..833d5f0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "talkis", "private": true, - "version": "0.3.1", + "version": "0.3.2", "license": "AGPL-3.0-or-later", "type": "module", "packageManager": "bun@1.2.13", diff --git a/site/index.html b/site/index.html index 4bad313..9a526ac 100644 --- a/site/index.html +++ b/site/index.html @@ -269,6 +269,27 @@ left: 0; color: rgba(255,255,255,0.4); } + + .first-launch-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 0; + border-bottom: 1px solid rgba(0,0,0,0.08); + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.14em; + color: #000; + } + .first-launch-link:hover { opacity: 0.55; } + .first-launch-copy { + font-size: 0.85rem; + line-height: 1.75; + color: #555; + } + .first-launch-copy strong { color: #000; } @@ -288,7 +309,7 @@ Скорость Варианты - + скачать @@ -306,16 +327,56 @@ Мгновенная трансформация ваших мыслей в структурированный текст. Работает везде, где есть курсор.

- +
macOS   Windows   Linux   Android   iOS
+ +
+
+
+

Download

+

Первый запуск

+ +
+ +
+
+

Почему ОС предупреждает

+

+ Talkis пока распространяется без платной подписи кода, поэтому Gatekeeper и SmartScreen могут показать предупреждение о непроверенном приложении. Это репутационная проверка нового файла, а не признак вируса: исходный код открыт, а сборки создаются публичным GitHub Actions. +

+
+
+

Как открыть

+

+ macOS: нажмите «Готово», затем откройте Системные настройки → Конфиденциальность и безопасность → «Всё равно открыть».

+ Windows: в окне SmartScreen нажмите «Подробнее» → «Выполнить в любом случае». +

+
+
+
+
+
@@ -429,7 +490,7 @@
- + Скачать приложение diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 33762fc..ffae187 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4969,7 +4969,7 @@ dependencies = [ [[package]] name = "talkis" -version = "0.3.1" +version = "0.3.2" dependencies = [ "base64 0.22.1", "block2 0.6.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a85daf0..81932fc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "talkis" -version = "0.3.1" +version = "0.3.2" description = "Talkis - Voice to Text Desktop Widget" authors = ["trixter"] license = "AGPL-3.0-or-later" diff --git a/src-tauri/binaries/talkis-llm-aarch64-apple-darwin b/src-tauri/binaries/talkis-llm-aarch64-apple-darwin index 222796d..dfa32fd 100644 Binary files a/src-tauri/binaries/talkis-llm-aarch64-apple-darwin and b/src-tauri/binaries/talkis-llm-aarch64-apple-darwin differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3a4aad3..0381abc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,7 +21,6 @@ use commands::{ use tauri::{Emitter, Manager}; use tauri_plugin_deep_link::DeepLinkExt; -#[cfg_attr(mobile, tauri::mobile_entry_point)] /// Bring the already-running instance to front when a second launch is blocked /// by the single-instance plugin (Windows/Linux only). #[cfg(any(windows, target_os = "linux"))] @@ -36,11 +35,13 @@ fn focus_existing_instance(app: &tauri::AppHandle) { } if let Some(win) = app.get_webview_window("widget") { + let _ = win.unminimize(); let _ = win.show(); let _ = win.set_focus(); } } +#[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { #[allow(unused_mut)] let mut builder = tauri::Builder::default(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3da5ea2..7197b7e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Talkis", "mainBinaryName": "Talkis", - "version": "0.3.1", + "version": "0.3.2", "identifier": "com.trixter.talkis", "build": { "beforeDevCommand": "bun run prepare:sidecars && bun run dev", diff --git a/src/components/TranscriptionStatsPanel.tsx b/src/components/TranscriptionStatsPanel.tsx index ec1d419..7465bbc 100644 --- a/src/components/TranscriptionStatsPanel.tsx +++ b/src/components/TranscriptionStatsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; import { listen } from "@tauri-apps/api/event"; import { logError } from "../lib/logger"; @@ -39,6 +39,10 @@ interface StatItem { value: string; } +interface TranscriptionStatsPanelProps { + footer?: ReactNode; +} + function buildStats(view: TranscriptionStatsView, t: TranslateFn): StatItem[] { return [ { @@ -59,7 +63,9 @@ function buildStats(view: TranscriptionStatsView, t: TranslateFn): StatItem[] { ]; } -export function TranscriptionStatsPanel(): ReactElement { +export function TranscriptionStatsPanel({ + footer, +}: TranscriptionStatsPanelProps): ReactElement { const { t } = useI18n(); const [view, setView] = useState(() => getEmptyView()); @@ -95,31 +101,59 @@ export function TranscriptionStatsPanel(): ReactElement { return (
- {stats.map(({ key, label, value }) => ( +
+ {stats.map(({ key, label, value }) => ( +
+ + {value} + + {label} +
+ ))} +
+ + {footer ? (
- - {value} - - {label} + {footer}
- ))} + ) : null}
); } diff --git a/src/components/UserPanel.tsx b/src/components/UserPanel.tsx index e1f6ce9..f891c56 100644 --- a/src/components/UserPanel.tsx +++ b/src/components/UserPanel.tsx @@ -4,7 +4,22 @@ import { listen } from "@tauri-apps/api/event"; import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { LogOut, User, Crown } from "lucide-react"; -import { CloudProfile, fetchCloudProfile, cloudLogout, getAuthLoginUrl, handleAuthToken, generateExchangeCode, getAuthLoginUrlWithCode, pollForToken, getCachedCloudProfile, subscribeCloudProfile } from "../lib/cloudAuth"; +import { + beginCloudAuthFlow, + cancelCloudAuthFlow, + CloudProfile, + fetchCloudProfile, + cloudLogout, + getAuthLoginUrl, + handleAuthToken, + generateExchangeCode, + getAuthLoginUrlWithCode, + isCloudAuthFlowActive, + pollForToken, + getCachedCloudProfile, + subscribeCloudProfile, + type CloudAuthFlowId, +} from "../lib/cloudAuth"; import { logError, logInfo } from "../lib/logger"; import { SETTINGS_UPDATED_EVENT } from "../lib/hotkeyEvents"; import { useI18n } from "../lib/i18n"; @@ -26,6 +41,34 @@ export function UserPanel() { const [waitingForAuth, setWaitingForAuth] = useState(false); const pollingRef = useRef | null>(null); const exchangeCodeRef = useRef(null); + const authFlowRef = useRef(null); + + const clearLocalAuthPolling = useCallback(() => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + exchangeCodeRef.current = null; + authFlowRef.current = null; + setWaitingForAuth(false); + }, []); + + const cancelLocalAuthPolling = useCallback(() => { + cancelCloudAuthFlow(); + clearLocalAuthPolling(); + }, [clearLocalAuthPolling]); + + const applyAuthTokenForCurrentFlow = useCallback(async (token: string): Promise => { + const flowId = authFlowRef.current; + if (!isCloudAuthFlowActive(flowId)) { + logInfo("USER_PANEL", "Ignoring auth token without an active local auth flow"); + return null; + } + + const data = await handleAuthToken(token, { authFlowId: flowId }); + clearLocalAuthPolling(); + return data; + }, [clearLocalAuthPolling]); const loadProfile = useCallback(async () => { if (getCachedCloudProfile() === undefined) { @@ -53,10 +96,10 @@ export function UserPanel() { setProfile(nextProfile); setLoading(false); if (nextProfile) { - setWaitingForAuth(false); + clearLocalAuthPolling(); } }); - }, []); + }, [clearLocalAuthPolling]); useEffect(() => { const unlistenPromise = listen(SETTINGS_UPDATED_EVENT, () => { @@ -72,14 +115,16 @@ export function UserPanel() { useEffect(() => { const unlistenPromise = listen("deep-link-auth", async (event) => { logInfo("USER_PANEL", "Received auth token via Tauri event"); - await handleAuthToken(event.payload); - await loadProfile(); + const data = await applyAuthTokenForCurrentFlow(event.payload); + if (data) { + await loadProfile(); + } }); return () => { void unlistenPromise.then((unlisten) => unlisten()); }; - }, [loadProfile]); + }, [applyAuthTokenForCurrentFlow, loadProfile]); // ── Deep link: JS plugin API ────────────────────────────── useEffect(() => { @@ -93,8 +138,10 @@ export function UserPanel() { logInfo("USER_PANEL", `Deep link (JS): ${url}`); const token = extractTokenFromUrl(url); if (token) { - await handleAuthToken(token); - await loadProfile(); + const data = await applyAuthTokenForCurrentFlow(token); + if (data) { + await loadProfile(); + } } } }); @@ -109,7 +156,7 @@ export function UserPanel() { return () => { cancelled = true; }; - }, [loadProfile]); + }, [applyAuthTokenForCurrentFlow, loadProfile]); // ── Polling fallback via exchange code ────────────────────── useEffect(() => { @@ -124,56 +171,54 @@ export function UserPanel() { logInfo("USER_PANEL", `Starting auth polling with code: ${exchangeCodeRef.current?.slice(0, 8)}...`); pollingRef.current = setInterval(async () => { const code = exchangeCodeRef.current; - if (!code) return; + const flowId = authFlowRef.current; + if (!code || !isCloudAuthFlowActive(flowId)) return; const token = await pollForToken(code); - if (token && exchangeCodeRef.current === code) { + if (token && exchangeCodeRef.current === code && authFlowRef.current === flowId && isCloudAuthFlowActive(flowId)) { logInfo("USER_PANEL", "Auth polling: token received!"); - const data = await handleAuthToken(token); + const data = await handleAuthToken(token, { authFlowId: flowId }); if (data) { setProfile(data); } - setWaitingForAuth(false); - exchangeCodeRef.current = null; + clearLocalAuthPolling(); } }, 3000); // Stop polling after 2 minutes const timeout = setTimeout(() => { logInfo("USER_PANEL", "Auth polling timed out"); - setWaitingForAuth(false); + cancelLocalAuthPolling(); }, 120_000); return () => { if (pollingRef.current) clearInterval(pollingRef.current); clearTimeout(timeout); }; - }, [waitingForAuth]); + }, [cancelLocalAuthPolling, clearLocalAuthPolling, waitingForAuth]); const handleActivate = async () => { try { - // Generate exchange code for polling - const code = generateExchangeCode(); - exchangeCodeRef.current = code; + if (profile) { + await openUrl(getAuthLoginUrl().replace("/auth/login?device=true", "/dashboard")); + return; + } - const url = profile - ? `${getAuthLoginUrl().replace('/auth/login?device=true', '/dashboard')}` - : getAuthLoginUrlWithCode(code); - await openUrl(url); - // Start polling for token via exchange code + const code = generateExchangeCode(); + const flowId = beginCloudAuthFlow(); + exchangeCodeRef.current = code; + authFlowRef.current = flowId; setWaitingForAuth(true); + + await openUrl(getAuthLoginUrlWithCode(code)); } catch (error) { + cancelLocalAuthPolling(); logError("USER_PANEL", `Failed to open auth URL: ${error}`); } }; const handleLogout = async () => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - exchangeCodeRef.current = null; - setWaitingForAuth(false); + cancelLocalAuthPolling(); setProfile(null); await cloudLogout(); logInfo("USER_PANEL", "User logged out"); diff --git a/src/index.css b/src/index.css index 938ce82..b82f2e8 100644 --- a/src/index.css +++ b/src/index.css @@ -240,6 +240,23 @@ select { color: var(--text-low); } +.subtle-row-text { + font-size: 11px; + font-weight: 700; + line-height: 1.2; + color: var(--text-low); +} + +.subtle-row-link { + color: var(--text-low); + text-decoration: underline; + text-underline-offset: 3px; +} + +.subtle-row-link:hover { + color: var(--text-hi); +} + .kicker { display: inline-flex; align-items: center; @@ -269,7 +286,7 @@ select { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; - border-radius: var(--radius-pill); + border-radius: var(--radius-sm); cursor: pointer; transition: background-color 0.18s ease, @@ -415,6 +432,76 @@ select { line-height: 1.6; } +.main-hero-slide { + animation: slide-down 0.22s var(--ease-standard); +} + +.stats-glass-card { + position: relative; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.26), rgba(255, 255, 255, 0.07)); + border: 1px solid transparent; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.68), + 0 3px 10px rgba(39, 34, 27, 0.025); + backdrop-filter: blur(26px) saturate(1.28); + -webkit-backdrop-filter: blur(26px) saturate(1.28); + isolation: isolate; +} + +.stats-glass-card::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + background: + linear-gradient(115deg, rgba(255, 255, 255, 0.54), transparent 30%), + linear-gradient(250deg, transparent 58%, rgba(255, 255, 255, 0.12)); + pointer-events: none; +} + +.stats-glass-card > * { + position: relative; + z-index: 1; +} + +.stats-glass-metric { + position: relative; +} + +.stats-glass-metric:not(:last-child)::after { + content: ""; + position: absolute; + top: 14px; + right: 0; + bottom: 14px; + width: 1px; + background: rgba(255, 255, 255, 0.28); +} + +:root[data-theme="dark"] .stats-glass-card { + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.085), rgba(255, 255, 255, 0.025)); + border-color: transparent; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.13), + 0 4px 12px rgba(0, 0, 0, 0.14); +} + +:root[data-theme="dark"] .stats-glass-card::before { + opacity: 0.12; +} + +:root[data-theme="dark"] .stats-glass-metric:not(:last-child)::after { + background: rgba(255, 255, 255, 0.09); +} + +@media (prefers-reduced-motion: reduce) { + .main-hero-slide { + animation: none; + } +} + ::-webkit-scrollbar { width: 8px; height: 8px; diff --git a/src/lib/cloudAuth.ts b/src/lib/cloudAuth.ts index 343a8d8..c1f7b34 100644 --- a/src/lib/cloudAuth.ts +++ b/src/lib/cloudAuth.ts @@ -16,10 +16,13 @@ import { SETTINGS_UPDATED_EVENT } from "./hotkeyEvents"; const CLOUD_API_BASE = "https://talkis.ru"; type CloudProfileListener = (profile: CloudProfile | null | undefined) => void; +export type CloudAuthFlowId = number; let cachedCloudProfile: CloudProfile | null | undefined; let inflightCloudProfileRequest: Promise | null = null; let authRevision = 0; +let authFlowRevision = 0; +let activeAuthFlowId: CloudAuthFlowId | null = null; const cloudProfileListeners = new Set(); function notifyCloudProfileListeners(profile: CloudProfile | null | undefined): void { @@ -66,6 +69,21 @@ export function subscribeCloudProfile(listener: CloudProfileListener): () => voi }; } +export function beginCloudAuthFlow(): CloudAuthFlowId { + authFlowRevision += 1; + activeAuthFlowId = authFlowRevision; + return activeAuthFlowId; +} + +export function cancelCloudAuthFlow(): void { + authFlowRevision += 1; + activeAuthFlowId = null; +} + +export function isCloudAuthFlowActive(flowId: CloudAuthFlowId | null | undefined): flowId is CloudAuthFlowId { + return typeof flowId === "number" && activeAuthFlowId === flowId; +} + /** * Fetch user profile and subscription status from the cloud. * Returns null if token is missing or invalid. @@ -132,8 +150,20 @@ export async function fetchCloudProfile({ force = false }: { force?: boolean } = /** * Save device token received from deep link callback. */ -export async function handleAuthToken(token: string): Promise { +export async function handleAuthToken( + token: string, + options: { authFlowId?: CloudAuthFlowId | null } = {}, +): Promise { + if ( + Object.prototype.hasOwnProperty.call(options, "authFlowId") && + !isCloudAuthFlowActive(options.authFlowId) + ) { + logInfo("CLOUD", "Ignoring auth token from stale or cancelled auth flow"); + return cachedCloudProfile ?? null; + } + logInfo("CLOUD", "Received auth token from deep link"); + activeAuthFlowId = null; authRevision += 1; await saveCloudSettings({ deviceToken: token, useOwnKey: false }); return fetchCloudProfile({ force: true }); @@ -144,6 +174,7 @@ export async function handleAuthToken(token: string): Promise { logInfo("CLOUD", "Logging out"); + cancelCloudAuthFlow(); authRevision += 1; inflightCloudProfileRequest = null; setCachedCloudProfile(null); diff --git a/src/lib/i18n/dict/settingsRest.ts b/src/lib/i18n/dict/settingsRest.ts index a2e3716..c1ac413 100644 --- a/src/lib/i18n/dict/settingsRest.ts +++ b/src/lib/i18n/dict/settingsRest.ts @@ -26,6 +26,11 @@ export const settingsRest = { ru: "Удерживайте горячую клавишу, говорите и отпустите ее, когда закончите. После обработки текст вставится автоматически.", en: "Hold the hotkey, speak and release it when you're done. After processing the text is pasted automatically.", }, + "mainTab.hero.supportTitle": { + ru: "Нашли ошибку или есть вопрос?", + en: "Found a bug or have a question?", + }, + "mainTab.hero.supportAction": { ru: "Написать", en: "Write" }, "mainTab.combination": { ru: "Комбинация", en: "Combination" }, "mainTab.historyTitle": { ru: "История записей", en: "Recording history" }, "mainTab.historyDescFilled": { diff --git a/src/windows/settings/SettingsApp.tsx b/src/windows/settings/SettingsApp.tsx index 392ba4c..1ae27a0 100644 --- a/src/windows/settings/SettingsApp.tsx +++ b/src/windows/settings/SettingsApp.tsx @@ -86,12 +86,6 @@ const TABS: { id: Tab; labelKey: MsgKey; icon: LucideIcon; note: string }[] = [ icon: Languages, note: "Realtime Interpreter beta", }, - { - id: "settings", - labelKey: "settingsApp.tab.settings", - icon: Sliders, - note: "Язык, микрофон и горячая клавиша", - }, { id: "model", labelKey: "settingsApp.tab.model", @@ -99,6 +93,12 @@ const TABS: { id: Tab; labelKey: MsgKey; icon: LucideIcon; note: string }[] = [ note: "Ключи и подключение модели", }, { id: "style", labelKey: "settingsApp.tab.style", icon: Sparkles, note: "Стиль обработки и Промпты для саммари" }, + { + id: "settings", + labelKey: "settingsApp.tab.settings", + icon: Sliders, + note: "Язык, микрофон и горячая клавиша", + }, ]; function TabButton({ diff --git a/src/windows/settings/tabs/MainTab.tsx b/src/windows/settings/tabs/MainTab.tsx index 68976e2..f6dcd05 100644 --- a/src/windows/settings/tabs/MainTab.tsx +++ b/src/windows/settings/tabs/MainTab.tsx @@ -1,6 +1,11 @@ -import { useState, useEffect, useMemo } from "react"; -import type { ReactElement } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; +import type { + FocusEvent as ReactFocusEvent, + PointerEvent as ReactPointerEvent, + ReactElement, +} from "react"; import { emit, listen } from "@tauri-apps/api/event"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { clearHistory, DEFAULT_HOTKEY, @@ -60,12 +65,31 @@ type HistorySource = "voice" | "file" | "call"; type HistoryFilter = "all" | HistorySource; const HISTORY_TEXT_PREVIEW_LIMIT = 250; +const SUPPORT_EMAIL = "david.perov60@gmail.com"; +const MAIN_HERO_FIRST_SLIDE_DELAY_MS = 30_000; +const MAIN_HERO_SLIDE_DELAY_MS = 30_000; const HISTORY_FILTER_OPTIONS: { id: HistoryFilter; labelKey: MsgKey }[] = [ { id: "all", labelKey: "mainTab.filter.all" }, { id: "voice", labelKey: "mainTab.filter.voice" }, { id: "file", labelKey: "mainTab.filter.file" }, { id: "call", labelKey: "mainTab.filter.call" }, ]; +const MAIN_HERO_SLIDES = [ + { + id: "record", + titleKey: "mainTab.howToStart", + actionKey: "mainTab.howItWorks", + }, + { + id: "support", + titleKey: "mainTab.hero.supportTitle", + actionKey: "mainTab.hero.supportAction", + }, +] as const satisfies readonly { + id: "record" | "support"; + titleKey: MsgKey; + actionKey: MsgKey; +}[]; function getHistorySource(entry: HistoryEntry): HistorySource { if (entry.source === "file" || entry.source === "call") { @@ -282,12 +306,16 @@ export function MainTab({ initialHistory = [] }: MainTabProps) { const [isClearArmed, setIsClearArmed] = useState(false); const [historyFilter, setHistoryFilter] = useState("all"); const [hintHelpOpen, setHintHelpOpen] = useState(false); + const [heroSlideIndex, setHeroSlideIndex] = useState(0); + const [heroHasAdvanced, setHeroHasAdvanced] = useState(false); + const [heroPaused, setHeroPaused] = useState(false); const [expandedIds, setExpandedIds] = useState>(() => new Set()); const [editingSpeakerEntryId, setEditingSpeakerEntryId] = useState< string | null >(null); const [summaryEntry, setSummaryEntry] = useState(null); const [summaryAvailable, setSummaryAvailable] = useState(false); + const heroPointerStartX = useRef(null); useEffect(() => { const syncHotkeyLabel = async (reload = false) => { @@ -333,6 +361,24 @@ export function MainTab({ initialHistory = [] }: MainTabProps) { }; }, []); + useEffect(() => { + if (heroPaused || MAIN_HERO_SLIDES.length <= 1) { + return; + } + + const timeout = window.setTimeout( + () => { + setHeroSlideIndex((current) => (current + 1) % MAIN_HERO_SLIDES.length); + setHeroHasAdvanced(true); + }, + heroHasAdvanced + ? MAIN_HERO_SLIDE_DELAY_MS + : MAIN_HERO_FIRST_SLIDE_DELAY_MS, + ); + + return () => window.clearTimeout(timeout); + }, [heroHasAdvanced, heroPaused, heroSlideIndex]); + const deleteEntry = async (id: string) => { await deleteHistoryEntry(id); setHistory((h) => h.filter((x) => x.id !== id)); @@ -364,6 +410,33 @@ export function MainTab({ initialHistory = [] }: MainTabProps) { ); }; + const contactSupport = async (): Promise => { + const subject = encodeURIComponent( + t("settingsGeneralExtra.support.mailSubject"), + ); + const body = encodeURIComponent( + t("settingsGeneralExtra.support.mailBody"), + ); + const mailto = `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${body}`; + + try { + await openUrl(mailto); + } catch (error) { + void logError( + "MAIN", + `Failed to open support mail: ${error instanceof Error ? error.message : String(error)}`, + ); + try { + await navigator.clipboard.writeText(SUPPORT_EMAIL); + } catch (clipboardError) { + void logError( + "MAIN", + `Failed to copy support mail: ${clipboardError instanceof Error ? clipboardError.message : String(clipboardError)}`, + ); + } + } + }; + const editEntry = (entry: HistoryEntry): void => { if (!entry.segments?.length || !entry.speakers?.length) { toggleExpanded(entry.id); @@ -543,90 +616,196 @@ export function MainTab({ initialHistory = [] }: MainTabProps) { return groups; }, [filteredHistory, t, lang]); - return ( -
-
{ + setHeroSlideIndex( + (current) => + (current + direction + MAIN_HERO_SLIDES.length) % + MAIN_HERO_SLIDES.length, + ); + setHeroHasAdvanced(true); + }; + + const handleHeroAction = (): void => { + if (isSupportHeroSlide) { + void contactSupport(); + return; + } + + setHintHelpOpen((value) => !value); + }; + + const handleHeroPointerDown = ( + event: ReactPointerEvent, + ): void => { + if (event.pointerType === "mouse" && event.button !== 0) { + return; + } + + heroPointerStartX.current = event.clientX; + }; + + const handleHeroPointerUp = ( + event: ReactPointerEvent, + ): void => { + const startX = heroPointerStartX.current; + heroPointerStartX.current = null; + + if (startX === null) { + return; + } + + const deltaX = event.clientX - startX; + if (Math.abs(deltaX) < 48) { + return; + } + + showAdjacentHeroSlide(deltaX < 0 ? 1 : -1); + }; + + const handleHeroBlur = (event: ReactFocusEvent): void => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setHeroPaused(false); + } + }; + + const heroHintFooter = ( +
setHeroPaused(true)} + onMouseLeave={() => setHeroPaused(false)} + onFocus={() => setHeroPaused(true)} + onBlur={handleHeroBlur} + onPointerDown={handleHeroPointerDown} + onPointerUp={handleHeroPointerUp} + onPointerCancel={() => { + heroPointerStartX.current = null; + }} + style={{ display: "grid", gap: 8, touchAction: "pan-y" }} + > +
-
-
- - {t("mainTab.howToStart")} - - -
- {hintHelpOpen && ( -
- {t("mainTab.howToStartHint")} -
- )} -
- -
- - {t("mainTab.combination")} - - {hotkeyLabel} -
+ {t(activeHeroSlide.titleKey)} + + {!isSupportHeroSlide && ( + + )}
-
- +
+ {isSupportHeroSlide && ( + + )} + + {!isSupportHeroSlide && ( +
+ {t("mainTab.combination")} + {hotkeyLabel} +
+ )} +
+
+ + {!isSupportHeroSlide && hintHelpOpen && ( +
+ {t("mainTab.howToStartHint")} +
+ )} + + ); + + return ( +
+
-
+
+
-
- {t("models.cta.freeTrial")} -
- +
); } -function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) { +function SubscriptionPromoContent({ onActivate }: { onActivate: () => void }) { const { t } = useI18n(); return ( -
-
+ <> +
- {t("models.guest.title")} - {t("models.cta.freeTrial")} +
+ {t("models.guest.title")} + {t("models.cta.freeTrial")} +
  • {t("models.guest.benefit1")}
  • @@ -733,6 +720,14 @@ function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) { + + ); +} + +function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) { + return ( +
    +
    ); } @@ -1243,8 +1238,24 @@ export function SettingsTabs({ type }: SettingsTabsProps) { const [localModelActionStates, setLocalModelActionStates] = useState>>({}); const authPollingRef = useRef | null>(null); const exchangeCodeRef = useRef(null); + const authFlowRef = useRef(null); const settingsSaveQueueRef = useRef>(Promise.resolve()); + const clearLocalAuthPolling = useCallback(() => { + if (authPollingRef.current) { + clearInterval(authPollingRef.current); + authPollingRef.current = null; + } + exchangeCodeRef.current = null; + authFlowRef.current = null; + setWaitingForAuth(false); + }, []); + + const cancelLocalAuthPolling = useCallback(() => { + cancelCloudAuthFlow(); + clearLocalAuthPolling(); + }, [clearLocalAuthPolling]); + const syncSettings = useCallback(async () => { const nextSettings = await getSettings({ reload: true }); setSettings(nextSettings); @@ -1257,14 +1268,19 @@ export function SettingsTabs({ type }: SettingsTabsProps) { }, []); const applyCloudToken = useCallback(async (token: string) => { - await handleAuthToken(token); + const flowId = authFlowRef.current; + if (!isCloudAuthFlowActive(flowId)) { + logInfo("SETTINGS", "Ignoring auth token without an active local auth flow"); + return null; + } + + await handleAuthToken(token, { authFlowId: flowId }); + clearLocalAuthPolling(); await syncSettings(); const profile = await loadCloudProfile(); - setWaitingForAuth(false); - exchangeCodeRef.current = null; setWaitingForSubscriptionRefresh(!profile?.subscription.active); return profile; - }, [loadCloudProfile, syncSettings]); + }, [clearLocalAuthPolling, loadCloudProfile, syncSettings]); const refreshLocalInstalledModels = useCallback(async () => { if (!settings || type !== "model" || !settings.useOwnKey || settings.provider !== "custom") { @@ -1350,8 +1366,11 @@ export function SettingsTabs({ type }: SettingsTabsProps) { useEffect(() => { return subscribeCloudProfile((nextProfile) => { setCloudProfile(nextProfile); + if (nextProfile) { + clearLocalAuthPolling(); + } }); - }, []); + }, [clearLocalAuthPolling]); useEffect(() => { const refreshCloudProfile = () => { @@ -1424,18 +1443,18 @@ export function SettingsTabs({ type }: SettingsTabsProps) { authPollingRef.current = setInterval(async () => { const code = exchangeCodeRef.current; - if (!code) return; + const flowId = authFlowRef.current; + if (!code || !isCloudAuthFlowActive(flowId)) return; const token = await pollForToken(code); - if (!token || exchangeCodeRef.current !== code) return; + if (!token || exchangeCodeRef.current !== code || authFlowRef.current !== flowId || !isCloudAuthFlowActive(flowId)) return; logInfo("SETTINGS", "Auth polling returned device token"); await applyCloudToken(token); }, 3000); const timeout = setTimeout(() => { - setWaitingForAuth(false); - exchangeCodeRef.current = null; + cancelLocalAuthPolling(); }, 120_000); return () => { @@ -1445,7 +1464,7 @@ export function SettingsTabs({ type }: SettingsTabsProps) { } clearTimeout(timeout); }; - }, [applyCloudToken, waitingForAuth]); + }, [applyCloudToken, cancelLocalAuthPolling, waitingForAuth]); useEffect(() => { if (!waitingForSubscriptionRefresh) { @@ -1595,21 +1614,18 @@ export function SettingsTabs({ type }: SettingsTabsProps) { } const code = generateExchangeCode(); + const flowId = beginCloudAuthFlow(); exchangeCodeRef.current = code; + authFlowRef.current = flowId; setWaitingForAuth(true); await openUrl(getAuthLoginUrlWithCode(code)); } catch { - // Error handled silently + cancelLocalAuthPolling(); } }; const handleCloudLogout = async () => { - if (authPollingRef.current) { - clearInterval(authPollingRef.current); - authPollingRef.current = null; - } - exchangeCodeRef.current = null; - setWaitingForAuth(false); + cancelLocalAuthPolling(); setWaitingForSubscriptionRefresh(false); await cloudLogout(); setCloudProfile(null); @@ -1617,7 +1633,6 @@ export function SettingsTabs({ type }: SettingsTabsProps) { }; if (type === "model") { - const isAuthenticated = cloudProfile !== null && cloudProfile !== undefined; const hasActiveSubscription = cloudProfile?.subscription.active === true; const isCloudMode = !settings.useOwnKey; const isCustom = settings.provider === "custom"; @@ -2477,14 +2492,12 @@ export function SettingsTabs({ type }: SettingsTabsProps) { }} /> - {isAuthenticated && ( + {hasActiveSubscription && (
    {t("models.mode.cloud")}
    - {hasActiveSubscription - ? t("models.cloud.descActive") - : t("models.cloud.descGuest")} + {t("models.cloud.descActive")}