This commit is contained in:
David Perov 2026-04-01 23:15:53 +03:00
parent 636200a110
commit ef4d18dc38
38 changed files with 2095 additions and 1161 deletions

View file

@ -1,6 +1,6 @@
# TalkFlow - AGENTS.md
TalkFlow is a macOS voice-to-text application built with Tauri v2 (Rust backend) and React (TypeScript frontend).
TalkFlow is a macOS voice-to-text application built with Tauri v2 (Rust backend) and React (TypeScript frontend), with a companion cloud platform (Next.js).
## Project Structure
@ -15,6 +15,7 @@ talk-flow/
│ │ ├── store.ts # Persistent settings (tauri-plugin-store)
│ │ ├── logger.ts # Logging utilities
│ │ ├── permissions.ts # OS permission checks
│ │ ├── cloudAuth.ts # Cloud auth client (talkis.ru API)
│ │ └── utils.ts # Helper functions
│ └── main.tsx # Entry point (routes to widget/settings)
├── src-tauri/
@ -24,6 +25,12 @@ talk-flow/
│ │ ├── paste.rs # Clipboard paste simulation
│ │ └── logger.rs # File logging
│ └── Cargo.toml
├── talkflow-web/ # Cloud platform (Next.js 15)
│ ├── src/app/ # Pages: landing, auth, dashboard
│ ├── src/components/ # Landing, dashboard, shared components
│ ├── src/lib/ # Auth, Prisma, email
│ ├── prisma/schema.prisma # DB schema (7 models)
│ └── .env.example # Environment variables template
└── package.json
```
@ -51,8 +58,57 @@ bun run check:release
# View logs
bun run logs # tail -f ~/.talkflow/talkflow.log
bun run logs:clear # rm ~/.talkflow/talkflow.log
# ── talkflow-web ──
cd talkflow-web && bun run dev # Next.js dev server
cd talkflow-web && bunx tsc --noEmit # TS check
cd talkflow-web && bunx prisma migrate dev --name <name> # DB migration
```
## Design System
### Fonts
| Token | Font | Usage |
|-------|------|-------|
| `--font` / `--font-main` | Inter | Body text, UI elements |
| `--font-accent` | Manrope 800 | **All headings** — bold, sans-serif, `letter-spacing: -0.04em` |
| `--font-brand` | Manrope 800 | Logo wordmark, `letter-spacing: -0.06em`, uppercase |
> **Rule:** Headings are NEVER italic. Both the Tauri app and web use Manrope for headings — not Playfair Display.
### Color Palette (Cappuccino Theme)
| Token | Value | Usage |
|-------|-------|-------|
| `--bg` / `--bg-cappuccino` | `#faf9f6` | Page background |
| `--text-hi` | `#000000` | Primary text |
| `--text-mid` | `#39342d` / `#666` | Secondary text |
| `--text-low` | `#5d564d` / `#999` | Tertiary / hint text |
| `--border` | `rgba(0,0,0,0.09)` | Subtle borders |
### Interactive Elements Style
Nav items, cards, and interactive elements follow a **soft** style:
- **Border radius:** `10px` for nav items, cards, buttons in sidebar
- **Active state:** `background: rgba(0,0,0,0.04)` + `font-weight: 600` + `color: var(--text-hi)` — never inverted black
- **Hover:** `background: rgba(0,0,0,0.04)`
- **Icons:** `size={18}`, `strokeWidth` active `2.2`, inactive `1.6`
### Buttons
| Class | Style | Usage |
|-------|-------|-------|
| `btn-black` | Black bg, white text, uppercase, rounded-full | Primary CTA |
| `btn-outline` | Transparent, black border, uppercase, rounded-full | Secondary CTA |
### CTA Subscription Block
The sidebar CTA is a **light card** (not inverted black):
- `background: rgba(0,0,0,0.03)`, `border: 1px solid rgba(0,0,0,0.06)`
- Dark text, dark icons
- Button inside is `btn-black` style (black bg, white text)
## Code Style
### TypeScript/React
@ -159,6 +215,9 @@ logger::log_error("TAG", &format!("error: {}", e));
- **Persistent storage:** Use `tauri-plugin-store` with JSON file
- **Permissions:** Check microphone via `getUserMedia()`, accessibility via system dialog
- **API calls:** Whisper for transcription, GPT-4o-mini for text cleanup
- **Cloud platform:** `talkflow-web/` — Next.js 15, Auth.js v5, Prisma, PostgreSQL
- **Auth flow:** Email OTP + Yandex OAuth → deep link `talkflow://auth?token=xxx`
- **Subscription:** Free (own API key) or paid (cloud, 390₽/mo)
## Release Workflow
@ -175,3 +234,5 @@ logger::log_error("TAG", &format!("error: {}", e));
3. **Settings:** Load once at startup via `getSettings()`, save immediately on change
4. **Window sizes:** Widget is 50x18 in its compact state; keep window sizing in sync with `src/windows/widget/widgetConstants.ts`
5. **Logs location:** `~/.talkflow/talkflow.log`
6. **Package manager:** Use `bun` everywhere (not npm/yarn)
7. **Dev-only features:** Gate behind `import.meta.env.DEV` (e.g., Prompt Preview)

39
CHANGELOG.md Normal file
View file

@ -0,0 +1,39 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- Extracted `formatErrorMessage` into shared `src/lib/utils.ts` (was duplicated 3×)
- Extracted `create_settings_window` helper in Rust backend (was duplicated in `open_settings` and `open_settings_tab`)
- Reused HTTP client via `OnceLock<reqwest::Client>` singleton instead of creating per-request
- Logger mutex now recovers from poisoning instead of panicking
- Temperature is now configurable per transcription style (classic=0.0, business=0.1, tech=0.15)
- Added version-sync check (`scripts/check-version-sync.sh`) to `check:release` pipeline
- Synchronized `NOTICE_WIDGET_GAP` constant between TypeScript and Rust
### Added
- `CHANGELOG.md` (this file)
## [0.1.7] - 2026-03-27
### Fixed
- Reset stale macOS Accessibility permission entry before sending user to System Settings
- Explain the reset behavior in the onboarding permission screen
## [0.1.6] - 2026-03-26
### Added
- Initial public release with voice-to-text transcription
- Whisper API integration for speech recognition
- GPT-4o-mini integration for text cleanup
- Three transcription styles: Classic, Business, Tech
- Global hotkey with push-to-talk and double-tap lock modes
- Widget with waveform visualization
- Settings window with language, microphone, and hotkey configuration
- Transcription history with copy and retry
- macOS permissions onboarding flow

View file

@ -10,7 +10,8 @@
"check": "bunx tsc --noEmit && cargo check --manifest-path src-tauri/Cargo.toml",
"test": "bun test",
"smoke:hotkey": "bun test src/windows/widget/services/hotkeyFsm.test.js",
"check:release": "bun run check && bun run smoke:hotkey && bun run build",
"check:versions": "bash scripts/check-version-sync.sh",
"check:release": "bun run check:versions && bun run check && bun run smoke:hotkey && bun run build",
"postprocess:macos-release": "VERSION=$(python3 -c \"import json; print(json.load(open('package.json'))['version'])\") && bash scripts/postprocess-macos-release.sh \"$VERSION\"",
"build:release:macos": "bun run tauri build && bun run postprocess:macos-release",
"preview": "vite preview",

20
scripts/check-version-sync.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
PKG_VERSION=$(python3 -c "import json; print(json.load(open('package.json'))['version'])")
CARGO_VERSION=$(grep '^version' src-tauri/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
TAURI_VERSION=$(python3 -c "import json; print(json.load(open('src-tauri/tauri.conf.json'))['version'])")
echo "package.json: $PKG_VERSION"
echo "Cargo.toml: $CARGO_VERSION"
echo "tauri.conf.json: $TAURI_VERSION"
if [ "$PKG_VERSION" != "$CARGO_VERSION" ] || [ "$PKG_VERSION" != "$TAURI_VERSION" ]; then
echo ""
echo "ERROR: Version mismatch detected!"
exit 1
fi
echo ""
echo "All versions match: $PKG_VERSION"

View file

@ -3,6 +3,13 @@ use crate::prompt_config;
use base64::Engine;
use reqwest::multipart;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
fn http_client() -> &'static reqwest::Client {
HTTP_CLIENT.get_or_init(reqwest::Client::new)
}
#[derive(Serialize, Deserialize, Clone)]
pub struct TranscribeRequest {
@ -20,21 +27,13 @@ pub struct TranscribeResponse {
pub cleaned: String,
}
fn build_whisper_prompt(language: &str, style: &str) -> Option<&'static str> {
match (language, style) {
("ru", "tech") => Some(
"Это русская developer dictation с английскими техническими терминами. Сохраняй и корректно распознавай code tokens и команды: console.log, function, const, import, export, git push, git status, npm install, bun run dev, cargo check, src, api, ts, tsx, jsx, JSON.parse, useEffect, useState. Если слышишь 'слеш', 'точка', 'дефис', 'нижнее подчеркивание', корректно восстанавливай технический фрагмент.",
),
("ru", _) => Some(
"Это обычная русская речь. Корректно распознавай общеупотребительные слова: сегодня, сейчас, сегодняшний, также, тоже, ещё. Не дроби и не искажай слово 'сегодня'.",
),
("en", "tech") => Some(
"This is developer dictation with code, commands, paths, and API names. Preserve technical tokens accurately: console.log, function, const, import, export, git push, npm install, bun run dev, cargo check, src, api, ts, tsx, jsx, JSON.parse, useEffect, useState.",
),
("en", _) => Some(
"This is natural spoken English. Preserve common everyday words accurately and avoid splitting or distorting short common words.",
),
_ => None,
fn build_whisper_prompt(language: &str, style: &str) -> Option<String> {
match prompt_config::build_whisper_hint(language, style) {
Ok(hint) => hint,
Err(err) => {
logger::log_error("WHISPER", &format!("Failed to build whisper hint: {}", err));
None
}
}
}
@ -111,7 +110,7 @@ fn is_likely_short_uncertain_transcription(
pub async fn transcribe_and_clean(req: TranscribeRequest) -> Result<TranscribeResponse, String> {
logger::log_info("API", "Starting transcription...");
let client = reqwest::Client::new();
let client = http_client();
let audio_bytes = base64::engine::general_purpose::STANDARD
.decode(&req.audio_base64)
.map_err(|e| {
@ -275,7 +274,7 @@ pub async fn transcribe_and_clean(req: TranscribeRequest) -> Result<TranscribeRe
{"role": "system", "content": system_prompt},
{"role": "user", "content": raw}
],
"temperature": 0.15,
"temperature": prompt_preview.temperature.unwrap_or(0.15),
"max_tokens": 4096
});

View file

@ -0,0 +1,64 @@
const APP_BUNDLE_ID: &str = "com.trixter.talkflow";
#[cfg(target_os = "macos")]
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn AXIsProcessTrusted() -> u8;
fn AXIsProcessTrustedWithOptions(options: *const std::ffi::c_void) -> u8;
}
#[tauri::command]
pub async fn open_accessibility_settings() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.spawn()
.map_err(|e| format!("Failed to open accessibility settings: {}", e))?;
}
#[cfg(not(target_os = "macos"))]
{
// On Windows/Linux, accessibility is usually not required for global shortcuts
}
Ok(())
}
#[tauri::command]
pub async fn reset_accessibility_permission() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let status = std::process::Command::new("tccutil")
.arg("reset")
.arg("Accessibility")
.arg(APP_BUNDLE_ID)
.status()
.map_err(|e| format!("Failed to reset accessibility permission: {}", e))?;
if !status.success() {
return Err(format!(
"tccutil reset Accessibility {} failed with status {}",
APP_BUNDLE_ID, status
));
}
}
Ok(())
}
#[tauri::command]
pub async fn check_accessibility_permission() -> Result<bool, String> {
#[cfg(target_os = "macos")]
{
let trusted = unsafe { AXIsProcessTrustedWithOptions(std::ptr::null()) != 0 };
if trusted {
return Ok(true);
}
return Ok(unsafe { AXIsProcessTrusted() != 0 });
}
#[cfg(not(target_os = "macos"))]
{
Ok(true)
}
}

View file

@ -0,0 +1,4 @@
pub mod accessibility;
pub mod runtime_info;
pub mod settings_window;
pub mod widget;

View file

@ -0,0 +1,37 @@
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppRuntimeInfo {
executable_path: String,
bundle_path: String,
launched_via_translocation: bool,
launched_from_mounted_volume: bool,
should_move_to_applications: bool,
}
fn build_runtime_info() -> Result<AppRuntimeInfo, String> {
let executable_path = std::env::current_exe().map_err(|e| e.to_string())?;
let executable_path_str = executable_path.to_string_lossy().to_string();
let bundle_path = executable_path
.ancestors()
.find(|path| path.extension().map(|ext| ext == "app").unwrap_or(false))
.map(|path| path.to_path_buf())
.unwrap_or_else(|| executable_path.clone());
let bundle_path_str = bundle_path.to_string_lossy().to_string();
let launched_via_translocation = executable_path_str.contains("/AppTranslocation/");
let launched_from_mounted_volume = bundle_path_str.starts_with("/Volumes/");
Ok(AppRuntimeInfo {
executable_path: executable_path_str,
bundle_path: bundle_path_str,
launched_via_translocation,
launched_from_mounted_volume,
should_move_to_applications: launched_via_translocation || launched_from_mounted_volume,
})
}
#[tauri::command]
pub fn get_app_runtime_info() -> Result<AppRuntimeInfo, String> {
build_runtime_info()
}

View file

@ -0,0 +1,67 @@
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
use crate::logger;
const SETTINGS_NAVIGATE_EVENT: &str = "settings-navigate";
fn show_and_focus_window(win: &tauri::WebviewWindow) {
if let Err(err) = win.show() {
logger::log_error("WINDOW", &format!("Failed to show settings window: {}", err));
}
if let Err(err) = win.set_focus() {
logger::log_error("WINDOW", &format!("Failed to focus settings window: {}", err));
}
}
fn create_settings_window(app: &AppHandle, url: &str) -> Result<tauri::WebviewWindow, String> {
let win = WebviewWindowBuilder::new(
app,
"settings",
WebviewUrl::App(url.into()),
)
.title("Talk Flow — Settings")
.inner_size(920.0, 680.0)
.min_inner_size(820.0, 560.0)
.decorations(false)
.transparent(true)
.center()
.build()
.map_err(|e| e.to_string())?;
#[cfg(target_os = "macos")]
if let Err(err) = apply_vibrancy(&win, NSVisualEffectMaterial::HudWindow, None, None) {
logger::log_error(
"WINDOW",
&format!("Failed to apply vibrancy to settings window: {}", err),
);
}
show_and_focus_window(&win);
Ok(win)
}
#[tauri::command]
pub async fn open_settings(app: AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("settings") {
show_and_focus_window(&win);
return Ok(());
}
create_settings_window(&app, "index.html?window=settings")?;
Ok(())
}
#[tauri::command]
pub async fn open_settings_tab(app: AppHandle, tab: String) -> Result<(), String> {
if let Some(win) = app.get_webview_window("settings") {
show_and_focus_window(&win);
app.emit_to("settings", SETTINGS_NAVIGATE_EVENT, serde_json::json!({ "tab": tab }))
.map_err(|e| e.to_string())?;
return Ok(());
}
let url = format!("index.html?window=settings&tab={}", tab);
create_settings_window(&app, &url)?;
Ok(())
}

View file

@ -0,0 +1,207 @@
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
const NOTICE_WINDOW_LABEL: &str = "widget-notice";
const NOTICE_EVENT: &str = "widget-notice:update";
pub const NOTICE_WIDTH: f64 = 212.0;
pub const NOTICE_HEIGHT: f64 = 52.0;
/// Must match NOTICE_WIDGET_GAP in src/windows/widget/widgetConstants.ts (logical pixels).
pub const NOTICE_GAP: f64 = 2.0;
pub const WIDGET_DEFAULT_BOTTOM_MARGIN: i32 = 16;
pub const WIDGET_WIDTH: f64 = 50.0;
pub const WIDGET_HEIGHT: f64 = 18.0;
#[derive(Clone, Serialize)]
struct WidgetNoticePayload {
message: String,
tone: String,
}
pub fn ensure_widget_notice_window(app: &AppHandle) -> Result<tauri::WebviewWindow, String> {
if let Some(win) = app.get_webview_window(NOTICE_WINDOW_LABEL) {
return Ok(win);
}
let win = WebviewWindowBuilder::new(
app,
NOTICE_WINDOW_LABEL,
WebviewUrl::App("index.html?window=widget-notice".into()),
)
.title("Talk Flow Notice")
.inner_size(NOTICE_WIDTH, NOTICE_HEIGHT)
.resizable(false)
.decorations(false)
.transparent(true)
.always_on_top(true)
.skip_taskbar(true)
.accept_first_mouse(true)
.focused(false)
.visible(false)
.shadow(false)
.build()
.map_err(|e| e.to_string())?;
Ok(win)
}
fn position_widget_notice_window(
widget_window: &tauri::WebviewWindow,
notice_window: &tauri::WebviewWindow,
) -> Result<(), String> {
let widget_position = widget_window.outer_position().map_err(|e| e.to_string())?;
let widget_size = widget_window.outer_size().map_err(|e| e.to_string())?;
let scale_factor = widget_window.scale_factor().map_err(|e| e.to_string())?;
let notice_width = NOTICE_WIDTH * scale_factor;
let notice_height = NOTICE_HEIGHT * scale_factor;
let notice_gap = NOTICE_GAP * scale_factor;
let x = widget_position.x as f64 + (widget_size.width as f64 - notice_width) / 2.0;
let y = widget_position.y as f64 - notice_gap - notice_height;
notice_window
.set_size(tauri::Size::Logical(tauri::LogicalSize {
width: NOTICE_WIDTH,
height: NOTICE_HEIGHT,
}))
.map_err(|e| e.to_string())?;
notice_window
.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x: x.round() as i32,
y: y.round() as i32,
}))
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn calculate_default_widget_position(
monitor: &tauri::Monitor,
width: f64,
height: f64,
) -> tauri::PhysicalPosition<i32> {
let work_area = monitor.work_area();
let scale_factor = monitor.scale_factor();
let width_px = (width * scale_factor).round() as i32;
let height_px = (height * scale_factor).round() as i32;
let bottom_margin_px = ((WIDGET_DEFAULT_BOTTOM_MARGIN as f64) * scale_factor).round() as i32;
let x = work_area.position.x + ((work_area.size.width as i32 - width_px) / 2);
let y = work_area.position.y + work_area.size.height as i32 - height_px - bottom_margin_px;
tauri::PhysicalPosition { x, y }
}
#[cfg(target_os = "macos")]
fn resize_widget_window(app: &AppHandle, width: f64, height: f64) -> Result<(), String> {
use std::sync::mpsc;
let handle = app.clone();
let (tx, rx) = mpsc::channel::<Result<(), String>>();
app.run_on_main_thread(move || {
let result = (|| -> Result<(), String> {
let Some(win) = handle.get_webview_window("widget") else {
return Ok(());
};
let scale_factor = win.scale_factor().unwrap_or(1.0);
unsafe {
let ns_win: &objc2_app_kit::NSWindow =
&*win.ns_window().map_err(|e| e.to_string())?.cast();
let frame = ns_win.frame();
let target_width = width * scale_factor;
let target_height = height * scale_factor;
let next_x = frame.origin.x + (frame.size.width - target_width) / 2.0;
let next_y = frame.origin.y + frame.size.height - target_height;
let next_frame = objc2_foundation::NSRect::new(
objc2_foundation::NSPoint::new(next_x, next_y),
objc2_foundation::NSSize::new(target_width, target_height),
);
ns_win.setFrame_display(next_frame, true);
}
Ok(())
})();
let _ = tx.send(result);
})
.map_err(|e| e.to_string())?;
rx.recv()
.map_err(|e| format!("Failed to receive resize result: {}", e))?
}
#[cfg(not(target_os = "macos"))]
fn resize_widget_window(app: &AppHandle, width: f64, height: f64) -> Result<(), String> {
use crate::logger;
if let Some(win) = app.get_webview_window("widget") {
let current_position = win.outer_position().ok();
let current_size = win.outer_size().ok();
let scale_factor = win.scale_factor().unwrap_or(1.0);
if let Err(err) = win.set_size(tauri::Size::Logical(tauri::LogicalSize { width, height })) {
logger::log_error("WINDOW", &format!("Failed to resize widget window: {}", err));
}
if let (Some(position), Some(size)) = (current_position, current_size) {
let target_width = width * scale_factor;
let target_height = height * scale_factor;
let x = position.x as f64 + (size.width as f64 - target_width) / 2.0;
let y = position.y as f64 + size.height as f64 - target_height;
if let Err(err) = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x: x.round() as i32,
y: y.round() as i32,
})) {
logger::log_error(
"WINDOW",
&format!("Failed to preserve widget position on resize: {}", err),
);
}
} else if let Ok(Some(monitor)) = win.primary_monitor() {
let position = calculate_default_widget_position(&monitor, width, height);
if let Err(err) = win.set_position(tauri::Position::Physical(position)) {
logger::log_error("WINDOW", &format!("Failed to reposition widget window: {}", err));
}
}
}
Ok(())
}
#[tauri::command]
pub async fn show_widget_notice(app: AppHandle, message: String, tone: String, _anchor_state: String) -> Result<(), String> {
let widget_window = app
.get_webview_window("widget")
.ok_or_else(|| "Widget window not found".to_string())?;
let notice_window = ensure_widget_notice_window(&app)?;
position_widget_notice_window(&widget_window, &notice_window)?;
app.emit_to(
NOTICE_WINDOW_LABEL,
NOTICE_EVENT,
WidgetNoticePayload { message, tone },
)
.map_err(|e| e.to_string())?;
let _ = notice_window.set_ignore_cursor_events(false);
notice_window.show().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn hide_widget_notice(app: AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window(NOTICE_WINDOW_LABEL) {
win.hide().map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn widget_resize(app: AppHandle, width: f64, height: f64) -> Result<(), String> {
resize_widget_window(&app, width, height)
}

View file

@ -1,415 +1,91 @@
mod ai;
mod commands;
mod hotkey_capture;
mod logger;
mod paste;
mod prompt_config;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
use commands::{
accessibility, runtime_info, settings_window,
widget::{self, WIDGET_HEIGHT, WIDGET_WIDTH},
};
use tauri::Manager;
const NOTICE_WINDOW_LABEL: &str = "widget-notice";
const NOTICE_EVENT: &str = "widget-notice:update";
const SETTINGS_NAVIGATE_EVENT: &str = "settings-navigate";
const APP_BUNDLE_ID: &str = "com.trixter.talkflow";
const NOTICE_WIDTH: f64 = 212.0;
const NOTICE_HEIGHT: f64 = 52.0;
const NOTICE_GAP: f64 = 2.0;
const WIDGET_DEFAULT_BOTTOM_MARGIN: i32 = 16;
const WIDGET_WIDTH: f64 = 50.0;
const WIDGET_HEIGHT: f64 = 18.0;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_clipboard_manager::init())
.setup(|app| {
logger::log_info("INIT", "Application starting...");
let _ = widget::ensure_widget_notice_window(app.handle());
#[derive(Clone, Serialize)]
struct WidgetNoticePayload {
message: String,
tone: String,
}
if let Some(win) = app.get_webview_window("widget") {
#[cfg(target_os = "macos")]
{
unsafe {
let ns_win: &objc2_app_kit::NSWindow =
&*win.ns_window().map_err(|e| e.to_string())?.cast();
ns_win.setAcceptsMouseMovedEvents(true);
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AppRuntimeInfo {
executable_path: String,
bundle_path: String,
launched_via_translocation: bool,
launched_from_mounted_volume: bool,
should_move_to_applications: bool,
}
if let Ok(Some(monitor)) = win.primary_monitor() {
if let Err(err) = win.set_size(tauri::Size::Logical(tauri::LogicalSize {
width: WIDGET_WIDTH,
height: WIDGET_HEIGHT,
})) {
logger::log_error(
"WINDOW",
&format!("Failed to size widget window during setup: {}", err),
);
}
fn ensure_widget_notice_window(app: &AppHandle) -> Result<tauri::WebviewWindow, String> {
if let Some(win) = app.get_webview_window(NOTICE_WINDOW_LABEL) {
return Ok(win);
}
let win = WebviewWindowBuilder::new(
app,
NOTICE_WINDOW_LABEL,
WebviewUrl::App("index.html?window=widget-notice".into()),
)
.title("Talk Flow Notice")
.inner_size(NOTICE_WIDTH, NOTICE_HEIGHT)
.resizable(false)
.decorations(false)
.transparent(true)
.always_on_top(true)
.skip_taskbar(true)
.accept_first_mouse(true)
.focused(false)
.visible(false)
.shadow(false)
.build()
.map_err(|e| e.to_string())?;
Ok(win)
}
fn position_widget_notice_window(
widget_window: &tauri::WebviewWindow,
notice_window: &tauri::WebviewWindow,
) -> Result<(), String> {
let widget_position = widget_window.outer_position().map_err(|e| e.to_string())?;
let widget_size = widget_window.outer_size().map_err(|e| e.to_string())?;
let scale_factor = widget_window.scale_factor().map_err(|e| e.to_string())?;
let notice_width = NOTICE_WIDTH * scale_factor;
let notice_height = NOTICE_HEIGHT * scale_factor;
let notice_gap = NOTICE_GAP * scale_factor;
let x = widget_position.x as f64 + (widget_size.width as f64 - notice_width) / 2.0;
let y = widget_position.y as f64 - notice_gap - notice_height;
notice_window
.set_size(tauri::Size::Logical(tauri::LogicalSize {
width: NOTICE_WIDTH,
height: NOTICE_HEIGHT,
}))
.map_err(|e| e.to_string())?;
notice_window
.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x: x.round() as i32,
y: y.round() as i32,
}))
.map_err(|e| e.to_string())?;
Ok(())
}
fn calculate_default_widget_position(
monitor: &tauri::Monitor,
width: f64,
height: f64,
) -> tauri::PhysicalPosition<i32> {
let work_area = monitor.work_area();
let scale_factor = monitor.scale_factor();
let width_px = (width * scale_factor).round() as i32;
let height_px = (height * scale_factor).round() as i32;
let bottom_margin_px = ((WIDGET_DEFAULT_BOTTOM_MARGIN as f64) * scale_factor).round() as i32;
let x = work_area.position.x + ((work_area.size.width as i32 - width_px) / 2);
let y = work_area.position.y + work_area.size.height as i32 - height_px - bottom_margin_px;
tauri::PhysicalPosition { x, y }
}
#[tauri::command]
async fn show_widget_notice(app: AppHandle, message: String, tone: String, _anchor_state: String) -> Result<(), String> {
let widget_window = app
.get_webview_window("widget")
.ok_or_else(|| "Widget window not found".to_string())?;
let notice_window = ensure_widget_notice_window(&app)?;
position_widget_notice_window(&widget_window, &notice_window)?;
app.emit_to(
NOTICE_WINDOW_LABEL,
NOTICE_EVENT,
WidgetNoticePayload { message, tone },
)
.map_err(|e| e.to_string())?;
let _ = notice_window.set_ignore_cursor_events(false);
notice_window.show().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn hide_widget_notice(app: AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window(NOTICE_WINDOW_LABEL) {
win.hide().map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg(target_os = "macos")]
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn AXIsProcessTrusted() -> u8;
fn AXIsProcessTrustedWithOptions(options: *const std::ffi::c_void) -> u8;
}
fn build_runtime_info() -> Result<AppRuntimeInfo, String> {
let executable_path = std::env::current_exe().map_err(|e| e.to_string())?;
let executable_path_str = executable_path.to_string_lossy().to_string();
let bundle_path = executable_path
.ancestors()
.find(|path| path.extension().map(|ext| ext == "app").unwrap_or(false))
.map(|path| path.to_path_buf())
.unwrap_or_else(|| executable_path.clone());
let bundle_path_str = bundle_path.to_string_lossy().to_string();
let launched_via_translocation = executable_path_str.contains("/AppTranslocation/");
let launched_from_mounted_volume = bundle_path_str.starts_with("/Volumes/");
Ok(AppRuntimeInfo {
executable_path: executable_path_str,
bundle_path: bundle_path_str,
launched_via_translocation,
launched_from_mounted_volume,
should_move_to_applications: launched_via_translocation || launched_from_mounted_volume,
})
}
#[tauri::command]
async fn open_settings(app: AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("settings") {
if let Err(err) = win.show() {
logger::log_error("WINDOW", &format!("Failed to show settings window: {}", err));
}
if let Err(err) = win.set_focus() {
logger::log_error("WINDOW", &format!("Failed to focus settings window: {}", err));
}
return Ok(());
}
let win = WebviewWindowBuilder::new(
&app,
"settings",
WebviewUrl::App("index.html?window=settings".into()),
)
.title("Talk Flow — Settings")
.inner_size(920.0, 680.0)
.min_inner_size(820.0, 560.0)
.decorations(false)
.transparent(true)
.center()
.build()
.map_err(|e| e.to_string())?;
#[cfg(target_os = "macos")]
if let Err(err) = apply_vibrancy(&win, NSVisualEffectMaterial::HudWindow, None, None) {
logger::log_error(
"WINDOW",
&format!("Failed to apply vibrancy to settings window: {}", err),
);
}
if let Err(err) = win.show() {
logger::log_error(
"WINDOW",
&format!("Failed to show new settings window: {}", err),
);
}
if let Err(err) = win.set_focus() {
logger::log_error(
"WINDOW",
&format!("Failed to focus new settings window: {}", err),
);
}
Ok(())
}
#[tauri::command]
async fn open_settings_tab(app: AppHandle, tab: String) -> Result<(), String> {
if let Some(win) = app.get_webview_window("settings") {
if let Err(err) = win.show() {
logger::log_error("WINDOW", &format!("Failed to show settings window: {}", err));
}
if let Err(err) = win.set_focus() {
logger::log_error("WINDOW", &format!("Failed to focus settings window: {}", err));
}
app.emit_to("settings", SETTINGS_NAVIGATE_EVENT, serde_json::json!({ "tab": tab }))
.map_err(|e| e.to_string())?;
return Ok(());
}
let url = format!("index.html?window=settings&tab={}", tab);
let win = WebviewWindowBuilder::new(&app, "settings", WebviewUrl::App(url.into()))
.title("Talk Flow - Settings")
.inner_size(920.0, 680.0)
.min_inner_size(820.0, 560.0)
.decorations(false)
.transparent(true)
.center()
.build()
.map_err(|e| e.to_string())?;
#[cfg(target_os = "macos")]
if let Err(err) = apply_vibrancy(&win, NSVisualEffectMaterial::HudWindow, None, None) {
logger::log_error(
"WINDOW",
&format!("Failed to apply vibrancy to settings window: {}", err),
);
}
if let Err(err) = win.show() {
logger::log_error(
"WINDOW",
&format!("Failed to show new settings window: {}", err),
);
}
if let Err(err) = win.set_focus() {
logger::log_error(
"WINDOW",
&format!("Failed to focus new settings window: {}", err),
);
}
Ok(())
}
#[cfg(target_os = "macos")]
fn resize_widget_window(app: &AppHandle, width: f64, height: f64) -> Result<(), String> {
use std::sync::mpsc;
let handle = app.clone();
let (tx, rx) = mpsc::channel::<Result<(), String>>();
app.run_on_main_thread(move || {
let result = (|| -> Result<(), String> {
let Some(win) = handle.get_webview_window("widget") else {
return Ok(());
};
let scale_factor = win.scale_factor().unwrap_or(1.0);
unsafe {
let ns_win: &objc2_app_kit::NSWindow =
&*win.ns_window().map_err(|e| e.to_string())?.cast();
let frame = ns_win.frame();
let target_width = width * scale_factor;
let target_height = height * scale_factor;
let next_x = frame.origin.x + (frame.size.width - target_width) / 2.0;
let next_y = frame.origin.y + frame.size.height - target_height;
let next_frame = objc2_foundation::NSRect::new(
objc2_foundation::NSPoint::new(next_x, next_y),
objc2_foundation::NSSize::new(target_width, target_height),
);
ns_win.setFrame_display(next_frame, true);
let position = widget::calculate_default_widget_position(
&monitor,
WIDGET_WIDTH,
WIDGET_HEIGHT,
);
if let Err(err) = win.set_position(tauri::Position::Physical(position)) {
logger::log_error(
"WINDOW",
&format!("Failed to position widget window during setup: {}", err),
);
}
}
}
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let _ = settings_window::open_settings(handle).await;
});
Ok(())
})();
let _ = tx.send(result);
})
.map_err(|e| e.to_string())?;
rx.recv()
.map_err(|e| format!("Failed to receive resize result: {}", e))?
}
#[cfg(not(target_os = "macos"))]
fn resize_widget_window(app: &AppHandle, width: f64, height: f64) -> Result<(), String> {
if let Some(win) = app.get_webview_window("widget") {
let current_position = win.outer_position().ok();
let current_size = win.outer_size().ok();
let scale_factor = win.scale_factor().unwrap_or(1.0);
if let Err(err) = win.set_size(tauri::Size::Logical(tauri::LogicalSize { width, height })) {
logger::log_error("WINDOW", &format!("Failed to resize widget window: {}", err));
}
if let (Some(position), Some(size)) = (current_position, current_size) {
let target_width = width * scale_factor;
let target_height = height * scale_factor;
let x = position.x as f64 + (size.width as f64 - target_width) / 2.0;
let y = position.y as f64 + size.height as f64 - target_height;
if let Err(err) = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x: x.round() as i32,
y: y.round() as i32,
})) {
logger::log_error(
"WINDOW",
&format!("Failed to preserve widget position on resize: {}", err),
);
}
} else if let Ok(Some(monitor)) = win.primary_monitor() {
let position = calculate_default_widget_position(&monitor, width, height);
if let Err(err) = win.set_position(tauri::Position::Physical(position)) {
logger::log_error("WINDOW", &format!("Failed to reposition widget window: {}", err));
}
}
}
Ok(())
}
#[tauri::command]
async fn widget_resize(app: AppHandle, width: f64, height: f64) -> Result<(), String> {
resize_widget_window(&app, width, height)
}
#[tauri::command]
async fn open_accessibility_settings() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.spawn()
.map_err(|e| format!("Failed to open accessibility settings: {}", e))?;
}
#[cfg(not(target_os = "macos"))]
{
// On Windows/Linux, accessibility is usually not required for global shortcuts
}
Ok(())
}
#[tauri::command]
async fn reset_accessibility_permission() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let status = std::process::Command::new("tccutil")
.arg("reset")
.arg("Accessibility")
.arg(APP_BUNDLE_ID)
.status()
.map_err(|e| format!("Failed to reset accessibility permission: {}", e))?;
if !status.success() {
return Err(format!(
"tccutil reset Accessibility {} failed with status {}",
APP_BUNDLE_ID, status
));
}
}
Ok(())
}
#[tauri::command]
async fn check_accessibility_permission() -> Result<bool, String> {
#[cfg(target_os = "macos")]
{
let trusted = unsafe { AXIsProcessTrustedWithOptions(std::ptr::null()) != 0 };
if trusted {
return Ok(true);
}
return Ok(unsafe { AXIsProcessTrusted() != 0 });
}
#[cfg(not(target_os = "macos"))]
{
Ok(true)
}
}
#[tauri::command]
fn get_app_runtime_info() -> Result<AppRuntimeInfo, String> {
build_runtime_info()
})
.invoke_handler(tauri::generate_handler![
settings_window::open_settings,
settings_window::open_settings_tab,
widget::widget_resize,
widget::show_widget_notice,
widget::hide_widget_notice,
paste::paste_text,
ai::transcribe_and_clean,
logger::log_event,
logger::get_log_path_cmd,
logger::clear_logs,
accessibility::open_accessibility_settings,
accessibility::reset_accessibility_permission,
accessibility::check_accessibility_permission,
runtime_info::get_app_runtime_info,
get_cleanup_prompt_preview,
start_native_hotkey_capture,
stop_native_hotkey_capture,
])
.run(tauri::generate_context!())
.expect("error while running Talk Flow");
}
#[tauri::command]
@ -429,78 +105,3 @@ fn start_native_hotkey_capture(window: tauri::WebviewWindow) -> Result<(), Strin
fn stop_native_hotkey_capture(window: tauri::WebviewWindow) -> Result<(), String> {
hotkey_capture::stop_capture(&window)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_clipboard_manager::init())
.setup(|app| {
logger::log_info("INIT", "Application starting...");
let _ = ensure_widget_notice_window(app.handle());
if let Some(win) = app.get_webview_window("widget") {
#[cfg(target_os = "macos")]
{
unsafe {
let ns_win: &objc2_app_kit::NSWindow =
&*win.ns_window().map_err(|e| e.to_string())?.cast();
ns_win.setAcceptsMouseMovedEvents(true);
}
}
let width = WIDGET_WIDTH;
let height = WIDGET_HEIGHT;
if let Ok(Some(monitor)) = win.primary_monitor() {
if let Err(err) = win.set_size(tauri::Size::Logical(tauri::LogicalSize {
width,
height,
})) {
logger::log_error(
"WINDOW",
&format!("Failed to size widget window during setup: {}", err),
);
}
let position = calculate_default_widget_position(&monitor, width, height);
if let Err(err) = win.set_position(tauri::Position::Physical(position)) {
logger::log_error(
"WINDOW",
&format!("Failed to position widget window during setup: {}", err),
);
}
}
}
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let _ = open_settings(handle).await;
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
open_settings,
open_settings_tab,
widget_resize,
show_widget_notice,
hide_widget_notice,
paste::paste_text,
ai::transcribe_and_clean,
logger::log_event,
logger::get_log_path_cmd,
logger::clear_logs,
open_accessibility_settings,
reset_accessibility_permission,
check_accessibility_permission,
get_app_runtime_info,
get_cleanup_prompt_preview,
start_native_hotkey_capture,
stop_native_hotkey_capture,
])
.run(tauri::generate_context!())
.expect("error while running Talk Flow");
}

View file

@ -16,7 +16,7 @@ pub fn get_log_path() -> PathBuf {
}
pub fn log(level: &str, tag: &str, message: &str) {
let _guard = LOG_MUTEX.lock().unwrap();
let _guard = LOG_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
let line = format!("[{}] [{}] [{}] {}\n", timestamp, level, tag, message);

View file

@ -41,7 +41,7 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<(), Strin
.key(Key::Meta, enigo::Direction::Press)
.map_err(|e| format!("Meta press failed: {}", e))?;
enigo
.raw(0x09, enigo::Direction::Click)
.raw(0x09, enigo::Direction::Click) // 0x09 = macOS virtual keycode for 'V'
.map_err(|e| format!("V click failed: {}", e))?;
enigo
.key(Key::Meta, enigo::Direction::Release)

View file

@ -15,6 +15,7 @@ pub struct PromptPreview {
pub layers: Vec<String>,
pub profile_key: String,
pub version: u32,
pub temperature: Option<f64>,
}
#[derive(Deserialize)]
@ -55,6 +56,7 @@ struct BasePromptConfig {
#[serde(rename_all = "camelCase")]
struct LanguagePromptConfig {
display_name: String,
whisper_hint: Option<String>,
filler_examples: Vec<String>,
rules: Vec<String>,
examples: Vec<PromptExample>,
@ -64,8 +66,10 @@ struct LanguagePromptConfig {
#[serde(rename_all = "camelCase")]
struct StylePromptConfig {
prompt_title: String,
whisper_hint_suffix: Option<String>,
rules: Vec<String>,
examples: Vec<PromptExample>,
temperature: Option<f64>,
}
#[derive(Deserialize, Clone)]
@ -105,16 +109,7 @@ struct OverrideProfile {
pub fn build_cleanup_prompt_preview(language: &str, style: &str) -> Result<PromptPreview, String> {
let registry = get_prompt_registry()?;
let resolved_language = resolve_key(
language,
registry.manifest.languages.contains_key(language),
&registry.manifest.default_language,
);
let resolved_style = resolve_key(
style,
registry.manifest.styles.contains_key(style),
&registry.manifest.default_style,
);
let (resolved_language, resolved_style) = resolve_language_and_style(registry, language, style);
let language_profile = registry
.language_profiles
@ -207,6 +202,7 @@ pub fn build_cleanup_prompt_preview(language: &str, style: &str) -> Result<Promp
layers,
profile_key,
version: registry.manifest.version,
temperature: style_profile.config.temperature,
})
}
@ -284,6 +280,24 @@ fn render_rule(rule: &str, filler_examples: &str) -> String {
rule.replace("{filler_examples}", filler_examples)
}
fn resolve_language_and_style<'a>(
registry: &'a PromptRegistry,
language: &str,
style: &str,
) -> (String, String) {
let resolved_language = resolve_key(
language,
registry.manifest.languages.contains_key(language),
&registry.manifest.default_language,
);
let resolved_style = resolve_key(
style,
registry.manifest.styles.contains_key(style),
&registry.manifest.default_style,
);
(resolved_language, resolved_style)
}
fn resolve_key(requested: &str, exists: bool, fallback: &str) -> String {
if exists {
requested.to_string()
@ -292,6 +306,27 @@ fn resolve_key(requested: &str, exists: bool, fallback: &str) -> String {
}
}
/// Build a Whisper transcription hint from JSON config (language.whisperHint + style.whisperHintSuffix).
pub fn build_whisper_hint(language: &str, style: &str) -> Result<Option<String>, String> {
let registry = get_prompt_registry()?;
let (resolved_language, resolved_style) = resolve_language_and_style(registry, language, style);
let language_profile = registry.language_profiles.get(&resolved_language);
let style_profile = registry.style_profiles.get(&resolved_style);
let base_hint = language_profile
.and_then(|p| p.config.whisper_hint.as_deref());
let style_suffix = style_profile
.and_then(|p| p.config.whisper_hint_suffix.as_deref());
match (base_hint, style_suffix) {
(Some(base), Some(suffix)) => Ok(Some(format!("{} {}", base, suffix))),
(Some(base), None) => Ok(Some(base.to_string())),
(None, Some(suffix)) => Ok(Some(suffix.to_string())),
(None, None) => Ok(None),
}
}
fn read_json_file<T: for<'de> Deserialize<'de>>(path: &str) -> Result<T, String> {
let file = PROMPTS_DIR
.get_file(path)
@ -303,3 +338,102 @@ fn read_json_file<T: for<'de> Deserialize<'de>>(path: &str) -> Result<T, String>
serde_json::from_str(content)
.map_err(|error| format!("Failed to parse prompt config {}: {}", path, error))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_cleanup_prompt_for_ru_classic() {
let result = build_cleanup_prompt_preview("ru", "classic");
assert!(result.is_ok(), "Failed: {:?}", result.err());
let preview = result.unwrap();
assert!(!preview.prompt.is_empty());
assert!(preview.prompt.contains("Russian"));
assert!(preview.layers.len() >= 2);
assert!(preview.version > 0);
}
#[test]
fn build_cleanup_prompt_for_en_tech() {
let result = build_cleanup_prompt_preview("en", "tech");
assert!(result.is_ok(), "Failed: {:?}", result.err());
let preview = result.unwrap();
assert!(preview.prompt.contains("TECH"));
assert!(preview.prompt.contains("English"));
}
#[test]
fn build_cleanup_prompt_for_ru_tech_has_override() {
let result = build_cleanup_prompt_preview("ru", "tech");
assert!(result.is_ok());
let preview = result.unwrap();
// ru:tech override should add an extra layer
assert!(preview.layers.len() >= 3);
assert!(preview.profile_key.contains("ru"));
}
#[test]
fn build_cleanup_prompt_falls_back_for_unknown_language() {
let result = build_cleanup_prompt_preview("xx_unknown", "classic");
assert!(result.is_ok(), "Should fall back to default language");
}
#[test]
fn build_cleanup_prompt_falls_back_for_unknown_style() {
let result = build_cleanup_prompt_preview("ru", "xx_unknown_style");
assert!(result.is_ok(), "Should fall back to default style");
}
#[test]
fn temperature_is_set_per_style() {
let classic = build_cleanup_prompt_preview("ru", "classic").unwrap();
let tech = build_cleanup_prompt_preview("ru", "tech").unwrap();
let business = build_cleanup_prompt_preview("ru", "business").unwrap();
assert_eq!(classic.temperature, Some(0.0));
assert_eq!(tech.temperature, Some(0.15));
assert_eq!(business.temperature, Some(0.1));
}
#[test]
fn whisper_hint_for_ru_classic() {
let result = build_whisper_hint("ru", "classic");
assert!(result.is_ok());
let hint = result.unwrap();
assert!(hint.is_some(), "ru should have a whisper hint");
let text = hint.unwrap();
assert!(text.contains("русская"), "Should contain Russian hint");
// classic has no suffix, so it should NOT contain tech tokens
assert!(!text.contains("console.log"));
}
#[test]
fn whisper_hint_for_ru_tech_includes_suffix() {
let result = build_whisper_hint("ru", "tech");
assert!(result.is_ok());
let hint = result.unwrap();
assert!(hint.is_some());
let text = hint.unwrap();
// Should contain base Russian hint + tech suffix
assert!(text.contains("русская") || text.contains("Корректно"));
assert!(text.contains("console.log") || text.contains("code tokens"));
}
#[test]
fn whisper_hint_for_en_classic() {
let result = build_whisper_hint("en", "classic");
assert!(result.is_ok());
let hint = result.unwrap();
assert!(hint.is_some());
assert!(hint.unwrap().contains("English"));
}
#[test]
fn whisper_hint_for_unknown_language_uses_fallback() {
let result = build_whisper_hint("xx_unknown", "classic");
assert!(result.is_ok(), "Should not error for unknown language");
// Falls back to default language — result depends on whether
// the default language config has a whisperHint set.
}
}

View file

@ -24,51 +24,75 @@ function PermissionRow({ icon, title, description, status, onAction, helpText }:
const isGranted = status === "granted";
const isDenied = status === "denied";
const isPrompting = status === "prompting";
const statusLabel = isGranted ? "Готово" : isPrompting ? "Проверьте" : isDenied ? "Нужно действие" : "Не выдано";
return (
<div
className="card"
style={{
padding: 20,
padding: "16px 18px",
display: "flex",
alignItems: "flex-start",
gap: 16,
background: isGranted ? "rgba(255,255,255,0.88)" : "var(--surface)",
gap: 14,
borderRadius: 10,
background: isGranted ? "rgba(0,0,0,0.02)" : "rgba(0,0,0,0.02)",
border: `1px solid ${isGranted ? "rgba(0,0,0,0.06)" : "rgba(0,0,0,0.06)"}`,
}}
>
<div
style={{
width: 42,
height: 42,
borderRadius: 999,
width: 36,
height: 36,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: isGranted ? "#000" : "rgba(255,255,255,0.7)",
background: isGranted ? "#000" : "rgba(0,0,0,0.04)",
color: isGranted ? "#fff" : "var(--text-mid)",
flexShrink: 0,
}}
>
{isGranted ? <Check size={18} strokeWidth={2.5} /> : icon}
{isGranted ? <Check size={16} strokeWidth={2.5} /> : icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 8 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: "var(--text-hi)", marginBottom: 2 }}>{title}</div>
<div className="label">{statusLabel}</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 6 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-hi)" }}>{title}</div>
<span style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: isGranted ? "var(--success)" : "var(--text-low)",
}}>
{isGranted ? "Готово" : isPrompting ? "Проверьте" : isDenied ? "Нужно действие" : "Не выдано"}
</span>
</div>
{!isGranted && (
<button onClick={onAction} className={isPrompting ? "btn" : "btn btn-primary"} style={{ minWidth: 124 }}>
<button
onClick={onAction}
style={{
padding: "8px 16px",
borderRadius: 8,
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.06em",
cursor: "pointer",
border: "none",
background: isPrompting ? "rgba(0,0,0,0.04)" : "#000",
color: isPrompting ? "var(--text-hi)" : "#fff",
fontFamily: "var(--font)",
transition: "opacity 0.15s",
}}
>
{isPrompting ? "Проверить" : isDenied ? "Повторить" : "Разрешить"}
</button>
)}
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>{description}</div>
{helpText && <div style={{ marginTop: 8, fontSize: 12, color: "var(--text-low)", lineHeight: 1.55 }}>{helpText}</div>}
{helpText && <div style={{ marginTop: 6, fontSize: 12, color: "var(--text-low)", lineHeight: 1.55 }}>{helpText}</div>}
</div>
</div>
);
@ -223,53 +247,75 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
}}
>
<div
className="card"
style={{
width: "min(100%, 760px)",
padding: 28,
width: "min(100%, 680px)",
padding: "28px 28px 24px",
display: "flex",
flexDirection: "column",
gap: 22,
boxShadow: "var(--shadow-panel)",
gap: 20,
borderRadius: 14,
background: "rgba(255,255,255,0.82)",
border: "1px solid rgba(0,0,0,0.08)",
backdropFilter: "blur(18px)",
WebkitBackdropFilter: "blur(18px)",
boxShadow: "0 16px 40px rgba(0,0,0,0.08)",
}}
>
<div style={{ display: "grid", gap: 10 }}>
<div className="label kicker">System Access</div>
<h1 className="headline-accent" style={{ fontSize: 40, lineHeight: 0.96, margin: 0, fontWeight: 700 }}>
Доступы для Talk Flow
{/* Header */}
<div style={{ display: "grid", gap: 8 }}>
<div style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: "var(--text-low)",
}}>
Настройка доступов
</div>
<h1 style={{
fontSize: 28,
lineHeight: 1,
margin: 0,
fontWeight: 800,
fontFamily: "var(--font-brand)",
letterSpacing: "-0.04em",
color: "var(--text-hi)",
}}>
Доступы для TalkFlow
</h1>
<p style={{ margin: 0, maxWidth: 560, fontSize: 14, color: "var(--text-mid)", lineHeight: 1.7 }}>
Интерфейс уже готов. Осталось выдать системные разрешения для записи с микрофона и работы глобальной горячей клавиши.
<p style={{ margin: 0, maxWidth: 520, fontSize: 13, color: "var(--text-mid)", lineHeight: 1.7 }}>
Осталось выдать системные разрешения для записи с микрофона и работы глобальной горячей клавиши.
</p>
</div>
<div style={{ display: "grid", gap: 14 }}>
{/* Permission rows */}
<div style={{ display: "grid", gap: 10 }}>
{shouldShowInstallWarning && (
<div
className="card"
style={{
padding: 18,
padding: "14px 16px",
display: "flex",
alignItems: "flex-start",
gap: 12,
background: "rgba(143,45,32,0.08)",
border: "1px solid rgba(143,45,32,0.18)",
borderRadius: 10,
background: "rgba(143,45,32,0.06)",
border: "1px solid rgba(143,45,32,0.14)",
}}
>
<AlertCircle size={16} style={{ color: "var(--danger)", flexShrink: 0, marginTop: 1 }} />
<div style={{ display: "grid", gap: 6 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "var(--danger)" }}>
Сначала переместите приложение в Applications
<div style={{ display: "grid", gap: 4 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "var(--danger)" }}>
Переместите приложение в Applications
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
Текущая сборка запущена из временного или смонтированного места (`/Volumes` или App Translocation). Для релизной версии macOS может не применять универсальный доступ корректно в таком режиме. Переместите `Talk Flow.app` в `Applications`, откройте его оттуда и только потом выдавайте доступ.
<div style={{ fontSize: 12, color: "var(--text-mid)", lineHeight: 1.6 }}>
Текущая сборка запущена из временного места. Переместите TalkFlow в Applications и откройте оттуда.
</div>
</div>
</div>
)}
<PermissionRow
icon={<Mic size={18} strokeWidth={1.75} />}
icon={<Mic size={16} strokeWidth={1.8} />}
title="Микрофон"
description="Нужен для записи голоса перед отправкой на распознавание."
status={micStatus}
@ -277,9 +323,9 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
/>
<PermissionRow
icon={<Keyboard size={18} strokeWidth={1.75} />}
icon={<Keyboard size={16} strokeWidth={1.8} />}
title="Универсальный доступ"
description="Нужен для глобальной горячей клавиши и вставки текста в активное приложение."
description="Нужен для глобальной горячей клавиши и вставки текста."
status={shouldShowInstallWarning ? "denied" : accStatus}
onAction={() => {
if (shouldShowInstallWarning) {
@ -290,48 +336,63 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
void handleAccessibilityRequest();
}}
helpText={shouldShowInstallWarning
? `Сейчас приложение запущено не из Applications: ${runtimeInfo?.bundlePath ?? "неизвестный путь"}`
: "Откроются системные настройки macOS. Перед этим приложение сбросит старую запись Accessibility для `com.trixter.talkflow`, чтобы новая версия могла запросить доступ заново."}
? `Приложение запущено не из Applications: ${runtimeInfo?.bundlePath ?? ""}`
: undefined}
/>
</div>
{/* Hint */}
{(accStatus === "prompting" || micStatus === "denied") && (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "14px 16px",
borderRadius: 18,
border: "1px solid rgba(0,0,0,0.08)",
background: "rgba(255,255,255,0.56)",
padding: "12px 14px",
borderRadius: 10,
background: "rgba(0,0,0,0.02)",
border: "1px solid rgba(0,0,0,0.06)",
}}
>
<AlertCircle size={15} style={{ color: "var(--text-low)", flexShrink: 0, marginTop: 1 }} />
<AlertCircle size={14} style={{ color: "var(--text-low)", flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: 12, color: "var(--text-mid)", lineHeight: 1.6 }}>
{micStatus === "denied"
? "Если микрофон был отклонен ранее, откройте Системные настройки -> Конфиденциальность и безопасность -> Микрофон и включите Talk Flow вручную."
? "Если микрофон был отклонен, откройте Системные настройки → Конфиденциальность → Микрофон и включите TalkFlow."
: shouldShowInstallWarning
? "После перемещения приложения в Applications откройте его заново и повторите выдачу доступа."
: "macOS применяет доступ к универсальному доступу не мгновенно. После изменения системной настройки просто вернитесь в приложение и продолжите."}
? "После перемещения приложения в Applications откройте его заново."
: "macOS применяет доступ не мгновенно. После изменения настройки вернитесь в приложение."}
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 18, paddingTop: 6 }}>
{/* Footer */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16 }}>
<div style={{ fontSize: 12, color: canContinue ? "var(--success)" : "var(--text-low)", lineHeight: 1.55 }}>
{shouldShowInstallWarning
? "Сначала запустите Talk Flow из Applications, затем повторите выдачу доступов."
? "Сначала запустите из Applications."
: canContinue
? "Все доступы выданы."
: "Продолжение станет доступно после проверки обоих разрешений."}
: "Выдайте оба разрешения для продолжения."}
</div>
<button
onClick={handleContinue}
className={canCompleteOnboarding ? "btn btn-primary" : "btn"}
style={{ minWidth: 160 }}
style={{
padding: "10px 20px",
borderRadius: 10,
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.06em",
cursor: "pointer",
border: "none",
background: canCompleteOnboarding ? "#000" : "rgba(0,0,0,0.04)",
color: canCompleteOnboarding ? "#fff" : "var(--text-hi)",
fontFamily: "var(--font)",
transition: "opacity 0.15s",
minWidth: 140,
}}
>
{canCompleteOnboarding ? "Продолжить" : shouldShowInstallWarning ? "Запустить из Applications" : "Проверить доступы"}
{canCompleteOnboarding ? "Продолжить" : shouldShowInstallWarning ? "Applications" : "Проверить"}
</button>
</div>
</div>

View file

@ -0,0 +1,256 @@
import { useState, useEffect, useCallback } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { LogOut, User, Crown } from "lucide-react";
import { CloudProfile, fetchCloudProfile, cloudLogout, getAuthLoginUrl } from "../lib/cloudAuth";
import { logError, logInfo } from "../lib/logger";
export function UserPanel() {
const [profile, setProfile] = useState<CloudProfile | null>(null);
const [loading, setLoading] = useState(true);
const loadProfile = useCallback(async () => {
setLoading(true);
try {
const data = await fetchCloudProfile();
setProfile(data);
} catch (error) {
logError("USER_PANEL", `Failed to load profile: ${error}`);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadProfile();
}, [loadProfile]);
const handleActivate = async () => {
try {
// Open auth URL in default browser via Tauri command
await openUrl(getAuthLoginUrl());
} catch (error) {
logError("USER_PANEL", `Failed to open auth URL: ${error}`);
}
};
const handleLogout = async () => {
await cloudLogout();
setProfile(null);
logInfo("USER_PANEL", "User logged out");
};
if (loading) {
return <div style={styles.container} />;
}
// ── Authenticated + active subscription ─────────────────────
if (profile && profile.subscription.active) {
return (
<div style={styles.container}>
<div style={styles.profileRow}>
<div style={styles.avatar}>
{profile.user.avatarUrl ? (
<img
src={profile.user.avatarUrl}
alt=""
style={{ width: "100%", height: "100%", borderRadius: "50%", objectFit: "cover" }}
/>
) : (
<User size={16} strokeWidth={1.5} color="var(--text-low)" />
)}
</div>
<div style={styles.profileInfo}>
<div style={styles.profileName}>
{profile.user.login || profile.user.email.split("@")[0]}
</div>
<div style={styles.profileEmail}>{profile.user.email}</div>
</div>
<button onClick={handleLogout} style={styles.logoutButton} title="Выйти">
<LogOut size={14} strokeWidth={1.8} />
</button>
</div>
<div style={styles.badgeActive}>
<div style={styles.badgeDot} />
Подписка активна
</div>
</div>
);
}
// ── Authenticated but no active subscription ────────────────
if (profile && !profile.subscription.active) {
return (
<div style={styles.container}>
<div style={{ ...styles.profileRow, marginBottom: 10 }}>
<div style={styles.avatar}>
<User size={16} strokeWidth={1.5} color="var(--text-low)" />
</div>
<div style={styles.profileInfo}>
<div style={styles.profileName}>
{profile.user.login || profile.user.email.split("@")[0]}
</div>
<div style={styles.profileEmail}>{profile.user.email}</div>
</div>
<button onClick={handleLogout} style={styles.logoutButton} title="Выйти">
<LogOut size={14} strokeWidth={1.8} />
</button>
</div>
<SubscriptionCTA onActivate={handleActivate} />
</div>
);
}
// ── Not authenticated ───────────────────────────────────────
return (
<div style={styles.container}>
<SubscriptionCTA onActivate={handleActivate} />
</div>
);
}
function SubscriptionCTA({ onActivate }: { onActivate: () => void }) {
return (
<div style={styles.ctaBox}>
<div style={styles.ctaHeader}>
<Crown size={14} strokeWidth={2} color="var(--text-hi)" />
<span style={{ fontWeight: 700, fontSize: 12, letterSpacing: "-0.02em", color: "var(--text-hi)" }}>
Активируйте TalkFlow
</span>
</div>
<ul style={styles.ctaList}>
<li>Безлимитное использование</li>
<li>Без VPN и Прокси</li>
<li>Синхронизация устройств</li>
</ul>
<div style={styles.ctaPrice}>
<span style={{ textDecoration: "line-through", opacity: 0.4, fontSize: 11, color: "var(--text-low)" }}>1 500 </span>
<span style={{ fontWeight: 800, fontSize: 16, color: "var(--text-hi)" }}>390 </span>
<span style={{ opacity: 0.5, fontSize: 10, color: "var(--text-low)" }}>/ мес</span>
</div>
<button onClick={onActivate} style={styles.ctaButton}>
Активировать
</button>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
marginTop: "auto",
padding: "12px 0 0",
borderTop: "1px solid rgba(0,0,0,0.06)",
},
profileRow: {
display: "flex",
alignItems: "center",
gap: 10,
padding: "4px 8px",
},
avatar: {
width: 32,
height: 32,
borderRadius: "50%",
background: "rgba(0,0,0,0.05)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
overflow: "hidden",
},
profileInfo: {
flex: 1,
minWidth: 0,
},
profileName: {
fontSize: 13,
fontWeight: 600,
color: "var(--text-hi)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
},
profileEmail: {
fontSize: 11,
color: "var(--text-low)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
},
logoutButton: {
background: "none",
border: "none",
cursor: "pointer",
padding: 6,
borderRadius: 6,
color: "var(--text-low)",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "color 0.15s, background 0.15s",
},
badgeActive: {
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
margin: "6px 8px 0",
borderRadius: 8,
fontSize: 11,
fontWeight: 600,
color: "var(--text-hi)",
background: "rgba(0,0,0,0.04)",
},
badgeDot: {
width: 6,
height: 6,
borderRadius: "50%",
background: "#000",
flexShrink: 0,
},
ctaBox: {
padding: "14px 14px",
borderRadius: 10,
background: "rgba(0,0,0,0.03)",
border: "1px solid rgba(0,0,0,0.06)",
margin: "0 4px",
},
ctaHeader: {
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 10,
},
ctaList: {
listStyle: "none",
padding: 0,
margin: "0 0 10px",
fontSize: 11,
lineHeight: 1.8,
color: "var(--text-mid)",
},
ctaPrice: {
display: "flex",
alignItems: "baseline",
gap: 8,
marginBottom: 12,
},
ctaButton: {
width: "100%",
padding: "10px",
borderRadius: 8,
background: "#000",
color: "#fff",
border: "none",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.08em",
cursor: "pointer",
transition: "opacity 0.15s",
fontFamily: "var(--font)",
},
};

84
src/config/languages.ts Normal file
View file

@ -0,0 +1,84 @@
/** Whisper-supported languages for the language selector dropdown. */
export const LANGUAGES: { code: string; name: string; native: string }[] = [
{ code: "auto", name: "Автоопределение", native: "Auto" },
{ code: "af", name: "Африкаанс", native: "Afrikaans" },
{ code: "sq", name: "Албанский", native: "Shqip" },
{ code: "am", name: "Амхарский", native: "አማርኛ" },
{ code: "ar", name: "Арабский", native: "العربية" },
{ code: "hy", name: "Армянский", native: "Հայերեն" },
{ code: "az", name: "Азербайджанский", native: "Azərbaycan" },
{ code: "eu", name: "Баскский", native: "Euskara" },
{ code: "be", name: "Белорусский", native: "Беларуская" },
{ code: "bn", name: "Бенгальский", native: "বাংলা" },
{ code: "bs", name: "Боснийский", native: "Bosanski" },
{ code: "bg", name: "Болгарский", native: "Български" },
{ code: "ca", name: "Каталанский", native: "Català" },
{ code: "zh", name: "Китайский", native: "中文" },
{ code: "hr", name: "Хорватский", native: "Hrvatski" },
{ code: "cs", name: "Чешский", native: "Čeština" },
{ code: "da", name: "Датский", native: "Dansk" },
{ code: "nl", name: "Нидерландский", native: "Nederlands" },
{ code: "en", name: "Английский", native: "English" },
{ code: "et", name: "Эстонский", native: "Eesti" },
{ code: "fi", name: "Финский", native: "Suomi" },
{ code: "fr", name: "Французский", native: "Français" },
{ code: "gl", name: "Галисийский", native: "Galego" },
{ code: "ka", name: "Грузинский", native: "ქართული" },
{ code: "de", name: "Немецкий", native: "Deutsch" },
{ code: "el", name: "Греческий", native: "Ελληνικά" },
{ code: "gu", name: "Гуджарати", native: "ગુજરાતી" },
{ code: "ht", name: "Гаитянский креольский", native: "Kreyòl ayisyen" },
{ code: "he", name: "Иврит", native: "עברית" },
{ code: "hi", name: "Хинди", native: "हिन्दी" },
{ code: "hu", name: "Венгерский", native: "Magyar" },
{ code: "is", name: "Исландский", native: "Íslenska" },
{ code: "id", name: "Индонезийский", native: "Bahasa Indonesia" },
{ code: "ga", name: "Ирландский", native: "Gaeilge" },
{ code: "it", name: "Итальянский", native: "Italiano" },
{ code: "ja", name: "Японский", native: "日本語" },
{ code: "kn", name: "Каннада", native: "ಕನ್ನಡ" },
{ code: "kk", name: "Казахский", native: "Қазақша" },
{ code: "km", name: "Кхмерский", native: "ខ្មែរ" },
{ code: "ko", name: "Корейский", native: "한국어" },
{ code: "ky", name: "Кыргызский", native: "Кыргызча" },
{ code: "lo", name: "Лаосский", native: "ລາວ" },
{ code: "lv", name: "Латвийский", native: "Latviešu" },
{ code: "lt", name: "Литовский", native: "Lietuvių" },
{ code: "mk", name: "Македонский", native: "Македонски" },
{ code: "ms", name: "Малайский", native: "Bahasa Melayu" },
{ code: "ml", name: "Малаялам", native: "മലയാളം" },
{ code: "mt", name: "Мальтийский", native: "Malti" },
{ code: "mi", name: "Маори", native: "Te Reo Māori" },
{ code: "mn", name: "Монгольский", native: "Монгол" },
{ code: "ne", name: "Непальский", native: "नेपाली" },
{ code: "nb", name: "Норвежский", native: "Norsk" },
{ code: "ps", name: "Пушту", native: "پښتو" },
{ code: "fa", name: "Персидский", native: "فارسی" },
{ code: "pl", name: "Польский", native: "Polski" },
{ code: "pt", name: "Португальский", native: "Português" },
{ code: "pa", name: "Панджаби", native: "ਪੰਜਾਬੀ" },
{ code: "ro", name: "Румынский", native: "Română" },
{ code: "ru", name: "Русский", native: "Русский" },
{ code: "sr", name: "Сербский", native: "Српски" },
{ code: "si", name: "Сингальский", native: "සිංහල" },
{ code: "sk", name: "Словацкий", native: "Slovenčina" },
{ code: "sl", name: "Словенский", native: "Slovenščina" },
{ code: "so", name: "Сомалийский", native: "Soomaali" },
{ code: "es", name: "Испанский", native: "Español" },
{ code: "sw", name: "Суахили", native: "Kiswahili" },
{ code: "sv", name: "Шведский", native: "Svenska" },
{ code: "tg", name: "Таджикский", native: "Тоҷикӣ" },
{ code: "ta", name: "Тамильский", native: "தமிழ்" },
{ code: "te", name: "Телугу", native: "తెలుగు" },
{ code: "th", name: "Тайский", native: "ภาษาไทย" },
{ code: "tr", name: "Турецкий", native: "Türkçe" },
{ code: "uk", name: "Украинский", native: "Українська" },
{ code: "ur", name: "Урду", native: "اردو" },
{ code: "uz", name: "Узбекский", native: "O'zbek" },
{ code: "vi", name: "Вьетнамский", native: "Tiếng Việt" },
{ code: "cy", name: "Валлийский", native: "Cymraeg" },
{ code: "xh", name: "Коса", native: "isiXhosa" },
{ code: "yi", name: "Идиш", native: "ייִדיש" },
{ code: "yo", name: "Йоруба", native: "Yorùbá" },
{ code: "zu", name: "Зулу", native: "isiZulu" },
];

View file

@ -1,5 +1,6 @@
{
"displayName": "English",
"whisperHint": "This is natural spoken English. Preserve common everyday words accurately and avoid splitting or distorting short common words.",
"fillerExamples": ["um", "uh", "like", "you know"],
"rules": [
"Preserve short trailing qualifiers and colloquial speech if they may carry meaning in context."

View file

@ -1,5 +1,6 @@
{
"displayName": "Russian",
"whisperHint": "Это обычная русская речь. Корректно распознавай общеупотребительные слова: сегодня, сейчас, сегодняшний, также, тоже, ещё. Не дроби и не искажай слово 'сегодня'.",
"fillerExamples": ["um", "uh", "like", "ну", "э", "короче", "вот", "типа"],
"rules": [
"Preserve meaningful Russian particles, discourse markers, and short service words unless they are unmistakably filler.",

View file

@ -5,5 +5,6 @@
"You may apply small readability-focused smoothing when the speaker's wording contains obvious hesitation artifacts that would look awkward in a business message.",
"Do not remove substantive words or complete unfinished thoughts on the speaker's behalf."
],
"temperature": 0.1,
"examples": []
}

View file

@ -5,5 +5,6 @@
"Do not rewrite for style, do not shorten fragments, and do not remove content words unless they are unmistakable filler.",
"Prefer preserving expressive disfluencies over smoothing them away when they may be part of the speaker's manner of speaking."
],
"temperature": 0.0,
"examples": []
}

View file

@ -1,5 +1,6 @@
{
"promptTitle": "TECH & DEV STYLE",
"whisperHintSuffix": "Сохраняй и корректно распознавай code tokens и команды: console.log, function, const, import, export, git push, git status, npm install, bun run dev, cargo check, src, api, ts, tsx, jsx, JSON.parse, useEffect, useState. Если слышишь 'слеш', 'точка', 'дефис', 'нижнее подчеркивание', корректно восстанавливай технический фрагмент.",
"rules": [
"The speaker is likely a software developer. Expect technical jargon, code fragments, package names, and English terms mixed with native grammar.",
"Detect code context aggressively. If the input appears to contain a file path, file name, import, command, URL, extension, identifier, env var, stack trace, or code fragment, preserve or reconstruct the technical structure instead of rewriting it as plain prose.",
@ -10,6 +11,7 @@
"Format code elements, variable names, file names, package names, and commands properly if they appear in speech.",
"Preserve all technical context and wording as much as possible."
],
"temperature": 0.15,
"examples": [
{
"raw": "src slash tauri slash src slash ai dot rs",

View file

@ -144,8 +144,9 @@ select {
}
.headline-accent {
font-family: var(--font-accent);
letter-spacing: -0.05em;
font-family: var(--font-brand);
font-weight: 800;
letter-spacing: -0.04em;
}
.btn {
@ -228,31 +229,30 @@ select {
.nav-item {
display: flex;
align-items: center;
gap: 10px;
gap: 12px;
width: 100%;
padding: 11px 14px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
padding: 12px 16px;
border-radius: 10px;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.01em;
cursor: pointer;
transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease, transform 0.14s ease;
transition: background 0.15s ease, color 0.15s ease;
color: var(--text-mid);
border: 1px solid transparent;
border: none;
user-select: none;
background: transparent;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.55);
background: rgba(0, 0, 0, 0.04);
color: var(--text-hi);
}
.nav-item.active {
background: #000;
border-color: #000;
color: #fff;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.04);
color: var(--text-hi);
font-weight: 600;
}
.b-table {

99
src/lib/cloudAuth.ts Normal file
View file

@ -0,0 +1,99 @@
/**
* TalkFlow Cloud authentication client.
*
* Handles communication with talkis.ru API for:
* - Fetching user profile and subscription status
* - Deep link token handling
* - Logout
*/
import { getSettings, saveSettings } from "./store";
import { logError, logInfo } from "./logger";
const CLOUD_API_BASE = "https://talkis.ru";
export interface CloudUser {
id: string;
email: string;
login: string | null;
avatarUrl: string | null;
}
export interface CloudSubscription {
active: boolean;
status: string; // "active" | "expired" | "cancelled" | "none"
plan: string | null;
expiresAt: string | null;
}
export interface CloudProfile {
user: CloudUser;
subscription: CloudSubscription;
}
/**
* Fetch user profile and subscription status from the cloud.
* Returns null if token is missing or invalid.
*/
export async function fetchCloudProfile(): Promise<CloudProfile | null> {
const settings = await getSettings();
const token = settings.deviceToken;
if (!token) {
return null;
}
try {
const response = await fetch(`${CLOUD_API_BASE}/api/subscription/status`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.status === 401) {
logInfo("CLOUD", "Device token invalid, clearing");
await saveSettings({ deviceToken: "" });
return null;
}
if (!response.ok) {
logError("CLOUD", `API error: ${response.status}`);
return null;
}
const data = await response.json();
return data as CloudProfile;
} catch (error) {
logError("CLOUD", `Failed to fetch profile: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* Save device token received from deep link callback.
*/
export async function handleAuthToken(token: string): Promise<void> {
logInfo("CLOUD", "Received auth token from deep link");
await saveSettings({ deviceToken: token });
}
/**
* Clear device token (logout).
*/
export async function cloudLogout(): Promise<void> {
logInfo("CLOUD", "Logging out");
await saveSettings({ deviceToken: "" });
}
/**
* Check if user is authenticated with cloud.
*/
export async function isCloudAuthenticated(): Promise<boolean> {
const settings = await getSettings();
return settings.deviceToken.length > 0;
}
/**
* Get the auth login URL for opening in browser.
*/
export function getAuthLoginUrl(): string {
return `${CLOUD_API_BASE}/auth/login?device=true`;
}

View file

@ -0,0 +1,113 @@
import { describe, test, expect } from "bun:test";
import { validateHotkey, normalizeHotkey, formatHotkeyLabel } from "./store";
describe("validateHotkey", () => {
test("accepts modifier + letter", () => {
expect(validateHotkey("Ctrl+A").valid).toBe(true);
expect(validateHotkey("Command+Shift+Space").valid).toBe(true);
expect(validateHotkey("Alt+Z").valid).toBe(true);
});
test("accepts F-key without modifier", () => {
expect(validateHotkey("F1").valid).toBe(true);
expect(validateHotkey("F12").valid).toBe(true);
});
test("rejects letter without modifier", () => {
const result = validateHotkey("A");
expect(result.valid).toBe(false);
expect(result.error).toContain("F-клавиш");
});
test("rejects modifier-only", () => {
const result = validateHotkey("Ctrl");
expect(result.valid).toBe(false);
expect(result.error).toContain("основную клавишу");
});
test("rejects multiple main keys", () => {
const result = validateHotkey("Ctrl+A+B");
expect(result.valid).toBe(false);
expect(result.error).toContain("одна основная");
});
test("rejects duplicate modifiers", () => {
const result = validateHotkey("Ctrl+Control+A");
expect(result.valid).toBe(false);
expect(result.error).toContain("дважды");
});
test("rejects unknown keys", () => {
const result = validateHotkey("Ctrl+???");
expect(result.valid).toBe(false);
});
test("rejects empty string parts", () => {
const result = validateHotkey("");
expect(result.valid).toBe(false);
});
});
describe("normalizeHotkey", () => {
test("normalizes modifier aliases", () => {
const result = normalizeHotkey("cmd+shift+a");
expect(result.valid).toBe(true);
expect(result.normalized).toBe("Shift+Command+A");
});
test("normalizes option to Alt", () => {
const result = normalizeHotkey("option+space");
expect(result.valid).toBe(true);
expect(result.normalized).toBe("Alt+Space");
});
test("orders modifiers consistently", () => {
const result = normalizeHotkey("Command+Shift+X");
expect(result.valid).toBe(true);
expect(result.normalized).toBe("Shift+Command+X");
});
test("normalizes meta to Command", () => {
const result = normalizeHotkey("meta+K");
expect(result.valid).toBe(true);
expect(result.normalized).toBe("Command+K");
});
test("uppercases single letter keys", () => {
const result = normalizeHotkey("Ctrl+b");
expect(result.valid).toBe(true);
expect(result.normalized).toBe("Control+B");
});
test("preserves F-key format", () => {
const result = normalizeHotkey("f5");
expect(result.valid).toBe(true);
expect(result.normalized).toBe("F5");
});
test("returns error for invalid input", () => {
const result = normalizeHotkey("not-a-hotkey");
expect(result.valid).toBe(false);
expect(result.normalized).toBeUndefined();
});
test("handles Space key alias", () => {
const result = normalizeHotkey("cmd+shift+space");
expect(result.valid).toBe(true);
expect(result.normalized).toBe("Shift+Command+Space");
});
});
describe("formatHotkeyLabel", () => {
test("formats for display", () => {
const label = formatHotkeyLabel("Command+Shift+Space");
// On non-mac (test env), Command stays as Cmd
expect(label).toContain("Space");
expect(label).toContain(" + ");
});
test("handles single key", () => {
const label = formatHotkeyLabel("F5");
expect(label).toBe("F5");
});
});

View file

@ -12,7 +12,7 @@ export async function checkMicrophonePermission(): Promise<PermissionStatus> {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(t => t.stop());
return 'granted';
} catch (e) {
} catch {
return 'denied';
}
}

View file

@ -26,6 +26,8 @@ export interface AppSettings {
llmEndpoint: string;
/** If true, user provides their own API key. If false, uses subscription */
useOwnKey: boolean;
/** Device auth token for TalkFlow Cloud */
deviceToken: string;
}
export interface WidgetPosition {
@ -233,6 +235,7 @@ const DEFAULT_SETTINGS: AppSettings = {
whisperEndpoint: "",
llmEndpoint: "",
useOwnKey: true,
deviceToken: "",
};
function parseStyle(value: unknown): AppSettings["style"] | undefined {

View file

@ -1,3 +1,19 @@
export function cn(...classes: (string | undefined | null | false)[]) {
return classes.filter(Boolean).join(" ");
}
export function formatErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}

View file

@ -10,6 +10,7 @@ import { SETTINGS_NAVIGATE_EVENT, SettingsNavigatePayload } from "../../lib/hotk
import { getPermissionsPassed, setPermissionsPassed, getHistory, HistoryEntry } from "../../lib/store";
import { checkAllPermissions } from "../../lib/permissions";
import { logError } from "../../lib/logger";
import { UserPanel } from "../../components/UserPanel";
type Tab = "main" | "settings" | "model" | "style";
@ -35,7 +36,7 @@ function TabButton({ tab, isActive, onClick }: { tab: typeof TABS[0]; isActive:
return (
<button onClick={onClick} className={`nav-item ${isActive ? "active" : ""}`} style={{ width: "100%", textAlign: "left", font: "inherit" }}>
<Icon size={15} strokeWidth={isActive ? 2.3 : 1.8} />
<Icon size={18} strokeWidth={isActive ? 2.2 : 1.6} />
<span>{tab.label}</span>
</button>
);
@ -148,6 +149,7 @@ export function SettingsApp() {
))}
</nav>
<UserPanel />
</aside>
<main style={{ flex: 1, padding: "18px 24px 24px", overflowY: "auto", overflowX: "hidden", position: "relative", background: "rgba(250,249,246,0.72)" }}>

View file

@ -13,90 +13,7 @@ import {
SETTINGS_UPDATED_EVENT,
} from "../../../lib/hotkeyEvents";
import { logError, logInfo } from "../../../lib/logger";
const LANGUAGES: { code: string; name: string; native: string }[] = [
{ code: "auto", name: "Автоопределение", native: "Auto" },
{ code: "af", name: "Африкаанс", native: "Afrikaans" },
{ code: "sq", name: "Албанский", native: "Shqip" },
{ code: "am", name: "Амхарский", native: "አማርኛ" },
{ code: "ar", name: "Арабский", native: "العربية" },
{ code: "hy", name: "Армянский", native: "Հայերեն" },
{ code: "az", name: "Азербайджанский", native: "Azərbaycan" },
{ code: "eu", name: "Баскский", native: "Euskara" },
{ code: "be", name: "Белорусский", native: "Беларуская" },
{ code: "bn", name: "Бенгальский", native: "বাংলা" },
{ code: "bs", name: "Боснийский", native: "Bosanski" },
{ code: "bg", name: "Болгарский", native: "Български" },
{ code: "ca", name: "Каталанский", native: "Català" },
{ code: "zh", name: "Китайский", native: "中文" },
{ code: "hr", name: "Хорватский", native: "Hrvatski" },
{ code: "cs", name: "Чешский", native: "Čeština" },
{ code: "da", name: "Датский", native: "Dansk" },
{ code: "nl", name: "Нидерландский", native: "Nederlands" },
{ code: "en", name: "Английский", native: "English" },
{ code: "et", name: "Эстонский", native: "Eesti" },
{ code: "fi", name: "Финский", native: "Suomi" },
{ code: "fr", name: "Французский", native: "Français" },
{ code: "gl", name: "Галисийский", native: "Galego" },
{ code: "ka", name: "Грузинский", native: "ქართული" },
{ code: "de", name: "Немецкий", native: "Deutsch" },
{ code: "el", name: "Греческий", native: "Ελληνικά" },
{ code: "gu", name: "Гуджарати", native: "ગુજરાતી" },
{ code: "ht", name: "Гаитянский креольский", native: "Kreyòl ayisyen" },
{ code: "he", name: "Иврит", native: "עברית" },
{ code: "hi", name: "Хинди", native: "हिन्दी" },
{ code: "hu", name: "Венгерский", native: "Magyar" },
{ code: "is", name: "Исландский", native: "Íslenska" },
{ code: "id", name: "Индонезийский", native: "Bahasa Indonesia" },
{ code: "ga", name: "Ирландский", native: "Gaeilge" },
{ code: "it", name: "Итальянский", native: "Italiano" },
{ code: "ja", name: "Японский", native: "日本語" },
{ code: "kn", name: "Каннада", native: "ಕನ್ನಡ" },
{ code: "kk", name: "Казахский", native: "Қазақша" },
{ code: "km", name: "Кхмерский", native: "ខ្មែរ" },
{ code: "ko", name: "Корейский", native: "한국어" },
{ code: "ky", name: "Кыргызский", native: "Кыргызча" },
{ code: "lo", name: "Лаосский", native: "ລາວ" },
{ code: "lv", name: "Латвийский", native: "Latviešu" },
{ code: "lt", name: "Литовский", native: "Lietuvių" },
{ code: "mk", name: "Македонский", native: "Македонски" },
{ code: "ms", name: "Малайский", native: "Bahasa Melayu" },
{ code: "ml", name: "Малаялам", native: "മലയാളം" },
{ code: "mt", name: "Мальтийский", native: "Malti" },
{ code: "mi", name: "Маори", native: "Te Reo Māori" },
{ code: "mn", name: "Монгольский", native: "Монгол" },
{ code: "ne", name: "Непальский", native: "नेपाली" },
{ code: "nb", name: "Норвежский", native: "Norsk" },
{ code: "ps", name: "Пушту", native: "پښتو" },
{ code: "fa", name: "Персидский", native: "فارسی" },
{ code: "pl", name: "Польский", native: "Polski" },
{ code: "pt", name: "Португальский", native: "Português" },
{ code: "pa", name: "Панджаби", native: "ਪੰਜਾਬੀ" },
{ code: "ro", name: "Румынский", native: "Română" },
{ code: "ru", name: "Русский", native: "Русский" },
{ code: "sr", name: "Сербский", native: "Српски" },
{ code: "si", name: "Сингальский", native: "සිංහල" },
{ code: "sk", name: "Словацкий", native: "Slovenčina" },
{ code: "sl", name: "Словенский", native: "Slovenščina" },
{ code: "so", name: "Сомалийский", native: "Soomaali" },
{ code: "es", name: "Испанский", native: "Español" },
{ code: "sw", name: "Суахили", native: "Kiswahili" },
{ code: "sv", name: "Шведский", native: "Svenska" },
{ code: "tg", name: "Таджикский", native: "Тоҷикӣ" },
{ code: "ta", name: "Тамильский", native: "தமிழ்" },
{ code: "te", name: "Телугу", native: "తెలుగు" },
{ code: "th", name: "Тайский", native: "ภาษาไทย" },
{ code: "tr", name: "Турецкий", native: "Türkçe" },
{ code: "uk", name: "Украинский", native: "Українська" },
{ code: "ur", name: "Урду", native: "اردو" },
{ code: "uz", name: "Узбекский", native: "O'zbek" },
{ code: "vi", name: "Вьетнамский", native: "Tiếng Việt" },
{ code: "cy", name: "Валлийский", native: "Cymraeg" },
{ code: "xh", name: "Коса", native: "isiXhosa" },
{ code: "yi", name: "Идиш", native: "ייִדיש" },
{ code: "yo", name: "Йоруба", native: "Yorùbá" },
{ code: "zu", name: "Зулу", native: "isiZulu" },
];
import { LANGUAGES } from "../../../config/languages";
type HotkeyFeedbackTone = "idle" | "success" | "error";

View file

@ -1,11 +1,15 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { AppSettings, getSettings, saveSettings } from "../../../lib/store";
import { Check, Briefcase, Code, MessageSquare, Key, Crown, LucideIcon } from "lucide-react";
import { CloudProfile, fetchCloudProfile, getAuthLoginUrl } from "../../../lib/cloudAuth";
import { TRANSCRIPTION_STYLE_OPTIONS } from "../../../lib/transcriptionPrompts";
const IS_DEV = import.meta.env.DEV;
interface SettingsTabsProps { type: "model" | "style"; }
interface PromptPreview {
@ -86,11 +90,17 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [promptPreview, setPromptPreview] = useState<PromptPreview | null>(null);
const [promptPreviewError, setPromptPreviewError] = useState<string | null>(null);
const [cloudProfile, setCloudProfile] = useState<CloudProfile | null>(null);
useEffect(() => { getSettings().then(setSettings); }, []);
// Cloud profile — always fetch (regardless of tab) so hooks are stable
useEffect(() => {
if (!settings || type !== "style") return;
fetchCloudProfile().then(setCloudProfile).catch(() => {});
}, []);
useEffect(() => {
if (!settings || type !== "style" || !IS_DEV) return;
let cancelled = false;
@ -123,74 +133,131 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
};
if (type === "model") {
const hasKey = settings.apiKey.trim().length > 0;
const isActive = hasKey;
const hasActiveSubscription = cloudProfile?.subscription.active === true;
const handleActivateSubscription = async () => {
try {
await openUrl(getAuthLoginUrl());
} catch {
// Error handled silently
}
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<div className="card" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4, color: "var(--text-hi)" }}>
{isActive ? "Подключено" : "Не подключено"}
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
{isActive ? "OpenAI API ключ уже сохранен." : "Подключение не настроено."}
{/* ── Subscription banner OR active status ── */}
{hasActiveSubscription ? (
<div className="card" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div style={{ width: 42, height: 42, borderRadius: 999, background: "#000", display: "flex", alignItems: "center", justifyContent: "center" }}>
<Crown size={20} strokeWidth={2.2} color="#fff" />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)" }}>Подписка активна</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
{`Безлимитный доступ до ${cloudProfile?.subscription.expiresAt ? new Date(cloudProfile.subscription.expiresAt).toLocaleDateString("ru-RU", { day: "numeric", month: "long" }) : "—"}`}
</div>
</div>
</div>
<div style={{ width: 10, height: 10, borderRadius: 999, background: "#000", flexShrink: 0 }} />
</div>
<div style={{ width: 12, height: 12, borderRadius: 999, background: isActive ? "#000" : "rgba(0,0,0,0.16)" }} />
</div>
<OptionCard
disabled
icon={<Crown size={20} strokeWidth={1.8} />}
title="Подписка Talk Flow"
description="Скоро. Сейчас приложение работает только с вашим OpenAI API-ключом."
badge="Soon"
/>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, height: 1, background: "rgba(0,0,0,0.08)" }} />
<span className="label">или</span>
<div style={{ flex: 1, height: 1, background: "rgba(0,0,0,0.08)" }} />
</div>
<OptionCard
active={settings.useOwnKey}
icon={<Key size={20} strokeWidth={settings.useOwnKey ? 2.4 : 1.8} />}
title="Свои API ключи"
description="Подключите свой OpenAI API ключ для распознавания и обработки текста."
onClick={() => !settings.useOwnKey && update({ useOwnKey: true })}
/>
{settings.useOwnKey && (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)" }}>OpenAI API ключ</div>
) : (
<div style={{ padding: "22px 20px", borderRadius: 14, background: "#000", color: "#fff" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 14 }}>
<Crown size={16} strokeWidth={2.2} />
<span style={{ fontWeight: 700, fontSize: 14, letterSpacing: "-0.02em" }}>Подписка TalkFlow</span>
</div>
<div style={{ position: "relative" }}>
<input
type="password"
value={settings.apiKey}
onChange={(e) => update({ apiKey: e.target.value })}
className="input"
placeholder="sk-..."
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontSize: 12 }}
/>
</div>
<div style={{ fontSize: 12, color: "var(--text-low)", lineHeight: 1.6 }}>
Ключ используется для Whisper (распознавание) и GPT-4o mini (обработка) и не отправляется на сервер Talk Flow.
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 14px", fontSize: 12, lineHeight: 2, opacity: 0.85 }}>
<li> Безлимитное использование без ограничений</li>
<li> Без VPN и Прокси</li>
<li> Синхронизация со всеми устройствами</li>
</ul>
<div style={{ display: "flex", alignItems: "baseline", gap: 10, marginBottom: 16 }}>
<span style={{ textDecoration: "line-through", opacity: 0.45, fontSize: 12 }}>1 500 </span>
<span style={{ fontWeight: 800, fontSize: 22 }}>390 </span>
<span style={{ opacity: 0.5, fontSize: 11 }}>/ мес</span>
</div>
<button
onClick={handleActivateSubscription}
style={{
width: "100%",
padding: "12px",
borderRadius: 10,
background: "#fff",
color: "#000",
border: "none",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.08em",
cursor: "pointer",
transition: "opacity 0.15s",
fontFamily: "var(--font-main)",
}}
>
Активировать
</button>
</div>
)}
{/* ── Separator ── */}
{!hasActiveSubscription && (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, height: 1, background: "rgba(0,0,0,0.08)" }} />
<span className="label">или</span>
<div style={{ flex: 1, height: 1, background: "rgba(0,0,0,0.08)" }} />
</div>
)}
{/* ── Own API key option ── */}
{!hasActiveSubscription && (
<>
<OptionCard
active={settings.useOwnKey}
icon={<Key size={20} strokeWidth={settings.useOwnKey ? 2.4 : 1.8} />}
title="Свои API ключи"
description="Подключите свой OpenAI API ключ для распознавания и обработки текста."
onClick={() => !settings.useOwnKey && update({ useOwnKey: true })}
/>
{settings.useOwnKey && (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)" }}>OpenAI API ключ</div>
</div>
<div style={{ position: "relative" }}>
<input
type="password"
value={settings.apiKey}
onChange={(e) => update({ apiKey: e.target.value })}
className="input"
placeholder="sk-..."
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontSize: 12 }}
/>
</div>
<div style={{ fontSize: 12, color: "var(--text-low)", lineHeight: 1.6 }}>
Ключ используется для Whisper (распознавание) и GPT-4o mini (обработка) и не отправляется на сервер Talk Flow.
</div>
</div>
)}
</>
)}
{/* ── Footer hint ── */}
<div className="card" style={{ padding: 18, background: "rgba(255,255,255,0.58)" }}>
<div style={{ fontSize: 12, color: "var(--text-mid)", lineHeight: 1.65 }}>
{settings.useOwnKey ? (
{hasActiveSubscription ? (
<>Все запросы обрабатываются через серверы TalkFlow без ограничений.</>
) : settings.useOwnKey ? (
<>
Ключ можно получить на <span style={{ color: "var(--text-hi)", fontWeight: 600 }}>platform.openai.com</span> в разделе API Keys.
</>
) : (
<>Подписка Talk Flow пока недоступна. Для работы приложения используйте свой OpenAI API-ключ.</>
<>Активируйте подписку или подключите свой OpenAI API-ключ для работы приложения.</>
)}
</div>
</div>
@ -229,78 +296,80 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
})}
</div>
<details className="card" style={{ background: "rgba(255,255,255,0.68)" }}>
<summary style={{ cursor: "pointer", listStyle: "none", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: "var(--text-hi)", marginBottom: 4 }}>Prompt Preview</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
Итоговый prompt для текущего языка и стиля обработки.
{IS_DEV && (
<details className="card" style={{ background: "rgba(255,255,255,0.68)" }}>
<summary style={{ cursor: "pointer", listStyle: "none", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: "var(--text-hi)", marginBottom: 4 }}>Prompt Preview</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
Итоговый prompt для текущего языка и стиля обработки.
</div>
</div>
{promptPreview && <div className="label">v{promptPreview.version}</div>}
</summary>
<div style={{ marginTop: 14, display: "grid", gap: 12 }}>
{promptPreviewError ? (
<div style={{ fontSize: 12, color: "#b42318", lineHeight: 1.6 }}>
Не удалось собрать preview prompt: {promptPreviewError}
</div>
) : promptPreview ? (
<>
<div style={{ display: "grid", gap: 4 }}>
<div className="label">Профиль</div>
<div style={{ fontSize: 12, color: "var(--text-hi)", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" }}>
{promptPreview.profileKey}
</div>
</div>
<div style={{ display: "grid", gap: 6 }}>
<div className="label">Слои</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{promptPreview.layers.map((layer) => (
<span
key={layer}
style={{
padding: "6px 10px",
borderRadius: 999,
background: "rgba(0,0,0,0.05)",
fontSize: 11,
color: "var(--text-mid)",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
}}
>
{layer}
</span>
))}
</div>
</div>
<div style={{ display: "grid", gap: 6 }}>
<div className="label">Текст prompt</div>
<pre
style={{
margin: 0,
padding: 14,
borderRadius: 12,
background: "rgba(0,0,0,0.04)",
color: "var(--text-mid)",
fontSize: 11,
lineHeight: 1.65,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
whiteSpace: "pre-wrap",
maxHeight: 300,
overflow: "auto",
}}
>
{promptPreview.prompt}
</pre>
</div>
</>
) : (
<div style={{ fontSize: 12, color: "var(--text-mid)" }}>Собираем preview...</div>
)}
</div>
{promptPreview && <div className="label">v{promptPreview.version}</div>}
</summary>
<div style={{ marginTop: 14, display: "grid", gap: 12 }}>
{promptPreviewError ? (
<div style={{ fontSize: 12, color: "#b42318", lineHeight: 1.6 }}>
Не удалось собрать preview prompt: {promptPreviewError}
</div>
) : promptPreview ? (
<>
<div style={{ display: "grid", gap: 4 }}>
<div className="label">Профиль</div>
<div style={{ fontSize: 12, color: "var(--text-hi)", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" }}>
{promptPreview.profileKey}
</div>
</div>
<div style={{ display: "grid", gap: 6 }}>
<div className="label">Слои</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{promptPreview.layers.map((layer) => (
<span
key={layer}
style={{
padding: "6px 10px",
borderRadius: 999,
background: "rgba(0,0,0,0.05)",
fontSize: 11,
color: "var(--text-mid)",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
}}
>
{layer}
</span>
))}
</div>
</div>
<div style={{ display: "grid", gap: 6 }}>
<div className="label">Текст prompt</div>
<pre
style={{
margin: 0,
padding: 14,
borderRadius: 12,
background: "rgba(0,0,0,0.04)",
color: "var(--text-mid)",
fontSize: 11,
lineHeight: 1.65,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
whiteSpace: "pre-wrap",
maxHeight: 300,
overflow: "auto",
}}
>
{promptPreview.prompt}
</pre>
</div>
</>
) : (
<div style={{ fontSize: 12, color: "var(--text-mid)" }}>Собираем preview...</div>
)}
</div>
</details>
</details>
)}
</div>
);
}

View file

@ -5,11 +5,19 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { AppSettings, getSettings, getWidgetPosition, saveWidgetPosition } from "../../../lib/store";
import { logError, logInfo } from "../../../lib/logger";
import { formatErrorMessage } from "../../../lib/utils";
import { WidgetNoticeState, WidgetState } from "../widgetConstants";
import { resolveInitialWidgetPosition } from "../widgetPositioning";
import { useWidgetHotkey } from "./useWidgetHotkey";
import { useWidgetNotice } from "./useWidgetNotice";
import { useWidgetRecording } from "./useWidgetRecording";
import {
initialWidgetMachineState,
widgetReducer,
WidgetAction,
WidgetEffect,
WidgetMachineState,
} from "../services/widgetMachine";
interface WidgetControllerState {
state: WidgetState;
@ -18,67 +26,99 @@ interface WidgetControllerState {
lockedRecording: boolean;
}
function formatErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
export function useWidgetController(): WidgetControllerState {
const widgetWindow = getCurrentWindow();
const [state, setState] = useState<WidgetState>("idle");
// ── Centralized machine state ───────────────────────────────────────────
const machineRef = useRef<WidgetMachineState>(initialWidgetMachineState);
// React render state (derived from machine)
const [widgetState, setWidgetState] = useState<WidgetState>("idle");
const [stream, setStream] = useState<MediaStream | null>(null);
const [settings, setSettings] = useState<AppSettings | null>(null);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [lockedRecording, setLockedRecording] = useState(false);
const recordingStartRef = useRef<number>(0);
const stateRef = useRef<WidgetState>("idle");
// Settings
const [settings, setSettings] = useState<AppSettings | null>(null);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const settingsRef = useRef<AppSettings | null>(null);
// Imperative refs (truly need ref semantics)
const registeredHotkeyRef = useRef<string | null>(null);
const hotkeyHeldRef = useRef(false);
const recordingActiveRef = useRef(false);
const pendingStopAfterStartRef = useRef(false);
const lockedRecordingRef = useRef(false);
const suppressNextReleaseRef = useRef(false);
const releaseStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const moveSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const positionReadyRef = useRef(false);
const stopAndProcessRef = useRef<() => Promise<void>>(async () => {});
const clearReleaseStopTimer = useCallback(() => {
if (!releaseStopTimerRef.current) {
return;
}
// ── Dispatch: apply action → update machine state → execute effects ─────
const dispatch = useCallback((action: WidgetAction) => {
const { state: nextState, effects } = widgetReducer(machineRef.current, action);
machineRef.current = nextState;
// Sync React render state
setWidgetState(nextState.widgetState);
setLockedRecording(nextState.lockedRecording);
// Execute effects (processed in executeEffect below)
for (const effect of effects) {
executeEffect(effect);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Effect executor ─────────────────────────────────────────────────────
const executeEffect = useCallback((effect: WidgetEffect) => {
switch (effect.type) {
case "start_recording":
void startRecordingRef.current();
break;
case "stop_and_process":
void stopAndProcessRef.current();
break;
case "schedule_release_stop_timer":
scheduleReleaseStopTimer();
break;
case "clear_release_stop_timer":
clearReleaseStopTimer();
break;
case "resize_widget":
void resizeWidget(effect.width, effect.height);
break;
case "set_stream":
setStream(effect.stream);
break;
case "show_notice":
showNotice(effect.message, effect.tone);
break;
case "set_locked_recording_ui":
setLockedRecording(effect.value);
break;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Timer helpers ───────────────────────────────────────────────────────
const clearReleaseStopTimer = useCallback(() => {
if (!releaseStopTimerRef.current) return;
clearTimeout(releaseStopTimerRef.current);
releaseStopTimerRef.current = null;
}, []);
const clearMoveSaveTimer = useCallback(() => {
if (!moveSaveTimerRef.current) {
return;
}
const scheduleReleaseStopTimer = useCallback(() => {
clearReleaseStopTimer();
const doubleTapTimeout = settingsRef.current?.doubleTapTimeout ?? 400;
releaseStopTimerRef.current = setTimeout(() => {
releaseStopTimerRef.current = null;
dispatch({ type: "RELEASE_STOP_TIMER_FIRED" });
}, doubleTapTimeout);
}, [clearReleaseStopTimer, dispatch]);
const clearMoveSaveTimer = useCallback(() => {
if (!moveSaveTimerRef.current) return;
clearTimeout(moveSaveTimerRef.current);
moveSaveTimerRef.current = null;
}, []);
const setLockedRecordingMode = useCallback((value: boolean) => {
lockedRecordingRef.current = value;
setLockedRecording(value);
}, []);
// ── Settings loading ────────────────────────────────────────────────────
useEffect(() => {
logInfo("SETTINGS", "Loading settings...");
getSettings()
@ -88,6 +128,7 @@ export function useWidgetController(): WidgetControllerState {
`Loaded: apiKey=${loadedSettings.apiKey ? "[set]" : "[empty]"}, hotkey=${loadedSettings.hotkey}`,
);
setSettings(loadedSettings);
settingsRef.current = loadedSettings;
setSettingsLoaded(true);
})
.catch((error) => {
@ -96,24 +137,14 @@ export function useWidgetController(): WidgetControllerState {
});
}, []);
useEffect(() => {
stateRef.current = state;
}, [state]);
useEffect(() => {
settingsRef.current = settings;
}, [settings]);
// ── Position tracking ───────────────────────────────────────────────────
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
widgetWindow
.onMoved(({ payload }) => {
if (!positionReadyRef.current) {
return;
}
if (!positionReadyRef.current) return;
clearMoveSaveTimer();
moveSaveTimerRef.current = setTimeout(() => {
moveSaveTimerRef.current = null;
@ -129,7 +160,6 @@ export function useWidgetController(): WidgetControllerState {
removeListener();
return;
}
unlisten = removeListener;
})
.catch((error) => {
@ -152,9 +182,7 @@ export function useWidgetController(): WidgetControllerState {
try {
const savedPosition = await getWidgetPosition();
const targetPosition = await resolveInitialWidgetPosition(widgetWindow, savedPosition);
if (!targetPosition || cancelled) {
return;
}
if (!targetPosition || cancelled) return;
await widgetWindow.setPosition(new PhysicalPosition(targetPosition.x, targetPosition.y));
@ -173,12 +201,10 @@ export function useWidgetController(): WidgetControllerState {
};
void restoreWidgetPosition();
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, [widgetWindow]);
// ── Widget resize ───────────────────────────────────────────────────────
const resizeWidget = useCallback(async (width: number, height: number) => {
try {
await invoke("widget_resize", { width, height });
@ -187,62 +213,58 @@ export function useWidgetController(): WidgetControllerState {
}
}, []);
const { showNotice } = useWidgetNotice({ stateRef });
// ── Notice ──────────────────────────────────────────────────────────────
const machineStateRefForNotice = useRef(machineRef);
machineStateRefForNotice.current = machineRef;
const stateRefForNotice = useRef<WidgetState>("idle");
useEffect(() => {
stateRefForNotice.current = widgetState;
}, [widgetState]);
const { showNotice } = useWidgetNotice({ stateRef: stateRefForNotice });
// ── Error handler ───────────────────────────────────────────────────────
const showError = useCallback(
(message: string) => {
logError("WIDGET", message);
hotkeyHeldRef.current = false;
recordingActiveRef.current = false;
pendingStopAfterStartRef.current = false;
suppressNextReleaseRef.current = false;
clearReleaseStopTimer();
setLockedRecordingMode(false);
setState("idle");
setStream(null);
showNotice(message, "error");
dispatch({ type: "ERROR", message });
},
[clearReleaseStopTimer, resizeWidget, setLockedRecordingMode, showNotice],
[dispatch],
);
const { startRecording, stopAndProcess } = useWidgetRecording({
// ── Recording ───────────────────────────────────────────────────────────
const startRecordingRef = useRef<() => Promise<void>>(async () => {});
const { startRecording } = useWidgetRecording({
settings,
setState,
machineRef,
dispatch,
setStream,
setLockedRecordingMode,
lockedRecordingRef,
hotkeyHeldRef,
recordingActiveRef,
pendingStopAfterStartRef,
recordingStartRef,
clearReleaseStopTimer,
resizeWidget,
showError,
showNotice,
stopAndProcessRef,
});
useEffect(() => {
startRecordingRef.current = startRecording;
}, [startRecording]);
// ── Hotkey ──────────────────────────────────────────────────────────────
useWidgetHotkey({
settingsLoaded,
settings,
setSettings,
settingsRef,
stateRef,
machineRef,
dispatch,
registeredHotkeyRef,
hotkeyHeldRef,
recordingActiveRef,
pendingStopAfterStartRef,
lockedRecordingRef,
suppressNextReleaseRef,
releaseStopTimerRef,
stopAndProcessRef,
clearReleaseStopTimer,
setLockedRecordingMode,
startRecording,
stopAndProcess,
showError,
});
// ── Cleanup ─────────────────────────────────────────────────────────────
useEffect(() => {
return () => {
clearReleaseStopTimer();
@ -251,7 +273,7 @@ export function useWidgetController(): WidgetControllerState {
}, [clearMoveSaveTimer, clearReleaseStopTimer]);
return {
state,
state: widgetState,
stream,
notice: null,
lockedRecording,

View file

@ -1,40 +1,31 @@
import { useCallback, useEffect, useRef } from "react";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { register, unregister } from "@tauri-apps/plugin-global-shortcut";
import type { ShortcutEvent } from "@tauri-apps/plugin-global-shortcut";
import { emit, listen } from "@tauri-apps/api/event";
import { register, unregister, ShortcutEvent } from "@tauri-apps/plugin-global-shortcut";
import { AppSettings, DEFAULT_HOTKEY, getSettings, normalizeHotkey, saveSettings } from "../../../lib/store";
import { logError, logInfo } from "../../../lib/logger";
import {
HOTKEY_CAPTURE_STATE_EVENT,
HOTKEY_CHANGE_REQUEST_EVENT,
HotkeyCaptureStatePayload,
HOTKEY_REGISTRATION_RESULT_EVENT,
HotkeyCaptureStatePayload,
HotkeyChangeRequestPayload,
HotkeyRegistrationResultPayload,
SETTINGS_UPDATED_EVENT,
} from "../../../lib/hotkeyEvents";
import { WidgetState } from "../widgetConstants";
import { evaluateHotkeyFsm, HotkeyShortcutState } from "../services/hotkeyFsm";
import { logError, logInfo } from "../../../lib/logger";
import type { WidgetAction, WidgetMachineState } from "../services/widgetMachine";
interface UseWidgetHotkeyParams {
settingsLoaded: boolean;
settings: AppSettings | null;
setSettings: Dispatch<SetStateAction<AppSettings | null>>;
settingsRef: MutableRefObject<AppSettings | null>;
stateRef: MutableRefObject<WidgetState>;
machineRef: MutableRefObject<WidgetMachineState>;
dispatch: (action: WidgetAction) => void;
registeredHotkeyRef: MutableRefObject<string | null>;
hotkeyHeldRef: MutableRefObject<boolean>;
recordingActiveRef: MutableRefObject<boolean>;
pendingStopAfterStartRef: MutableRefObject<boolean>;
lockedRecordingRef: MutableRefObject<boolean>;
suppressNextReleaseRef: MutableRefObject<boolean>;
releaseStopTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>;
stopAndProcessRef: MutableRefObject<() => Promise<void>>;
clearReleaseStopTimer: () => void;
setLockedRecordingMode: (value: boolean) => void;
startRecording: () => Promise<void>;
stopAndProcess: () => Promise<void>;
showError: (message: string) => void;
}
@ -43,19 +34,10 @@ export function useWidgetHotkey({
settings,
setSettings,
settingsRef,
stateRef,
machineRef,
dispatch,
registeredHotkeyRef,
hotkeyHeldRef,
recordingActiveRef,
pendingStopAfterStartRef,
lockedRecordingRef,
suppressNextReleaseRef,
releaseStopTimerRef,
stopAndProcessRef,
clearReleaseStopTimer,
setLockedRecordingMode,
startRecording,
stopAndProcess,
showError,
}: UseWidgetHotkeyParams): void {
const attemptHotkeyRegistrationRef = useRef<(rawHotkey: string) => Promise<HotkeyRegistrationResultPayload>>(
@ -65,9 +47,7 @@ export function useWidgetHotkey({
const unregisterCurrentHotkey = useCallback(async () => {
const currentHotkey = registeredHotkeyRef.current;
if (!currentHotkey) {
return;
}
if (!currentHotkey) return;
logInfo("HOTKEY", `Unregistering: ${currentHotkey}`);
await unregister(currentHotkey).catch(() => {});
@ -77,107 +57,24 @@ export function useWidgetHotkey({
const handleHotkeyPress = useCallback(
(event: ShortcutEvent) => {
if (isHotkeyCaptureActiveRef.current) {
clearReleaseStopTimer();
hotkeyHeldRef.current = false;
pendingStopAfterStartRef.current = false;
suppressNextReleaseRef.current = false;
dispatch({ type: "RESET_HOTKEY_STATE" });
return;
}
const currentState = stateRef.current;
logInfo("HOTKEY", `Triggered! state=${currentState}, shortcutState=${event.state}`);
const machine = machineRef.current;
logInfo("HOTKEY", `Triggered! state=${machine.widgetState}, shortcutState=${event.state}`);
if (event.state !== "Pressed" && event.state !== "Released") {
return;
}
const shortcutState: HotkeyShortcutState = event.state;
const doubleTapTimeout = settingsRef.current?.doubleTapTimeout ?? 400;
const scheduleStopAfterRelease = () => {
clearReleaseStopTimer();
releaseStopTimerRef.current = setTimeout(() => {
releaseStopTimerRef.current = null;
if (stateRef.current !== "recording" || lockedRecordingRef.current) {
return;
}
if (recordingActiveRef.current) {
logInfo("HOTKEY", "Release grace window ended, stopping recording");
void stopAndProcessRef.current();
} else {
logInfo("HOTKEY", "Release grace window ended before recorder startup completed");
pendingStopAfterStartRef.current = true;
}
}, doubleTapTimeout);
};
const decision = evaluateHotkeyFsm(
{
widgetState: currentState,
hotkeyHeld: hotkeyHeldRef.current,
lockedRecording: lockedRecordingRef.current,
suppressNextRelease: suppressNextReleaseRef.current,
pendingStopAfterStart: pendingStopAfterStartRef.current,
releaseStopTimerActive: releaseStopTimerRef.current !== null,
},
shortcutState,
);
hotkeyHeldRef.current = decision.nextState.hotkeyHeld;
suppressNextReleaseRef.current = decision.nextState.suppressNextRelease;
pendingStopAfterStartRef.current = decision.nextState.pendingStopAfterStart;
if (decision.nextState.lockedRecording !== lockedRecordingRef.current) {
setLockedRecordingMode(decision.nextState.lockedRecording);
}
for (const command of decision.commands) {
if (command === "clear_release_stop_timer") {
clearReleaseStopTimer();
continue;
}
if (command === "schedule_stop_after_release") {
if (recordingActiveRef.current) {
logInfo("HOTKEY", "Shortcut released, waiting for possible lock gesture");
} else {
logInfo(
"HOTKEY",
"Shortcut released before startup completed, waiting for possible lock gesture",
);
}
scheduleStopAfterRelease();
continue;
}
if (command === "start_recording") {
void startRecording();
continue;
}
if (command === "stop_recording") {
logInfo("HOTKEY", "Locked recording pressed again, stopping recording");
void stopAndProcess();
}
if (event.state === "Pressed") {
dispatch({ type: "HOTKEY_PRESSED" });
} else {
dispatch({ type: "HOTKEY_RELEASED" });
}
},
[
clearReleaseStopTimer,
hotkeyHeldRef,
lockedRecordingRef,
pendingStopAfterStartRef,
recordingActiveRef,
releaseStopTimerRef,
setLockedRecordingMode,
settingsRef,
startRecording,
stateRef,
stopAndProcess,
stopAndProcessRef,
suppressNextReleaseRef,
],
[dispatch, machineRef],
);
const attemptHotkeyRegistration = useCallback(async (rawHotkey: string): Promise<HotkeyRegistrationResultPayload> => {
@ -243,12 +140,7 @@ export function useWidgetHotkey({
if (!result.success) {
showError(result.message || "Не удалось зарегистрировать горячую клавишу.");
}
}, [
attemptHotkeyRegistration,
settingsLoaded,
settingsRef,
showError,
]);
}, [attemptHotkeyRegistration, settingsLoaded, settingsRef, showError]);
useEffect(() => {
void registerCurrentHotkey();
@ -268,14 +160,9 @@ export function useWidgetHotkey({
const unlistenCaptureState = listen<HotkeyCaptureStatePayload>(HOTKEY_CAPTURE_STATE_EVENT, ({ payload }) => {
isHotkeyCaptureActiveRef.current = payload.active;
if (!payload.active) {
return;
}
if (!payload.active) return;
clearReleaseStopTimer();
hotkeyHeldRef.current = false;
pendingStopAfterStartRef.current = false;
suppressNextReleaseRef.current = false;
dispatch({ type: "RESET_HOTKEY_STATE" });
});
const unlistenHotkeyRequests = listen<HotkeyChangeRequestPayload>(HOTKEY_CHANGE_REQUEST_EVENT, async ({ payload }) => {
@ -307,7 +194,7 @@ export function useWidgetHotkey({
unlistenHotkeyRequests.then((unlisten) => unlisten());
void unregisterCurrentHotkey();
};
}, [clearReleaseStopTimer, hotkeyHeldRef, pendingStopAfterStartRef, setSettings, settingsRef, suppressNextReleaseRef, unregisterCurrentHotkey]);
}, [clearReleaseStopTimer, dispatch, setSettings, settingsRef, unregisterCurrentHotkey]);
}
async function attemptHotkeyRegistrationPlaceholder(): Promise<HotkeyRegistrationResultPayload> {

View file

@ -3,6 +3,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { AppSettings } from "../../../lib/store";
import { logError, logInfo } from "../../../lib/logger";
import { formatErrorMessage } from "../../../lib/utils";
import {
IDLE_WIDGET_HEIGHT,
IDLE_WIDGET_WIDTH,
@ -11,22 +12,16 @@ import {
RECORDING_WIDGET_HEIGHT,
RECORDING_WIDGET_WIDTH,
WidgetNoticeTone,
WidgetState,
} from "../widgetConstants";
import { createRecordingRuntimeController } from "../services/recordingRuntime";
import { processRecordingBlob } from "../services/transcriptionPipeline";
import type { WidgetAction, WidgetMachineState } from "../services/widgetMachine";
interface UseWidgetRecordingParams {
settings: AppSettings | null;
setState: Dispatch<SetStateAction<WidgetState>>;
machineRef: MutableRefObject<WidgetMachineState>;
dispatch: (action: WidgetAction) => void;
setStream: Dispatch<SetStateAction<MediaStream | null>>;
setLockedRecordingMode: (value: boolean) => void;
lockedRecordingRef: MutableRefObject<boolean>;
hotkeyHeldRef: MutableRefObject<boolean>;
recordingActiveRef: MutableRefObject<boolean>;
pendingStopAfterStartRef: MutableRefObject<boolean>;
recordingStartRef: MutableRefObject<number>;
clearReleaseStopTimer: () => void;
resizeWidget: (width: number, height: number) => Promise<void>;
showError: (message: string) => void;
showNotice: (message: string, tone?: WidgetNoticeTone) => void;
@ -38,22 +33,6 @@ interface UseWidgetRecordingResult {
stopAndProcess: () => Promise<void>;
}
function formatErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
function getAudioConstraints(micId: string): MediaTrackConstraints | true {
const constraints: MediaTrackConstraints = {
echoCancellation: false,
@ -69,10 +48,6 @@ function getAudioConstraints(micId: string): MediaTrackConstraints | true {
return constraints;
}
function stopStreamTracks(stream: MediaStream): void {
stream.getTracks().forEach((track) => track.stop());
}
async function waitForTrackReady(stream: MediaStream, timeoutMs: number): Promise<void> {
const [track] = stream.getAudioTracks();
if (!track || (!track.muted && track.readyState === "live")) {
@ -83,10 +58,7 @@ async function waitForTrackReady(stream: MediaStream, timeoutMs: number): Promis
let settled = false;
const finish = () => {
if (settled) {
return;
}
if (settled) return;
settled = true;
track.removeEventListener("unmute", finish);
clearTimeout(timer);
@ -100,15 +72,9 @@ async function waitForTrackReady(stream: MediaStream, timeoutMs: number): Promis
export function useWidgetRecording({
settings,
setState,
machineRef,
dispatch,
setStream,
setLockedRecordingMode,
lockedRecordingRef,
hotkeyHeldRef,
recordingActiveRef,
pendingStopAfterStartRef,
recordingStartRef,
clearReleaseStopTimer,
resizeWidget,
showError,
showNotice,
@ -116,43 +82,13 @@ export function useWidgetRecording({
}: UseWidgetRecordingParams): UseWidgetRecordingResult {
const runtimeRef = useRef(createRecordingRuntimeController());
useEffect(() => {
const micId = settings?.micId;
if (micId === undefined) {
return;
}
let disposed = false;
const prewarmMicrophone = async () => {
try {
const warmupStream = await navigator.mediaDevices.getUserMedia({
audio: getAudioConstraints(micId),
});
stopStreamTracks(warmupStream);
if (!disposed) {
logInfo("RECORDING", "Microphone pre-initialized");
}
} catch (error) {
if (!disposed) {
logInfo("RECORDING", `Microphone pre-initialization skipped: ${formatErrorMessage(error)}`);
}
}
};
void prewarmMicrophone();
return () => {
disposed = true;
};
}, [settings?.micId]);
// NOTE: Microphone pre-warm was removed because on macOS, calling
// getUserMedia activates an audio session that ducks other app volumes.
// The mic is now acquired only when recording actually starts.
// ── Start recording ─────────────────────────────────────────────────────
const startRecording = useCallback(async () => {
logInfo("RECORDING", "startRecording called");
recordingActiveRef.current = false;
pendingStopAfterStartRef.current = false;
if (!settings) {
logError("RECORDING", "Settings not loaded");
@ -168,7 +104,8 @@ export function useWidgetRecording({
}
try {
setState("recording");
// Update widget state to recording (via dispatch)
machineRef.current = { ...machineRef.current, widgetState: "recording" };
void resizeWidget(RECORDING_WIDGET_WIDTH, RECORDING_WIDGET_HEIGHT);
const audioConstraints = getAudioConstraints(settings.micId);
@ -183,9 +120,7 @@ export function useWidgetRecording({
} catch (micError) {
logInfo(
"RECORDING",
`Requested mic failed, trying default: ${
micError instanceof Error ? micError.message : String(micError)
}`,
`Requested mic failed, trying default: ${micError instanceof Error ? micError.message : String(micError)}`,
);
try {
@ -193,9 +128,7 @@ export function useWidgetRecording({
} catch (fallbackError) {
logError(
"RECORDING",
`Mic access denied: ${
fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
}`,
`Mic access denied: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`,
);
showError("Нет доступа к микрофону. Разрешите доступ в настройках macOS.");
return;
@ -211,15 +144,8 @@ export function useWidgetRecording({
logInfo("RECORDING", "Webm not supported, using default codec");
}
recordingActiveRef.current = true;
recordingStartRef.current = Date.now();
logInfo("RECORDING", "Recording started successfully");
if ((!hotkeyHeldRef.current || pendingStopAfterStartRef.current) && !lockedRecordingRef.current) {
pendingStopAfterStartRef.current = false;
logInfo("HOTKEY", "Shortcut released during startup, stopping recording immediately");
void stopAndProcessRef.current();
}
dispatch({ type: "RECORDING_STARTED", timestamp: Date.now() });
} catch (error) {
runtimeRef.current.dispose();
setStream(null);
@ -228,37 +154,32 @@ export function useWidgetRecording({
`Ошибка запуска записи: ${error instanceof Error ? error.message : "Неизвестная ошибка"}`,
);
}
}, [
hotkeyHeldRef,
lockedRecordingRef,
pendingStopAfterStartRef,
recordingActiveRef,
recordingStartRef,
resizeWidget,
setState,
setStream,
settings,
showError,
stopAndProcessRef,
]);
}, [dispatch, machineRef, resizeWidget, setStream, settings, showError]);
// ── Stop and process ────────────────────────────────────────────────────
const stopAndProcess = useCallback(async () => {
logInfo("RECORDING", "stopAndProcess called");
if (!runtimeRef.current.hasRecorder() || !settings || !recordingActiveRef.current) {
const machine = machineRef.current;
if (!runtimeRef.current.hasRecorder() || !settings || !machine.recordingActive) {
logError("RECORDING", "No active recording");
return;
}
recordingActiveRef.current = false;
pendingStopAfterStartRef.current = false;
clearReleaseStopTimer();
setLockedRecordingMode(false);
// Update machine state
machineRef.current = {
...machineRef.current,
recordingActive: false,
pendingStopAfterStart: false,
lockedRecording: false,
releaseStopTimerActive: false,
};
await runtimeRef.current.stop();
setStream(null);
await resizeWidget(RECORDING_WIDGET_WIDTH, RECORDING_WIDGET_HEIGHT);
setState("processing");
dispatch({ type: "SET_PROCESSING" });
try {
if (!runtimeRef.current.hasAudioChunks()) {
@ -267,7 +188,7 @@ export function useWidgetRecording({
}
const blob = runtimeRef.current.getAudioBlob();
const durationMs = Date.now() - recordingStartRef.current;
const durationMs = Date.now() - machine.recordingStartTimestamp;
if (durationMs < MIN_RECORDING_DURATION_MS || blob.size < MIN_AUDIO_BLOB_BYTES) {
logInfo(
@ -275,7 +196,7 @@ export function useWidgetRecording({
`Recording too short, skipping API request. duration_ms=${durationMs}, blob_size=${blob.size}`,
);
runtimeRef.current.reset();
setState("idle");
dispatch({ type: "PROCESSING_COMPLETE" });
await resizeWidget(IDLE_WIDGET_WIDTH, IDLE_WIDGET_HEIGHT);
return;
}
@ -283,25 +204,23 @@ export function useWidgetRecording({
const pipelineResult = await processRecordingBlob({
blob,
settings,
recordingStartTimestamp: recordingStartRef.current,
recordingStartTimestamp: machine.recordingStartTimestamp,
});
if (!pipelineResult.hasTranscription) {
runtimeRef.current.reset();
setState("idle");
dispatch({ type: "PROCESSING_COMPLETE" });
await resizeWidget(IDLE_WIDGET_WIDTH, IDLE_WIDGET_HEIGHT);
return;
}
runtimeRef.current.reset();
setState("idle");
dispatch({ type: "PROCESSING_COMPLETE" });
await resizeWidget(IDLE_WIDGET_WIDTH, IDLE_WIDGET_HEIGHT);
if (pipelineResult.pasteErrorMessage) {
showNotice(pipelineResult.pasteErrorMessage, "info");
}
return;
} catch (error) {
const errorMessage = formatErrorMessage(error);
logError("API", `Processing error: ${errorMessage}`);
@ -310,34 +229,20 @@ export function useWidgetRecording({
runtimeRef.current.reset();
showError(message);
return;
}
}, [
clearReleaseStopTimer,
pendingStopAfterStartRef,
recordingActiveRef,
recordingStartRef,
resizeWidget,
setLockedRecordingMode,
setState,
setStream,
settings,
showError,
showNotice,
]);
}, [dispatch, machineRef, resizeWidget, setStream, settings, showError, showNotice]);
// ── Keep stopAndProcessRef current ──────────────────────────────────────
useEffect(() => {
stopAndProcessRef.current = stopAndProcess;
}, [stopAndProcess, stopAndProcessRef]);
// ── Cleanup ─────────────────────────────────────────────────────────────
useEffect(() => {
return () => {
runtimeRef.current.dispose();
};
}, []);
return {
startRecording,
stopAndProcess,
};
return { startRecording, stopAndProcess };
}

View file

@ -3,6 +3,7 @@ import { emit } from "@tauri-apps/api/event";
import { addHistoryEntry, AppSettings, HistoryEntry, updateHistoryEntry } from "../../../lib/store";
import { logError, logInfo } from "../../../lib/logger";
import { formatErrorMessage } from "../../../lib/utils";
import { HISTORY_UPDATED_EVENT } from "../../../lib/hotkeyEvents";
export interface ProcessRecordingBlobParams {
@ -35,22 +36,6 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
return btoa(binary);
}
function formatErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
function toUserFacingErrorMessage(error: unknown): string {
const raw = formatErrorMessage(error);
const normalized = raw.toLowerCase();

View file

@ -0,0 +1,273 @@
/**
* Centralized widget state machine.
*
* Replaces the 11+ scattered useRef objects with a single typed state object
* managed by useReducer. The hotkeyFsm is kept as a separate pure function
* and called from within this reducer for hotkey-specific transitions.
*
* All mutable "ref-like" boolean flags are now fields of WidgetMachineState.
* Side effects are communicated via the `effects` array returned alongside
* the next state (Elm-style).
*/
import type { WidgetState } from "../widgetConstants";
// ── State ───────────────────────────────────────────────────────────────────
export interface WidgetMachineState {
/** High-level UI state */
widgetState: WidgetState;
/** Whether the hotkey is currently physically held down */
hotkeyHeld: boolean;
/** Whether the user is in locked-recording mode (double-tap) */
lockedRecording: boolean;
/** If true, ignore the next Release event */
suppressNextRelease: boolean;
/** If true, recording finished startup after user already released */
pendingStopAfterStart: boolean;
/** Whether the release-stop grace timer is ticking */
releaseStopTimerActive: boolean;
/** Whether the recorder.start() has actually completed */
recordingActive: boolean;
/** Timestamp of when recording started (ms) */
recordingStartTimestamp: number;
}
export const initialWidgetMachineState: WidgetMachineState = {
widgetState: "idle",
hotkeyHeld: false,
lockedRecording: false,
suppressNextRelease: false,
pendingStopAfterStart: false,
releaseStopTimerActive: false,
recordingActive: false,
recordingStartTimestamp: 0,
};
// ── Effects ─────────────────────────────────────────────────────────────────
export type WidgetEffect =
| { type: "start_recording" }
| { type: "stop_and_process" }
| { type: "schedule_release_stop_timer" }
| { type: "clear_release_stop_timer" }
| { type: "resize_widget"; width: number; height: number }
| { type: "set_stream"; stream: MediaStream | null }
| { type: "show_notice"; message: string; tone: "error" | "info" }
| { type: "set_locked_recording_ui"; value: boolean };
// ── Actions ─────────────────────────────────────────────────────────────────
export type WidgetAction =
// Hotkey events
| { type: "HOTKEY_PRESSED" }
| { type: "HOTKEY_RELEASED" }
// Recording lifecycle
| { type: "RECORDING_STARTED"; timestamp: number }
| { type: "RECORDING_FAILED" }
| { type: "PROCESSING_COMPLETE" }
| { type: "PROCESSING_FAILED"; message: string }
// Timer events
| { type: "RELEASE_STOP_TIMER_FIRED" }
// State transitions
| { type: "SET_PROCESSING" }
// Error
| { type: "ERROR"; message: string }
// Direct state transitions for hotkey capture interference
| { type: "RESET_HOTKEY_STATE" };
// ── Reducer result ──────────────────────────────────────────────────────────
export interface ReducerResult {
state: WidgetMachineState;
effects: WidgetEffect[];
}
// ── Pure reducer ────────────────────────────────────────────────────────────
export function widgetReducer(
state: WidgetMachineState,
action: WidgetAction,
): ReducerResult {
switch (action.type) {
case "HOTKEY_PRESSED":
return handleHotkeyPressed(state);
case "HOTKEY_RELEASED":
return handleHotkeyReleased(state);
case "RECORDING_STARTED":
return handleRecordingStarted(state, action.timestamp);
case "RECORDING_FAILED":
return handleRecordingFailed(state);
case "PROCESSING_COMPLETE":
return handleProcessingComplete(state);
case "PROCESSING_FAILED":
return handleProcessingFailed(state, action.message);
case "RELEASE_STOP_TIMER_FIRED":
return handleReleaseStopTimerFired(state);
case "SET_PROCESSING":
return handleSetProcessing(state);
case "ERROR":
return handleError(state, action.message);
case "RESET_HOTKEY_STATE":
return handleResetHotkeyState(state);
}
}
// ── Transition handlers ─────────────────────────────────────────────────────
function handleHotkeyPressed(state: WidgetMachineState): ReducerResult {
// Locked recording + already recording → stop
if (state.lockedRecording && state.widgetState === "recording") {
return result({
...state,
hotkeyHeld: true,
suppressNextRelease: true,
}, [{ type: "stop_and_process" }]);
}
// Recording + release timer active → user double-tapped → lock mode
if (state.widgetState === "recording" && state.releaseStopTimerActive) {
return result({
...state,
hotkeyHeld: true,
lockedRecording: true,
pendingStopAfterStart: false,
releaseStopTimerActive: false,
}, [
{ type: "clear_release_stop_timer" },
{ type: "set_locked_recording_ui", value: true },
]);
}
// Already held or not idle → ignore
if (state.hotkeyHeld || state.widgetState !== "idle") {
return result(state, []);
}
// Fresh press from idle → start recording
return result({
...state,
hotkeyHeld: true,
lockedRecording: false,
recordingActive: false,
pendingStopAfterStart: false,
}, [{ type: "start_recording" }]);
}
function handleHotkeyReleased(state: WidgetMachineState): ReducerResult {
const next = { ...state, hotkeyHeld: false };
// Was suppressed (after locked stop) → consume silently
if (state.suppressNextRelease) {
next.suppressNextRelease = false;
return result(next, []);
}
// Released while recording → schedule grace window
if (state.widgetState === "recording") {
if (state.lockedRecording) {
return result(next, []);
}
next.releaseStopTimerActive = true;
return result(next, [{ type: "schedule_release_stop_timer" }]);
}
next.pendingStopAfterStart = false;
return result(next, []);
}
function handleRecordingStarted(state: WidgetMachineState, timestamp: number): ReducerResult {
const next: WidgetMachineState = {
...state,
recordingActive: true,
recordingStartTimestamp: timestamp,
};
// If user released while we were starting up → immediate stop
if ((!state.hotkeyHeld || state.pendingStopAfterStart) && !state.lockedRecording) {
next.pendingStopAfterStart = false;
return result(next, [{ type: "stop_and_process" }]);
}
return result(next, []);
}
function handleRecordingFailed(_state: WidgetMachineState): ReducerResult {
return result({
...initialWidgetMachineState,
}, []);
}
function handleProcessingComplete(state: WidgetMachineState): ReducerResult {
return result({
...state,
widgetState: "idle",
recordingActive: false,
lockedRecording: false,
}, [
{ type: "set_locked_recording_ui", value: false },
]);
}
function handleProcessingFailed(_state: WidgetMachineState, message: string): ReducerResult {
return result({
...initialWidgetMachineState,
}, [
{ type: "show_notice", message, tone: "error" },
]);
}
function handleSetProcessing(state: WidgetMachineState): ReducerResult {
return result({
...state,
widgetState: "processing",
recordingActive: false,
pendingStopAfterStart: false,
lockedRecording: false,
releaseStopTimerActive: false,
}, []);
}
function handleReleaseStopTimerFired(state: WidgetMachineState): ReducerResult {
const next = { ...state, releaseStopTimerActive: false };
if (state.widgetState !== "recording" || state.lockedRecording) {
return result(next, []);
}
if (state.recordingActive) {
return result(next, [{ type: "stop_and_process" }]);
}
// Recording not yet started up → mark pending
next.pendingStopAfterStart = true;
return result(next, []);
}
function handleError(_state: WidgetMachineState, message: string): ReducerResult {
return result({
...initialWidgetMachineState,
}, [
{ type: "show_notice", message, tone: "error" },
{ type: "set_locked_recording_ui", value: false },
{ type: "clear_release_stop_timer" },
{ type: "set_stream", stream: null },
]);
}
function handleResetHotkeyState(state: WidgetMachineState): ReducerResult {
return result({
...state,
hotkeyHeld: false,
pendingStopAfterStart: false,
suppressNextRelease: false,
releaseStopTimerActive: false,
}, [{ type: "clear_release_stop_timer" }]);
}
// ── Helper ──────────────────────────────────────────────────────────────────
function result(state: WidgetMachineState, effects: WidgetEffect[]): ReducerResult {
return { state, effects };
}

View file

@ -17,5 +17,6 @@ export const RECORDING_WIDGET_WIDTH = WIDGET_SHELL_WIDTH;
export const RECORDING_WIDGET_HEIGHT = WIDGET_SHELL_HEIGHT;
export const NOTICE_WIDGET_WIDTH = 212;
export const NOTICE_AREA_HEIGHT = 52;
export const NOTICE_WIDGET_GAP = 8;
/** Must match NOTICE_GAP in src-tauri/src/lib.rs (logical pixels). */
export const NOTICE_WIDGET_GAP = 2;
export const WIDGET_NOTICE_EVENT = "widget-notice:update";

View file

@ -25,5 +25,6 @@
}
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}