pub mod cli;
#[cfg(all(debug_assertions, target_os = "macos"))]
pub(crate) mod dashboard;
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) mod developer_control;
#[cfg(not(target_os = "macos"))]
pub(crate) mod host;
pub mod settings;
pub(crate) mod transcript_cli;
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::dictation::vocabulary::{self, VocabularyEntry};
pub const CONTROL_VERSION: u32 = 4;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum CaptureAction {
Start,
Stop,
Toggle,
Cancel,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum ScrawlCaptureAction {
Start,
Stop,
Cancel,
Toggle,
Status,
Replay,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum Pane {
Settings,
Models,
#[value(alias = "replacements")]
Modes,
History,
Meetings,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "command", rename_all = "kebab-case")]
pub enum ControlCommand {
Status,
Quit {
#[serde(default)]
force: bool,
},
Restart {
#[serde(default)]
force: bool,
},
Dictation {
action: CaptureAction,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
print: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
raw: bool,
},
PasteLast,
Screenshot {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pick: bool,
},
MeetingRecord {
#[serde(default)]
title: Option<String>,
},
MeetingStop,
MeetingCopyLast,
SettingsGet {
#[serde(default)]
key: Option<String>,
},
SettingsSet {
key: String,
value: String,
},
SettingsApply {
values: serde_json::Map<String, serde_json::Value>,
},
ModelList,
ModelUse {
model: String,
#[serde(default)]
language: Option<String>,
},
ModelInstall {
model: String,
#[serde(default)]
language: Option<String>,
},
ModelCancel,
WindowShow {
#[serde(default)]
pane: Option<Pane>,
},
WindowHide,
OnboardingReset,
WindowStory {
story: String,
#[serde(default)]
variant: Option<String>,
#[serde(default)]
play: bool,
},
HistoryClear,
CheckForUpdates,
LaunchAtLogin {
#[serde(default)]
enabled: Option<bool>,
},
VocabularyList,
VocabularyAdd {
term: String,
#[serde(default)]
sounds_like: Vec<String>,
},
VocabularyRemove {
term: String,
},
ScrawlCapture {
action: ScrawlCaptureAction,
},
ScrawlCaptureList {
#[serde(default)]
limit: Option<usize>,
},
ScrawlCaptureShow {
capture: String,
},
ScrawlCaptureSend {
capture: String,
#[serde(default)]
to: Option<String>,
#[serde(default)]
always: bool,
},
ScrawlCaptureUndo,
ScrawlCaptureRoute {
capture: String,
#[serde(default)]
ask_jev: bool,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ControlRequest {
pub version: u32,
#[serde(flatten)]
pub command: ControlCommand,
}
impl ControlRequest {
pub fn new(command: ControlCommand) -> Self {
Self {
version: CONTROL_VERSION,
command,
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum ControlReply {
Done {
message: String,
changed: bool,
},
Status(Box<StatusReport>),
Settings {
settings: Vec<settings::SettingValue>,
},
Models {
models: Vec<ModelInfo>,
},
Vocabulary {
entries: Vec<VocabularyEntry>,
},
ScrawlCapture(Box<ScrawlCaptureReport>),
ScrawlCaptures {
captures: Vec<ScrawlCaptureSummary>,
},
ScrawlCaptureDetail(Box<ScrawlCaptureDetail>),
ScrawlCaptureRoute {
explanation: String,
},
Transcript {
text: String,
},
Error {
code: String,
message: String,
},
}
impl ControlReply {
pub fn done(message: impl Into<String>) -> Self {
Self::Done {
message: message.into(),
changed: true,
}
}
pub fn unchanged(message: impl Into<String>) -> Self {
Self::Done {
message: message.into(),
changed: false,
}
}
pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
Self::Error {
code: code.into(),
message: message.into(),
}
}
}
impl From<ControlError> for ControlReply {
fn from(error: ControlError) -> Self {
Self::error(error.code, error.message)
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ControlResponse {
pub version: u32,
#[serde(flatten)]
pub reply: ControlReply,
}
impl From<ControlReply> for ControlResponse {
fn from(reply: ControlReply) -> Self {
Self {
version: CONTROL_VERSION,
reply,
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ScrawlCaptureState {
#[default]
Idle,
Recording,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ScrawlCaptureReport {
pub changed: bool,
pub state: ScrawlCaptureState,
pub bundle: Option<std::path::PathBuf>,
pub elapsed_ms: Option<u64>,
#[serde(default)]
pub finishing: Vec<std::path::PathBuf>,
pub last: Option<ScrawlCaptureOutcome>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ScrawlCaptureOutcome {
pub bundle: std::path::PathBuf,
pub stage: String,
pub words: Option<usize>,
#[serde(default)]
pub gaps: Option<usize>,
#[serde(default)]
pub missing_transcript: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ScrawlCaptureSummary {
pub id: String,
pub bundle: std::path::PathBuf,
pub created_at_ms: u64,
pub duration_ms: u64,
pub mode: String,
pub app: Option<String>,
pub words: usize,
pub sent_to: Option<ScrawlCaptureSession>,
#[serde(default)]
pub queued: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ScrawlCaptureSession {
pub session: String,
pub title: String,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ScrawlCaptureDetail {
#[serde(flatten)]
pub summary: ScrawlCaptureSummary,
pub window_title: Option<String>,
pub transcript: String,
pub note: Option<String>,
pub missing_transcript: Option<String>,
pub marks: usize,
pub keyframes: usize,
pub video: Option<std::path::PathBuf>,
pub audio: Option<std::path::PathBuf>,
pub contact_sheet: Option<std::path::PathBuf>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ControlError {
pub code: &'static str,
pub message: String,
}
impl ControlError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
pub const PRINT_WAIT: Duration = Duration::from_secs(60);
const CONTROL_DEADLINE: Duration = Duration::from_secs(10);
impl ControlCommand {
pub fn deadline(&self) -> Duration {
match self {
Self::Dictation { print: true, .. } => PRINT_WAIT + Duration::from_secs(5),
_ => CONTROL_DEADLINE,
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum PrintedTranscript {
Text(String),
NoSpeech,
#[cfg_attr(target_os = "macos", allow(dead_code))]
NotRecording,
Failed(String),
}
#[derive(Clone, Debug)]
pub struct PrintReply(SyncSender<PrintedTranscript>);
impl PrintReply {
pub fn channel() -> (Self, Receiver<PrintedTranscript>) {
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
(Self(sender), receiver)
}
pub fn send(self, transcript: PrintedTranscript) {
let _ = self.0.send(transcript);
}
}
impl PartialEq for PrintReply {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Eq for PrintReply {}
pub fn await_printed(receiver: &Receiver<PrintedTranscript>, wait: Duration) -> ControlReply {
match receiver.recv_timeout(wait) {
Ok(PrintedTranscript::Text(text)) => ControlReply::Transcript { text },
Ok(PrintedTranscript::NoSpeech) => {
ControlReply::error("no-speech", "no speech was recorded; nothing to print")
}
Ok(PrintedTranscript::NotRecording) => ControlReply::error(
"not-recording",
"dictation isn't recording; nothing to print",
),
Ok(PrintedTranscript::Failed(error)) => {
ControlReply::error("failed", format!("couldn't transcribe it: {error}"))
}
Err(RecvTimeoutError::Timeout) => ControlReply::error(
"failed",
format!(
"no transcript within {} s; nothing was printed or pasted",
wait.as_secs()
),
),
Err(RecvTimeoutError::Disconnected) => ControlReply::error(
"failed",
"the dictation was cancelled or failed; nothing to print",
),
}
}
pub fn check_flags(
action: CaptureAction,
print: bool,
raw: bool,
) -> Result<CaptureAction, ControlError> {
if print && action != CaptureAction::Stop {
return Err(ControlError::new(
"invalid-value",
"--print works only with `hex dictation stop`",
));
}
if raw && !matches!(action, CaptureAction::Start | CaptureAction::Toggle) {
return Err(ControlError::new(
"invalid-value",
"--raw works only with `hex dictation start` and `hex dictation toggle`",
));
}
Ok(action)
}
pub struct ControlCall {
pub request: ControlRequest,
pub reply: SyncSender<ControlResponse>,
}
pub fn check_version(request: &ControlRequest) -> Result<(), ControlError> {
if request.version == CONTROL_VERSION {
return Ok(());
}
Err(version_mismatch(request.version.into()))
}
pub fn version_mismatch(version: u64) -> ControlError {
ControlError::new(
"version-mismatch",
format!(
"this hex speaks control version {version}, but the running Hex speaks \
{CONTROL_VERSION}; use the hex inside the running app or restart Hex"
),
)
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct StatusReport {
pub app_version: String,
pub pid: u32,
pub memory_footprint_bytes: Option<u64>,
pub listener: ListenerReport,
pub capture: CaptureReport,
pub meeting: MeetingReport,
pub model: ModelReport,
pub features: FeatureReport,
pub permissions: PermissionReport,
pub window: WindowReport,
pub update: String,
#[serde(default)]
pub labs: Vec<String>,
#[serde(default)]
pub capabilities: std::collections::BTreeMap<String, bool>,
}
pub fn update_label(status: &crate::desktop::host::DesktopUpdateStatus) -> &'static str {
use crate::desktop::host::DesktopUpdateStatus;
match status {
DesktopUpdateStatus::Checking | DesktopUpdateStatus::Downloading => "checking",
DesktopUpdateStatus::Available
| DesktopUpdateStatus::ReadyToRestart
| DesktopUpdateStatus::SwitchAvailable { .. }
| DesktopUpdateStatus::PackageUpdate { .. } => "available",
DesktopUpdateStatus::Unavailable
| DesktopUpdateStatus::Managed
| DesktopUpdateStatus::Failed => "unavailable",
DesktopUpdateStatus::Current
| DesktopUpdateStatus::SourceBuild
| DesktopUpdateStatus::PackageManager => "current",
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ListenerReport {
pub running: bool,
pub status: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shortcuts: Vec<GrantedShortcutReport>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct GrantedShortcutReport {
pub name: String,
pub trigger: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CaptureState {
#[default]
Idle,
Recording,
Transcribing,
Unknown,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CaptureKind {
Dictation,
Service,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct CaptureReport {
pub state: CaptureState,
pub recording: Option<CaptureKind>,
pub pending_jobs: usize,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct CaptureSnapshot {
pub recording: Option<CaptureKind>,
pub pending_jobs: usize,
pub microphone_unavailable: bool,
}
impl From<CaptureSnapshot> for CaptureReport {
fn from(snapshot: CaptureSnapshot) -> Self {
Self {
state: if snapshot.recording.is_some() {
CaptureState::Recording
} else if snapshot.pending_jobs > 0 {
CaptureState::Transcribing
} else {
CaptureState::Idle
},
recording: snapshot.recording,
pending_jobs: snapshot.pending_jobs,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct MeetingReport {
pub state: String,
pub title: Option<String>,
pub elapsed_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ModelReport {
pub id: String,
pub name: String,
pub language: String,
pub installed: bool,
pub preparing: Option<String>,
pub downloaded_bytes: u64,
pub error: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct FeatureReport {
pub meetings: bool,
pub release_microphone_while_idle: bool,
pub history_retention: String,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct PermissionReport {
pub ready: bool,
pub microphone: String,
pub accessibility: String,
pub transcription_model: bool,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct WindowReport {
pub open: bool,
pub pane: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub languages: Vec<String>,
pub size: String,
pub installed: bool,
pub selected: bool,
pub streams: bool,
pub vocabulary: String,
pub supports_recognition_hints: bool,
#[serde(default)]
pub source: String,
}
pub fn model_source(
model: &crate::transcription::models::ModelDefinition,
language: &str,
) -> String {
use crate::transcription::found_models::describe;
use crate::transcription::models::{is_installed, model_path};
let installed = is_installed(model, language);
let here = model_path(model)
.ok()
.filter(|_| installed)
.and_then(|path| describe(&path));
match (model.id.is_custom(), here) {
(true, Some(origin)) => format!("found: {origin} (not tested by Hex)"),
(false, Some(origin)) => format!("found: {origin}"),
(true, None) => "imported".into(),
(false, None) if installed => "hex".into(),
(false, None) => "download".into(),
}
}
pub fn model_infos(
selection: &crate::transcription::models::TranscriptionSelection,
) -> Vec<ModelInfo> {
use crate::transcription::found_models;
use crate::transcription::models::VocabularyUse;
let found = found_models::scan();
let source = |model| model_source(model, &selection.language);
let mut infos: Vec<ModelInfo> = crate::transcription::models::all_models()
.into_iter()
.filter(|model| model.available())
.map(|model| ModelInfo {
source: source(model),
id: model.id.as_str().into(),
name: model.name.into(),
languages: model.languages.iter().map(|&code| code.into()).collect(),
size: model.size_label(),
installed: crate::transcription::models::is_installed(model, &selection.language),
selected: model.id == selection.model,
streams: model.streams,
vocabulary: match model.vocabulary_use() {
VocabularyUse::Prompt => "prompt",
VocabularyUse::Bias => "bias",
VocabularyUse::None => "none",
}
.into(),
supports_recognition_hints: model.vocabulary_use() == VocabularyUse::Prompt,
})
.collect();
for model in found.into_iter().filter(|found| {
found.catalog.is_none() && !crate::transcription::custom_models::uses_path(&found.path)
}) {
let Some(sha256) = &model.blob_sha256 else {
continue;
};
infos.push(ModelInfo {
id: format!("custom-{}", &sha256[..16]),
name: model.name.clone(),
languages: model.languages.clone(),
size: format!("{} MB", model.bytes / 1_000_000),
installed: true,
selected: false,
streams: false,
vocabulary: if matches!(model.architecture.as_str(), "whisper" | "qwen3_asr") {
"prompt"
} else if model.architecture == "parakeet" {
"bias"
} else {
"none"
}
.into(),
supports_recognition_hints: matches!(
model.architecture.as_str(),
"whisper" | "qwen3_asr"
),
source: format!("found: {} (not tested by Hex)", model.source()),
});
}
infos
}
pub fn model_selection(
settings: &crate::app_settings::AppSettings,
model: &str,
language: Option<&str>,
) -> Result<crate::transcription::models::TranscriptionSelection, ControlError> {
use crate::transcription::custom_models::{self, Status};
use crate::transcription::models::TranscriptionModelId;
let parsed = model.parse::<TranscriptionModelId>().ok();
if let Some(TranscriptionModelId::Custom(id)) = parsed
&& !custom_models::is_registered(id)
&& let Some(found) = crate::transcription::found_models::scan()
.into_iter()
.find(|found| {
found.catalog.is_none()
&& found
.blob_sha256
.as_deref()
.is_some_and(|sha256| id.as_str().ends_with(&sha256[..16]))
})
{
custom_models::adopt_in_place(
&found.path,
found.blob_sha256.as_deref(),
&found.source(),
&std::sync::atomic::AtomicBool::new(false),
)
.map_err(|error| ControlError::new("model-unavailable", format!("{error:#}")))?;
}
if let Some(TranscriptionModelId::Custom(id)) = parsed
&& custom_models::is_registered(id)
{
let why = match custom_models::status(id) {
Status::Ready => None,
Status::Missing => Some("its file is missing; import it again or remove it"),
Status::Changed => {
Some("its file changed since it was imported; import it again or remove it")
}
};
if let Some(why) = why {
return Err(ControlError::new(
"model-unavailable",
format!("`{model}` can't be used: {why}"),
));
}
}
if parsed == Some(TranscriptionModelId::ParakeetUltra) && !crate::labs::parakeet_ultra() {
return Err(ControlError::new(
"model-unavailable",
format!(
"`{model}` can't be used: turn on Labs › Parakeet Ultra: hex labs on parakeet-ultra"
),
));
}
let id = parsed
.filter(|id| crate::transcription::models::definition(*id).available())
.ok_or_else(|| {
ControlError::new(
"unknown-model",
format!("unknown model `{model}`; run `hex model list` for the ids"),
)
})?;
let mut selection = settings.transcription_for_model(id);
if let Some(language) = language {
selection.language = language.into();
}
let definition = crate::transcription::models::definition(id);
crate::transcription::models::validate(&selection).map_err(|error| {
let supported = if definition.languages.contains(&"*") {
"any language code Hex knows (en, de, fr, ja, …)".to_owned()
} else {
definition.languages.join(", ")
};
ControlError::new(
"invalid-value",
format!("{error}; {} supports {supported}", definition.name),
)
})?;
Ok(selection)
}
pub fn check_quit(
meeting: &MeetingReport,
capture: Result<CaptureSnapshot, ControlError>,
force: bool,
) -> Result<(), ControlError> {
if force {
return Ok(());
}
if meeting.state != "idle" {
return Err(ControlError::new(
"meeting-active",
format!(
"a meeting is {}; stop it first (`hex meetings stop`) or pass --force",
meeting.state
),
));
}
match capture {
Ok(capture) if capture.recording.is_some() || capture.pending_jobs > 0 => {
Err(ControlError::new(
"busy",
"a dictation is recording or transcribing; wait for it or pass --force",
))
}
Ok(_) => Ok(()),
Err(error) => Err(ControlError::new(
"busy",
format!(
"couldn't confirm nothing is recording ({}); try again or pass --force",
error.message
),
)),
}
}
pub fn edit_vocabulary(
list: &mut Vec<VocabularyEntry>,
command: &ControlCommand,
) -> Result<ControlReply, ControlError> {
let invalid = |message: String| ControlError::new("invalid-value", message);
match command {
ControlCommand::VocabularyAdd { term, sounds_like } => {
let sounds_like: Vec<String> = sounds_like
.iter()
.flat_map(|spellings| vocabulary::parse_sounds_like(spellings))
.collect();
let existed = vocabulary::contains(list, term);
Ok(
match vocabulary::add(list, term, &sounds_like).map_err(invalid)? {
false => ControlReply::unchanged(format!(
"“{}” is already in the vocabulary",
term.trim()
)),
true if existed => {
ControlReply::done(format!("Added spellings to “{}”", term.trim()))
}
true => ControlReply::done(format!("Added “{}”", term.trim())),
},
)
}
ControlCommand::VocabularyRemove { term } => Ok(if vocabulary::remove(list, term) {
ControlReply::done(format!("Removed “{}”", term.trim()))
} else {
ControlReply::unchanged(format!("“{}” isn't in the vocabulary", term.trim()))
}),
_ => Err(ControlError::new("internal", "not a vocabulary edit")),
}
}
pub fn memory_footprint() -> Option<u64> {
#[cfg(target_os = "macos")]
{
let mut info = unsafe { std::mem::zeroed::<libc::rusage_info_v4>() };
let status = unsafe {
libc::proc_pid_rusage(
std::process::id() as i32,
libc::RUSAGE_INFO_V4,
(&mut info as *mut libc::rusage_info_v4).cast(),
)
};
(status == 0).then_some(info.ri_phys_footprint)
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_printing_stop_answers_through_its_reply_channel() {
let wait = Duration::from_millis(50);
let (reply, printed) = PrintReply::channel();
reply.send(PrintedTranscript::Text("Ship it.".into()));
assert_eq!(
await_printed(&printed, wait),
ControlReply::Transcript {
text: "Ship it.".into()
}
);
let (reply, printed) = PrintReply::channel();
reply.send(PrintedTranscript::NoSpeech);
assert!(matches!(
await_printed(&printed, wait),
ControlReply::Error { code, .. } if code == "no-speech"
));
let (reply, printed) = PrintReply::channel();
drop(reply);
assert!(matches!(
await_printed(&printed, wait),
ControlReply::Error { code, .. } if code == "failed"
));
let (_reply, printed) = PrintReply::channel();
assert!(matches!(
await_printed(&printed, wait),
ControlReply::Error { code, .. } if code == "failed"
));
assert!(check_flags(CaptureAction::Start, true, false).is_err());
assert!(check_flags(CaptureAction::Stop, true, false).is_ok());
assert!(check_flags(CaptureAction::Toggle, false, true).is_ok());
assert!(check_flags(CaptureAction::Stop, false, true).is_err());
assert_eq!(
serde_json::from_str::<ControlRequest>(
r#"{"version":4,"command":"dictation","action":"stop"}"#
)
.unwrap()
.command,
ControlCommand::Dictation {
action: CaptureAction::Stop,
print: false,
raw: false,
}
);
}
#[test]
fn cancel_parses_and_takes_no_flags() {
let cancel = serde_json::from_str::<ControlRequest>(
r#"{"version":4,"command":"dictation","action":"cancel"}"#,
)
.unwrap();
let ControlCommand::Dictation { action, .. } = cancel.command else {
panic!("a dictation command");
};
assert_eq!(action, CaptureAction::Cancel);
assert_eq!(check_flags(action, false, false), Ok(CaptureAction::Cancel));
assert!(check_flags(action, true, false).is_err());
assert!(check_flags(action, false, true).is_err());
}
fn round_trip_request(command: ControlCommand) {
let request = ControlRequest::new(command);
let json = serde_json::to_string(&request).unwrap();
assert_eq!(
serde_json::from_str::<ControlRequest>(&json).unwrap(),
request
);
}
#[test]
fn every_command_round_trips() {
for command in [
ControlCommand::Status,
ControlCommand::Quit { force: true },
ControlCommand::Restart { force: false },
ControlCommand::Dictation {
action: CaptureAction::Toggle,
print: false,
raw: false,
},
ControlCommand::Dictation {
action: CaptureAction::Stop,
print: true,
raw: false,
},
ControlCommand::Dictation {
action: CaptureAction::Start,
print: false,
raw: true,
},
ControlCommand::PasteLast,
ControlCommand::Screenshot { pick: false },
ControlCommand::Screenshot { pick: true },
ControlCommand::MeetingRecord {
title: Some("Design sync".into()),
},
ControlCommand::MeetingStop,
ControlCommand::MeetingCopyLast,
ControlCommand::SettingsGet { key: None },
ControlCommand::SettingsSet {
key: "release_microphone_while_idle".into(),
value: "on".into(),
},
ControlCommand::SettingsApply {
values: serde_json::Map::from_iter([("sound_effects".into(), false.into())]),
},
ControlCommand::ModelList,
ControlCommand::ModelUse {
model: "parakeet_v3".into(),
language: Some("pl".into()),
},
ControlCommand::ModelInstall {
model: "parakeet_v2".into(),
language: None,
},
ControlCommand::ModelCancel,
ControlCommand::WindowShow {
pane: Some(Pane::History),
},
ControlCommand::WindowHide,
ControlCommand::OnboardingReset,
ControlCommand::WindowStory {
story: "HUD/Processing".into(),
variant: Some("2".into()),
play: true,
},
ControlCommand::HistoryClear,
ControlCommand::CheckForUpdates,
ControlCommand::LaunchAtLogin {
enabled: Some(true),
},
ControlCommand::VocabularyList,
ControlCommand::VocabularyAdd {
term: "cappuccino".into(),
sounds_like: vec!["capuchin".into()],
},
ControlCommand::VocabularyRemove {
term: "cappuccino".into(),
},
] {
round_trip_request(command);
}
let request: ControlRequest =
serde_json::from_str(r#"{"version":4,"command":"vocabulary-add","term":"Rune"}"#)
.unwrap();
assert_eq!(
request.command,
ControlCommand::VocabularyAdd {
term: "Rune".into(),
sounds_like: vec![],
}
);
}
#[test]
fn vocabulary_edits_report_whether_they_changed_the_list() {
let add = |term: &str, sounds_like: &[&str]| ControlCommand::VocabularyAdd {
term: term.into(),
sounds_like: sounds_like.iter().map(|&text| text.into()).collect(),
};
let remove = |term: &str| ControlCommand::VocabularyRemove { term: term.into() };
let mut list = Vec::new();
assert_eq!(
edit_vocabulary(&mut list, &add("cappuccino", &["capuchin, cap a chino"])).unwrap(),
ControlReply::done("Added “cappuccino”")
);
assert_eq!(list[0].sounds_like, ["capuchin", "cap a chino"]);
assert_eq!(
edit_vocabulary(&mut list, &add("Cappuccino", &[])).unwrap(),
ControlReply::unchanged("“Cappuccino” is already in the vocabulary")
);
assert_eq!(
edit_vocabulary(&mut list, &add("cappuccino", &["cap you chino"])).unwrap(),
ControlReply::done("Added spellings to “cappuccino”")
);
assert_eq!(
edit_vocabulary(&mut list, &add(" ", &[])).unwrap_err().code,
"invalid-value"
);
assert_eq!(
edit_vocabulary(&mut list, &remove("CAPPUCCINO")).unwrap(),
ControlReply::done("Removed “CAPPUCCINO”")
);
assert_eq!(
edit_vocabulary(&mut list, &remove("cappuccino")).unwrap(),
ControlReply::unchanged("“cappuccino” isn't in the vocabulary")
);
assert!(list.is_empty());
}
#[test]
fn requests_are_flat_and_versioned() {
let json = serde_json::to_value(ControlRequest::new(ControlCommand::MeetingRecord {
title: Some("Standup".into()),
}))
.unwrap();
assert_eq!(
json,
serde_json::json!({"version": 4, "command": "meeting-record", "title": "Standup"})
);
let request: ControlRequest =
serde_json::from_str(r#"{"version":4,"command":"quit"}"#).unwrap();
assert_eq!(request.command, ControlCommand::Quit { force: false });
assert!(serde_json::from_str::<ControlRequest>(r#"{"version":4,"command":"x"}"#).is_err());
let capture: ControlRequest =
serde_json::from_str(r#"{"version":4,"command":"scrawl-capture","action":"stop"}"#)
.unwrap();
assert_eq!(
capture.command,
ControlCommand::ScrawlCapture {
action: ScrawlCaptureAction::Stop
}
);
let send: ControlRequest = serde_json::from_str(
r#"{"version":4,"command":"scrawl-capture-send","capture":"latest"}"#,
)
.unwrap();
assert_eq!(
send.command,
ControlCommand::ScrawlCaptureSend {
capture: "latest".into(),
to: None,
always: false,
}
);
}
#[test]
fn every_reply_round_trips() {
for reply in [
ControlReply::done("Started dictation"),
ControlReply::unchanged("Already recording"),
ControlReply::Status(Box::default()),
ControlReply::Settings {
settings: vec![settings::SettingValue {
key: "sound_effects".into(),
value: serde_json::json!(true),
kind: "bool".into(),
choices: vec![],
writable: true,
about: "Play tones".into(),
available: true,
}],
},
ControlReply::Models { models: vec![] },
ControlReply::Vocabulary {
entries: vec![VocabularyEntry {
term: "cappuccino".into(),
sounds_like: vec!["capuchin".into()],
}],
},
ControlReply::ScrawlCapture(Box::new(ScrawlCaptureReport {
changed: true,
state: ScrawlCaptureState::Idle,
bundle: Some("/tmp/1.capture".into()),
elapsed_ms: None,
finishing: vec!["/tmp/1.capture".into()],
last: Some(ScrawlCaptureOutcome {
bundle: "/tmp/0.capture".into(),
stage: "done".into(),
words: Some(3),
gaps: Some(1),
missing_transcript: None,
}),
})),
ControlReply::ScrawlCaptures {
captures: vec![ScrawlCaptureSummary {
id: "1.scrawl".into(),
sent_to: Some(ScrawlCaptureSession {
session: "ses_1".into(),
title: "Hex".into(),
}),
..ScrawlCaptureSummary::default()
}],
},
ControlReply::ScrawlCaptureDetail(Box::new(ScrawlCaptureDetail {
summary: ScrawlCaptureSummary {
id: "1.scrawl".into(),
words: 3,
..ScrawlCaptureSummary::default()
},
transcript: "make it blue".into(),
video: Some("/tmp/1.scrawl/video.mp4".into()),
..ScrawlCaptureDetail::default()
})),
ControlReply::ScrawlCaptureRoute {
explanation: "Capture 1.scrawl".into(),
},
ControlReply::Transcript {
text: "Ship it.".into(),
},
ControlReply::error("busy", "Hex is busy"),
] {
let response = ControlResponse::from(reply);
let json = serde_json::to_string(&response).unwrap();
assert_eq!(
serde_json::from_str::<ControlResponse>(&json).unwrap(),
response
);
}
let json = serde_json::to_value(ControlResponse::from(ControlReply::Transcript {
text: "Ship it.".into(),
}))
.unwrap();
assert_eq!(
json,
serde_json::json!({"version": 4, "status": "transcript", "text": "Ship it."})
);
let json =
serde_json::to_value(ControlResponse::from(ControlReply::error("busy", "x"))).unwrap();
assert_eq!(
json,
serde_json::json!({"version": 4, "status": "error", "code": "busy", "message": "x"})
);
}
#[test]
fn a_request_from_another_version_is_refused() {
let mut request = ControlRequest::new(ControlCommand::Status);
assert!(check_version(&request).is_ok());
request.version = 99;
assert_eq!(
check_version(&request).unwrap_err().code,
"version-mismatch"
);
}
#[test]
fn quitting_waits_for_recordings_unless_forced() {
let idle = MeetingReport {
state: "idle".into(),
..Default::default()
};
let recording = MeetingReport {
state: "recording".into(),
..Default::default()
};
let quiet = || Ok(CaptureSnapshot::default());
assert!(check_quit(&idle, quiet(), false).is_ok());
assert_eq!(
check_quit(&recording, quiet(), false).unwrap_err().code,
"meeting-active"
);
assert!(check_quit(&recording, quiet(), true).is_ok());
let busy = CaptureSnapshot {
recording: None,
pending_jobs: 1,
microphone_unavailable: false,
};
assert_eq!(check_quit(&idle, Ok(busy), false).unwrap_err().code, "busy");
let unanswered = || {
Err(ControlError::new(
"unavailable",
"the listener didn't answer",
))
};
assert_eq!(
check_quit(&idle, unanswered(), false).unwrap_err().code,
"busy"
);
assert!(check_quit(&idle, unanswered(), true).is_ok());
}
#[test]
fn model_selection_keeps_the_remembered_language_and_validates_it() {
use crate::transcription::models::{TranscriptionModelId as Id, TranscriptionSelection};
let settings = crate::app_settings::AppSettings {
transcription: TranscriptionSelection {
model: Id::ParakeetV3,
language: "en".into(),
},
transcription_recents: vec![TranscriptionSelection {
model: Id::WhisperLargeV3Turbo,
language: "de".into(),
}],
..Default::default()
};
let pick = |model, language| model_selection(&settings, model, language);
let remembered = pick("whisper_large_v3_turbo", None).unwrap();
assert_eq!(remembered.language, "de");
let french = pick("whisper_large_v3_turbo", Some("fr")).unwrap();
assert_eq!(french.language, "fr");
for (model, language) in [
("whisper_large_v3_turbo", "klingon"),
("sense_voice_small", "de"),
] {
assert_eq!(
pick(model, Some(language)).unwrap_err().code,
"invalid-value",
"{model} {language}"
);
}
assert_eq!(
pick("apple_speech", None).unwrap_err().code,
"unknown-model"
);
}
#[test]
fn capture_reports_derive_their_state() {
let report = CaptureReport::from(CaptureSnapshot {
recording: Some(CaptureKind::Dictation),
pending_jobs: 2,
microphone_unavailable: false,
});
assert_eq!(report.state, CaptureState::Recording);
let report = CaptureReport::from(CaptureSnapshot {
recording: None,
pending_jobs: 2,
microphone_unavailable: true,
});
assert_eq!(report.state, CaptureState::Transcribing);
}
}