Merge remote-tracking branch 'origin/main' into work/1955

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Palash Debnath
2026-09-09 12:29:36 -07:00
24 changed files with 1539 additions and 180 deletions
+3
View File
@@ -10,6 +10,7 @@ the frozen-backend fallback mirror it for their toolchains.
**Highlights**
- Transcriptions dictation wakes the desktop recorder, presents one contextual start action, and centers its microphone icon with the label (#1902)
- Validate current-user Windows installers under a standard account on hosted runners (#1883)
- The desktop app builds and opens from a fresh clone again (#1818) — thanks @flutterkage2k!
@@ -55,6 +56,7 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Windows desktop launches no longer freeze at "Loading ML runtime (PyTorch)": the parent-liveness watchdog polls the stdin pipe instead of leaving a read pending, which deadlocked numpy's OpenBLAS initializer (#1955)
- Voice synthesis progress no longer races to a fabricated 95%; it stays indeterminate until the active generation path reports real progress (#1907) — thanks @psiberfunk!
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
@@ -96,6 +98,7 @@ the frozen-backend fallback mirror it for their toolchains.
- Fast macOS process exits no longer turn a completed shutdown into a permission error (#1809)
- The bootstrap splash no longer shows fabricated first-run install steps on a warm start or repair sync — a step now renders done only once it was actually observed (#1894)
- Model Catalogue engine rows stack into one column on narrow shells instead of clipping actions off-screen (#1891)
+9 -9
View File
@@ -78,7 +78,7 @@
"playwright-core": "1.62.1",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vitest": "4.1.9",
"vitest": "4.1.11",
},
},
},
@@ -749,19 +749,19 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw=="],
"@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="],
"@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="],
"@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="],
"@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="],
"@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="],
"@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="],
"@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="],
"@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="],
"@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="],
"@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.42", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ=="],
@@ -1593,7 +1593,7 @@
"vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="],
"vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="],
"vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="],
"vue": ["vue@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/server-renderer": "3.5.42", "@vue/shared": "3.5.42" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA=="],
+8
View File
@@ -17,6 +17,14 @@ own microphone audio to the versioned WebSocket API. See the
3. Put the cursor in a text field, press the shortcut, speak, then release or
press again.
The **Transcriptions** page offers the same recorder as one contextual
**Start dictation** action: it appears in the empty state before the first
transcript and moves to the page header once history exists. A desktop start
wakes the recorder window before dispatch, so a hidden WebView cannot silently
miss the request. The in-app action confirms listener receipt, then resolves
only after microphone startup is accepted. Disabled, rejected, timed-out, or
failed starts are reported back on the page.
Whisper Tiny is the recommended default on macOS, Windows, and Linux. It
auto-detects more than 90 languages. Parakeet TDT v3 remains available for its
25 supported European languages, but it is not selected automatically.
+1 -1
View File
@@ -88,6 +88,6 @@
"playwright-core": "1.62.1",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vitest": "4.1.9"
"vitest": "4.1.11"
}
}
+153 -5
View File
@@ -12,7 +12,7 @@ use tauri_plugin_dialog::DialogExt;
use crate::config::{load_config, save_config};
use crate::dictation_shortcut::{update_tray_hint, DictationShortcutManager, ShortcutInfo};
use crate::{AppFlags, TrayHandle};
use crate::{AppFlags, CaptureAcceptanceTimeout, CaptureReceiptCancellation, TrayHandle};
use crate::{TRAY_ICON_DEFAULT, TRAY_ICON_RECORDING};
// ── Native host-path authorization ───────────────────────────────────────
@@ -1006,12 +1006,145 @@ pub fn get_effective_dictation_shortcut(
}
#[tauri::command]
pub fn request_dictation_capture(app: tauri::AppHandle, action: String) -> Result<(), String> {
pub async fn request_dictation_capture(
app: tauri::AppHandle,
action: String,
) -> Result<(), String> {
if action != "start" && action != "stop" && action != "toggle" {
return Err("capture action must be start, stop, or toggle".into());
}
crate::dispatch_dictation_capture(&app, &action);
Ok(())
let delivery_id = crate::request_dictation_capture_delivery(&app, &action)
.ok_or_else(|| "capture request could not be queued".to_string())?;
let wait_app = app.clone();
let mut acknowledged = tauri::async_runtime::spawn_blocking(move || {
wait_for_capture_delivery(
|| {
let flags = wait_app.state::<AppFlags>();
let capture = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?;
Ok(capture.delivery_pending(delivery_id))
},
CAPTURE_DELIVERY_TIMEOUT,
)
})
.await
.map_err(|error| format!("capture acknowledgement worker failed: {error}"))??;
if !acknowledged {
let flags = app.state::<AppFlags>();
let timeout_outcome = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.cancel_unreceived_delivery(delivery_id);
match timeout_outcome {
CaptureReceiptCancellation::Received => acknowledged = true,
CaptureReceiptCancellation::Cancelled(event) => {
if event.name == "tray-dictate" {
flags.output.finish_session(event.payload.session_id);
}
return Err("capture window did not acknowledge the request".into());
}
CaptureReceiptCancellation::Missing => {
return Err("capture request disappeared before acknowledgement".into());
}
}
}
debug_assert!(acknowledged);
let outcome_app = app.clone();
let completed = tauri::async_runtime::spawn_blocking(move || {
wait_for_capture_delivery(
|| {
let flags = outcome_app.state::<AppFlags>();
let capture = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?;
Ok(!capture.completion_ready(delivery_id))
},
CAPTURE_ACCEPTANCE_TIMEOUT,
)
})
.await
.map_err(|error| format!("capture acceptance worker failed: {error}"))??;
let flags = app.state::<AppFlags>();
if completed {
let completion = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.take_completion(delivery_id);
return completion
.unwrap_or_else(|| Err("capture request completed without an outcome".into()));
}
let timeout_outcome = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.take_completion_or_cancel(delivery_id);
match timeout_outcome {
CaptureAcceptanceTimeout::Completed(completion) => return completion,
CaptureAcceptanceTimeout::Cancelled(event) if event.name == "tray-dictate" => {
flags.output.finish_session(event.payload.session_id);
}
CaptureAcceptanceTimeout::Cancelled(_) | CaptureAcceptanceTimeout::Missing => {}
}
Err("dictation capture did not start in time".into())
}
const CAPTURE_DELIVERY_TIMEOUT: Duration = Duration::from_secs(2);
const CAPTURE_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(60);
const CAPTURE_DELIVERY_POLL: Duration = Duration::from_millis(20);
fn wait_for_capture_delivery<F>(mut pending: F, timeout: Duration) -> Result<bool, String>
where
F: FnMut() -> Result<bool, String>,
{
let deadline = Instant::now() + timeout;
loop {
if !pending()? {
return Ok(true);
}
if Instant::now() >= deadline {
return Ok(false);
}
std::thread::sleep(CAPTURE_DELIVERY_POLL);
}
}
#[cfg(test)]
mod capture_request_tests {
use super::wait_for_capture_delivery;
use std::time::Duration;
#[test]
fn listener_acknowledgement_completes_the_request() {
let mut polls = 0;
let acknowledged = wait_for_capture_delivery(
|| {
polls += 1;
Ok(polls < 2)
},
Duration::from_millis(50),
)
.expect("poll succeeds");
assert!(acknowledged);
}
#[test]
fn missing_listener_acknowledgement_times_out() {
let acknowledged = wait_for_capture_delivery(
|| Ok(true),
Duration::from_millis(0),
)
.expect("poll succeeds");
assert!(!acknowledged);
}
}
/// Distance from the bottom edge of the work area, in logical pixels — clear of
@@ -1162,13 +1295,28 @@ pub fn acknowledge_dictation_capture_delivery(
app: tauri::AppHandle,
registration_id: u64,
delivery_id: u64,
) -> bool {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return false;
};
capture.acknowledge(registration_id, delivery_id)
}
#[tauri::command]
pub fn complete_dictation_capture_delivery(
app: tauri::AppHandle,
registration_id: u64,
delivery_id: u64,
error: Option<String>,
) {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
capture.acknowledge(registration_id, delivery_id);
capture.complete(registration_id, delivery_id, error);
}
#[tauri::command]
+401 -43
View File
@@ -26,7 +26,7 @@ pub mod watch_folder;
#[cfg(target_os = "linux")]
pub mod wayland_shortcut;
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::process::Child;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -107,6 +107,29 @@ pub struct CaptureDispatchState {
registration_counter: u64,
delivery_counter: u64,
active_registration: Option<u64>,
in_flight: HashMap<u64, CaptureInFlight>,
}
struct CaptureInFlight {
event: CaptureEvent,
outcome: Option<Result<(), String>>,
}
pub(crate) enum CaptureReceiptCancellation {
Received,
Cancelled(CaptureEvent),
Missing,
}
pub(crate) enum CaptureAcceptanceTimeout {
Completed(Result<(), String>),
Cancelled(CaptureEvent),
Missing,
}
struct CaptureEnqueue {
delivery_id: u64,
event: Option<CaptureEvent>,
}
impl Default for CaptureDispatchState {
@@ -117,6 +140,7 @@ impl Default for CaptureDispatchState {
registration_counter: 0,
delivery_counter: 0,
active_registration: None,
in_flight: HashMap::new(),
}
}
}
@@ -147,25 +171,125 @@ impl CaptureDispatchState {
.collect()
}
pub(crate) fn enqueue(&mut self, mut event: CaptureEvent) -> Option<CaptureEvent> {
fn enqueue(&mut self, mut event: CaptureEvent) -> CaptureEnqueue {
self.delivery_counter = self.delivery_counter.wrapping_add(1).max(1);
event.payload.delivery_id = self.delivery_counter;
self.pending.push_back(event.clone());
let registration_id = self.active_registration.filter(|_| self.ready)?;
event.payload.registration_id = registration_id;
Some(event)
let ready_event = self
.active_registration
.filter(|_| self.ready)
.map(|registration_id| {
event.payload.registration_id = registration_id;
event
});
CaptureEnqueue {
delivery_id: self.delivery_counter,
event: ready_event,
}
}
pub(crate) fn acknowledge(&mut self, registration_id: u64, delivery_id: u64) {
pub(crate) fn acknowledge(&mut self, registration_id: u64, delivery_id: u64) -> bool {
if self.active_registration != Some(registration_id) {
return;
return false;
}
if let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
{
self.pending.remove(index);
if let Some(event) = self.pending.remove(index) {
if event.await_result {
self.in_flight.insert(
delivery_id,
CaptureInFlight {
event,
outcome: None,
},
);
}
return true;
}
}
false
}
pub(crate) fn complete(
&mut self,
registration_id: u64,
delivery_id: u64,
error: Option<String>,
) {
if self.active_registration != Some(registration_id) {
return;
}
if let Some(delivery) = self.in_flight.get_mut(&delivery_id) {
delivery.outcome = Some(error.map_or_else(|| Ok(()), Err));
}
}
pub(crate) fn completion_ready(&self, delivery_id: u64) -> bool {
self.in_flight
.get(&delivery_id)
.is_some_and(|delivery| delivery.outcome.is_some())
}
pub(crate) fn take_completion(&mut self, delivery_id: u64) -> Option<Result<(), String>> {
let ready = self.completion_ready(delivery_id);
ready
.then(|| self.in_flight.remove(&delivery_id))
.flatten()
.and_then(|delivery| delivery.outcome)
}
pub(crate) fn delivery_pending(&self, delivery_id: u64) -> bool {
self.pending
.iter()
.any(|event| event.payload.delivery_id == delivery_id)
}
pub(crate) fn cancel_unreceived_delivery(
&mut self,
delivery_id: u64,
) -> CaptureReceiptCancellation {
if self.in_flight.contains_key(&delivery_id) {
return CaptureReceiptCancellation::Received;
}
let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
else {
return CaptureReceiptCancellation::Missing;
};
self.pending
.remove(index)
.map_or(CaptureReceiptCancellation::Missing, CaptureReceiptCancellation::Cancelled)
}
pub(crate) fn cancel_delivery(&mut self, delivery_id: u64) -> Option<CaptureEvent> {
if let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
{
return self.pending.remove(index);
}
self.in_flight
.remove(&delivery_id)
.map(|delivery| delivery.event)
}
pub(crate) fn take_completion_or_cancel(
&mut self,
delivery_id: u64,
) -> CaptureAcceptanceTimeout {
if let Some(completion) = self.take_completion(delivery_id) {
CaptureAcceptanceTimeout::Completed(completion)
} else {
self.cancel_delivery(delivery_id).map_or(
CaptureAcceptanceTimeout::Missing,
CaptureAcceptanceTimeout::Cancelled,
)
}
}
@@ -189,6 +313,7 @@ pub(crate) struct DictationCapturePayload {
pub(crate) struct CaptureEvent {
pub(crate) name: &'static str,
pub(crate) payload: DictationCapturePayload,
await_result: bool,
}
pub struct TrayHandle {
@@ -205,10 +330,22 @@ fn dictation_capture_event(action: &str, dictating: bool) -> &'static str {
}
pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut);
let _ = dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut, false);
}
fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin: CaptureOrigin) {
pub(crate) fn request_dictation_capture_delivery(
app: &tauri::AppHandle,
action: &str,
) -> Option<u64> {
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut, true)
}
fn dispatch_dictation_capture_from(
app: &tauri::AppHandle,
action: &str,
origin: CaptureOrigin,
await_result: bool,
) -> Option<u64> {
let flags = app.state::<AppFlags>();
let event = dictation_capture_event(action, flags.dictating.load(Ordering::SeqCst));
let session_id = if event == "tray-dictate" {
@@ -217,8 +354,21 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
session_id
} else {
log::warn!("Dictation capture '{action}' ignored — no active output session");
return;
return None;
};
// The recorder lives in the widget WebView. WebKit can suspend that
// document while its window is hidden, so an event cannot be relied on to
// wake the very listener that must receive it. Preserve the output target
// first, then show the non-activating pill before enqueueing/emitting the
// start event. The widget's idle reconcile hides it again if capture is
// disabled or startup exits early.
if event == "tray-dictate" {
if let Err(error) = commands::show_dictation_pill(app.clone()) {
log::warn!("Dictation capture '{action}' could not wake the capture window: {error}");
}
}
let capture_event = CaptureEvent {
name: event,
payload: DictationCapturePayload {
@@ -226,12 +376,15 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
delivery_id: 0,
registration_id: 0,
},
await_result,
};
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
return None;
};
if let Some(capture_event) = capture.enqueue(capture_event) {
let enqueued = capture.enqueue(capture_event);
let delivery_id = enqueued.delivery_id;
if let Some(capture_event) = enqueued.event {
drop(capture);
// A press that reaches Rust but produces no recording is otherwise
// indistinguishable from one the compositor never delivered, so say
@@ -246,12 +399,14 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
"Dictation capture '{action}' queued — the capture window has not registered yet"
);
}
Some(delivery_id)
}
#[cfg(test)]
mod dictation_capture_tests {
use super::{
dictation_capture_event, CaptureDispatchState, CaptureEvent, DictationCapturePayload,
dictation_capture_event, CaptureAcceptanceTimeout, CaptureDispatchState, CaptureEvent,
CaptureReceiptCancellation, DictationCapturePayload,
};
fn capture_event(name: &'static str) -> CaptureEvent {
@@ -262,6 +417,7 @@ mod dictation_capture_tests {
delivery_id: 0,
registration_id: 0,
},
await_result: false,
}
}
@@ -295,10 +451,108 @@ mod dictation_capture_tests {
assert_eq!(retried[0].payload.delivery_id, delivery_id);
assert_eq!(retried[0].payload.registration_id, current);
state.acknowledge(stale, delivery_id);
assert!(!state.acknowledge(stale, delivery_id));
assert_eq!(state.pending.len(), 1);
state.acknowledge(current, delivery_id);
assert!(state.delivery_pending(delivery_id));
assert!(state.acknowledge(current, delivery_id));
assert!(state.pending.is_empty());
assert!(!state.delivery_pending(delivery_id));
}
#[test]
fn timed_out_delivery_can_be_cancelled_without_touching_others() {
let mut state = CaptureDispatchState::default();
state.enqueue(capture_event("tray-dictate"));
state.enqueue(capture_event("tray-dictate-stop"));
let first_id = state.pending[0].payload.delivery_id;
let second_id = state.pending[1].payload.delivery_id;
let cancelled = state.cancel_delivery(first_id).expect("delivery exists");
assert_eq!(cancelled.payload.session_id, 7);
assert!(!state.delivery_pending(first_id));
assert!(state.delivery_pending(second_id));
}
#[test]
fn awaited_delivery_preserves_frontend_rejection_for_the_requester() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let delivery_id = state.enqueue(event).delivery_id;
assert!(state.acknowledge(registration_id, delivery_id));
assert!(!state.completion_ready(delivery_id));
state.complete(
registration_id,
delivery_id,
Some("Dictation is disabled".into()),
);
assert!(state.completion_ready(delivery_id));
assert_eq!(
state.take_completion(delivery_id),
Some(Err("Dictation is disabled".into()))
);
assert_eq!(state.take_completion(delivery_id), None);
}
#[test]
fn cancellation_suppresses_an_event_cloned_for_ready_emission() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let enqueued = state.enqueue(event);
let emitted = enqueued.event.expect("ready event was cloned");
assert!(matches!(
state.cancel_unreceived_delivery(enqueued.delivery_id),
CaptureReceiptCancellation::Cancelled(_)
));
assert!(!state.acknowledge(
emitted.payload.registration_id,
emitted.payload.delivery_id
));
}
#[test]
fn listener_receipt_wins_atomically_over_timeout_cancellation() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let delivery_id = state.enqueue(event).delivery_id;
assert!(state.acknowledge(registration_id, delivery_id));
assert!(matches!(
state.cancel_unreceived_delivery(delivery_id),
CaptureReceiptCancellation::Received
));
}
#[test]
fn completion_at_the_timeout_boundary_wins_over_cancellation() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let delivery_id = state.enqueue(event).delivery_id;
state.acknowledge(registration_id, delivery_id);
state.complete(registration_id, delivery_id, None);
assert!(matches!(
state.take_completion_or_cancel(delivery_id),
CaptureAcceptanceTimeout::Completed(Ok(()))
));
assert!(matches!(
state.take_completion_or_cancel(delivery_id),
CaptureAcceptanceTimeout::Missing
));
}
#[test]
@@ -611,6 +865,90 @@ pub fn shutdown_backend_for_exit<R: tauri::Runtime>(app_handle: &tauri::AppHandl
}
}
/// Show, unminimize and focus the main window. Shared by the tray's "Show
/// VoiceStudio" menu item and the macOS `RunEvent::Reopen` handler below (Dock
/// icon clicked while the main window is hidden), so the two recovery paths
/// behave identically instead of drifting apart over time.
fn show_and_focus_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
#[cfg(not(target_os = "macos"))]
let _ = win.set_skip_taskbar(false);
let _ = win.unminimize();
let _ = win.set_focus();
// Self-recovery: if the webview failed to load the dev/prod URL
// earlier (Vite restarted, backend not up yet at first show, etc.)
// the window shows a blank `<body></body>` with a "Could not connect
// to the server" console error. Reload only when the body is empty
// so a healthy window doesn't blink on every show.
let _ = win.eval(
"if (document.body && document.body.childElementCount === 0) { location.reload(); }",
);
}
}
/// Whether a macOS `RunEvent::Reopen` (Dock icon clicked — Cocoa's
/// `applicationShouldHandleReopen:hasVisibleWindows:`) should restore the
/// main window. Pure so it's unit-testable — the actual event only fires
/// inside the real Cocoa event loop and can't be synthesized under
/// `cargo test` (see the `with_noactivate_style` comment above for the same
/// rationale). `CloseRequested` (see `on_window_event` below) hides the main
/// window rather than destroying it, so it is merely invisible once the user
/// has closed it — exactly when the Dock icon should bring it back.
///
/// Keyed on the MAIN window specifically, not on Cocoa's `has_visible_windows`
/// flag. This app owns a second window: the always-on-top dictation pill
/// (`widget`, built below), which is shown and hidden independently and can
/// sit on screen for a long time on its own — the Accessibility-setup state
/// persists until the permission is granted. Keying on "any window visible"
/// would report `true` from the pill alone and leave the Dock icon dead in
/// precisely the case this handler exists to fix.
///
/// Only called from the macOS-gated `RunEvent::Reopen` arm below outside of
/// tests — `#[allow(dead_code)]` elsewhere, same treatment as `is_app_origin`
/// and `with_noactivate_style` above.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn should_restore_on_reopen(main_window_visible: bool, _cocoa_has_visible_windows: bool) -> bool {
// Cocoa's aggregate flag is accepted and deliberately ignored. Taking it
// as a parameter rather than dropping it at the call site is what lets
// the tests below pin the contract: `(main: false, cocoa: true)` — the
// pill up, the main window closed — must still restore. An earlier
// revision decided on the aggregate alone and left the Dock icon dead in
// exactly that state.
!main_window_visible
}
#[cfg(test)]
mod reopen_tests {
use super::should_restore_on_reopen;
#[test]
fn restores_when_the_main_window_is_hidden() {
assert!(should_restore_on_reopen(false, false));
}
#[test]
fn does_nothing_when_the_main_window_is_already_visible() {
assert!(!should_restore_on_reopen(true, true));
}
/// Regression guard: the dictation pill is a separate always-on-top
/// window that can be visible while the main window is closed — the
/// Accessibility-setup state stays up until the permission is granted.
/// An earlier revision keyed this decision on Cocoa's
/// `has_visible_windows`, which the pill alone sets to `true`, leaving
/// the Dock icon dead in exactly the situation this handler is for.
/// The decision must depend only on the main window.
#[test]
fn restores_even_when_another_window_such_as_the_pill_is_visible() {
// Cocoa reports a visible window (the pill) while the main window is
// hidden. Passing both values separately is the point: this case is
// what distinguishes the main-window rule from the aggregate one, and
// it fails if the body ever goes back to `!cocoa_has_visible_windows`.
assert!(should_restore_on_reopen(false, true));
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// #879: if the previous run requested a WebView cache repair (splash
@@ -727,6 +1065,7 @@ pub fn run() {
commands::begin_dictation_capture_registration,
commands::mark_dictation_capture_ready,
commands::acknowledge_dictation_capture_delivery,
commands::complete_dictation_capture_delivery,
commands::end_dictation_capture_registration,
commands::show_dictation_pill,
commands::get_launch_as_widget,
@@ -879,12 +1218,8 @@ pub fn run() {
match event.state {
ShortcutState::Pressed => {
log::info!("Global shortcut pressed: dictation start");
// The widget window stays hidden until the
// capture itself reaches a state worth
// showing — the widget calls
// `show_dictation_pill` then, so a press
// that bails early never strands an empty
// capsule on the desktop.
// Dispatch preserves the focused target,
// wakes the recorder WebView, then emits.
dispatch_dictation_capture(app_handle, "start");
}
ShortcutState::Released => {
@@ -983,23 +1318,7 @@ pub fn run() {
.on_menu_event(move |app, event| {
match event.id().as_ref() {
"show" => {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
#[cfg(not(target_os = "macos"))]
let _ = win.set_skip_taskbar(false);
let _ = win.set_focus();
// Self-recovery: if the webview failed to load
// the dev/prod URL earlier (Vite restarted,
// backend not up yet at first show, etc.) the
// window shows a blank `<body></body>` with a
// "Could not connect to the server" console
// error. Reload only when the body is empty
// so a healthy window doesn't blink on every
// tray click.
let _ = win.eval(
"if (document.body && document.body.childElementCount === 0) { location.reload(); }",
);
}
show_and_focus_main_window(app);
}
"open_studio" => {
// Persist the preference (so next launch is studio, not pill)
@@ -1034,9 +1353,19 @@ pub fn run() {
// current by the frontend's existing
// `set_tray_recording` call on every start and stop.
if app.state::<AppFlags>().dictating.load(Ordering::SeqCst) {
dispatch_dictation_capture_from(app, "stop", CaptureOrigin::Tray);
let _ = dispatch_dictation_capture_from(
app,
"stop",
CaptureOrigin::Tray,
false,
);
} else {
dispatch_dictation_capture_from(app, "start", CaptureOrigin::Tray);
let _ = dispatch_dictation_capture_from(
app,
"start",
CaptureOrigin::Tray,
false,
);
}
}
"settings" => {
@@ -1193,12 +1522,41 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("error while building tauri application");
app.run(|app_handle, event| {
if let tauri::RunEvent::ExitRequested { code, api, .. } = event {
app.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { code, api, .. } => {
if !persistence_exit::handle_exit_requested(app_handle, code, &api) {
return;
}
shutdown_backend_for_exit(app_handle);
}
// macOS: clicking the Dock icon while the app has no visible windows
// fires this (instead of relaunching) via Cocoa's
// `applicationShouldHandleReopen:hasVisibleWindows:`. CloseRequested
// (see `on_window_event` above) hides the main window rather than
// destroying it, so without this arm the click did nothing — the
// process stayed alive with a live Dock icon and the only way back
// was the tray's "Show VoiceStudio" item. `show_and_focus_main_window`
// is the same sequence that item runs, so both paths behave
// identically.
#[cfg(target_os = "macos")]
tauri::RunEvent::Reopen {
has_visible_windows,
..
} => {
// Cocoa's `has_visible_windows` is deliberately NOT used: the
// dictation pill is a separate always-on-top window that sets it
// to `true` on its own. Ask the main window directly instead.
// `is_visible()` errors only if the window has gone away, and a
// redundant show is harmless next to a Dock icon that stays dead,
// so treat an error as "not visible" and restore.
let main_visible = app_handle
.get_webview_window("main")
.map(|win| win.is_visible().unwrap_or(false))
.unwrap_or(false);
if should_restore_on_reopen(main_visible, has_visible_windows) {
show_and_focus_main_window(app_handle);
}
}
_ => {}
});
}
+2
View File
@@ -399,6 +399,7 @@ function App() {
setPendingTrimFile,
isGenerating,
generationTime,
generationProgress,
textAreaRef,
ingestRefAudio,
insertTag,
@@ -1748,6 +1749,7 @@ function App() {
setVdStates={setVdStates}
isGenerating={isGenerating}
generationTime={generationTime}
generationProgress={generationProgress}
applyPreset={applyPreset}
insertTag={insertTag}
handleSelectProfile={handleSelectProfile}
+187 -58
View File
@@ -120,6 +120,17 @@ const STEPS = [
'starting_backend',
];
// Stages that only occur when there is actual first-run/repair work to do.
// `awaiting_setup` renders its own screen (FirstRunSetup) rather than the
// step list below, but it still counts as "install work observed" (#1894):
// reaching it means Rust found no venv and is about to do real work, so the
// journey chrome should already be armed by the time the step list appears.
const INSTALL_STAGES = ['downloading_uv', 'creating_venv', 'installing_deps', 'awaiting_setup'];
// Stages the bootstrap restarts *from*. Arriving at one of these from
// anywhere else means a new attempt began (Retry, or a Rust-side restart).
const RESTART_STAGES = new Set(['checking', 'awaiting_setup']);
const MAX_LOG_LINES = 200;
/** Scan logs + error message for known failure patterns and return i18n keys
@@ -445,22 +456,71 @@ export function BootstrapSplash({ stage, message }) {
const [progress, setProgress] = useState(null);
const [region, setRegionState] = useState('auto');
const [retrying, setRetrying] = useState(false);
// Stages actually seen during the CURRENT bootstrap attempt (see the
// tracking effect below). Drives "done" ticks and journey visibility off
// observed reality instead of list position (#1894).
const [polledStages, setPolledStages] = useState(() => new Set([stage]));
// Wall-clock start of the current attempt. A retry restarts the bootstrap;
// log lines from the previous attempt must not count toward this one.
const [attemptStart, setAttemptStart] = useState(0);
const logRef = useRef(null);
const prevStageRef = useRef(stage); // previous stage, for restart detection
// True between a retry WE initiated and the poll catching up to it, so the
// fallback effect below doesn't re-stamp an already-exact attempt boundary.
const selfInitiatedRef = useRef(false);
const prevProgRef = useRef(null); // {bytes, t} — last progress event
const rateRef = useRef(0); // EMA bytes/sec across events
// The union of what we polled and what actually logged.
//
// Neither source alone is complete. `bootstrap_status` is sampled ~1/s, so
// a stage that starts and finishes between samples is never polled — on a
// fast disk `creating_venv` routinely does. Stage-tagged `bootstrap-log`
// lines close that gap: the Rust side emits them as the work happens, so a
// line tagged with a stage is proof that stage ran, whether or not the
// poll ever saw it. Lines are filtered to the current attempt so a retry
// cannot inherit the previous one's evidence (the visible log is left
// alone — clearing it on a Rust-side restart would destroy the user's
// context, cf. #1847).
const observedStages = useMemo(() => {
const seen = new Set(polledStages);
for (const entry of logs) {
if (entry?.stage && (entry.t ?? 0) >= attemptStart) seen.add(entry.stage);
}
return seen;
}, [polledStages, logs, attemptStart]);
const label = t(`bootstrap.${stage}`, STAGE_LABEL[stage]);
const stepIndex = Math.max(0, STEPS.indexOf(stage));
const isFailed = stage === 'failed';
// Retrying an Intel-Mac install can never succeed — don't offer the dead end.
const isUnrecoverable = isFailed && isUnrecoverableFailure(message, logs);
// True once any genuine install stage has been observed this session. On a
// warm start the Rust stage jumps straight from `checking` to
// `starting_backend` — nothing here ever fires — so the first-run install
// chrome (journey rail, "Installing" heading, step list) stays suppressed
// instead of fabricating completed work (#1894).
const installWorkSeen = INSTALL_STAGES.some((s) => observedStages.has(s));
// Open a new bootstrap attempt. Called at the moment a retry is INITIATED,
// not when the ~1s status poll later reports `checking`: the Rust side
// starts emitting logs for the new attempt immediately, and a boundary
// stamped at detection time would sit *after* those lines and discard them
// as belonging to the old attempt — losing exactly the fast-stage evidence
// the log union exists to capture.
const beginAttempt = () => {
selfInitiatedRef.current = true;
setLogs([]);
setPolledStages(new Set());
setAttemptStart(Date.now());
};
const handleRetry = async () => {
if (retrying) return;
setRetrying(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
setLogs([]);
beginAttempt();
await invoke('retry_bootstrap');
} catch (e) {
console.error('retry failed', e);
@@ -475,7 +535,7 @@ export function BootstrapSplash({ stage, message }) {
setRetrying(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
setLogs([]);
beginAttempt();
await invoke('clean_and_retry_bootstrap');
} catch (e) {
console.error('clean retry failed', e);
@@ -484,6 +544,57 @@ export function BootstrapSplash({ stage, message }) {
}
};
// Record `stage` as observed the moment it's seen, and reset when a new
// attempt begins.
//
// Sticky WITHIN an attempt: the functional updater bails out (same Set
// reference) once a stage is recorded, so it never un-observes and never
// loops. But NOT across attempts — a Retry restarts the bootstrap from
// `checking`, and what the previous attempt did says nothing about what
// this one will do. Without the reset, stages the new attempt skips would
// still render as completed, which is the very fabrication this change
// exists to remove.
//
// Retries we initiate call beginAttempt() directly, so their boundary is
// exact. This effect is the FALLBACK for a restart begun on the Rust side,
// where the stage poll is the only signal we get. There the boundary can
// land up to one poll interval late, and log lines from the new attempt in
// that window are discarded rather than counted. That is the deliberate
// direction to fail in: a discarded line can leave a step showing pending
// (conservative, and honest), whereas counting a stale line would render a
// step DONE for work this attempt never did — the bug this change exists to
// fix. Closing the window entirely needs a Rust-provided attempt id, which
// would be a new IPC surface and is deliberately out of scope here.
useEffect(() => {
const prev = prevStageRef.current;
prevStageRef.current = stage;
// Two ways a new attempt shows up in the poll. Arriving at a restart
// stage is the common one. But the poll can also miss the restart stage
// entirely — `failed` -> (retry) -> `checking` -> `starting_backend`
// inside one ~1s sample window surfaces as `failed` -> `starting_backend`,
// and keying only off restart stages would leave the FAILED attempt's
// evidence in place and render its install chrome as this attempt's
// completed work. Leaving `failed` at all means a retry began, since
// retry_bootstrap/clean_and_retry_bootstrap are the only exits from it.
const restarted =
(RESTART_STAGES.has(stage) && !RESTART_STAGES.has(prev)) ||
(prev === 'failed' && stage !== 'failed');
if (restarted) {
if (selfInitiatedRef.current) {
// beginAttempt() already opened this attempt with an exact boundary.
// Re-stamping it here — a poll interval later — would discard the
// new attempt's own early log lines, which is the bug this guard
// exists to prevent.
selfInitiatedRef.current = false;
} else {
setAttemptStart(Date.now());
}
setPolledStages(new Set([stage]));
return;
}
setPolledStages((p) => (p.has(stage) ? p : new Set(p).add(stage)));
}, [stage]);
// Load persisted region on mount.
useEffect(() => {
if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return;
@@ -638,7 +749,10 @@ export function BootstrapSplash({ stage, message }) {
data-tauri-drag-region
>
<Waveform />
<JourneyRail t={t} />
{/* Suppressed until real install work is observed — otherwise a
warm start (or a repair sync) shows "Setup done / Installing
active" for work that never happened (#1894). */}
{installWorkSeen && <JourneyRail t={t} />}
<div className="mt-2 flex flex-wrap items-end justify-between gap-6">
<div className="min-w-0">
{/* Version rides beside the app name — same masthead across all
@@ -760,9 +874,16 @@ export function BootstrapSplash({ stage, message }) {
</section>
) : (
<section className="fr-rise flex flex-col gap-2.5" style={{ '--rise': 1 }}>
<h2 className="m-0 font-mono text-[0.62rem] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{t('firstrun.installing_title', 'Installing')}
</h2>
{/* Heading, step list and resume note only make sense once real
install work has actually been observed — otherwise a warm
start or a repair sync narrates a first-run install that
never happened (#1894). The live stage label in the masthead
and the progress meter below stay visible either way. */}
{installWorkSeen && (
<h2 className="m-0 font-mono text-[0.62rem] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{t('firstrun.installing_title', 'Installing')}
</h2>
)}
{/* Overall journey meter. */}
<Progress
value={overallPct}
@@ -770,61 +891,69 @@ export function BootstrapSplash({ stage, message }) {
size="md"
aria-valuenow={Math.round(overallPct)}
/>
<ol className="m-0 mt-1 flex list-none flex-col gap-2 p-0">
{STEPS.map((s, i) => {
const done = i < stepIndex;
const activeStep = i === stepIndex;
return (
<li
key={s}
className={cn(
'flex min-w-0 items-center gap-2 text-sm',
!done && !activeStep && 'opacity-45',
)}
>
<span
{installWorkSeen && (
<ol className="m-0 mt-1 flex list-none flex-col gap-2 p-0">
{STEPS.map((s, i) => {
const activeStep = i === stepIndex;
// Done only if this stage was actually observed AND it isn't
// the one currently in progress — list POSITION alone lies on
// a warm start or a repair sync, where earlier stages in the
// fixed STEPS order are skipped by Rust entirely (#1894).
const done = !activeStep && observedStages.has(s);
return (
<li
key={s}
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
done
? 'bg-success shadow-[0_0_5px_1px_color-mix(in_srgb,var(--color-success)_50%,transparent)]'
: activeStep
? 'bg-primary shadow-[0_0_6px_1px_var(--color-brand-glow)] fr-pulse'
: 'bg-fg-subtle/40',
'flex min-w-0 items-center gap-2 text-sm',
!done && !activeStep && 'opacity-45',
)}
aria-hidden="true"
/>
<span className={cn(activeStep && 'font-semibold', done && 'text-fg-muted')}>
{t(`bootstrap.${s}`, STAGE_LABEL[s])}
</span>
{activeStep && stageProgress && (
<span className="ml-auto whitespace-nowrap font-mono text-[0.64rem] tabular-nums text-fg-muted">
{formatBytes(stageProgress.bytes_done)}
{stageProgress.bytes_total > 0
? ` / ${formatBytes(stageProgress.bytes_total)}`
: ''}
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
{stageProgress.bytes_total > 0 &&
rateRef.current > 0 &&
stageProgress.bytes_done < stageProgress.bytes_total &&
` · ${t('firstrun.eta_left', {
eta: formatEta(
(stageProgress.bytes_total - stageProgress.bytes_done) /
rateRef.current,
),
defaultValue: '~{{eta}} left',
})}`}
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
done
? 'bg-success shadow-[0_0_5px_1px_color-mix(in_srgb,var(--color-success)_50%,transparent)]'
: activeStep
? 'bg-primary shadow-[0_0_6px_1px_var(--color-brand-glow)] fr-pulse'
: 'bg-fg-subtle/40',
)}
aria-hidden="true"
/>
<span className={cn(activeStep && 'font-semibold', done && 'text-fg-muted')}>
{t(`bootstrap.${s}`, STAGE_LABEL[s])}
</span>
)}
</li>
);
})}
</ol>
<p className="m-0 text-xs text-fg-subtle">
{t(
'firstrun.resume_note',
'Interrupted downloads resume automatically — closing the app is safe.',
)}
</p>
{activeStep && stageProgress && (
<span className="ml-auto whitespace-nowrap font-mono text-[0.64rem] tabular-nums text-fg-muted">
{formatBytes(stageProgress.bytes_done)}
{stageProgress.bytes_total > 0
? ` / ${formatBytes(stageProgress.bytes_total)}`
: ''}
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
{stageProgress.bytes_total > 0 &&
rateRef.current > 0 &&
stageProgress.bytes_done < stageProgress.bytes_total &&
` · ${t('firstrun.eta_left', {
eta: formatEta(
(stageProgress.bytes_total - stageProgress.bytes_done) /
rateRef.current,
),
defaultValue: '~{{eta}} left',
})}`}
</span>
)}
</li>
);
})}
</ol>
)}
{installWorkSeen && (
<p className="m-0 text-xs text-fg-subtle">
{t(
'firstrun.resume_note',
'Interrupted downloads resume automatically — closing the app is safe.',
)}
</p>
)}
</section>
)}
+85 -17
View File
@@ -711,13 +711,31 @@ export default function CaptureWidget({ onDismiss }) {
const eventRegistrationId = event?.payload?.registrationId;
if (eventRegistrationId != null && eventRegistrationId !== registrationId) return false;
if (deliveryId != null) {
void tauriInvoke('acknowledge_dictation_capture_delivery', {
return tauriInvoke('acknowledge_dictation_capture_delivery', {
registrationId,
deliveryId,
}).catch((err) => console.warn('dictation delivery acknowledgement failed:', err));
})
.catch((err) => {
console.warn('dictation delivery acknowledgement failed:', err);
return false;
})
.then((acknowledged) => acknowledged !== false);
}
return true;
};
const completeDelivery = async (event, error = null) => {
const deliveryId = event?.payload?.deliveryId;
if (deliveryId == null) return;
try {
await tauriInvoke('complete_dictation_capture_delivery', {
registrationId,
deliveryId,
error,
});
} catch (err) {
console.warn('dictation delivery completion failed:', err);
}
};
(async () => {
try {
registrationId = await tauriInvoke('begin_dictation_capture_registration');
@@ -727,13 +745,20 @@ export default function CaptureWidget({ onDismiss }) {
}
const { listen } = await import('@tauri-apps/api/event');
unlistenStart = await listen('tray-dictate', async (event) => {
if (!acknowledgeDelivery(event)) return;
let acknowledgement = acknowledgeDelivery(event);
if (acknowledgement === false) return;
if (acknowledgement !== true) acknowledgement = await acknowledgement;
if (!acknowledgement) return;
const now = Date.now();
if (now - nativeEventAtRef.current.start < 150) return;
if (now - nativeEventAtRef.current.start < 150) {
await completeDelivery(event, 'Duplicate dictation start ignored');
return;
}
nativeEventAtRef.current.start = now;
const sessionId = event?.payload?.sessionId;
if (!sessionId) {
hideWidgetWindow();
await completeDelivery(event, 'Dictation output session is missing');
return;
}
await ensureDictationPrefsHydrated();
@@ -741,7 +766,8 @@ export default function CaptureWidget({ onDismiss }) {
// The hotkey is inert, but Rust has already shown the window.
// Put it back rather than leaving an empty capsule on screen.
hideWidgetWindow();
finishOutputSession(sessionId);
await finishOutputSession(sessionId);
await completeDelivery(event, 'Dictation is disabled');
return;
}
const sequence = ++nativeStartSequenceRef.current;
@@ -757,6 +783,7 @@ export default function CaptureWidget({ onDismiss }) {
} catch (err) {
console.warn('reject dictation output session failed:', err);
}
await completeDelivery(event, 'Dictation is already active');
return;
}
const trackHold = modeRef.current === 'hold';
@@ -781,11 +808,13 @@ export default function CaptureWidget({ onDismiss }) {
clearPendingHold();
await finishOutputSession(sessionId);
hideWidgetWindow();
await completeDelivery(event, `Could not activate dictation output: ${err}`);
return;
}
if (cancelled || sequence !== nativeStartSequenceRef.current || !enabledRef.current) {
clearPendingHold();
await finishOutputSession(sessionId);
await completeDelivery(event, 'Dictation start was cancelled');
return;
}
if (startupWasInFlight) {
@@ -793,8 +822,10 @@ export default function CaptureWidget({ onDismiss }) {
if (startInFlightRef.current) {
outputSessionIdRef.current = sessionId;
pendingNativeStartRef.current = { sessionId, trackHold, sequence };
await completeDelivery(event, 'Another dictation start is already in progress');
} else if (current === 'recording' || current === 'transcribing') {
outputSessionIdRef.current = sessionId;
await completeDelivery(event);
} else if (
current === 'idle' ||
current === 'done' ||
@@ -802,10 +833,14 @@ export default function CaptureWidget({ onDismiss }) {
current === 'setup'
) {
outputSessionIdRef.current = sessionId;
startRecordingRef.current?.(trackHold, sessionId);
void Promise.resolve(startRecordingRef.current?.(trackHold, sessionId)).then(
(accepted) =>
completeDelivery(event, accepted ? null : 'Dictation could not start'),
);
} else {
clearPendingHold();
await finishOutputSession(sessionId);
await completeDelivery(event, 'Dictation could not accept this start');
}
return;
}
@@ -814,34 +849,66 @@ export default function CaptureWidget({ onDismiss }) {
// in System Settings. A missing grant no longer blocks capture:
// native delivery can truthfully fall back to clipboard-only.
outputSessionIdRef.current = sessionId;
checkAccessibility().then(() => {
if (outputSessionIdRef.current !== sessionId) return;
startRecordingRef.current?.(modeRef.current === 'hold', sessionId);
void checkAccessibility().then(async () => {
if (outputSessionIdRef.current !== sessionId) {
await completeDelivery(event, 'Dictation output session changed before startup');
return;
}
const accepted = await startRecordingRef.current?.(
modeRef.current === 'hold',
sessionId,
);
await completeDelivery(event, accepted ? null : 'Dictation could not start');
});
return;
}
const idle = s === 'idle' || s === 'done' || s === 'error';
if (modeRef.current === 'toggle') {
// Press once to start, again to stop.
if (idle) startRecordingRef.current?.(false, sessionId);
else if (s === 'recording') stopRecordingRef.current?.();
if (idle) {
void Promise.resolve(startRecordingRef.current?.(false, sessionId)).then((accepted) =>
completeDelivery(event, accepted ? null : 'Dictation could not start'),
);
return;
} else if (s === 'recording') {
stopRecordingRef.current?.();
await completeDelivery(event);
return;
}
} else if (idle) {
// Hold mode: keydown → start.
startRecordingRef.current?.(true, sessionId);
void Promise.resolve(startRecordingRef.current?.(true, sessionId)).then((accepted) =>
completeDelivery(event, accepted ? null : 'Dictation could not start'),
);
return;
}
await completeDelivery(event, 'Dictation could not start');
});
unlistenStop = await listen('tray-dictate-stop', async (event) => {
if (!acknowledgeDelivery(event)) return;
let acknowledgement = acknowledgeDelivery(event);
if (acknowledgement === false) return;
if (acknowledgement !== true) acknowledgement = await acknowledgement;
if (!acknowledgement) return;
const now = Date.now();
if (now - nativeEventAtRef.current.stop < 150) return;
if (now - nativeEventAtRef.current.stop < 150) {
await completeDelivery(event, 'Duplicate dictation stop ignored');
return;
}
nativeEventAtRef.current.stop = now;
await ensureDictationPrefsHydrated();
// Only hold mode acts on release; toggle ignores it.
let accepted = false;
if (modeRef.current === 'hold' && stateRef.current === 'recording') {
stopRecordingRef.current?.();
accepted = true;
} else if (modeRef.current === 'hold' && holdStartRef.current === 'starting') {
holdStartRef.current = 'released';
accepted = true;
}
await completeDelivery(
event,
accepted ? null : 'Dictation is not recording in hold mode',
);
});
await ensureDictationPrefsHydrated();
if (cancelled) {
@@ -1856,11 +1923,11 @@ export default function CaptureWidget({ onDismiss }) {
// newer non-empty lease without launching a second microphone graph.
outputSessionIdRef.current = sessionId;
}
return;
return false;
}
if (inTauri() && !sessionId) {
hideWidgetWindow();
return;
return false;
}
startInFlightRef.current = true;
if (sessionId) outputSessionIdRef.current = sessionId;
@@ -1893,6 +1960,7 @@ export default function CaptureWidget({ onDismiss }) {
}
}
}
return stateRef.current === 'recording' || stateRef.current === 'transcribing';
},
[startRecordingImpl],
);
@@ -2176,7 +2244,7 @@ export default function CaptureWidget({ onDismiss }) {
<div className="min-w-0 flex-1 overflow-hidden">
<span
className="block overflow-hidden text-ellipsis whitespace-nowrap text-[12.5px] font-medium tracking-[0.01em]"
title={state === 'error' ? errorInfo?.message || undefined : undefined}
title={state === 'error' ? errorInfo?.message || label || undefined : label || undefined}
>
{emoji} {label}
</span>
@@ -1429,6 +1429,14 @@ describe('CaptureWidget', () => {
expect(screen.queryByText(/Listening/)).not.toBeInTheDocument();
});
it('exposes the full setup label as a title, since the 300px pill clips it', async () => {
mocks.holder.a11y = false;
render(withI18n(<CaptureWidget />));
const labelEl = await screen.findByText(/Allow Accessibility/);
expect(labelEl.title).toBe(i18n.t('capture.a11y_setup'));
});
it('clears the Accessibility setup pill after the native grant changes', async () => {
vi.useFakeTimers();
try {
+2 -6
View File
@@ -62,6 +62,7 @@ export default function ActionBar({
isGenerating,
handleGenerate,
generationTime,
generationProgress,
wasGeneratingRef,
}) {
return (
@@ -288,12 +289,7 @@ export default function ActionBar({
</Button>
)}
{isGenerating && (
<Progress
value={Math.min((generationTime / 8) * 100, 95)}
tone="brand"
size="sm"
className="mt-[6px]"
/>
<Progress value={generationProgress} tone="brand" size="sm" className="mt-[6px]" />
)}
{/* 10x P4 a11y (spec §3): persistent polite live region screen
readers hear generation start AND finish in-workspace, without
@@ -35,6 +35,7 @@ const baseProps = {
isGenerating: false,
handleGenerate: setter,
generationTime: 0,
generationProgress: null,
wasGeneratingRef: { current: false },
};
@@ -97,4 +98,40 @@ describe('ActionBar', () => {
fireEvent.click(screen.getByRole('button', { name: /clone.production_overrides/ }));
expect(screen.getByRole('slider', { name: 'clone.steps' })).toBeInTheDocument();
});
it('shows indeterminate progress instead of inventing a percentage from elapsed time', () => {
render(
<ActionBar
{...baseProps}
showOverrides={false}
setShowOverrides={setter}
isGenerating
generationTime="10.6"
generationProgress={null}
/>,
);
const progress = screen.getByRole('progressbar');
expect(progress).not.toHaveAttribute('aria-valuenow');
expect(progress).toHaveAttribute('data-state', 'indeterminate');
});
it('shows determinate progress only when the generation path reports it', () => {
render(
<ActionBar
{...baseProps}
showOverrides={false}
setShowOverrides={setter}
isGenerating
generationTime="10.6"
generationProgress={42}
/>,
);
const progress = screen.getByRole('progressbar');
expect(progress).toHaveAttribute('aria-valuenow', '42');
expect(progress.querySelector('[data-slot="progress-indicator"]')).toHaveStyle({
width: '42%',
});
});
});
+9 -6
View File
@@ -70,6 +70,9 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
const [pendingTrimFile, setPendingTrimFile] = useState(null);
const [isGenerating, setIsGenerating] = useState(false);
const [generationTime, setGenerationTime] = useState(0);
// Real 0100 progress when the active delivery path can measure it.
// null means the backend has not supplied a meaningful fraction yet.
const [generationProgress, setGenerationProgress] = useState(null);
const timerRef = useRef(null);
const textAreaRef = useRef(null);
@@ -126,13 +129,11 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
addBreadcrumb(`generate:start (${defineMethod})`);
setIsGenerating(true);
setGenerationTime(0);
setGenerationProgress(null);
const st = Date.now();
timerRef.current = setInterval(() => {
const elapsed = ((Date.now() - st) / 1000).toFixed(1);
setGenerationTime((prev) => {
const suffix = /\(\d+%\)$/.exec(String(prev))?.[0];
return suffix ? `${elapsed} ${suffix}` : elapsed;
});
setGenerationTime(elapsed);
}, 100);
let abortTimer = null;
try {
@@ -292,8 +293,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
}
}
};
const setProgressPct = (pct) =>
setGenerationTime((prev) => `${prev.toString().split(' ')[0]} (${pct}%)`);
const setProgressPct = (pct) => setGenerationProgress(pct);
// Streaming preview (feat: streaming-tts-preview): playback starts from
// the FIRST synthesized chunk while the rest is still rendering, via
@@ -355,6 +355,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
'Streaming preview failed mid-stream; falling back to the classic generate:',
err?.message || err,
);
setGenerationProgress(null);
addBreadcrumb('generate:stream-fallback');
}
}
@@ -410,6 +411,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
if (abortTimer) clearTimeout(abortTimer);
clearInterval(timerRef.current);
setIsGenerating(false);
setGenerationProgress(null);
}
}, [
text,
@@ -444,6 +446,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
setPendingTrimFile,
isGenerating,
generationTime,
generationProgress,
textAreaRef,
ingestRefAudio,
insertTag,
+43
View File
@@ -3668,6 +3668,49 @@ html[data-ui-scale-engine='native'] .app-bootstrap-scale {
border-color: rgba(245, 158, 11, 0.35);
}
/* Widget-window shadow (standalone Tauri window)
The `widget` window (tauri.conf.json, label `widget`) is exactly
300x64, `transparent: true`, `decorations: false`. Its body gets a
flush `padding: 8px` on every edge (see `body:has(.capture-pill)`
below) around the 48px-tall pill, plus `overflow: hidden` so there
is only an 8px gutter on every side for anything painted outside the
pill's box before the clip kicks in.
The shadows above need real clearance (`0 8px 32px` alone wants ~40px)
that only the main window (`.capture-pill-host`, ~line 2236) has. Here
they were hard-clipped into a straight edge at the window boundary
a rounded capsule sitting inside a hard-edged dark rectangle instead of
floating free. The shadow is decorative, so shrink it to actually fit
rather than let it clip: every non-inset layer must keep
`|y-offset| + blur + spread <= 8` (the gutter), which bounds every
side since padding is symmetric and every offset-x here is 0. Applies
to every state that sets its own box-shadow, not just the base pill,
otherwise the clip reappears the moment recording/transcribing starts.
Also caps `max-width` to the 300px window's 284px content box
(300 - 8px padding * 2) the shared 340px value is wider than the
window itself. */
html[data-window='widget'] .capture-pill {
max-width: 284px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.35), /* 1 + 3 + 0 = 4 <= 8 */
0 1px 2px rgba(0, 0, 0, 0.25), /* 1 + 2 + 0 = 3 <= 8 */
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
html[data-window='widget'] .capture-pill--recording {
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.35), /* 1 + 3 + 0 = 4 <= 8 */
0 0 6px rgba(239, 68, 68, 0.1), /* 0 + 6 + 0 = 6 <= 8 */
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
html[data-window='widget'] .capture-pill--transcribing {
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.35), /* 1 + 3 + 0 = 4 <= 8 */
0 0 6px rgba(99, 102, 241, 0.1), /* 0 + 6 + 0 = 6 <= 8 */
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* ── Animations ─────────────────────────────────────────────────────── */
@keyframes pill-slide-in {
+2
View File
@@ -73,6 +73,7 @@ export default function CloneDesignTab(props) {
setVdStates,
isGenerating,
generationTime,
generationProgress,
applyPreset,
insertTag,
handleSaveProfile,
@@ -595,6 +596,7 @@ export default function CloneDesignTab(props) {
isGenerating={isGenerating}
handleGenerate={handleGenerate}
generationTime={generationTime}
generationProgress={generationProgress}
wasGeneratingRef={wasGeneratingRef}
/>
)}
@@ -96,6 +96,7 @@ function baseProps(overrides = {}) {
setVdStates: NOOP,
isGenerating: false,
generationTime: 0,
generationProgress: null,
applyPreset: NOOP,
insertTag: NOOP,
handleSaveProfile: NOOP,
@@ -121,6 +122,16 @@ function renderDesignTab(overrides = {}) {
}
describe('CloneDesignTab — Voice Design panel redesign regressions', () => {
it('forwards measurable generation progress to the action bar', () => {
renderDesignTab({ isGenerating: true, generationTime: '10.6', generationProgress: 42 });
const progress = screen.getByRole('progressbar');
expect(progress).toHaveAttribute('aria-valuenow', '42');
expect(progress.querySelector('[data-slot="progress-indicator"]')).toHaveStyle({
width: '42%',
});
});
it.each([
['recording is active', { isRecording: true }],
['microphone startup is pending', { isStartingRecording: true }],
+12 -5
View File
@@ -180,9 +180,11 @@ export default function TranscriptionsPage() {
</span>
</div>
<div className="txn-header__right flex items-center gap-[6px]">
<Button size="sm" variant="primary" onClick={startCapture}>
<Mic size={13} /> {t('transcriptions.capture')}
</Button>
{transcriptions.length > 0 && (
<Button size="sm" variant="primary" leading={<Mic size={13} />} onClick={startCapture}>
{t('transcriptions.capture')}
</Button>
)}
<div className="txn-search relative flex items-center">
<Search
size={13}
@@ -235,8 +237,13 @@ export default function TranscriptionsPage() {
{normalizedSearch ? t('transcriptions.empty_search_desc') : emptyDescription}
</p>
{!normalizedSearch && (
<Button size="sm" variant="primary" onClick={startCapture}>
<Mic size={13} /> {t('transcriptions.capture')}
<Button
size="sm"
variant="primary"
leading={<Mic size={13} />}
onClick={startCapture}
>
{t('transcriptions.capture')}
</Button>
)}
</div>
+16 -3
View File
@@ -34,14 +34,14 @@ describe('Transcriptions capture entry point', () => {
render(<TranscriptionsPage />);
expect(screen.getByText(/Super\+Shift\+V/)).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Start dictation' }).at(-1));
fireEvent.click(screen.getByRole('button', { name: 'Start dictation' }));
await waitFor(() => expect(requestDictationCapture).toHaveBeenCalledWith('start'));
});
it('reports a capture-controller failure', async () => {
requestDictationCapture.mockRejectedValueOnce(new Error('event channel unavailable'));
render(<TranscriptionsPage />);
fireEvent.click(screen.getAllByRole('button', { name: 'Start dictation' }).at(-1));
fireEvent.click(screen.getByRole('button', { name: 'Start dictation' }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
@@ -56,10 +56,23 @@ describe('Transcriptions capture entry point', () => {
target: { value: ' ' },
});
expect(screen.getAllByRole('button', { name: 'Start dictation' })).toHaveLength(2);
const button = screen.getByRole('button', { name: 'Start dictation' });
expect(button.querySelector(':scope > svg')).toBeInTheDocument();
expect(button.querySelector(':scope > span')).toHaveTextContent('Start dictation');
expect(screen.getByText('No transcriptions yet')).toBeInTheDocument();
});
it('moves the single capture action to the header once history exists', () => {
addTranscription({ text: 'Existing transcript.', language: 'en' });
render(<TranscriptionsPage />);
const button = screen.getByRole('button', { name: 'Start dictation' });
expect(button.closest('.txn-header__right')).toBeInTheDocument();
expect(button.querySelector(':scope > svg')).toBeInTheDocument();
expect(button.querySelector(':scope > span')).toHaveTextContent('Start dictation');
expect(screen.queryByText('No transcriptions yet')).not.toBeInTheDocument();
});
it('shows a successful transcript emitted by the shared recorder', async () => {
render(<TranscriptionsPage />);
act(() => {
@@ -0,0 +1,229 @@
/**
* Regression tests for #1894 the first-run INSTALLING journey was shown on
* every launch, with green ticks for work that never ran.
*
* `BootstrapSplash` used to derive "done" purely from `STEPS.indexOf(stage)`
* (BootstrapSplash.jsx:453/774 pre-fix): on a warm start Rust jumps straight
* from `checking` to `starting_backend` (bootstrap.rs finds the venv healthy
* and returns early), so `downloading_uv`, `creating_venv` and
* `installing_deps` including the "first run, 510 min." label all
* rendered with a green DONE tick for work that never happened. The same
* fabrication hit a repair sync (venv exists, only `installing_deps` runs,
* but `downloading_uv`/`creating_venv` still rendered done), and `JourneyRail`
* hardcoded Setup=done/Installing=active regardless of `stage`.
*
* The fix tracks which stages were actually observed and derives doneness +
* journey-chrome visibility from that instead of list position.
*
* Two follow-up findings from bot review on PR #1896 are covered at the end:
* - the ~1s `bootstrap_status` poll can miss a stage that starts and
* finishes between samples, so stage-tagged `bootstrap-log` lines are
* unioned in as independent proof a stage ran;
* - a Retry restarts the bootstrap, so stages observed during the previous
* attempt must not carry over and render as done in the new one.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
import { BootstrapSplash } from '../components/BootstrapSplash';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(async () => null),
}));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock('@tauri-apps/plugin-opener', () => ({
revealItemInDir: vi.fn(),
}));
beforeEach(() => {
// Not in a Tauri context: the log/progress subscription effects no-op.
delete window.__TAURI_INTERNALS__;
});
describe('BootstrapSplash — observed-stage tracking (#1894)', () => {
it('warm start (checking -> starting_backend): no fabricated done ticks, no "first run" chrome', () => {
const { rerender } = render(<BootstrapSplash stage="checking" message={null} />);
// The journey rail and "Installing" heading must not appear before any
// real install stage has ever been observed.
expect(screen.queryByText('Installing')).toBeNull();
expect(screen.queryByText('Setup')).toBeNull();
expect(screen.queryByText('Models & engines')).toBeNull();
rerender(<BootstrapSplash stage="starting_backend" message={null} />);
// Live stage label still shows this is not a blank screen.
expect(screen.getByText('Starting backend…')).toBeInTheDocument();
// The install journey never appeared: bootstrap.rs never entered any of
// downloading_uv/creating_venv/installing_deps on this run.
expect(screen.queryByText('Installing')).toBeNull();
expect(screen.queryByText('Downloading uv (Python package manager)…')).toBeNull();
expect(screen.queryByText('Creating Python virtual environment…')).toBeNull();
expect(screen.queryByText(/first run, 5.10 min/)).toBeNull();
});
it('repair sync (checking -> installing_deps): installing_deps active, earlier steps not fabricated done', () => {
const { rerender } = render(<BootstrapSplash stage="checking" message={null} />);
rerender(<BootstrapSplash stage="installing_deps" message={null} />);
// Real install work observed: the journey chrome comes back (both the
// JourneyRail item and the section heading render the same "Installing"
// string, so there are two matches).
expect(screen.getAllByText('Installing').length).toBeGreaterThan(0);
// The live stage label (masthead) and the step-list item both render the
// "first run, 5-10 min" text; scope to the step-list one inside the <ol>.
const activeLabel = screen.getAllByText(/first run, 5.10 min/).find((el) => el.closest('ol'));
expect(activeLabel).toBeInTheDocument();
// The active step renders semibold, not the muted "done" styling.
expect(activeLabel.className).toMatch(/font-semibold/);
expect(activeLabel.className).not.toMatch(/text-fg-muted/);
// downloading_uv/creating_venv were never entered by Rust on a repair
// sync they must render as pending, not done.
const uvStep = screen.getByText('Downloading uv (Python package manager)…');
const venvStep = screen.getByText('Creating Python virtual environment…');
expect(uvStep.className).not.toMatch(/text-fg-muted/);
expect(venvStep.className).not.toMatch(/text-fg-muted/);
});
it('genuine first run walks all five stages: every step is marked done as it passes (no regression)', () => {
const stages = [
'checking',
'downloading_uv',
'creating_venv',
'installing_deps',
'starting_backend',
];
const { rerender } = render(<BootstrapSplash stage={stages[0]} message={null} />);
for (let i = 1; i < stages.length; i += 1) {
rerender(<BootstrapSplash stage={stages[i]} message={null} />);
// Every stage strictly before the current one must show as done
// (muted styling), since this run genuinely walked through each one.
for (let j = 0; j < i; j += 1) {
const stepLabel = screen.getByText(
{
checking: 'Checking environment…',
downloading_uv: 'Downloading uv (Python package manager)…',
creating_venv: 'Creating Python virtual environment…',
installing_deps: /first run, 5.10 min/,
starting_backend: 'Starting backend…',
}[stages[j]],
);
expect(stepLabel.className).toMatch(/text-fg-muted/);
}
}
});
it('a stage the 1s poll never sampled still counts as done when its logs prove it ran', async () => {
// Greptile finding on #1896: `bootstrap_status` is sampled ~1/s, so on a
// fast disk `creating_venv` can start and finish between two samples and
// never be polled. Stage-tagged bootstrap logs are emitted as the work
// happens, so they are independent proof the stage ran.
window.__TAURI_INTERNALS__ = {};
const { invoke } = await import('@tauri-apps/api/core');
invoke.mockImplementation(async (cmd) =>
cmd === 'get_bootstrap_logs'
? [{ stage: 'creating_venv', line: 'Creating virtualenv at .venv' }]
: null,
);
// Poll sequence skips creating_venv entirely.
const { rerender } = render(<BootstrapSplash stage="downloading_uv" message={null} />);
rerender(<BootstrapSplash stage="installing_deps" message={null} />);
await waitFor(() => {
const venvStep = screen.getByText('Creating Python virtual environment…');
expect(venvStep.className).toMatch(/text-fg-muted/);
});
});
it('a retry drops stages observed during the previous attempt', () => {
// Greptile finding on #1896: the observed set was add-only and the splash
// stays mounted across a Retry, so a stage the FAILED attempt reached
// would still render done in the new attempt even if that attempt skips
// it. Arriving back at `checking` from elsewhere means a new attempt.
const { rerender } = render(<BootstrapSplash stage="checking" message={null} />);
rerender(<BootstrapSplash stage="downloading_uv" message={null} />);
rerender(<BootstrapSplash stage="installing_deps" message={null} />);
rerender(<BootstrapSplash stage="failed" message="uv sync failed" />);
// Retry: Rust goes back to `checking`, then this attempt finds the venv
// healthy and jumps straight to starting_backend.
rerender(<BootstrapSplash stage="checking" message={null} />);
rerender(<BootstrapSplash stage="starting_backend" message={null} />);
// Nothing from the previous attempt may be presented as this attempt's
// completed work so the install chrome is gone entirely again.
expect(screen.queryByText('Downloading uv (Python package manager)…')).toBeNull();
expect(screen.queryByText(/first run, 5.10 min/)).toBeNull();
expect(screen.getByText('Starting backend…')).toBeInTheDocument();
});
it('logs arriving after Retry but before the next poll are not discarded', async () => {
// Greptile finding on ece08bd7: the attempt boundary was stamped when the
// ~1s poll first reported `checking`, which lands AFTER the Rust side has
// already emitted the new attempt's first log lines so that evidence was
// filtered out as "previous attempt" and a fast stage missed by polling
// stayed pending. The boundary must open when the retry is initiated.
window.__TAURI_INTERNALS__ = {};
const { invoke } = await import('@tauri-apps/api/core');
const { listen } = await import('@tauri-apps/api/event');
const handlers = {};
listen.mockImplementation(async (name, cb) => {
handlers[name] = cb;
return () => {};
});
invoke.mockImplementation(async () => null);
const { rerender } = render(<BootstrapSplash stage="failed" message="uv sync failed" />);
await waitFor(() => expect(handlers['bootstrap-log']).toBeTypeOf('function'));
// User clicks Retry this opens the new attempt.
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /^Retry$/ }));
});
// Rust immediately emits the new attempt's logs, still before the poll
// has reported `checking`.
await act(async () => {
handlers['bootstrap-log']({
payload: { stage: 'creating_venv', line: 'Creating virtualenv at .venv' },
});
});
// Only now does the poll catch up, and it never samples creating_venv.
rerender(<BootstrapSplash stage="checking" message={null} />);
rerender(<BootstrapSplash stage="installing_deps" message={null} />);
// The log line proved creating_venv ran in THIS attempt; it must not have
// been discarded by a boundary stamped after it arrived.
await waitFor(() => {
const venvStep = screen.getByText('Creating Python virtual environment…');
expect(venvStep.className).toMatch(/text-fg-muted/);
});
});
it('a retry whose restart stage the poll never sampled still drops the old evidence', () => {
// CodeRabbit finding on 5e9538a0: a retry can go failed -> checking ->
// starting_backend inside one ~1s sample window, so the poll observes
// only failed -> starting_backend. Keying the reset solely off arriving
// at a restart stage would leave the FAILED attempt's stages in place and
// present them as this attempt's completed work.
const { rerender } = render(<BootstrapSplash stage="checking" message={null} />);
rerender(<BootstrapSplash stage="downloading_uv" message={null} />);
rerender(<BootstrapSplash stage="installing_deps" message={null} />);
rerender(<BootstrapSplash stage="failed" message="uv sync failed" />);
// Retry and the poll misses `checking` entirely.
rerender(<BootstrapSplash stage="starting_backend" message={null} />);
expect(screen.getByText('Starting backend…')).toBeInTheDocument();
// Nothing from the failed attempt may be shown as this attempt's work.
expect(screen.queryByText('Downloading uv (Python package manager)…')).toBeNull();
expect(screen.queryByText(/first run, 5.10 min/)).toBeNull();
expect(screen.queryByText('Installing')).toBeNull();
});
});
@@ -126,6 +126,27 @@ describe('CaptureWidget — mic permission pre-flight (Tauri)', () => {
window.__TAURI_INTERNALS__ = {};
});
// #1884: the pill clips its label, so every state that can clip needs a
// hover title. This covers the error branch that carries a message the
// title is the detailed message, which is strictly more than the clipped
// label shows. The no-message branch falls through to the label and is
// covered by the setup-state test in CaptureWidget.test.jsx.
it('uses the error detail as the title, not just the clipped label', async () => {
stubInvoke({ mic: 'denied' });
installGum(async () => {
throw notFound();
});
render(<CaptureWidget />);
pressShortcut();
const labelEl = await screen.findByText(/Mic access denied/);
// Assert the detail itself, not merely "some different string". This
// phrase lives only in `errorInfo.message` (capture.mic_hint_linux) and
// never in the label, so a regression that fell back to the label or
// any unrelated tooltip fails here.
expect(labelEl.title).toMatch(/audio group/);
});
it('OS-denied → guided error pill with Open Settings, getUserMedia never called', async () => {
stubInvoke({ mic: 'denied' });
const gum = installGum(async () => {
@@ -112,8 +112,11 @@ class FakeWS {
function pressShortcut() {
const handler = eventHandlers['tray-dictate'];
if (handler) handler({ payload: { sessionId: 'setup-race-session' } });
else eventState.pendingStart = true;
if (handler) {
handler({
payload: { sessionId: 'setup-race-session', deliveryId: 9, registrationId: 1 },
});
} else eventState.pendingStart = true;
}
let realWebSocket;
@@ -128,7 +131,7 @@ beforeEach(() => {
if (cmd === 'mark_dictation_capture_ready' && eventState.pendingStart) {
eventState.pendingStart = false;
return eventHandlers['tray-dictate']?.({
payload: { sessionId: 'setup-race-session' },
payload: { sessionId: 'setup-race-session', deliveryId: 9, registrationId: 1 },
});
}
return undefined;
@@ -136,6 +139,7 @@ beforeEach(() => {
eventState.pendingStart = false;
eventUnlisteners.length = 0;
FakeWS.instances = [];
storeState.dictationEnabled = true;
storeState.dictationModelId = 'sherpa-parakeet-v3';
realWebSocket = globalThis.WebSocket;
globalThis.WebSocket = FakeWS;
@@ -192,6 +196,74 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
});
});
it('does not start capture when native receipt acknowledgement fails', async () => {
invokeMock.mockImplementation(async (cmd) => {
if (cmd === 'begin_dictation_capture_registration') return 1;
if (cmd === 'check_microphone') return 'granted';
if (cmd === 'check_accessibility') return true;
if (cmd === 'acknowledge_dictation_capture_delivery') {
throw new Error('receipt state unavailable');
}
return undefined;
});
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
await eventHandlers['tray-dictate']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled();
expect(invokeMock).not.toHaveBeenCalledWith('activate_dictation_output_session', {
sessionId: 7,
});
expect(invokeMock).not.toHaveBeenCalledWith(
'complete_dictation_capture_delivery',
expect.anything(),
);
});
it('completes an in-page delivery only after microphone startup is accepted', async () => {
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
await eventHandlers['tray-dictate']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
expect(invokeMock).not.toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: null,
});
micControl.resolve(micStop);
await waitFor(() =>
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: null,
}),
);
});
it('rejects an in-page delivery when dictation is disabled', async () => {
storeState.dictationEnabled = false;
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
await eventHandlers['tray-dictate']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: 'Dictation is disabled',
});
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled();
});
it('turns a PCM-fallback socket failure into a terminal error', async () => {
storeState.dictationModelId = 'whisperx';
render(<CaptureWidget />);
@@ -238,6 +310,11 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
expect(screen.getByText(/No speech-to-text model/)).toBeInTheDocument();
expect(screen.queryByText(/Listening/)).not.toBeInTheDocument();
expect(invokeMock).not.toHaveBeenCalledWith('set_tray_recording', { recording: true });
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
error: 'Dictation could not start',
});
});
it('setup REJECTION after the terminal frame must not clobber it with a mic error', async () => {
+65 -1
View File
@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { renderHook, act, waitFor } from '@testing-library/react';
import useTTS from '../hooks/useTTS';
import { useAppStore } from '../store';
import { playBlobAudio } from '../utils/media';
import {
StreamingPreviewError,
resolveRemoteTtsTarget,
streamGenerateSpeech,
supportsStreamingPreview,
@@ -129,6 +130,69 @@ describe('useTTS delivery path vs the chosen GPU', () => {
expect(generateSpeech).not.toHaveBeenCalled();
});
it('keeps real stream progress separate from the elapsed timer', async () => {
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
vi.mocked(streamGenerateSpeech).mockImplementationOnce(async (_formData, { onProgress }) => {
onProgress(42);
await gate;
return { id: 'x', audio_path: 'x.wav' };
});
const { result } = renderHook(() => useTTS(hookProps()));
let generation;
act(() => {
generation = result.current.handleGenerate();
});
await waitFor(() => expect(result.current.generationProgress).toBe(42));
expect(String(result.current.generationTime)).not.toContain('%');
await act(async () => {
release();
await generation;
});
expect(result.current.generationProgress).toBeNull();
});
it('clears stale stream progress before a classic fallback', async () => {
let releaseClassic;
const classicGate = new Promise((resolve) => {
releaseClassic = resolve;
});
vi.mocked(streamGenerateSpeech).mockImplementationOnce(async (_formData, { onProgress }) => {
onProgress(42);
throw new StreamingPreviewError('stream transport dropped');
});
vi.mocked(generateSpeech).mockImplementationOnce(async () => ({
body: {
getReader: () => ({
read: async () => {
await classicGate;
return { done: true, value: undefined };
},
}),
},
headers: { get: () => null },
}));
const { result } = renderHook(() => useTTS(hookProps()));
let generation;
act(() => {
generation = result.current.handleGenerate();
});
await waitFor(() => expect(generateSpeech).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.generationProgress).toBeNull());
await act(async () => {
releaseClassic();
await generation;
});
});
it('takes the classic path when the resolved target is a worker', async () => {
// Streaming would have rendered here a local job wearing the badge of
// the 4090 the user picked. The classic path is the one that goes remote.
@@ -0,0 +1,107 @@
/**
* The widget window's dictation pill must not have its drop shadow clipped
* into a hard-edged rectangle.
*
* The `widget` window (tauri.conf.json, label `widget`) is exactly 300x64.
* `html[data-window='widget'] body:has(.capture-pill)` (index.css ~3766)
* gives that body a flush `padding: 8px` on every edge and `overflow:
* hidden`. That leaves only an 8px gutter around the 48px-tall pill for
* anything painted outside its box before the clip kicks in.
*
* `.capture-pill`'s shared (main-window) shadow `0 8px 32px` + `0 2px 8px`
* needs roughly 40px of clearance. In the widget window it has 8px, so the
* blur is cut off in a straight line at the window edge: a rounded capsule
* sitting inside a hard-edged dark rectangle instead of floating free.
*
* The fix scopes a tighter shadow (and a matching max-width) to
* `html[data-window='widget'] .capture-pill` that actually fits the gutter.
* This test pins that budget directly against index.css so a future edit
* that widens the shadow or the window without widening the padding
* gets caught immediately instead of shipping another visible clip.
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const css = fs.readFileSync(path.join(import.meta.dirname, '..', 'index.css'), 'utf8');
// Window content box: 300px window width - 8px padding * 2 sides.
const WINDOW_WIDTH = 300;
const GUTTER = 8; // body padding on every edge; also `overflow: hidden`'s clip boundary.
/** Extracts a `selector { ... }` rule's body (comments stripped), or null if not found. */
function extractRule(selector) {
const start = css.indexOf(`${selector} {`);
if (start === -1) return null;
const braceStart = css.indexOf('{', start);
const braceEnd = css.indexOf('}', braceStart);
return css.slice(braceStart + 1, braceEnd).replace(/\/\*[\s\S]*?\*\//g, '');
}
/** Asserts every non-inset box-shadow layer in `rule` fits the 8px gutter. */
function expectShadowFitsGutter(rule) {
const shadowMatch = rule.match(/box-shadow:\s*([^;]+);/);
expect(shadowMatch).not.toBeNull();
// Split shadow layers on top-level commas only (not the ones inside rgba(...)).
const layers = shadowMatch[1]
.split(/,(?![^(]*\))/)
.map((s) => s.trim())
.filter(Boolean);
const outerLayers = layers.filter((l) => !l.startsWith('inset'));
expect(outerLayers.length).toBeGreaterThan(0);
for (const layer of outerLayers) {
const lengths = layer
.replace(/rgba?\([^)]*\)/, '')
.trim()
.split(/\s+/)
.map(parseFloat);
const [, y, blur, spread = 0] = lengths;
// Offset-x is assumed 0 (true for every layer here); blur/spread extend
// symmetrically in every direction from the offset box, so this same
// budget bounds top/left/right too whenever offset-y >= 0.
const outsideExtent = Math.abs(y) + blur + spread;
expect(outsideExtent).toBeLessThanOrEqual(GUTTER);
}
}
describe('index.css: widget-scoped .capture-pill shadow fits the 8px gutter', () => {
it("defines an override under html[data-window='widget'] whose outer shadow layers and max-width both fit inside the padding gutter", () => {
// No such rule exists pre-fix: the widget window inherits the unscoped
// .capture-pill shadow verbatim, which is what gets clipped.
const rule = extractRule("html[data-window='widget'] .capture-pill");
expect(rule).not.toBeNull();
expectShadowFitsGutter(rule);
const maxWidthMatch = rule.match(/max-width:\s*(\d+(?:\.\d+)?)px/);
expect(maxWidthMatch).not.toBeNull();
// Equality, not `<=`: the pill must fill the content box exactly. A
// smaller cap would also "fit the gutter" while silently narrowing the
// pill and clipping more of the label the very truncation #1884 is
// about. Pin the required width so a future tightening can't pass here.
expect(parseFloat(maxWidthMatch[1])).toBe(WINDOW_WIDTH - GUTTER * 2);
});
// .capture-pill--recording / --transcribing fully override box-shadow
// (not just add to it) and win the cascade over the base .capture-pill
// rule while active, so the base override alone isn't enough the clip
// would reappear the instant the user starts recording, which is the
// pill's single most common state.
it.each(['recording', 'transcribing'])(
"also fits the %s state's own colored-glow shadow inside the gutter",
(state) => {
const rule = extractRule(`html[data-window='widget'] .capture-pill--${state}`);
expect(rule).not.toBeNull();
expectShadowFitsGutter(rule);
},
);
it('leaves the main-window .capture-pill-host shadow untouched', () => {
// The main window has real clearance for the shadow; only the widget
// window's tight 8px gutter forces the smaller override above.
expect(css).toContain('0 8px 32px rgba(0, 0, 0, 0.4)');
expect(css).toContain('0 2px 8px rgba(0, 0, 0, 0.2)');
});
});
+48 -23
View File
@@ -1,9 +1,9 @@
"""The dictation widget window must stay hidden, and must stamp its identity.
"""The dictation widget must stay safe while hidden and wake before capture.
The widget window hosts the recorder (`getUserMedia` + `MediaRecorder` + the
transcription WebSocket all live in `CaptureWidget.jsx`), so it has to exist
but it is never shown (owner decision, 2026-08-07): dictation gives no
on-screen pill.
transcription WebSocket all live in `CaptureWidget.jsx`), so it has to exist
while idle. Some WebView engines suspend that hidden document, however, so a
start request must wake it before emitting the event it needs to record.
Two things keep that safe, and both are easy to undo by accident:
@@ -14,8 +14,9 @@ Two things keep that safe, and both are easy to undo by accident:
300x64, with an opaque background and no CaptureWidget to hide it again:
the dark rectangle that could only be cleared by killing the app.
2. Nothing calls `.show()` on it. A re-added show would put that rectangle
back on screen for anyone whose window lost the identity race.
2. Capture dispatch wakes it only through the non-activating pill command,
after preserving the output target and before emitting the start event.
The widget owns hiding itself again when it is idle.
The frontend half is pinned by
`frontend/src/test/DictationNoPillWindow.test.jsx`.
@@ -27,6 +28,9 @@ from pathlib import Path
import pytest
_LIB_RS = Path(__file__).resolve().parents[1] / "frontend" / "src-tauri" / "src" / "lib.rs"
_COMMANDS_RS = (
Path(__file__).resolve().parents[1] / "frontend" / "src-tauri" / "src" / "commands.rs"
)
@pytest.fixture
@@ -34,6 +38,11 @@ def lib_rs() -> str:
return _LIB_RS.read_text(encoding="utf-8")
@pytest.fixture
def commands_rs() -> str:
return _COMMANDS_RS.read_text(encoding="utf-8")
def test_widget_window_stamps_its_identity_before_page_scripts(lib_rs: str) -> None:
assert "initialization_script" in lib_rs, (
"The widget window no longer injects an initialization_script. Window "
@@ -47,24 +56,40 @@ def test_widget_window_stamps_its_identity_before_page_scripts(lib_rs: str) -> N
)
def test_nothing_shows_the_widget_window(lib_rs: str) -> None:
"""No `.show()` may be reachable from a widget window handle.
Scoped to blocks that bind the widget handle, so an unrelated
`main_win.show()` elsewhere in the file doesn't trip this.
"""
offenders = []
for match in re.finditer(r'get_webview_window\("widget"\)', lib_rs):
# The handle's usable scope: to the end of the enclosing block. Take a
# generous window and look for a show on it — cheap and hard to fool.
block = lib_rs[match.start() : match.start() + 1200]
for show in re.finditer(r"\b(\w+)\.show\(\)|show_pill_noactivate\(", block):
offenders.append(show.group(0))
assert not offenders, (
f"Something shows the dictation widget window again: {offenders}. "
"It is a hidden recorder host — showing it is what put an empty "
"rectangle on the user's desktop."
def test_capture_dispatch_wakes_widget_before_emitting_start(lib_rs: str) -> None:
"""A hidden WebView cannot receive the event that tells it to show itself."""
dispatch = re.search(
r"fn dispatch_dictation_capture_from\(.*?\n\}", lib_rs, re.S
)
assert dispatch, "Could not locate dictation capture dispatch."
body = dispatch.group(0)
begin = body.find("begin_session")
wake_guard = body.find('if event == "tray-dictate"', begin)
wake = body.find("commands::show_dictation_pill")
emit = body.find("app.emit")
assert -1 not in (begin, wake_guard, wake, emit), (
"Capture dispatch must preserve the output target, wake the hidden "
"widget, then emit the start event."
)
assert begin < wake_guard < wake < emit, (
"Wake the hidden recorder after preserving the output target and "
"before emitting only a start; otherwise macOS can silently drop the "
"request or a stop can reopen the pill."
)
def test_in_page_capture_waits_for_listener_acceptance(commands_rs: str) -> None:
request = re.search(
r"pub async fn request_dictation_capture\(.*?\n\}", commands_rs, re.S
)
assert request, "The in-page capture command must remain asynchronous."
body = request.group(0)
assert "request_dictation_capture_delivery" in body
assert "wait_for_capture_delivery" in body
assert "completion_ready" in body
assert "take_completion_or_cancel" in body
assert "capture window did not acknowledge the request" in body
assert "dictation capture did not start in time" in body
def test_no_computed_window_target_can_resolve_to_the_widget(lib_rs: str) -> None: