From 9b2c89f2bf11f8d0ad9669e9af11f35bbf5e8b23 Mon Sep 17 00:00:00 2001 From: Jonas <123122329+jli-software@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:44:27 +0100 Subject: [PATCH 1/2] docs: claim 0.14.0 first-run assistant (in progress) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014LfoKbq3S36AiYRW7j8QLK --- docs/plans/v1.0-roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/v1.0-roadmap.md b/docs/plans/v1.0-roadmap.md index 5bbe4a3..399ac79 100644 --- a/docs/plans/v1.0-roadmap.md +++ b/docs/plans/v1.0-roadmap.md @@ -74,7 +74,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 | in progress | | 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 From 84767d2ec010dc4a347e8493244742bdf91743b9 Mon Sep 17 00:00:00 2001 From: Jonas <123122329+jli-software@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:16:01 +0100 Subject: [PATCH 2/2] feat: first-run assistant (0.14.0) A new installation opens on four short steps: where to transcribe, the OpenAI key or the local model, the language and optional words, then ChatGPT for text actions, the dictation key and start at login. Skip on every step ends it for good and applies nothing; nothing is stored, downloaded or registered before Finish. Settings files from before the assistant count as set up, so upgrades never see it. While it is open, every intent reveals it and none records. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014LfoKbq3S36AiYRW7j8QLK --- CHANGELOG.md | 10 + README.md | 10 + docs/ARCHITECTURE.md | 41 ++++ docs/DEVELOPMENT.md | 16 +- docs/TESTING.md | 23 +++ docs/plans/v1.0-roadmap.md | 5 +- docs/releases/v0.14.0.md | 32 +++ src-tauri/src/cli.rs | 73 +++++++ src-tauri/src/commands.rs | 52 +++++ src-tauri/src/domain.rs | 11 + src-tauri/src/hotkey.rs | 3 +- src-tauri/src/lib.rs | 78 ++++++- src-tauri/src/settings.rs | 29 +++ src/App.svelte | 36 +++- src/App.test.ts | 153 ++++++++++++++ src/app.css | 56 +++++ src/lib/OnboardingAssistant.svelte | 319 +++++++++++++++++++++++++++++ src/lib/api.ts | 5 + src/lib/onboarding.test.ts | 47 +++++ src/lib/onboarding.ts | 97 +++++++++ src/lib/types.ts | 9 + tests/ui.e2e.ts | 78 ++++++- 22 files changed, 1164 insertions(+), 19 deletions(-) create mode 100644 docs/releases/v0.14.0.md create mode 100644 src/lib/OnboardingAssistant.svelte create mode 100644 src/lib/onboarding.test.ts create mode 100644 src/lib/onboarding.ts 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 399ac79..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 | in progress | +| 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}