mirror of
https://github.com/SerTimBerrners-Lee/talkis.git
synced 2026-07-09 17:29:15 +00:00
review
This commit is contained in:
parent
636200a110
commit
ef4d18dc38
38 changed files with 2095 additions and 1161 deletions
63
AGENTS.md
63
AGENTS.md
|
|
@ -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
39
CHANGELOG.md
Normal 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
|
||||
|
|
@ -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
20
scripts/check-version-sync.sh
Executable 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"
|
||||
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
|||
64
src-tauri/src/commands/accessibility.rs
Normal file
64
src-tauri/src/commands/accessibility.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
4
src-tauri/src/commands/mod.rs
Normal file
4
src-tauri/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod accessibility;
|
||||
pub mod runtime_info;
|
||||
pub mod settings_window;
|
||||
pub mod widget;
|
||||
37
src-tauri/src/commands/runtime_info.rs
Normal file
37
src-tauri/src/commands/runtime_info.rs
Normal 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()
|
||||
}
|
||||
67
src-tauri/src/commands/settings_window.rs
Normal file
67
src-tauri/src/commands/settings_window.rs
Normal 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(())
|
||||
}
|
||||
207
src-tauri/src/commands/widget.rs
Normal file
207
src-tauri/src/commands/widget.rs
Normal 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, ¬ice_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)
|
||||
}
|
||||
|
|
@ -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, ¬ice_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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
®istry.manifest.default_language,
|
||||
);
|
||||
let resolved_style = resolve_key(
|
||||
style,
|
||||
registry.manifest.styles.contains_key(style),
|
||||
®istry.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),
|
||||
®istry.manifest.default_language,
|
||||
);
|
||||
let resolved_style = resolve_key(
|
||||
style,
|
||||
registry.manifest.styles.contains_key(style),
|
||||
®istry.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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
256
src/components/UserPanel.tsx
Normal file
256
src/components/UserPanel.tsx
Normal 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
84
src/config/languages.ts
Normal 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" },
|
||||
];
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
99
src/lib/cloudAuth.ts
Normal 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`;
|
||||
}
|
||||
113
src/lib/hotkeyValidation.test.ts
Normal file
113
src/lib/hotkeyValidation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)" }}>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
273
src/windows/widget/services/widgetMachine.ts
Normal file
273
src/windows/widget/services/widgetMachine.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -25,5 +25,6 @@
|
|||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue