Release v0.3.2

This commit is contained in:
David Perov 2026-06-30 09:40:53 +03:00
parent 775d343e39
commit 70ec6f4d13
20 changed files with 835 additions and 200 deletions

View file

@ -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: |

View file

@ -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.

View file

@ -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. Нажмите **«Выполнить в любом случае»**.
При первом запуске выдайте нужные разрешения:
- доступ к микрофону для записи;

View file

@ -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.

View file

@ -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/

View file

@ -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",

View file

@ -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; }
</style>
</head>
<body class="selection:bg-black selection:text-white">
@ -288,7 +309,7 @@
<a href="#speed" class="hover:text-gray-400 transition-colors text-black/50">Скорость</a>
<a href="#options" class="hover:text-gray-400 transition-colors text-black/50">Варианты</a>
</div>
<a href="#" class="btn-black px-4 py-2 md:px-6 md:py-2.5 rounded-full flex items-center space-x-2">
<a href="#first-launch" class="btn-black px-4 py-2 md:px-6 md:py-2.5 rounded-full flex items-center space-x-2">
<span class="platform-icon"></span>
<span class="text-[9px] md:text-[10px]">скачать</span>
</a>
@ -306,16 +327,56 @@
Мгновенная трансформация ваших мыслей в структурированный текст. Работает везде, где есть курсор.
</p>
<div class="flex flex-col items-center justify-center space-y-6 md:space-y-8">
<button class="btn-black w-full sm:w-auto px-12 py-5 rounded-full text-[11px] shadow-xl flex items-center justify-center space-x-4">
<a href="#first-launch" class="btn-black w-full sm:w-auto px-12 py-5 rounded-full text-[11px] shadow-xl flex items-center justify-center space-x-4">
<span class="platform-icon-large"></span>
<span>Скачать приложение</span>
</button>
</a>
<div class="text-[8px] md:text-[9px] uppercase tracking-[0.3em] md:tracking-[0.4em] text-gray-400 font-bold px-4 text-center leading-loose">
macOS &nbsp; Windows &nbsp; Linux &nbsp; Android &nbsp; iOS
</div>
</div>
</header>
<!-- ПЕРВЫЙ ЗАПУСК -->
<section id="first-launch" class="border-t border-b border-black/5 bg-white/45 reveal-node">
<div class="max-w-7xl mx-auto px-6 py-14 md:py-20 grid grid-cols-1 lg:grid-cols-[4fr_6fr] gap-12 md:gap-16 items-start">
<div>
<p class="text-[9px] md:text-[10px] uppercase tracking-[0.3em] text-gray-400 font-bold mb-5">Download</p>
<h2 class="text-3xl md:text-5xl font-bold tracking-tighter text-black mb-8">Первый запуск</h2>
<div class="border-t border-black/10">
<a class="first-launch-link" href="https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-macos.dmg">
<span>macOS DMG</span>
<span></span>
</a>
<a class="first-launch-link" href="https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-windows-x64-setup.exe">
<span>Windows x64</span>
<span></span>
</a>
<a class="first-launch-link" href="https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-linux-x64.AppImage">
<span>Linux AppImage</span>
<span></span>
</a>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-12">
<div>
<h3 class="text-xs uppercase tracking-widest font-bold text-black mb-4">Почему ОС предупреждает</h3>
<p class="first-launch-copy">
Talkis пока распространяется <strong>без платной подписи кода</strong>, поэтому Gatekeeper и SmartScreen могут показать предупреждение о непроверенном приложении. Это репутационная проверка нового файла, а не признак вируса: исходный код открыт, а сборки создаются публичным GitHub Actions.
</p>
</div>
<div>
<h3 class="text-xs uppercase tracking-widest font-bold text-black mb-4">Как открыть</h3>
<p class="first-launch-copy">
<strong>macOS:</strong> нажмите «Готово», затем откройте Системные настройки → Конфиденциальность и безопасность → «Всё равно открыть».<br><br>
<strong>Windows:</strong> в окне SmartScreen нажмите «Подробнее» → «Выполнить в любом случае».
</p>
</div>
</div>
</div>
</section>
<!-- ИНТЕГРАЦИИ -->
<section id="integrations" class="py-12 md:py-16 border-t border-b border-black/5 overflow-hidden reveal-node">
<div class="scroll-container">
@ -429,7 +490,7 @@
</div>
</div>
</div>
<button class="btn-black w-full py-5 rounded-full shadow-lg text-[10px] mt-8">Скачать приложение</button>
<a href="#first-launch" class="btn-black w-full py-5 rounded-full shadow-lg text-[10px] mt-8">Скачать приложение</a>
</div>
<!-- КОРПОРАЦИЯ (40%) -->

2
src-tauri/Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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();

View file

@ -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",

View file

@ -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<TranscriptionStatsView>(() => getEmptyView());
@ -95,31 +101,59 @@ export function TranscriptionStatsPanel(): ReactElement {
return (
<section
className="stats-glass-card"
style={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 16,
padding: "14px 2px 18px",
gap: 14,
padding: "16px 18px 14px",
borderRadius: "var(--radius-lg)",
overflow: "hidden",
}}
>
{stats.map(({ key, label, value }) => (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 14,
}}
>
{stats.map(({ key, label, value }) => (
<div
key={key}
className="stats-glass-metric"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 4,
minWidth: 0,
minHeight: 72,
padding: "8px 10px",
textAlign: "center",
}}
>
<span
className="headline-accent"
style={{ fontSize: 28, color: "var(--text-hi)" }}
>
{value}
</span>
<span className="label">{label}</span>
</div>
))}
</div>
{footer ? (
<div
key={key}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
minWidth: 0,
textAlign: "center",
borderTop: "1px solid var(--border-subtle)",
paddingTop: 12,
}}
>
<span className="headline-accent" style={{ fontSize: 28, color: "var(--text-hi)" }}>
{value}
</span>
<span className="label">{label}</span>
{footer}
</div>
))}
) : null}
</section>
);
}

View file

@ -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<ReturnType<typeof setInterval> | null>(null);
const exchangeCodeRef = useRef<string | null>(null);
const authFlowRef = useRef<CloudAuthFlowId | null>(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<CloudProfile | null> => {
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<string>("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");

View file

@ -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;

View file

@ -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<CloudProfile | null> | null = null;
let authRevision = 0;
let authFlowRevision = 0;
let activeAuthFlowId: CloudAuthFlowId | null = null;
const cloudProfileListeners = new Set<CloudProfileListener>();
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<CloudProfile | null> {
export async function handleAuthToken(
token: string,
options: { authFlowId?: CloudAuthFlowId | null } = {},
): Promise<CloudProfile | null> {
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<CloudProfile | nul
*/
export async function cloudLogout(): Promise<void> {
logInfo("CLOUD", "Logging out");
cancelCloudAuthFlow();
authRevision += 1;
inflightCloudProfileRequest = null;
setCachedCloudProfile(null);

View file

@ -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": {

View file

@ -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({

View file

@ -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<HistoryFilter>("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<Set<string>>(() => new Set());
const [editingSpeakerEntryId, setEditingSpeakerEntryId] = useState<
string | null
>(null);
const [summaryEntry, setSummaryEntry] = useState<HistoryEntry | null>(null);
const [summaryAvailable, setSummaryAvailable] = useState(false);
const heroPointerStartX = useRef<number | null>(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<void> => {
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 (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<section
className="card"
const activeHeroSlide =
MAIN_HERO_SLIDES[heroSlideIndex] ?? MAIN_HERO_SLIDES[0];
const isSupportHeroSlide = activeHeroSlide.id === "support";
const showAdjacentHeroSlide = (direction: 1 | -1): void => {
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<HTMLElement>,
): void => {
if (event.pointerType === "mouse" && event.button !== 0) {
return;
}
heroPointerStartX.current = event.clientX;
};
const handleHeroPointerUp = (
event: ReactPointerEvent<HTMLElement>,
): 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<HTMLElement>): void => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setHeroPaused(false);
}
};
const heroHintFooter = (
<div
onMouseEnter={() => 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" }}
>
<div
key={activeHeroSlide.id}
className="main-hero-slide"
style={{
display: "grid",
gap: 12,
padding: 18,
background: "var(--surface)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
flexWrap: "wrap",
minWidth: 0,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
flexWrap: "wrap",
gap: 8,
flex: "1 1 220px",
minWidth: 0,
}}
>
<div style={{ display: "grid", gap: 6, maxWidth: 560 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)" }}
>
{t("mainTab.howToStart")}
</span>
<button
type="button"
onClick={() => setHintHelpOpen((value) => !value)}
aria-expanded={hintHelpOpen}
aria-label={t("mainTab.howItWorks")}
title={t("mainTab.howItWorks")}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 20,
height: 20,
padding: 0,
border: "none",
background: "none",
cursor: "pointer",
flexShrink: 0,
color: hintHelpOpen ? "var(--text-hi)" : "var(--text-low)",
}}
>
<HelpCircle size={16} strokeWidth={2} />
</button>
</div>
{hintHelpOpen && (
<div
style={{
fontSize: 14,
color: "var(--text-mid)",
lineHeight: 1.7,
}}
>
{t("mainTab.howToStartHint")}
</div>
)}
</div>
<div
<span
className="subtle-row-text"
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: "var(--text-hi)",
fontSize: 12,
fontWeight: 700,
letterSpacing: "0.02em",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
<span style={{ color: "var(--text-low)", letterSpacing: "0.08em" }}>
{t("mainTab.combination")}
</span>
<span>{hotkeyLabel}</span>
</div>
{t(activeHeroSlide.titleKey)}
</span>
{!isSupportHeroSlide && (
<button
type="button"
onClick={handleHeroAction}
aria-expanded={hintHelpOpen}
aria-label={t(activeHeroSlide.actionKey)}
title={t(activeHeroSlide.actionKey)}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 20,
height: 20,
padding: 0,
border: "none",
background: "none",
cursor: "pointer",
flexShrink: 0,
color: hintHelpOpen ? "var(--text-hi)" : "var(--text-low)",
}}
>
<HelpCircle size={16} strokeWidth={2} />
</button>
)}
</div>
</section>
<TranscriptionStatsPanel />
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
flexShrink: 0,
}}
>
{isSupportHeroSlide && (
<button
type="button"
className="subtle-row-text subtle-row-link"
onClick={handleHeroAction}
aria-label={t(activeHeroSlide.actionKey)}
title={t(activeHeroSlide.actionKey)}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
border: "none",
background: "transparent",
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{t(activeHeroSlide.actionKey)}
</button>
)}
{!isSupportHeroSlide && (
<div
className="subtle-row-text"
style={{
display: "flex",
alignItems: "center",
gap: 8,
whiteSpace: "nowrap",
}}
>
<span>{t("mainTab.combination")}</span>
<span>{hotkeyLabel}</span>
</div>
)}
</div>
</div>
{!isSupportHeroSlide && hintHelpOpen && (
<div
style={{
fontSize: 13,
color: "var(--text-mid)",
lineHeight: 1.65,
}}
>
{t("mainTab.howToStartHint")}
</div>
)}
</div>
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<TranscriptionStatsPanel footer={heroHintFooter} />
<section style={{ display: "grid", gap: 14 }}>
<div

View file

@ -38,7 +38,22 @@ import {
upsertPrompt,
deletePrompt,
} from "../../../lib/store";
import { CloudProfile, fetchCloudProfile, getAuthLoginUrl, cloudLogout, handleAuthToken, generateExchangeCode, getAuthLoginUrlWithCode, pollForToken, getCachedCloudProfile, subscribeCloudProfile } from "../../../lib/cloudAuth";
import {
beginCloudAuthFlow,
cancelCloudAuthFlow,
CloudProfile,
fetchCloudProfile,
getAuthLoginUrl,
cloudLogout,
handleAuthToken,
generateExchangeCode,
getAuthLoginUrlWithCode,
isCloudAuthFlowActive,
pollForToken,
getCachedCloudProfile,
subscribeCloudProfile,
type CloudAuthFlowId,
} from "../../../lib/cloudAuth";
import { logInfo } from "../../../lib/logger";
import { TRANSCRIPTION_STYLE_OPTIONS } from "../../../lib/transcriptionPrompts";
@ -608,12 +623,12 @@ function CloudSubscriptionAccountCard({
}) {
const { t } = useI18n();
return (
<div className="card" style={{ padding: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "2px 2px 4px" }}>
<div className="card" style={{ padding: "22px 20px", borderRadius: 10, background: "var(--control-muted)", color: "var(--text-hi)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 14, paddingBottom: 16, marginBottom: 16, borderBottom: "1px solid var(--border-subtle)" }}>
<div
style={{
width: 46,
height: 46,
width: 42,
height: 42,
borderRadius: "50%",
background: "var(--avatar-bg)",
display: "flex",
@ -681,49 +696,21 @@ function CloudSubscriptionAccountCard({
</button>
</div>
<div style={{ textAlign: "center", marginTop: 10, fontSize: 13, fontWeight: 700, color: "var(--accent)" }}>
{t("models.cta.freeTrial")}
</div>
<button
onClick={onActivate}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
width: "100%",
margin: "8px 0 0",
padding: "12px 14px",
borderRadius: 8,
background: "var(--accent)",
color: "var(--accent-contrast)",
border: "none",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.04em",
lineHeight: 1,
whiteSpace: "nowrap",
cursor: "pointer",
transition: "opacity 0.15s",
fontFamily: "var(--font)",
}}
>
<Crown size={13} strokeWidth={2} color="var(--accent-contrast)" />
<span style={{ display: "flex", alignItems: "center", lineHeight: 1, whiteSpace: "nowrap" }}>{t("models.cta.upgradePro")}</span>
</button>
<SubscriptionPromoContent onActivate={onActivate} />
</div>
);
}
function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) {
function SubscriptionPromoContent({ onActivate }: { onActivate: () => void }) {
const { t } = useI18n();
return (
<div className="card" style={{ padding: "22px 20px", borderRadius: 10, background: "var(--control-muted)", color: "var(--text-hi)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14, flexWrap: "wrap" }}>
<>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
<Crown size={16} strokeWidth={2.2} />
<span style={{ fontWeight: 700, fontSize: 14, letterSpacing: "-0.02em" }}>{t("models.guest.title")}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--accent)" }}>{t("models.cta.freeTrial")}</span>
<div style={{ display: "flex", alignItems: "baseline", gap: 6, minWidth: 0, flexWrap: "wrap" }}>
<span style={{ fontWeight: 700, fontSize: 14, letterSpacing: "-0.02em", lineHeight: 1.2 }}>{t("models.guest.title")}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-low)", lineHeight: 1.2 }}>{t("models.cta.freeTrial")}</span>
</div>
</div>
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 14px", fontSize: 12, lineHeight: 2, opacity: 0.85 }}>
<li>{t("models.guest.benefit1")}</li>
@ -733,6 +720,14 @@ function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) {
<button onClick={onActivate} style={{ width: "100%", padding: "12px", borderRadius: 10, background: "var(--accent)", color: "var(--accent-contrast)", border: "none", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", cursor: "pointer", transition: "opacity 0.15s", fontFamily: "var(--font-main)" }}>
{t("models.cta.upgradePro")}
</button>
</>
);
}
function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) {
return (
<div className="card" style={{ padding: "22px 20px", borderRadius: 10, background: "var(--control-muted)", color: "var(--text-hi)" }}>
<SubscriptionPromoContent onActivate={onActivate} />
</div>
);
}
@ -1243,8 +1238,24 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
const [localModelActionStates, setLocalModelActionStates] = useState<Partial<Record<string, LocalModelActionState>>>({});
const authPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const exchangeCodeRef = useRef<string | null>(null);
const authFlowRef = useRef<CloudAuthFlowId | null>(null);
const settingsSaveQueueRef = useRef<Promise<void>>(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 && (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: "var(--text-hi)", marginBottom: 4 }}>{t("models.mode.cloud")}</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
{hasActiveSubscription
? t("models.cloud.descActive")
: t("models.cloud.descGuest")}
{t("models.cloud.descActive")}
</div>
</div>