diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a0dab..208ea3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.14.0 · Set up in a minute (on `main`, ships with 1.0) + +- Add a first-run assistant. A new installation opens on four short screens: where to transcribe (Cloud, recommended, or on this device), then the OpenAI key — with **Create key ↗** to OpenAI's key page — or the local model (Base recommended), then the language as a code such as `de`, suggested from the system language, and optional words, and last the ChatGPT subscription for text actions (experimental, only with a Codex login), the dictation key and start at login. +- **Skip setup** on every screen closes the assistant for good in one click and applies nothing of it. Every screen says that all of it can be changed later in Settings. +- Nothing happens until **Finish**: only then is the key stored, the model downloaded, the dictation key registered and start at login written, in that order. A step that fails returns to the screen where it is chosen, with the reason; what already worked is kept, and setup is marked done only after the last step. +- Anyone already using Utterform never sees the assistant: a settings file from before it counts as set up. +- While the assistant is open, the dictation key, `utterform --toggle` and a second launch bring it to the front instead of recording, and nothing is held back to record afterwards. + +See [0.14.0 release notes](docs/releases/v0.14.0.md). + ## 0.13.0 · Voice messages and recordings (on `main`, ships with 1.0) - Transcribe an audio file: the new folder button next to Settings or `O` opens one, dropping it on the window shows **Drop to transcribe** with the action and engine it will use, `utterform --file ` and **Open with → Utterform** in the file manager hand one over. Several files are done one after another. diff --git a/README.md b/README.md index 72ae0e3..ae33ab9 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,16 @@ Required runtime packages: sudo pacman -S --needed webkit2gtk-4.1 gtk3 alsa-lib libayatana-appindicator ``` +## Set up in a minute + +The first start asks four short questions: cloud or on this device, then the OpenAI key +(**Create key ↗** opens OpenAI's key page) or the local model, then your language as a code +such as `de` and any names to spell right, and last whether text actions may use your +ChatGPT subscription (experimental), plus the dictation key and start at login. +**Skip setup** ends it at once and changes nothing. Nothing is stored, downloaded or +registered before **Finish**, and everything can be changed later in Settings. An upgrade +from an earlier version does not show it. + ## Tune it once, then stay in flow Settings has a search field on top and five categories below it. Each setting is one diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c877c9e..1c05d5c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -266,6 +266,47 @@ Ghostty); macOS uses the localized name. `recording.app_action` (action) when a rule takes over. The application is never logged: which program someone dictates into says as much as a window title. +## First-run assistant (0.14.0) + +**Who sees it.** `settings.onboarding_completed`. `AppSettings::default()` — what `load` +returns when there is no settings file — has `false`; a stored file without the field +deserializes it as `true` (`#[serde(default = "already_set_up")]`), so an upgrade never +shows the assistant. An unreadable settings file stays an error, not a new user. The +interface's `DEFAULT_SETTINGS` say `true`: the backend alone decides who is new. + +**One decision for intents.** `setup` loads the settings once and sets the managed +`Onboarding` flag; `save_settings` and `complete_onboarding` keep it current. +`cli::Intent::route(setting_up)` decides for the launch intent, the second instance and the +global hotkey (`deliver_intent`): while setting up, every intent — including `--toggle`, +the hotkey and a stale autostart entry — reveals the window and none is forwarded or kept +as the startup intent, so nothing records afterwards out of nowhere. Otherwise the earlier +matrix holds (only `Show` reveals; `Autostart` reaches nothing). The interface also ignores +`remote-intent`, its keyboard shortcuts and audio files while the assistant is open, and +`main` is `inert` beneath it. + +**Draft, then Finish.** `lib/OnboardingAssistant.svelte` keeps an `OnboardingDraft` +(`lib/onboarding.ts`): mode, key text, local model, one language code, vocabulary, ChatGPT, +dictation key, autostart. Moving between the four steps calls nothing that changes state. +`applyDraft` maps it onto the loaded settings, touching only those fields; a cloud model +chosen earlier survives a local choice. Finish then runs, in order: `save_settings` with +`onboarding_completed: false` (a crash resumes setup), `set_openai_api_key` if one was +typed for the cloud, `download_local_model` if the chosen model is missing (progress from +the window's existing listener), `apply_global_hotkey` if the key changed, +enable/disable autostart if the switch changed (read back, believed over the request), and +`complete_onboarding`. A failing stage returns to its step with the message; what already +succeeded is remembered and not repeated, and setup stays incomplete. **Skip** calls only +`complete_onboarding(loaded settings, skipped: true)`. + +**Links.** `open_openai_api_keys` opens the compiled-in `https://platform.openai.com/api-keys` +and takes no argument, like `open_codex_setup`; no opener permission reaches the WebView. + +**Diagnostics.** `onboarding.shown` (source `first_launch` or `startup_intent`), +`onboarding.intent_consumed` (source, intent), `onboarding.skipped`, +`onboarding.completed` (engine, cloud model, local model id, text provider, language and +vocabulary counts, hotkey on/off), `onboarding.finish_failed` (stage `settings`) and +`onboarding.key_page_open_failed`. The other stages fail inside their existing commands, +which log their own events. Never the key, the language, the words or a typed draft. + ## What the local engine is compiled for whisper.cpp is built from source by `whisper-rs-sys`, and ggml's CMake targets the machine doing the compiling unless told otherwise. For a release built on someone else's server that is a trap rather than an optimization: the 0.7.2 Linux binary carried the runner's AVX-512 and AMX instructions and was stopped with SIGILL on an Intel N300 while loading a model — inside `ggml_backend_cpu_device_get_extra_buffers_type`, plain C++ frame code, which is before ggml's own runtime dispatch can choose a kernel and therefore beyond its help. The bug had been there since the first release; only the pairing of build machine and user machine decided whether it fired. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ec83344..f22420d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -19,7 +19,21 @@ proposals are in [plans/v1.0-roadmap.md](plans/v1.0-roadmap.md). The plan of 202 (reliability, assistant, finish) is superseded; 0.7.13 shipped, 0.7.14 (`feat/simple-settings`) was rejected and will not be merged. -## Current handoff — 0.12.0 Action per application +## Current handoff — 0.14.0 First-run assistant + +Stage 0.14.0 of [the roadmap](plans/v1.0-roadmap.md), branch +`work/utterform-0.14.0-assistant`. Jonas accepted the mock-up on 2026-11-08: four steps +(where to transcribe · key or model · language and words · ChatGPT, dictation key, start +at login), Skip on every step, nothing applied before Finish. Built on the contract of +Draft PR #39, adapted: no Live choice and no recording topic in the assistant (both stay in +Settings), and issue #40 stays with 1.0. `settings.onboarding_completed` (legacy files read +as done), `cli::Intent::route` holds every intent back while setting up, +`lib/OnboardingAssistant.svelte` with `lib/onboarding.ts`. Design in +[ARCHITECTURE.md → First-run assistant](ARCHITECTURE.md#first-run-assistant-0140), +validation and what is open for 1.0 in [TESTING.md](TESTING.md). Merged into `main` without +tag or release; ships with 1.0. Next stage: 1.0.0. + +## Previous handoff — 0.12.0 Action per application Stage 0.12.0 of [the roadmap](plans/v1.0-roadmap.md), branch `work/utterform-0.12.0-per-app`. Jonas decided to build it small and accepted the mock-up: **Settings → Context → Per app**, diff --git a/docs/TESTING.md b/docs/TESTING.md index 361799f..4d3be11 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -10,6 +10,29 @@ `PLAYWRIGHT_CHROMIUM_EXECUTABLE=/usr/bin/chromium npm run test:e2e`. - `cmake` is needed for whisper.cpp. Without root, `mise exec cmake@latest -- cargo test` works. +## 0.14.0 First-run assistant + +Rust: `settings.rs` — no file means not set up, a file without the field means set up, +explicit `false`/`true` survive a save and reload; `cli.rs` — while setting up every intent +reveals and none is forwarded, afterwards the dictation key leaves the window alone. +`lib/onboarding.test.ts`: system language, language names and typos, the starting draft, +`applyDraft` writing only its fields and keeping the cloud model, what holds Next back. +`App.test.ts`: never shown to someone set up; Skip from step 3 saves only completion and +calls no key, model, hotkey or autostart command; cloud needs a key, opens the key page, +stores the key only on Finish and before completion; a failed download returns to the +model step, stays incomplete and succeeds on retry; ChatGPT only with a Codex login; key +and autostart changes applied on Finish only; no hotkey intent or Space records beneath it. +Playwright, at 920×720 and 360×400: all four steps with screenshots, nothing sideways, +Skip and Next/Finish in view on every step, a toggle beneath it ignored, Finish writes the +local choice and does not store a key typed before switching to local; Skip in the light +theme leaves settings and the startup entry untouched. + +Not exercised, to check at 1.0: the installed app on a clean profile (first launch, +first launch through `utterform --toggle` and Alt+Space, second launch while the assistant +is open, launch after Skip), a real model download and keyring write from the assistant, +the key page opening in the browser, and an upgrade from real 0.8.0 settings on Linux, +Windows and macOS. + ## 0.12.0 Action per application Unit tests in `foreground.rs`: display names from classes and program names, Utterform's diff --git a/docs/plans/v1.0-roadmap.md b/docs/plans/v1.0-roadmap.md index 5bbe4a3..86b32e4 100644 --- a/docs/plans/v1.0-roadmap.md +++ b/docs/plans/v1.0-roadmap.md @@ -59,6 +59,7 @@ and without its subscription: | 2026-11-07 | 0.13.0: audio files open from a folder button next to Settings (and `O`) or by dropping on the window, which shows "Drop to transcribe" with action and engine; text to history and clipboard, never the cursor. Mock-up accepted. | | 2026-11-07 | Settings mock-up accepted: search field on top, categories as tabs below, one row per setting. The "Context" category appears only once 0.11.0 gives it rows. | | 2026-11-08 | 0.12.0 is built, small: Settings → Context → "Per app", empty by default; "+ App" offers the apps dictated into lately or a typed name; a rule applies only to the dictation key, everywhere else the window's action; a notification "Utterform · Email — for Thunderbird" only when a rule changes the action. Mock-up accepted. | +| 2026-11-08 | 0.14.0: four steps — where to transcribe · OpenAI key or local model · language code (system language suggested) and optional words · ChatGPT (only with Codex login), dictation key, start at login. Skip on every step, nothing applied before Finish. Mock-up accepted. | | 2026-11-07 | 0.10.0: ChatGPT is marked "Experimental" in Settings; **no fallback** to the OpenAI key — a ChatGPT failure is shown and the plain transcript delivered. Mock-up (AI → Provider: OpenAI key \| ChatGPT) accepted. | | 2026-11-07 | 0.11.0: context is its **own action "Context"**, no per-action switch; other actions never read context. **Linux first**: Linux reads the selection, Windows/macOS use the clipboard only — no simulated Ctrl+C, which would interrupt a terminal. | @@ -74,7 +75,7 @@ Proposals not yet confirmed by Jonas are marked **Proposal**. | 0.11.0 | Context: selected text and clipboard | merged, ships with 1.0 | | 0.12.0 | Action per application | merged, ships with 1.0 | | 0.13.0 | Audio files | merged, ships with 1.0 | -| 0.14.0 | First-run assistant | planned (was 0.8.0) | +| 0.14.0 | First-run assistant | merged, ships with 1.0 | | 1.0.0 | Acceptance on Linux, Windows, macOS; release; WinGet | planned | **Proposal (order):** the Omarchy backend first because it is small and the CLI already @@ -174,7 +175,7 @@ clipboard; text actions apply as usual. longer files). Check which formats the API accepts (WhatsApp `.opus`/`.ogg`). - Local: decode to 16 kHz mono for Whisper; check Opus support of the decoder used. -### 0.14.0 — First-run assistant +### 0.14.0 — First-run assistant (decided 2026-11-08, built) The contract on Draft PR #39 (`docs/plans/v0.8.0-onboarding-assistant.md`) stays the base, adapted to the new Settings and the Codex choice: skippable, short welcome, OpenAI key or diff --git a/docs/releases/v0.14.0.md b/docs/releases/v0.14.0.md new file mode 100644 index 0000000..4ad7037 --- /dev/null +++ b/docs/releases/v0.14.0.md @@ -0,0 +1,32 @@ +# Utterform 0.14.0 — Set up in a minute + +*Stage of the road to 1.0, merged into `main`; not released on its own. It ships with 1.0.* + +Until now a new user met the main window and had to find Settings to make Utterform work: +the key, the engine, the language were all one click away but nobody said so. Now the first +start asks four short questions. + +**Where to transcribe.** Cloud — GPT Transcribe, the most accurate, with an OpenAI key — or +on this device with Local Whisper, offline. + +**The key or the model.** For the cloud, paste the OpenAI key; **Create key ↗** opens +OpenAI's key page. It goes into the system keyring. On this device, pick the model; Base is +recommended, and one already downloaded says so. + +**The language.** Type a code such as `de`; the system language is already there, and the +field shows its name. Add names and terms Utterform should spell right, if you like. + +**Text actions.** Use the ChatGPT subscription for Email, Polish, Context and the others, +marked experimental and offered only when the Codex CLI is signed in. Below it the +dictation key and start at login. + +**Skip setup** on every screen ends the assistant for good and changes nothing. Whatever +it asks can be changed later in Settings, and every screen says so. + +**Nothing before Finish.** Moving between the screens stores, downloads and registers +nothing. Finish stores the key, downloads the model, sets the dictation key and start at +login, and only then counts setup as done. If something fails — a download, a key another +program holds — the assistant goes back to where it is chosen and says why; Finish again +picks up where it stopped. + +**Existing users** never see it: an upgrade keeps everything as it is. diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 3cfe05f..c24702e 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -66,6 +66,33 @@ impl Intent { pub fn reaches_the_interface(self) -> bool { self != Intent::Autostart } + + /// What to do with this intent, given whether the first-run assistant is + /// still open. Until setup is finished or skipped, every way of starting + /// or reaching Utterform shows the assistant and nothing records: a + /// dictation key pressed before there is a key or a model would only fail, + /// and a request kept for later would start a recording out of nowhere + /// the moment setup ends. + pub fn route(self, setting_up: bool) -> Route { + if setting_up { + return Route { + reveal: true, + forward: false, + }; + } + Route { + reveal: self.raises_window(), + forward: self.reaches_the_interface(), + } + } +} + +/// Where an intent goes: whether the window comes to the front, and whether +/// the interface is asked to act on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Route { + pub reveal: bool, + pub forward: bool, } /// Read the intent from a command line, ignoring the executable path and any @@ -300,4 +327,50 @@ mod tests { ); assert_eq!(file_url_path("server/share/x.opus"), None); } + + #[test] + fn while_setting_up_every_intent_shows_the_assistant_and_none_records() { + for intent in [ + Intent::Show, + Intent::Toggle, + Intent::Start, + Intent::Stop, + Intent::Cancel, + Intent::Autostart, + ] { + assert_eq!( + intent.route(true), + Route { + reveal: true, + forward: false + }, + "{intent:?}" + ); + } + } + + #[test] + fn once_set_up_the_dictation_key_leaves_the_window_where_it_is() { + assert_eq!( + Intent::Toggle.route(false), + Route { + reveal: false, + forward: true + } + ); + assert_eq!( + Intent::Show.route(false), + Route { + reveal: true, + forward: true + } + ); + assert_eq!( + Intent::Autostart.route(false), + Route { + reveal: false, + forward: false + } + ); + } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index da37567..923b4ef 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -111,6 +111,8 @@ pub fn get_settings(app: AppHandle) -> Result { pub fn save_settings(app: AppHandle, value: AppSettings) -> Result<(), String> { match settings::save(&app, &value) { Ok(()) => { + app.state::() + .set(!value.onboarding_completed); diagnostics::info!( "settings.saved", engine = value.engine.as_str(), @@ -129,6 +131,42 @@ pub fn save_settings(app: AppHandle, value: AppSettings) -> Result<(), String> { } } +/// End the first-run assistant for good: `value` is written with setup marked +/// done, and dictation intents reach the interface again. A skip hands back +/// the settings as they were loaded, so nothing of the assistant is applied. +/// Only modes and switches are logged, never what was typed. +#[tauri::command] +pub fn complete_onboarding( + app: AppHandle, + mut value: AppSettings, + skipped: bool, +) -> Result<(), String> { + value.onboarding_completed = true; + if let Err(failure) = settings::save(&app, &value) { + return Err(diagnostics::failure!( + "onboarding.finish_failed", + &failure, + stage = "settings" + )); + } + app.state::().set(false); + if skipped { + diagnostics::info!("onboarding.skipped"); + } else { + diagnostics::info!( + "onboarding.completed", + engine = value.engine.as_str(), + cloud_model = value.cloud_model.as_str(), + local_model = value.local_model_id.as_deref().unwrap_or("none"), + text_provider = value.text_provider.as_str(), + languages = value.language_hints.len(), + vocabulary = value.vocabulary.len(), + hotkey = value.global_hotkey.is_some(), + ); + } + Ok(()) +} + #[tauri::command] pub fn list_input_devices() -> Result, String> { audio::list_input_devices() @@ -1094,6 +1132,20 @@ pub fn open_codex_setup() -> Result<(), String> { }) } +/// Where an OpenAI key is made. A fixed address, like the Codex page above: +/// the first-run assistant asks for this page and can name no other. +pub const OPENAI_API_KEYS_URL: &str = "https://platform.openai.com/api-keys"; + +#[tauri::command] +pub fn open_openai_api_keys() -> Result<(), String> { + tauri_plugin_opener::open_url(OPENAI_API_KEYS_URL, None::<&str>).map_err(|error| { + diagnostics::failure!( + "onboarding.key_page_open_failed", + &Failure::new("opener", "The OpenAI key page could not be opened").detail(error) + ) + }) +} + #[tauri::command] pub fn has_openai_api_key() -> bool { secrets::has_openai_api_key() diff --git a/src-tauri/src/domain.rs b/src-tauri/src/domain.rs index fe8f56a..caf2993 100644 --- a/src-tauri/src/domain.rs +++ b/src-tauri/src/domain.rs @@ -187,6 +187,16 @@ pub struct AppSettings { /// The applications dictated into lately, newest first, to choose from /// when adding one of the above. Kept on this device only. pub recent_apps: Vec, + /// The first-run assistant was finished or skipped. A new installation + /// starts with `false`; a settings file from before the assistant has no + /// such field and means someone already uses Utterform, so it reads as + /// `true` and an upgrade never shows the assistant. + #[serde(default = "already_set_up")] + pub onboarding_completed: bool, +} + +fn already_set_up() -> bool { + true } /// An application as Settings knows it: the id rules match, and its name. @@ -242,6 +252,7 @@ impl Default for AppSettings { context_clipboard: true, app_actions: Vec::new(), recent_apps: Vec::new(), + onboarding_completed: false, } } } diff --git a/src-tauri/src/hotkey.rs b/src-tauri/src/hotkey.rs index 19ecff6..0ac2214 100644 --- a/src-tauri/src/hotkey.rs +++ b/src-tauri/src/hotkey.rs @@ -208,7 +208,8 @@ pub fn install(app: &AppHandle) -> Result<(), diagnostics::Failur ); // Deliberately not revealing the window: the point of the key // is to dictate into whatever the user is already typing in. - diagnostics::notify_interface(app, "remote-intent", Intent::Toggle); + // Only an unfinished setup brings it forward instead. + crate::deliver_intent(app, Intent::Toggle, "hotkey"); } }) .build(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bd70eb3..cb4c743 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -53,6 +53,46 @@ impl StartupIntent { } } +/// Whether the first-run assistant is open. Read from the settings at startup +/// and kept current by every save, so the single decision about intents +/// (`cli::Intent::route`) never guesses from anything else. +#[derive(Default)] +pub struct Onboarding(std::sync::atomic::AtomicBool); + +impl Onboarding { + pub fn active(&self) -> bool { + self.0.load(std::sync::atomic::Ordering::SeqCst) + } + + pub fn set(&self, active: bool) { + self.0.store(active, std::sync::atomic::Ordering::SeqCst); + } +} + +/// Hand a request from outside — a second launch, the dictation key — to the +/// window and the interface, as `cli::Intent::route` decides. +pub fn deliver_intent( + app: &tauri::AppHandle, + intent: Intent, + source: &'static str, +) { + let setting_up = app.state::().active(); + let route = intent.route(setting_up); + if setting_up { + diagnostics::info!( + "onboarding.intent_consumed", + source = source, + intent = intent.as_str() + ); + } + if route.reveal { + reveal_main_window(app, source); + } + if route.forward { + diagnostics::notify_interface(app, "remote-intent", intent); + } +} + /// Audio files handed to Utterform from outside — the command line, a file /// manager's "Open with", macOS' open event — waiting for the interface to /// transcribe them one after another. The interface is told with a @@ -151,16 +191,36 @@ fn setup(app: &mut tauri::App, startup_intent: Intent) -> Result<(), Box().set(startup_intent); + let handle = app.handle().clone(); + let loaded = settings::load(&handle); + // A settings file that cannot be read is not a new user: the existing + // error path reports it, and nothing here turns it into a setup. + let setting_up = loaded + .as_ref() + .is_ok_and(|settings| !settings.onboarding_completed); + app.state::().set(setting_up); + let route = startup_intent.route(setting_up); + if route.forward { + app.state::().set(startup_intent); + } diagnostics::info!("app.startup_intent", intent = startup_intent.as_str()); + if setting_up { + diagnostics::info!( + "onboarding.shown", + source = if startup_intent == Intent::Show { + "first_launch" + } else { + "startup_intent" + } + ); + } // Best-effort on purpose: a session that will not grant a global // shortcut, or a key another application already holds, must cost // the dictation key and not the application. The reason is kept so // Settings can report it when the user next looks. - let handle = app.handle().clone(); match hotkey::install(&handle) { Ok(()) => { - let configured = match settings::load(&handle) { + let configured = match loaded { Ok(settings) => settings.global_hotkey, Err(failure) => { diagnostics::fallback!( @@ -195,7 +255,7 @@ fn setup(app: &mut tauri::App, startup_intent: Intent) -> Result<(), Box() .offer(app, files, "command_line"); })) @@ -263,6 +318,7 @@ pub fn run() { .manage(tray::TrayState::default()) .manage(live::LiveState::default()) .manage(StartupIntent::default()) + .manage(Onboarding::default()) .manage(PendingFiles::default()) .manage(history::HistoryState::default()) .manage(hotkey::Failure::default()) @@ -310,6 +366,7 @@ pub fn run() { commands::announce_app_action, commands::get_settings, commands::save_settings, + commands::complete_onboarding, commands::list_input_devices, commands::start_recording, commands::get_recording_status, @@ -335,6 +392,7 @@ pub fn run() { commands::copy_text, commands::codex_status, commands::open_codex_setup, + commands::open_openai_api_keys, commands::has_openai_api_key, commands::set_openai_api_key, commands::delete_openai_api_key, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index a6d5763..6e3fb09 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -238,4 +238,33 @@ mod tests { assert_eq!(settings.global_hotkey, None); assert_eq!(settings.typing_method, TypingMethod::Keystrokes); } + + #[test] + fn a_new_installation_has_not_been_set_up() { + // `load` hands out exactly this when there is no settings file at all. + assert!(!AppSettings::default().onboarding_completed); + } + + #[test] + fn someone_upgrading_never_meets_the_first_run_assistant() { + // Every file written before the assistant lacks the field: whoever + // wrote it already uses Utterform. + let earlier = r#"{ "engine": "local_whisper", "language_hints": ["de"] }"#; + let settings: AppSettings = serde_json::from_str(earlier).unwrap(); + assert!(settings.onboarding_completed); + assert_eq!(settings.language_hints, vec!["de".to_string()]); + } + + #[test] + fn an_unfinished_setup_stays_unfinished_across_a_save_and_a_reload() { + for completed in [false, true] { + let settings = AppSettings { + onboarding_completed: completed, + ..AppSettings::default() + }; + let restored: AppSettings = + serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap(); + assert_eq!(restored.onboarding_completed, completed); + } + } } diff --git a/src/App.svelte b/src/App.svelte index 111cca3..c1a23bc 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -8,6 +8,7 @@ BUILT_IN_ACTIONS, DEFAULT_SETTINGS, FORBIDDEN_IN_KEYWORD, + LANGUAGE_CODE, actionName, actionPrompt, isActionEdited, @@ -45,6 +46,7 @@ import { formatHistoryTime, fullHistoryDate, historyTimestamp } from "./lib/history-time"; import { modalFocus } from "./lib/modal-focus"; import ChipInput from "./lib/ChipInput.svelte"; + import OnboardingAssistant from "./lib/OnboardingAssistant.svelte"; import { SETTINGS_CATEGORIES, categoryLabel, visibleRows } from "./lib/settings-catalog"; import { actionFor, addable, rememberApp, removeRule, setRule, typedApp } from "./lib/app-actions"; import type { SettingsCategory, SettingsRowId } from "./lib/settings-catalog"; @@ -68,7 +70,6 @@ { mode: "local_whisper", label: "Local", title: "Local Whisper: audio stays on this device" }, ]; /// Short codes such as de, en or pt-BR, the way the transcription API takes them. - const LANGUAGE_CODE = /^[a-z]{2,3}(-[a-z0-9]{2,8})?$/i; const EFFORTS: Array<{ value: TextEffort | null; label: string }> = [ { value: null, label: "Auto" }, { value: "minimal", label: "Minimal" }, @@ -275,7 +276,10 @@ $: settingsEmpty = settingsSearching && shownRows.size === 0; $: todaySeconds = Math.round(history.filter((entry) => new Date(historyTimestamp(entry) ?? NaN).toDateString() === new Date(now).toDateString()).reduce((total, entry) => total + entry.durationMs, 0) / 1000); $: controlsLocked = phase === "starting" || recordingActive || phase === "processing"; - $: canTakeFile = !controlsLocked && !showSettings && !transcriptionSaving; + /// A new installation opens on the first-run assistant; the backend decides + /// who is new, and until setup is finished or skipped nothing records. + $: onboarding = !settings.onboarding_completed; + $: canTakeFile = !controlsLocked && !showSettings && !onboarding && !transcriptionSaving; $: fileActionLabel = actionOptions.find((option) => option.value === effectiveAction)?.label ?? "Plain"; // Live dictation has no file mode: a file always goes to GPT Transcribe. $: fileEngineLabel = settings.engine === "open_ai" ? "GPT Transcribe" : "Local"; @@ -481,6 +485,9 @@ // released it for the moment; a compositor binding still arrives, and is // what this turns away. if (hotkeyCapturing) return; + // The backend already holds these back during setup; this covers one + // that was on its way as setup began. + if (onboarding) return; switch (intent) { case "toggle": if (recordingActive) await finishRecording(); @@ -512,6 +519,7 @@ } function handleKeyDown(event: KeyboardEvent) { + if (onboarding) return; if (!showSettings && (event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === "KeyC") { event.preventDefault(); if (!event.repeat) void copyDisplayedText(); @@ -1224,6 +1232,21 @@ scheduleSave(); } + /// The assistant is done: what it chose is now what the window runs with. + function finishOnboarding(result: { settings: AppSettings; hasApiKey: boolean; models: LocalModel[]; hotkeyApplied: boolean; skipped: boolean }) { + settings = result.settings; + hasApiKey = result.hasApiKey; + models = result.models; + if (result.hotkeyApplied) { + hotkeyError = ""; + hotkeySupport = { ...hotkeySupport, failure: null }; + } + if (!result.skipped) { + phase = "idle"; + message = "Setup complete"; + } + } + function openSettings() { confirmClear = false; settingsQuery = ""; @@ -1386,7 +1409,7 @@ -
+
@@ -1534,7 +1557,12 @@
{/snippet} -{#if showSettings} +{#if onboarding} + +{/if} + +{#if showSettings && !onboarding}