Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## v0.14.17

- Recording controls now identify the active session. Delayed stop or cancellation requests cannot affect a replacement recording, and duplicate capture-stop requests no longer cancel transcription.
- Existing CLI and shortcut commands retain compatible stop behavior, including dictionary-training captures.
- Settings, microphone selection, tray language changes, and setup activation share one ordered configuration path. Delayed responses no longer replace newer selections.
- Cancelling setup while activation is queued prevents it from changing the configured engine or model.
- Shared frontend test helpers and streamlined CI reduce maintenance overhead without removing application features.

## v0.14.16

- Home no longer suggests an unsafe terminal command to remove old Echo copies. Use the existing **Remove old copies** action.
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ edition = "2021"
license = "MIT"
repository = "https://github.com/ddv1982/echo"
rust-version = "1.89"
version = "0.14.16"
version = "0.14.17"

[workspace.lints.rust]
unsafe_code = "forbid"
Expand Down
14 changes: 14 additions & 0 deletions crates/echo-ipc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,23 @@ pub enum SettingsChange {
#[derive(Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct SettingsSnapshot {
pub revision: u64,
pub preferences: Settings,
pub transcription: TranscriptionSnapshot,
pub readiness: Readiness,
}

#[derive(Debug, Clone, PartialEq, Serialize, TS)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum ChannelReply<T> {
Ok { value: T },
Err { error: String },
}

#[derive(Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptionSnapshot {
Expand Down Expand Up @@ -581,6 +593,7 @@ pub enum MicrophoneSource {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct MicrophoneSnapshot {
pub revision: u64,
pub host: AudioHost,
pub source: MicrophoneSource,
pub system_default: Option<InputDevice>,
Expand Down Expand Up @@ -821,6 +834,7 @@ macro_rules! schema_types {
schema::AppPhase => schema::AppPhase,
schema::AppStatus => schema::AppStatus,
schema::AudioHost => schema::AudioHost,
schema::ChannelReply => schema::ChannelReply<schema::SettingsSnapshot>,
schema::ComponentId => schema::ComponentId,
schema::ComponentOrigin => schema::ComponentOrigin,
schema::ComponentStatus => schema::ComponentStatus,
Expand Down
1 change: 1 addition & 0 deletions crates/echo-ipc/src/projections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ impl From<echo::microphone::SelectionSource> for MicrophoneSource {
impl From<echo::microphone::MicrophoneSnapshot> for MicrophoneSnapshot {
fn from(value: echo::microphone::MicrophoneSnapshot) -> Self {
Self {
revision: 0,
host: value.host.into(),
source: value.source.into(),
system_default: value.system_default.map(Into::into),
Expand Down
24 changes: 17 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,18 @@ flat control-file protocol.

## Desktop boundary

Tauri command functions are thin adapters. Settings owns serialized config
writes and invalidates the status cache after a successful save. Status owns
health caching and the `AppStatus` projection. Focused command modules own
devices, library data, recording, settings, shortcuts, status, and system
operations.
Tauri command functions are thin adapters. `ConfigMutationService` accepts
settings and microphone requests synchronously, then performs their work in
FIFO order on a worker. Typed Tauri channels deliver the results without
blocking the UI thread. Tray language changes and setup-plan configuration
changes use the same owner. Capture and transcription run independently.

Every settings and microphone response carries a monotonic revision for that
desktop process. Views retain the newest snapshot, so a delayed response cannot
replace a newer selection. Successful saves invalidate the status cache.
Status owns health caching and the `AppStatus` projection. Focused command
modules own devices, library data, recording, settings, shortcuts, status, and
system operations.

The Settings boundary returns one `SettingsSnapshot`. The snapshot keeps saved
preferences, the resolved next transcription, setup readiness, and previous-run
Expand Down Expand Up @@ -95,8 +102,11 @@ browser development graph and cannot enter the production bundle.

`App.tsx` composes navigation and feature surfaces. Shared status, theme,
history, dictionary, and error state live in the app controller. Home and
Settings own their subscriptions and device/setup lifetimes. Settings changes
are serialized, and stale setup refreshes cannot replace a newer snapshot.
Settings own their subscriptions and device/setup lifetimes. Backend responses
define settings order, and stale setup refreshes cannot replace a newer
snapshot. Test suites share the typed desktop API harness in
`frontend/src/test/desktopApiHarness.ts`, with scenario-specific overrides kept
in each test.

Serial polling never overlaps requests and stops with component disposal.
Subscriptions await their unlisten handle and dispose it even when unmount
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/api/previewDesktopApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function createPreviewDesktopApi(): PreviewDesktopApi {
let recordingSequence = 0

let previewSettings: Settings = defaultPreviewSettings()
let previewRevision = 0
let previewRecordingDeadline: number | null = null
const previewTimers = new Set<number>()

Expand Down Expand Up @@ -470,6 +471,7 @@ export function createPreviewDesktopApi(): PreviewDesktopApi {
previewRecordingDeadline = null
previewSettings = defaultPreviewSettings()
previewStatus = richPreviewStatus()
previewRevision = 0
previewDictionary = defaultPreviewDictionary()
previewTrainingIndex = 0
activeTrainingCapture = null
Expand All @@ -491,7 +493,7 @@ export function createPreviewDesktopApi(): PreviewDesktopApi {
let previewMicrophones: MicrophoneSnapshot = defaultPreviewMicrophones(previewDevices)

function getMicrophones(): Promise<MicrophoneSnapshot> {
return Promise.resolve(ipcSnapshot(previewMicrophones))
return Promise.resolve(ipcSnapshot(revisionedMicrophones(previewMicrophones)))
}

function setMicrophone(id: string | null): Promise<MicrophoneSnapshot> {
Expand All @@ -512,7 +514,7 @@ export function createPreviewDesktopApi(): PreviewDesktopApi {
firstRunComplete:
microphoneReady && previewReadiness.speechReady && previewReadiness.hasSuccessfulDictation,
}
return Promise.resolve(ipcSnapshot(previewMicrophones))
return Promise.resolve(ipcSnapshot(revisionedMicrophones(previewMicrophones)))
}

function previewMicrophoneTest(device: InputDevice | null): MicrophoneTestResult {
Expand Down Expand Up @@ -657,6 +659,7 @@ export function createPreviewDesktopApi(): PreviewDesktopApi {
function previewSettingsSnapshot(): SettingsSnapshot {
const nextRun = previewNextRun()
return {
revision: nextPreviewRevision(),
preferences: previewSettings,
transcription: {
nextRun,
Expand All @@ -669,6 +672,15 @@ export function createPreviewDesktopApi(): PreviewDesktopApi {
}
}

function revisionedMicrophones(snapshot: MicrophoneSnapshot): MicrophoneSnapshot {
return { ...snapshot, revision: nextPreviewRevision() }
}

function nextPreviewRevision(): number {
previewRevision += 1
return previewRevision
}

function previewNextRun(): SettingsSnapshot['transcription']['nextRun'] {
if (previewSettings.engine.effective === 'fake') {
return { kind: 'ready', engine: { kind: 'fake' }, language: previewSettings.language.effective }
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/previewDesktopFixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function defaultPreviewSystemDefault(): InputDevice {

export function defaultPreviewMicrophones(devices: InputDevice[]): MicrophoneSnapshot {
const systemDefault = defaultPreviewSystemDefault()
return { host: 'pipe-wire', source: 'default', systemDefault, systemDefaultIsProxy: true, devices, selection: { kind: 'system-default', active: systemDefault }, enumerationWarning: null }
return { revision: 0, host: 'pipe-wire', source: 'default', systemDefault, systemDefaultIsProxy: true, devices, selection: { kind: 'system-default', active: systemDefault }, enumerationWarning: null }
}

export function defaultPreviewReadiness(): Readiness {
Expand Down
116 changes: 94 additions & 22 deletions frontend/src/api/tauriDesktopApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,63 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SettingsChange, SetupEvent } from '../generated/ipc'
import { createTauriDesktopApi, tauriDesktopApi } from './tauriDesktopApi'

const { invokeMock, listenMock } = vi.hoisted(() => ({
invokeMock: vi.fn((command: string, commandArguments?: unknown) => {
void command
void commandArguments
return Promise.resolve()
}),
listenMock: vi.fn((event: string, handler: (event: { payload: SetupEvent }) => void) => {
void event
void handler
return Promise.resolve(() => undefined)
}),
}))

vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }))
const { TestChannel, invokeMock, listenMock } = vi.hoisted(() => {
class HoistedTestChannel<T> {
onmessage: (message: T) => void

constructor(onmessage: (message: T) => void) {
this.onmessage = onmessage
}
}
return {
TestChannel: HoistedTestChannel,
invokeMock: vi.fn<(command: string, commandArguments?: unknown) => Promise<void>>(
(command, commandArguments) => {
void command
void commandArguments
return Promise.resolve()
},
),
listenMock: vi.fn((event: string, handler: (event: { payload: SetupEvent }) => void) => {
void event
void handler
return Promise.resolve(() => undefined)
}),
}
})

vi.mock('@tauri-apps/api/core', () => ({ Channel: TestChannel, invoke: invokeMock }))
vi.mock('@tauri-apps/api/event', () => ({ listen: listenMock }))

function requireFixture<T>(value: T | undefined, description: string): T {
if (value === undefined) throw new Error(`missing test fixture: ${description}`)
return value
}

function resolveLastChannel(message: unknown): void {
const call = requireFixture(invokeMock.mock.calls.at(-1), 'queued command invocation')
const args = call[1]
if (!hasReply(args)) {
throw new Error('queued command invocation did not include a reply channel')
}
args.reply.onmessage(message)
}

function hasReply(value: unknown): value is { reply: InstanceType<typeof TestChannel> } {
return typeof value === 'object' && value != null && 'reply' in value && value.reply instanceof TestChannel
}

function queuedArgs(command: string): unknown {
const call = invokeMock.mock.calls.find(([called]) => called === command)
return requireFixture(call, `${command} invocation`)[1]
}

function queuedReply(command: string): InstanceType<typeof TestChannel> {
const args = queuedArgs(command)
if (!hasReply(args)) throw new Error(`${command} invocation did not include a reply channel`)
return args.reply
}

describe('Tauri desktop adapter contract', () => {
beforeEach(() => {
invokeMock.mockClear()
Expand Down Expand Up @@ -53,14 +89,22 @@ describe('Tauri desktop adapter contract', () => {
await tauriDesktopApi.copyText('text')
await tauriDesktopApi.quitApp()
await tauriDesktopApi.removeStaleInstalls()
await tauriDesktopApi.getSettings()
const settings = tauriDesktopApi.getSettings()
resolveLastChannel({ kind: 'ok', value: undefined })
await settings
await tauriDesktopApi.listModels()
await tauriDesktopApi.listLanguages()
await tauriDesktopApi.setSettings(change)
const settingsWrite = tauriDesktopApi.setSettings(change)
resolveLastChannel({ kind: 'ok', value: undefined })
await settingsWrite
await tauriDesktopApi.listGpuDevices()
await tauriDesktopApi.listGpuDevices(true)
await tauriDesktopApi.getMicrophones()
await tauriDesktopApi.setMicrophone('device')
const microphones = tauriDesktopApi.getMicrophones()
resolveLastChannel({ kind: 'ok', value: undefined })
await microphones
const microphoneWrite = tauriDesktopApi.setMicrophone('device')
resolveLastChannel({ kind: 'ok', value: undefined })
await microphoneWrite
await tauriDesktopApi.testInputDevice('device')
await tauriDesktopApi.testMicrophoneFallback()
await tauriDesktopApi.getReadiness()
Expand Down Expand Up @@ -94,14 +138,14 @@ describe('Tauri desktop adapter contract', () => {
['copy_text', { text: 'text' }],
['quit_app'],
['remove_stale_installs'],
['get_settings'],
['get_settings', { reply: queuedReply('get_settings') }],
['list_models'],
['list_languages'],
['set_settings', { change }],
['set_settings', { change, reply: queuedReply('set_settings') }],
['list_gpu_devices', { refresh: false }],
['list_gpu_devices', { refresh: true }],
['get_microphones'],
['set_microphone', { id: 'device' }],
['get_microphones', { reply: queuedReply('get_microphones') }],
['set_microphone', { id: 'device', reply: queuedReply('set_microphone') }],
['test_input_device', { id: 'device' }],
['test_microphone_fallback'],
['get_readiness'],
Expand All @@ -112,6 +156,34 @@ describe('Tauri desktop adapter contract', () => {
['remove_managed', { component: 'whisper-small' }],
['cancel_setup', { operation: 'operation' }],
])
expect(invokeMock).toHaveBeenCalledWith('delete_history_item', { id: 'history-id' })
expect(invokeMock).toHaveBeenCalledWith('set_settings', {
change,
reply: queuedReply('set_settings'),
})
expect(queuedReply('get_settings')).toBeInstanceOf(TestChannel)
expect(queuedReply('get_microphones')).toBeInstanceOf(TestChannel)
expect(invokeMock).toHaveBeenCalledWith('set_microphone', {
id: 'device',
reply: queuedReply('set_microphone'),
})
})

it('resolves queued command promises from the channel reply', async () => {
const pending = tauriDesktopApi.getSettings()
expect(hasReply(queuedArgs('get_settings'))).toBe(true)

resolveLastChannel({ kind: 'ok', value: { revision: 42 } })

await expect(pending).resolves.toEqual({ revision: 42 })
})

it('rejects queued command promises from the channel reply', async () => {
const pending = tauriDesktopApi.setSettings({ kind: 'hud', value: false })

resolveLastChannel({ kind: 'err', error: 'write failed' })

await expect(pending).rejects.toThrow('write failed')
})

it('subscribes to setup-event and unwraps its payload', async () => {
Expand Down
Loading
Loading