diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index ee102721b5..9204d46616 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -3564,6 +3564,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.29.1" @@ -5608,9 +5614,11 @@ dependencies = [ "hkdf", "hmac 0.12.1", "hostname", + "hound", "iana-time-zone", "image", "keyring", + "lettre", "libc", "log", "mail-parser", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 2af2f6a3e3..dbf447cd81 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -137,11 +137,25 @@ cef = { version = "=146.4.1", default-features = false } # gates existed) means the embedded core does NOT inherit the root crate's # default gate set, so each default-ON gate must be forwarded explicitly to # keep the shipped desktop build byte-identical (AGENTS.md "Compile-time -# domain gates"). `media` re-registers the `media_generate_*` agent tools -# that #4804 moved behind `#[cfg(feature = "media")]`; it sheds no deps, so -# this only restores the pre-gate desktop tool surface. +# domain gates"). +# +# This list is NOT optional polish — a gate missing here vanishes from the +# shipped app silently, with no build error and no test failure: +# +# - `voice` — without it the `#[cfg(feature = "voice")]` controllers in +# `src/core/all.rs` are never registered, so the whole `openhuman.voice_*` +# namespace answers "unknown method" at runtime. This shipped broken from +# v0.58.19 to v0.61.x (#4901); the `VOICE_COMPILED_IN` const assert at the +# top of `src/lib.rs` now fails the build if it is dropped again. +# - `media` — re-registers the `media_generate_*` agent tools that #4804 moved +# behind `#[cfg(feature = "media")]`; it sheds no deps, so this only restores +# the pre-gate desktop tool surface. +# +# `tokenjuice-treesitter` is the remaining un-forwarded default — tracked in +# #4918, deliberately not bundled here because dropping it may be intentional. openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ "media", + "voice", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 5ca2b6375b..ee15b7897e 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,6 +3,20 @@ #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] compile_error!("src-tauri host supports desktop (Windows/macOS/Linux) only. Mobile lives in app/src-tauri-mobile."); +// The shipped desktop app must always embed the real voice domain. Cargo +// features are per-crate, so `#[cfg(feature = "voice")]` here would test THIS +// crate's features, not the core's — a voice-less core is only observable via +// the core's own always-compiled facade. Without this assert the failure is +// silent and runtime-only: every `openhuman.voice_*` RPC answers "unknown +// method" and the UI blames a stale sidecar (#4901). Keep `voice` in the +// `openhuman_core` feature list in Cargo.toml to satisfy this. +const _: () = assert!( + openhuman_core::openhuman::voice::VOICE_COMPILED_IN, + "openhuman_core must be built with the `voice` feature: the desktop app ships voice, \ + and without it every openhuman.voice_* controller is unregistered (#4901). \ + Add \"voice\" to the openhuman_core `features` list in app/src-tauri/Cargo.toml." +); + mod app_update; // Artifact export commands (#2779, #3162) — both cross-platform // (macOS/Windows/Linux): native Save-As dialog (rfd) + Downloads copy. diff --git a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx index 9a1a6450dc..bee206af37 100644 --- a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx +++ b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx @@ -100,7 +100,13 @@ describe('EditableFlowCanvas — validation + dirty state', () => { // The host header reads `hasErrors` off `onSaveMetaChange` to disable its // Save button; the canvas itself also refuses to fire `onSave` through // the imperative handle while hard errors exist, even though dirty. - expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: true }); + // `hasErrors` reaches the host via a follow-up effect that can lag the + // error-banner render (see `onSaveMetaChange` in EditableFlowCanvas), so + // poll the reported meta rather than reading it once — under a loaded + // full-suite run the synchronous read can still see the pre-validation meta. + await waitFor(() => + expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: true }) + ); act(() => ref.current?.save()); expect(onSave).not.toHaveBeenCalled(); diff --git a/app/src/features/human/MicComposer.test.tsx b/app/src/features/human/MicComposer.test.tsx index 42d418cf10..da5411b31d 100644 --- a/app/src/features/human/MicComposer.test.tsx +++ b/app/src/features/human/MicComposer.test.tsx @@ -7,12 +7,19 @@ import { MicComposer, STT_MAX_RETRIES, } from './MicComposer'; +import { VoiceNotCompiledError } from './voice/sttClient'; // transcribeWithFactory + encodeBlobToWav are the network/heavy boundaries — // mock them here so we can drive the state machine without touching real APIs. const transcribeWithFactoryMock = vi.fn(); const encodeBlobToWavMock = vi.fn(); -vi.mock('./voice/sttClient', () => ({ +// Spread the real module so `VoiceNotCompiledError` / `isVoiceNotCompiledError` +// keep their real behaviour; only the network-touching call is stubbed. A +// factory listing exports by hand silently yields `undefined` for anything it +// forgets, which fails as a TypeError at the call site rather than a clear mock +// error. +vi.mock('./voice/sttClient', async importOriginal => ({ + ...(await importOriginal()), transcribeWithFactory: (...args: unknown[]) => transcribeWithFactoryMock(...args), })); vi.mock('./voice/wavEncoder', () => ({ @@ -794,10 +801,11 @@ describe('MicComposer', () => { expect(onError).not.toHaveBeenCalled(); }); - it('does not retry permanent errors (stale sidecar)', async () => { + it('does not retry permanent errors (voice not compiled into the core)', async () => { transcribeWithFactoryMock.mockRejectedValueOnce( new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' + 'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' + + 'Update OpenHuman to the latest version; restarting will not help.' ) ); const onError = vi.fn(); @@ -816,6 +824,31 @@ describe('MicComposer', () => { expect(onSubmit).not.toHaveBeenCalled(); }); + // #4901: the raw error text is untranslated developer copy, so a + // VoiceNotCompiledError must surface the localized `mic.voiceNotCompiled` + // string instead of being spliced into the `{message}` slot. + it('renders translated copy for a voice-not-compiled error, not the raw message', async () => { + transcribeWithFactoryMock.mockRejectedValueOnce(new VoiceNotCompiledError()); + const onError = vi.fn(); + const onSubmit = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /start recording/i })); + await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument() + ); + fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i })); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + const shown = onError.mock.calls[0][0] as string; + // The English `mic.voiceNotCompiled` value, resolved through useT(). + expect(shown).toMatch(/not included in this version/i); + // The untranslated developer copy must not leak into the UI. + expect(shown).not.toMatch(/not compiled into the app/i); + expect(onSubmit).not.toHaveBeenCalled(); + }); + // ── Low-confidence transcript detection (#1206) ──────────────────────────── it('rejects a single-character transcript as low confidence', async () => { diff --git a/app/src/features/human/MicComposer.tsx b/app/src/features/human/MicComposer.tsx index 992a83f014..b843cac268 100644 --- a/app/src/features/human/MicComposer.tsx +++ b/app/src/features/human/MicComposer.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { createPortal } from 'react-dom'; import { useT } from '../../lib/i18n/I18nContext'; -import { transcribeWithFactory } from './voice/sttClient'; +import { isVoiceNotCompiledError, transcribeWithFactory } from './voice/sttClient'; import { encodeBlobToWav } from './voice/wavEncoder'; /** Minimal descriptor for an audio input device. */ @@ -33,7 +33,7 @@ const STT_RETRY_BASE_MS = 500; * Matched case-insensitively against the error message. */ const PERMANENT_ERROR_PATTERNS = [ - 'unknown method', // stale sidecar + 'unknown method', // core built without the `voice` feature (#4901) 'audio blob is empty', 'unavailable in this build', ]; @@ -576,6 +576,13 @@ export function MicComposer({ } const msg = err instanceof Error ? err.message : String(err); composerLog('transcribe failed: %s', msg); + // The core has no voice domain compiled in (#4901). `msg` is untranslated + // developer copy, so render the localized string rather than splicing it + // into the `{message}` slot. + if (isVoiceNotCompiledError(err)) { + onError?.(t('mic.voiceNotCompiled')); + return; + } onError?.(t('mic.transcriptionFailed').replace('{message}', msg)); } finally { if (!disposedRef.current) setState('idle'); diff --git a/app/src/features/human/voice/sttClient.test.ts b/app/src/features/human/voice/sttClient.test.ts index b4d93cc942..924bc98a8d 100644 --- a/app/src/features/human/voice/sttClient.test.ts +++ b/app/src/features/human/voice/sttClient.test.ts @@ -93,14 +93,31 @@ describe('transcribeCloud', () => { expect(await transcribeCloud(blob)).toBe(''); }); - // Issue #1289: stale sidecar binaries surface a generic - // "unknown method" error. Frontend rewrites it to an actionable - // message so users know to restart the desktop app. - it('rewrites "unknown method" errors to an actionable restart hint', async () => { + // #4901: a core built without the `voice` feature never registers the + // `openhuman.voice_*` controllers, so they answer "unknown method". That is a + // compile-time property of the binary, so the message must NOT tell users to + // restart (the pre-#4901 copy did, which could never work). + it('rewrites "unknown method" errors to a not-compiled-in message', async () => { const mock = callCoreRpc as ReturnType; mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); - await expect(transcribeCloud(blob)).rejects.toThrow(/Restart the OpenHuman desktop app/i); + await expect(transcribeCloud(blob)).rejects.toThrow(/not compiled into the app/i); + }); + + it('does not advise restarting for a compile-time voice gate', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeCloud(blob)).rejects.not.toThrow(/restart the openhuman desktop app/i); + }); + + // `MicComposer`'s PERMANENT_ERROR_PATTERNS matches this substring to skip the + // retry/backoff loop — a compile-time gate can never succeed on retry. + it('keeps the "unavailable in this build" substring for retry suppression', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeCloud(blob)).rejects.toThrow(/unavailable in this build/i); }); it('passes through non-unknown-method errors verbatim', async () => { @@ -148,11 +165,21 @@ describe('transcribeWithFactory', () => { expect(mock).not.toHaveBeenCalled(); }); - it('rewrites stale-sidecar "unknown method" errors', async () => { + // #4901 — same compile-time gate as the cloud path above. + it('rewrites "unknown method" errors to a not-compiled-in message', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_stt_dispatch')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeWithFactory(blob)).rejects.toThrow(/not compiled into the app/i); + }); + + it('does not advise restarting for a compile-time voice gate', async () => { const mock = callCoreRpc as ReturnType; mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_stt_dispatch')); const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); - await expect(transcribeWithFactory(blob)).rejects.toThrow(/Restart the OpenHuman desktop app/i); + await expect(transcribeWithFactory(blob)).rejects.not.toThrow( + /restart the openhuman desktop app/i + ); }); it('passes through non-unknown-method errors verbatim', async () => { diff --git a/app/src/features/human/voice/sttClient.ts b/app/src/features/human/voice/sttClient.ts index 1700b067d0..4432a69a10 100644 --- a/app/src/features/human/voice/sttClient.ts +++ b/app/src/features/human/voice/sttClient.ts @@ -4,6 +4,52 @@ import { callCoreRpc } from '../../../services/coreRpcClient'; const sttLog = debug('human:stt'); +/** + * Stable identifier for "the core has no voice domain compiled in" (#4901). + * + * Callers must branch on this code rather than on the message text: the message + * is untranslated developer/log copy, and the UI is responsible for rendering + * translated copy via `useT()` (see `MicComposer`'s `mic.voiceNotCompiled`). + */ +export const VOICE_NOT_COMPILED_CODE = 'voice_not_compiled'; + +/** + * Thrown when the core answers `unknown method` for a `voice_*` RPC — i.e. the + * core was compiled without the `voice` feature, so the domain is absent from + * the binary entirely (#4901). + * + * The `message` is English on purpose: it is what reaches debug logs and Sentry. + * User-facing copy is resolved from `code` at the UI boundary. + * + * Keep the `unavailable in this build` substring: `MicComposer`'s + * `PERMANENT_ERROR_PATTERNS` matches on it to skip the retry/backoff loop, + * which can never succeed for a compile-time gate. + */ +export class VoiceNotCompiledError extends Error { + readonly code = VOICE_NOT_COMPILED_CODE; + + constructor() { + super( + 'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' + + 'Update OpenHuman to the latest version; restarting will not help.' + ); + this.name = 'VoiceNotCompiledError'; + } +} + +/** + * Duck-typed on `code` rather than `instanceof`: the error crosses async + + * retry boundaries, and structured-clone/serialization paths can strip the + * prototype while preserving own properties. + */ +export function isVoiceNotCompiledError(err: unknown): err is VoiceNotCompiledError { + return ( + typeof err === 'object' && + err !== null && + (err as { code?: unknown }).code === VOICE_NOT_COMPILED_CODE + ); +} + export interface CloudTranscribeOptions { /** Override the backend STT model id. Default is whatever the backend * resolves `whisper-v1` to today. */ @@ -64,17 +110,16 @@ export async function transcribeCloud( params, }); } catch (err) { - // Issue #1289: an "unknown method" error means the bundled core - // sidecar is older than the frontend (e.g. a stale dev build, or a - // cached binary the desktop auto-update hasn't refreshed yet). - // The raw "unknown method: openhuman.voice_cloud_transcribe" string - // is opaque to end users — surface an actionable message instead. + // An "unknown method" error means the core serving this app was built + // without the `voice` Cargo feature, so the `openhuman.voice_*` + // controllers were never registered (#4901). This is a compile-time + // property of the binary — restarting cannot change it, which is why the + // old #1289-era "restart to pick up the latest core sidecar" copy was + // unactionable (and the sidecar itself is gone since #1061). const msg = err instanceof Error ? err.message : String(err); if (msg.includes('unknown method')) { - sttLog('transcribe rpc stale-sidecar path hit; rewriting unknown-method error: %s', msg); - throw new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' - ); + sttLog('[voice-stt] transcribe rpc: voice domain absent from core build: %s', msg); + throw new VoiceNotCompiledError(); } sttLog('transcribe rpc failed (passthrough): %O', err); throw err; @@ -149,10 +194,8 @@ export async function transcribeWithFactory( } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('unknown method')) { - sttLog('[voice-stt] dispatch stale-sidecar path: %s', msg); - throw new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' - ); + sttLog('[voice-stt] dispatch: voice domain absent from core build: %s', msg); + throw new VoiceNotCompiledError(); } sttLog('[voice-stt] dispatch failed (passthrough): %O', err); throw err; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index a86721859b..9b9662d400 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3163,6 +3163,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'تعذّر فهم الصوت بوضوح: يرجى المحاولة مرة أخرى', 'mic.failedToStopRecording': 'فشل إيقاف التسجيل: {message}', 'mic.transcriptionFailed': 'فشل النسخ: {message}', + 'mic.voiceNotCompiled': + 'خاصية تحويل الصوت إلى نص غير متوفرة في هذا الإصدار من التطبيق. حدّث OpenHuman لتفعيلها.', 'reflections.kind.retrospective': 'مراجعة', 'reflections.kind.derivedFact': 'حقيقة مستنتجة', 'reflections.kind.moodInsight': 'رؤية المزاج', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 340eb931f6..1e34ff6cc7 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3238,6 +3238,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'অডিও স্পষ্টভাবে বোঝা যায়নি: আবার চেষ্টা করুন', 'mic.failedToStopRecording': 'রেকর্ডিং বন্ধ করতে ব্যর্থ: {message}', 'mic.transcriptionFailed': 'ট্রান্সক্রিপশন ব্যর্থ: {message}', + 'mic.voiceNotCompiled': + 'অ্যাপের এই সংস্করণে ভয়েস ট্রান্সক্রিপশন অন্তর্ভুক্ত নেই। এটি চালু করতে OpenHuman আপডেট করুন।', 'reflections.kind.retrospective': 'পূর্বদর্শন', 'reflections.kind.derivedFact': 'ডেরাইভড ফ্যাক্ট', 'reflections.kind.moodInsight': 'মুড ইনসাইট', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 77cc64948d..871f54a665 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3328,6 +3328,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Audio konnte nicht klar verstanden werden: bitte erneut versuchen', 'mic.failedToStopRecording': 'Aufzeichnung konnte nicht gestoppt werden: {message}', 'mic.transcriptionFailed': 'Transkription fehlgeschlagen: {message}', + 'mic.voiceNotCompiled': + 'Sprachtranskription ist in dieser App-Version nicht enthalten. Aktualisiere OpenHuman, um sie zu aktivieren.', 'reflections.kind.retrospective': 'Retrospektive', 'reflections.kind.derivedFact': 'Abgeleitete Tatsache', 'reflections.kind.moodInsight': 'Stimmungseinsicht', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a1dfc6742f..0291d524c1 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3629,6 +3629,8 @@ const en: TranslationMap = { 'mic.lowConfidenceResult': 'Could not understand the audio clearly: please try again', 'mic.failedToStopRecording': 'Failed to stop recording: {message}', 'mic.transcriptionFailed': 'Transcription failed: {message}', + 'mic.voiceNotCompiled': + 'Voice transcription is not included in this version of the app. Update OpenHuman to enable it.', // Reflections: kind labels 'reflections.kind.retrospective': 'Retrospective', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d8d676616a..b41d056590 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3299,6 +3299,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'No se pudo entender el audio con claridad: intenta de nuevo', 'mic.failedToStopRecording': 'No se pudo detener la grabación: {message}', 'mic.transcriptionFailed': 'Transcripción fallida: {message}', + 'mic.voiceNotCompiled': + 'La transcripción de voz no está incluida en esta versión de la aplicación. Actualiza OpenHuman para activarla.', 'reflections.kind.retrospective': 'Retrospectiva', 'reflections.kind.derivedFact': 'Hecho derivado', 'reflections.kind.moodInsight': 'Insight de ánimo', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index c6a8907d4f..4d34ed6dfa 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3323,6 +3323,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': "Impossible de comprendre l'audio clairement: réessaie", 'mic.failedToStopRecording': "Échec de l'arrêt de l'enregistrement : {message}", 'mic.transcriptionFailed': 'Échec de la transcription : {message}', + 'mic.voiceNotCompiled': + "La transcription vocale n'est pas incluse dans cette version de l'application. Mettez à jour OpenHuman pour l'activer.", 'reflections.kind.retrospective': 'Rétrospective', 'reflections.kind.derivedFact': 'Fait dérivé', 'reflections.kind.moodInsight': 'Insight émotionnel', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 32c565246a..ce2250f929 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3236,6 +3236,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'ऑडियो स्पष्ट रूप से समझ नहीं आया: कृपया पुनः प्रयास करें', 'mic.failedToStopRecording': 'रिकॉर्डिंग रोकने में दिक्कत: {message}', 'mic.transcriptionFailed': 'ट्रांसक्रिप्शन विफल: {message}', + 'mic.voiceNotCompiled': + 'इस ऐप संस्करण में वॉइस ट्रांसक्रिप्शन शामिल नहीं है। इसे चालू करने के लिए OpenHuman को अपडेट करें।', 'reflections.kind.retrospective': 'रेट्रोस्पेक्टिव', 'reflections.kind.derivedFact': 'डिराइव्ड फैक्ट', 'reflections.kind.moodInsight': 'मूड इनसाइट', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 71dcb44573..2c9eddecaf 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3248,6 +3248,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Tidak dapat memahami audio dengan jelas: silakan coba lagi', 'mic.failedToStopRecording': 'Gagal menghentikan perekaman: {message}', 'mic.transcriptionFailed': 'Transkripsi gagal: {message}', + 'mic.voiceNotCompiled': + 'Transkripsi suara tidak tersedia di versi aplikasi ini. Perbarui OpenHuman untuk mengaktifkannya.', 'reflections.kind.retrospective': 'Retrospektif', 'reflections.kind.derivedFact': 'Fakta Turunan', 'reflections.kind.moodInsight': 'Wawasan Suasana Hati', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 92c48d9082..6ca7a6820e 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3297,6 +3297,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': "Impossibile comprendere l'audio chiaramente: riprova", 'mic.failedToStopRecording': 'Impossibile fermare la registrazione: {message}', 'mic.transcriptionFailed': 'Trascrizione fallita: {message}', + 'mic.voiceNotCompiled': + "La trascrizione vocale non è inclusa in questa versione dell'app. Aggiorna OpenHuman per attivarla.", 'reflections.kind.retrospective': 'Retrospettiva', 'reflections.kind.derivedFact': 'Fatto derivato', 'reflections.kind.moodInsight': "Insight sull'umore", diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 77a920ae01..0f484310e5 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3201,6 +3201,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': '오디오를 명확하게 이해할 수 없습니다: 다시 시도해 주세요', 'mic.failedToStopRecording': '녹음을 중지하지 못했습니다: {message}', 'mic.transcriptionFailed': '전사에 실패했습니다: {message}', + 'mic.voiceNotCompiled': + '이 버전의 앱에는 음성 받아쓰기가 포함되어 있지 않습니다. OpenHuman을 업데이트하면 사용할 수 있습니다.', 'reflections.kind.retrospective': '회고', 'reflections.kind.derivedFact': '파생된 사실', 'reflections.kind.moodInsight': '기분 인사이트', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 26d6481937..71a6700c09 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3274,6 +3274,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Nie udało się wyraźnie zrozumieć dźwięku: spróbuj ponownie', 'mic.failedToStopRecording': 'Nie udało się zatrzymać nagrywania: {message}', 'mic.transcriptionFailed': 'Transkrypcja nie powiodła się: {message}', + 'mic.voiceNotCompiled': + 'Transkrypcja głosu nie jest dostępna w tej wersji aplikacji. Zaktualizuj OpenHuman, aby ją włączyć.', 'reflections.kind.retrospective': 'Retrospektywa', 'reflections.kind.derivedFact': 'Wywiedziony fakt', 'reflections.kind.moodInsight': 'Wnioski o nastroju', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 8ed1b3f750..f88efaeb90 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3291,6 +3291,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Não foi possível entender o áudio com clareza: tente novamente', 'mic.failedToStopRecording': 'Falha ao parar a gravação: {message}', 'mic.transcriptionFailed': 'Falha na transcrição: {message}', + 'mic.voiceNotCompiled': + 'A transcrição de voz não está incluída nesta versão do aplicativo. Atualize o OpenHuman para ativá-la.', 'reflections.kind.retrospective': 'Retrospectiva', 'reflections.kind.derivedFact': 'Fato Derivado', 'reflections.kind.moodInsight': 'Insight de Humor', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 897d272052..45ea259140 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3264,6 +3264,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Не удалось чётко распознать аудио: попробуйте ещё раз', 'mic.failedToStopRecording': 'Не удалось остановить запись: {message}', 'mic.transcriptionFailed': 'Ошибка транскрипции: {message}', + 'mic.voiceNotCompiled': + 'Расшифровка речи не включена в эту версию приложения. Обновите OpenHuman, чтобы включить её.', 'reflections.kind.retrospective': 'Ретроспектива', 'reflections.kind.derivedFact': 'Выведенный факт', 'reflections.kind.moodInsight': 'Инсайт о настроении', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 23d8f0446c..517b1b8fc0 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3065,6 +3065,7 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': '无法清楚地理解音频:请重试', 'mic.failedToStopRecording': '停止录音失败: {message}', 'mic.transcriptionFailed': '转录失败: {message}', + 'mic.voiceNotCompiled': '此版本的应用未包含语音转文字功能。请更新 OpenHuman 后再使用。', 'reflections.kind.retrospective': '回顾', 'reflections.kind.derivedFact': '派生事实', 'reflections.kind.moodInsight': '情绪洞察', diff --git a/src/openhuman/voice/compile_status.rs b/src/openhuman/voice/compile_status.rs new file mode 100644 index 0000000000..c4973cd244 --- /dev/null +++ b/src/openhuman/voice/compile_status.rs @@ -0,0 +1,49 @@ +//! Compile-time visibility into the `voice` gate. +//! +//! Deliberately **ungated**: unlike the rest of the domain, this module is +//! compiled in both feature states, because its whole purpose is to report +//! which state the binary ended up in. It is part of the always-compiled facade +//! described in [`super`], alongside `stub`. + +/// Whether the real voice domain was compiled into this binary. +/// +/// Cargo features are per-crate and invisible to dependents' `#[cfg]`, so a +/// consumer that *requires* voice (the desktop shell) has no other way to detect +/// that it silently got the stubbed build — which is exactly how #4901 shipped: +/// `app/src-tauri/Cargo.toml` set `default-features = false`, dropping the +/// default-ON `voice` feature, so every `openhuman.voice_*` controller went +/// unregistered and answered "unknown method" at runtime. +/// +/// The shell asserts this at compile time (`const _: () = assert!(...)` in +/// `app/src-tauri/src/lib.rs`), turning that silent runtime failure into a build +/// failure. +pub const VOICE_COMPILED_IN: bool = cfg!(feature = "voice"); + +#[cfg(test)] +mod tests { + use super::VOICE_COMPILED_IN; + + /// Pins the constant to the gate rather than to a hardcoded value: the + /// assertion inverts with the feature, so it holds for both the default + /// build and the slim (`--no-default-features`) build. + #[test] + fn reports_the_compiled_gate_state() { + assert_eq!(VOICE_COMPILED_IN, cfg!(feature = "voice")); + } + + /// The default build ships voice; this is the state the desktop app + /// requires (#4901). Skipped when the slim build is under test. + #[test] + #[cfg(feature = "voice")] + fn is_true_when_the_voice_feature_is_on() { + assert!(VOICE_COMPILED_IN); + } + + /// The slim build must report honestly, otherwise the shell's const assert + /// would pass against a stubbed core and #4901 could ship again. + #[test] + #[cfg(not(feature = "voice"))] + fn is_false_when_the_voice_feature_is_off() { + assert!(!VOICE_COMPILED_IN); + } +} diff --git a/src/openhuman/voice/mod.rs b/src/openhuman/voice/mod.rs index 993dca1479..50b3508d5d 100644 --- a/src/openhuman/voice/mod.rs +++ b/src/openhuman/voice/mod.rs @@ -23,6 +23,12 @@ //! (`cargo check --no-default-features --features ""`): any //! signature drift fails that build. +// Ungated — part of the always-compiled facade (see the module docs above): it +// reports which side of the gate this binary landed on, so it must exist in +// both states. +pub mod compile_status; +pub use compile_status::VOICE_COMPILED_IN; + #[cfg(feature = "voice")] pub mod always_on; #[cfg(feature = "voice")]