From 58ebdf88ae645a2807d2a32f2affdbba419b767f Mon Sep 17 00:00:00 2001 From: quanru Date: Tue, 22 Sep 2026 19:49:18 +0800 Subject: [PATCH 1/6] feat(settings): show Voxtype runtime controls --- README.md | 5 ++ README.zh-CN.md | 3 + src/doubao_input/app.py | 15 ++++ src/doubao_input/ui/settings_window.py | 58 ++++++++++++ src/doubao_input/voxtype/control.py | 119 +++++++++++++++++++++++++ tests/unit/test_setup_actions.py | 35 ++++++++ tests/unit/test_voxtype_control.py | 72 +++++++++++++++ 7 files changed, 307 insertions(+) create mode 100644 src/doubao_input/voxtype/control.py create mode 100644 tests/unit/test_voxtype_control.py diff --git a/README.md b/README.md index 1c5d997..7c807d2 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,11 @@ file-mode completion API. Install Voxtype separately, run `voxtype setup --download`, start its daemon, then use **Refresh Voxtype status** in onboarding. The engine, model, language, audio device, and acceleration remain configured in Voxtype; this integration does not rewrite its configuration. +The Settings window reads Voxtype's versioned schema and extended status to show +the running daemon version, state, engine, model, audio device, compute backend, +schema version, and config path. **Open Voxtype configuration** launches +`voxtype configure` in an available terminal, so Voxtype still validates and +writes every setting itself. For each confirmed dictation, Doubao Say asks Voxtype to write one transcript in a private per-user runtime directory, waits for the `.done` completion signal, diff --git a/README.zh-CN.md b/README.zh-CN.md index 0dc74b5..161f518 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -66,6 +66,9 @@ API Key 保存在 `~/.config/doubao-say/deepgram_api_key`(或对应的 Voxtype,运行 `voxtype setup --download`,启动 daemon,再在首次设置中点击 **刷新 Voxtype 状态**。引擎、模型、语言、音频设备和加速方式仍在 Voxtype 中配置; 该接入不会改写 Voxtype 配置。 +设置窗口会读取 Voxtype 的版本化 Schema 和扩展状态,显示 daemon 版本、状态、引擎、 +模型、音频设备、计算后端、Schema 版本和配置文件路径。点击**打开 Voxtype 配置**后, +应用会在可用终端里启动 `voxtype configure`,所有配置仍由 Voxtype 自己校验和写入。 每次听写手势确认后,豆包说会让 Voxtype 把一份转写写入当前用户的私有 runtime 目录, 等待 `.done` 完成信号,读取原子写入的最终文字,然后删除这两个文件。若 Voxtype 已在 diff --git a/src/doubao_input/app.py b/src/doubao_input/app.py index a289f90..c008354 100644 --- a/src/doubao_input/app.py +++ b/src/doubao_input/app.py @@ -854,11 +854,26 @@ def _show_settings(self): preview=self._preview_overlay, apply_key=self._apply_trigger_key, asr_has_key=self._api_key_has_saved, save_asr=self._save_asr_key, clear_asr=self._clear_asr_key, test_asr=self._test_asr_key, + voxtype_details=self._voxtype_details, + configure_voxtype=self._configure_voxtype, diagnostic_report=lambda: diagnostic_report( self.settings, recording=self.app_state.is_recording, trace=self._diagnostics)) self._settings_window.show() + @staticmethod + def _voxtype_details(): + from doubao_input.voxtype.control import inspect_details + return inspect_details() + + @staticmethod + def _configure_voxtype(): + from doubao_input.voxtype.control import launch_configure + try: + launch_configure() + except RuntimeError as error: + raise ValueError(str(error)) from error + def _preview_overlay(self): if self._busy(): raise ValueError(tr("Finish the current operation first.", "请先结束当前操作。")) diff --git a/src/doubao_input/ui/settings_window.py b/src/doubao_input/ui/settings_window.py index 867f8d6..2267234 100644 --- a/src/doubao_input/ui/settings_window.py +++ b/src/doubao_input/ui/settings_window.py @@ -22,6 +22,8 @@ def __init__(self, parent, settings, apply, *, capture_key=None, apply_key=None, asr_has_key=lambda: False, save_asr=lambda key: None, clear_asr=lambda: None, test_asr=lambda key, completed: None, + voxtype_details=lambda: None, + configure_voxtype=lambda: None, diagnostic_report=None): self.window = Gtk.Window(title=tr("Doubao Say Settings", "豆包说设置"), transient_for=parent, modal=True) self.window.set_default_size(540, 580) @@ -35,6 +37,8 @@ def __init__(self, parent, settings, apply, *, capture_key=None, self._save_asr = save_asr self._clear_asr = clear_asr self._test_asr = test_asr + self._voxtype_details = voxtype_details + self._configure_voxtype = configure_voxtype self._asr_save_source = 0 self._asr_testing = False self._diagnostic_report = diagnostic_report or (lambda: report(self._settings)) @@ -141,6 +145,25 @@ def run(*_): self.asr_details.append(self.asr_status) self.asr_details.set_visible(selected_provider.uses_api_key) box.append(self.asr_details) + self.voxtype_details = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.voxtype_summary = Gtk.Label( + xalign=0, wrap=True, selectable=True) + self.voxtype_details.append(self.voxtype_summary) + voxtype_actions = Gtk.Box(spacing=8, homogeneous=True) + self.voxtype_refresh = Gtk.Button(label=tr( + "Refresh Voxtype status", "刷新 Voxtype 状态")) + self.voxtype_refresh.connect( + "clicked", self._refresh_voxtype_details) + self.voxtype_configure = Gtk.Button(label=tr( + "Open Voxtype configuration", "打开 Voxtype 配置")) + self.voxtype_configure.connect( + "clicked", self._open_voxtype_configuration) + voxtype_actions.append(self.voxtype_refresh) + voxtype_actions.append(self.voxtype_configure) + self.voxtype_details.append(voxtype_actions) + self.voxtype_details.set_visible(selected_provider.is_local) + box.append(self.voxtype_details) section(tr("Input", "输入")) self.input_method = Gtk.DropDown.new_from_strings([ @@ -468,8 +491,43 @@ def _sync_provider_details(self): self.clear_credentials_button.set_visible(provider.interactive_auth) self.provider_help.set_text(provider.credential_help) self.provider_help.set_visible(bool(provider.credential_help)) + self.voxtype_details.set_visible(provider.is_local) + if provider.is_local: + self._refresh_voxtype_details() self.privacy_copy.set_text(provider.privacy) + def _refresh_voxtype_details(self, *_): + try: + details = self._voxtype_details() + if details is None: + raise RuntimeError("Voxtype details are unavailable") + except (OSError, RuntimeError) as error: + self.voxtype_summary.set_text(tr( + "Voxtype status unavailable: ", + "无法读取 Voxtype 状态:") + str(error)) + return + self.voxtype_summary.set_text("\n".join(( + tr("CLI version: ", "CLI 版本:") + details.cli_version, + tr("Daemon version: ", "Daemon 版本:") + details.daemon_version, + tr("State: ", "状态:") + details.state, + tr("Engine: ", "引擎:") + details.engine, + tr("Model: ", "模型:") + details.model, + tr("Audio device: ", "音频设备:") + details.device, + tr("Compute backend: ", "计算后端:") + details.backend, + tr("Config schema: ", "配置 Schema:") + str(details.schema_version), + tr("Config file: ", "配置文件:") + details.config_path, + ))) + + def _open_voxtype_configuration(self, *_): + try: + self._configure_voxtype() + except (OSError, ValueError) as error: + self.voxtype_summary.set_text(str(error)) + return + self.voxtype_summary.set_text(tr( + "Voxtype configuration opened in a terminal.", + "已在终端中打开 Voxtype 配置。")) + def _queue_asr_key_save(self, *_): if self._updating or not self.asr_key.get_text().strip(): return diff --git a/src/doubao_input/voxtype/control.py b/src/doubao_input/voxtype/control.py new file mode 100644 index 0000000..c8e9568 --- /dev/null +++ b/src/doubao_input/voxtype/control.py @@ -0,0 +1,119 @@ +"""Read Voxtype's stable GUI-facing contracts and open its own configurator.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +import shlex +import shutil +import subprocess + +from doubao_input.voxtype.runtime import VoxtypeRuntime, inspect_runtime + + +def _run(command, *, timeout=3): + return subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + + +@dataclass(frozen=True) +class VoxtypeDetails: + cli_version: str + daemon_version: str + state: str + engine: str + model: str + device: str + backend: str + schema_version: int | str + config_path: str + + +def inspect_details(*, runtime=None, runner=_run) -> VoxtypeDetails: + runtime = runtime or inspect_runtime(runner=runner) + status = _json_command([ + runtime.executable, "status", "--extended", "--format", "json", + ], runner) + state = status.get("alt") or status.get("class") + if state == "stopped": + raise RuntimeError("Voxtype daemon is not running") + schema = _json_command([ + runtime.executable, "config", "schema", "--json", + ], runner) + return VoxtypeDetails( + cli_version=str(schema.get("voxtype_version") or runtime.version), + daemon_version=str(schema.get("daemon_version_label") or "unknown"), + state=str(state or "unknown"), + engine=str(schema.get("engine") or "unknown"), + model=str(status.get("model") or "unknown"), + device=str(status.get("device") or "system default"), + backend=str(status.get("backend") or "unknown"), + schema_version=schema.get("schema_version", "unknown"), + config_path=str(schema.get("config_path") or "unknown"), + ) + + +def _json_command(command, runner): + result = runner(command) + if result.returncode: + message = (result.stderr or result.stdout or "").strip() + raise RuntimeError(message[:500] or "Voxtype command failed") + try: + payload = json.loads(result.stdout) + except (TypeError, ValueError) as error: + raise RuntimeError("Voxtype returned invalid JSON") from error + if not isinstance(payload, dict): + raise RuntimeError("Voxtype returned invalid JSON") + return payload + + +def launch_configure(*, runtime: VoxtypeRuntime | None = None, + which=shutil.which, popen=subprocess.Popen, + environment=None) -> None: + runtime = runtime or inspect_runtime() + if environment is None: + environment = os.environ + terminal = environment.get("TERMINAL", "").strip() + if terminal: + prefix = shlex.split(terminal) + executable = which(prefix[0]) if prefix else None + if executable: + _launch([ + executable, *prefix[1:], "-e", + runtime.executable, "configure", + ], popen) + return + candidates = ( + ("kitty", "-e"), + ("alacritty", "-e"), + ("foot", "-e"), + ("wezterm", "start", "--"), + ("gnome-terminal", "--"), + ("konsole", "-e"), + ("xfce4-terminal", "-e"), + ("xterm", "-e"), + ) + for candidate in candidates: + executable = which(candidate[0]) + if executable: + _launch([ + executable, *candidate[1:], runtime.executable, "configure", + ], popen) + return + raise RuntimeError("No supported terminal was found for Voxtype configure") + + +def _launch(command, popen): + popen( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) diff --git a/tests/unit/test_setup_actions.py b/tests/unit/test_setup_actions.py index 87a618f..b293200 100644 --- a/tests/unit/test_setup_actions.py +++ b/tests/unit/test_setup_actions.py @@ -9,6 +9,7 @@ from doubao_input.ui.control_window import ControlWindow from doubao_input.ui.polish_settings import PolishSettings from doubao_input.ui.settings_window import SettingsWindow +from doubao_input.voxtype.control import VoxtypeDetails class SetupActionsTest(unittest.TestCase): @@ -250,3 +251,37 @@ def test_official_key_test_saves_first_and_has_adjacent_feedback(self): completed("API key accepted", "") self.assertFalse(view._asr_testing) self.assertEqual(view.asr_status.set_text.call_args.args[0], "API key accepted") + + def test_voxtype_details_are_rendered_without_editing_config(self): + details = VoxtypeDetails( + cli_version="1.0.1", + daemon_version="1.0.1", + state="idle", + engine="whisper", + model="large-v3-turbo", + device="Desk microphone", + backend="Vulkan", + schema_version=1, + config_path="/home/test/.config/voxtype/config.toml", + ) + view = SimpleNamespace( + _voxtype_details=Mock(return_value=details), + voxtype_summary=Mock(), + ) + + SettingsWindow._refresh_voxtype_details(view) + + rendered = view.voxtype_summary.set_text.call_args.args[0] + self.assertIn("large-v3-turbo", rendered) + self.assertIn("Vulkan", rendered) + self.assertIn("config.toml", rendered) + + def test_voxtype_configure_failure_stays_in_settings(self): + view = SimpleNamespace( + _configure_voxtype=Mock(side_effect=ValueError("no terminal")), + voxtype_summary=Mock(), + ) + + SettingsWindow._open_voxtype_configuration(view) + + view.voxtype_summary.set_text.assert_called_once_with("no terminal") diff --git a/tests/unit/test_voxtype_control.py b/tests/unit/test_voxtype_control.py new file mode 100644 index 0000000..abe53ca --- /dev/null +++ b/tests/unit/test_voxtype_control.py @@ -0,0 +1,72 @@ +import json +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import Mock + +from doubao_input.voxtype.control import inspect_details, launch_configure +from doubao_input.voxtype.runtime import VoxtypeRuntime + + +class VoxtypeControlTest(TestCase): + runtime = VoxtypeRuntime("/usr/bin/voxtype", "1.0.1", True) + + def test_reads_extended_status_and_schema_contracts(self): + def run(command): + if "status" in command: + payload = { + "alt": "idle", + "model": "large-v3-turbo", + "device": "Desk microphone", + "backend": "Vulkan", + } + else: + payload = { + "schema_version": 1, + "voxtype_version": "1.0.1", + "daemon_version_label": "1.0.1", + "config_path": "/home/test/.config/voxtype/config.toml", + "engine": "whisper", + "keys": [], + } + return SimpleNamespace( + returncode=0, stdout=json.dumps(payload), stderr="") + + details = inspect_details(runtime=self.runtime, runner=run) + self.assertEqual(details.state, "idle") + self.assertEqual(details.engine, "whisper") + self.assertEqual(details.model, "large-v3-turbo") + self.assertEqual(details.backend, "Vulkan") + self.assertEqual(details.schema_version, 1) + + def test_rejects_stopped_daemon(self): + runner = lambda _command: SimpleNamespace( + returncode=0, + stdout='{"alt":"stopped"}', + stderr="", + ) + with self.assertRaisesRegex(RuntimeError, "not running"): + inspect_details(runtime=self.runtime, runner=runner) + + def test_opens_voxtype_configure_in_available_terminal(self): + popen = Mock() + paths = {"kitty": "/usr/bin/kitty"} + launch_configure( + runtime=self.runtime, + which=paths.get, + popen=popen, + environment={}, + ) + command = popen.call_args.args[0] + self.assertEqual(command, [ + "/usr/bin/kitty", "-e", "/usr/bin/voxtype", "configure", + ]) + self.assertTrue(popen.call_args.kwargs["start_new_session"]) + + def test_reports_when_no_terminal_is_available(self): + with self.assertRaisesRegex(RuntimeError, "No supported terminal"): + launch_configure( + runtime=self.runtime, + which=lambda _name: None, + popen=Mock(), + environment={}, + ) From a2d249760999626e66202b732481aa680a05de26 Mon Sep 17 00:00:00 2001 From: quanru Date: Wed, 23 Sep 2026 10:55:36 +0800 Subject: [PATCH 2/6] test(e2e): inspect Voxtype settings through GTK --- tests/contracts/test_pages_report_history.mjs | 4 +-- tests/e2e/cases/onboarding-regressions.yaml | 11 +++++++ tests/e2e/gtk_fixture.py | 2 +- tests/e2e/gtk_onboarding_fixture.py | 30 +++++++++++++++++-- tests/e2e/midscene.config.ts | 1 + 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/tests/contracts/test_pages_report_history.mjs b/tests/contracts/test_pages_report_history.mjs index 87d769e..ebc63c9 100644 --- a/tests/contracts/test_pages_report_history.mjs +++ b/tests/contracts/test_pages_report_history.mjs @@ -124,9 +124,9 @@ test('assigns every product case to exactly one balanced shard', async () => { caseCount += 1; } } - assert.equal(caseCount, 16); + assert.equal(caseCount, 17); assert.deepEqual(Object.fromEntries(shardCounts), { - 'shard-1': 2, + 'shard-1': 3, 'shard-2': 3, 'shard-3': 7, 'shard-4': 4, diff --git a/tests/e2e/cases/onboarding-regressions.yaml b/tests/e2e/cases/onboarding-regressions.yaml index e1af8fc..3702b51 100644 --- a/tests/e2e/cases/onboarding-regressions.yaml +++ b/tests/e2e/cases/onboarding-regressions.yaml @@ -211,6 +211,17 @@ cases: - aiTap: Finish & check result button in the Voice test content panel - wait: { duration: 1250 } - aiAssert: The voice test shows Synthetic Voxtype English transcript, the Listening overlay is gone, and feedback says the synthetic Voxtype CLI completed with transcript files removed and nothing pasted or sent. + - name: Inspect Voxtype configuration in Settings + tags: [shard-1, provider] + steps: + - fixture.prepare: { mode: voxtype-settings } + - aiTap: Settings button in the Doubao Say header + - aiAct: Scroll down in the Settings window until Voxtype status and its two action buttons are fully visible. + - aiAssert: The Voxtype status shows CLI version 1.0.1, daemon version 1.0.1, idle state, whisper engine, synthetic-english-model, CI-only synthetic microphone, CPU backend, and config schema 1. The API Key field is hidden. + - aiTap: Refresh Voxtype status button in the Settings window + - aiAssert: The Voxtype status still shows idle and synthetic-english-model, and Open Voxtype configuration is available. + - aiTap: Open Voxtype configuration button in the Settings window + - aiAssert: The Settings window says Voxtype configuration opened in a terminal. - name: Changing microphone requires another check tags: [shard-2] steps: diff --git a/tests/e2e/gtk_fixture.py b/tests/e2e/gtk_fixture.py index 210179e..2eb6684 100644 --- a/tests/e2e/gtk_fixture.py +++ b/tests/e2e/gtk_fixture.py @@ -36,7 +36,7 @@ def main(): Gtk.init() set_language("en") mode = fixture_mode() - if mode == "voxtype-live": + if mode in {"voxtype-live", "voxtype-settings"}: fake_cli = Path(__file__).parent / "fakes" os.environ["PATH"] = str(fake_cli) + os.pathsep + os.environ["PATH"] cleanup = ( diff --git a/tests/e2e/gtk_onboarding_fixture.py b/tests/e2e/gtk_onboarding_fixture.py index 2670dd4..ac5d8f8 100644 --- a/tests/e2e/gtk_onboarding_fixture.py +++ b/tests/e2e/gtk_onboarding_fixture.py @@ -27,6 +27,7 @@ "microphone-change", "shortcut-capture", "voxtype-live", + "voxtype-settings", } @@ -38,6 +39,7 @@ def build_onboarding_fixture(mode): else LoginStatus.NOT_LOGGED_IN ) settings = Settings( + asr_provider="voxtype" if mode == "voxtype-settings" else "doubao", polish_enabled=True, polish_base_url="https://example.invalid/v1", polish_model="synthetic-model", @@ -55,8 +57,8 @@ def build_onboarding_fixture(mode): "shortcut-capture", }, "onboarding_complete": False, - "asr_provider": "voxtype" if mode == "voxtype-live" else "doubao", - "asr_provider_name": "Voxtype (local)" if mode == "voxtype-live" else "Doubao", + "asr_provider": "voxtype" if mode in {"voxtype-live", "voxtype-settings"} else "doubao", + "asr_provider_name": "Voxtype (local)" if mode in {"voxtype-live", "voxtype-settings"} else "Doubao", } holder = { "asr_key": False, @@ -64,6 +66,7 @@ def build_onboarding_fixture(mode): "login": None, "preview": False, "voxtype_manager": None, + "settings_window": None, } overlay = Overlay(state) @@ -319,6 +322,25 @@ def finish(): GLib.timeout_add(350, finish) + def open_settings(): + if mode != "voxtype-settings": + return + if holder["settings_window"]: + holder["settings_window"].show() + return + from doubao_input.ui.settings_window import SettingsWindow + from doubao_input.voxtype.control import inspect_details + + window = SettingsWindow( + holder["control"].window, + settings, + lambda _updated: None, + voxtype_details=inspect_details, + configure_voxtype=lambda: None, + ) + holder["settings_window"] = window + window.show() + def complete_setup(): summary["onboarding_complete"] = True control = holder["control"] @@ -330,7 +352,7 @@ def complete_setup(): actions = SetupActions( test_voice=test_voice, cancel_preview=cancel_preview, - open_settings=lambda: None, + open_settings=open_settings, is_preview_testing=lambda: holder["preview"], summary=lambda: summary, complete_setup=complete_setup, @@ -358,6 +380,8 @@ def complete_setup(): control.show() def cleanup(): + if holder["settings_window"]: + holder["settings_window"].window.destroy() if holder["voxtype_manager"]: holder["voxtype_manager"].handle_cancel() cancel_key_capture() diff --git a/tests/e2e/midscene.config.ts b/tests/e2e/midscene.config.ts index a74c4a1..d820be9 100644 --- a/tests/e2e/midscene.config.ts +++ b/tests/e2e/midscene.config.ts @@ -200,6 +200,7 @@ const fixtureMode = z.strictObject({ 'deepgram', 'voxtype', 'voxtype-live', + 'voxtype-settings', 'microphone-change', 'shortcut-capture', 'runtime-delivery', From e86eefad55f7b72f4289cb9644d0976f7bb55bef Mon Sep 17 00:00:00 2001 From: quanru Date: Wed, 23 Sep 2026 11:42:00 +0800 Subject: [PATCH 3/6] test: focus Voxtype settings fixture for Midscene --- tests/e2e/cases/onboarding-regressions.yaml | 3 +-- tests/e2e/gtk_onboarding_fixture.py | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/e2e/cases/onboarding-regressions.yaml b/tests/e2e/cases/onboarding-regressions.yaml index b9c40f1..7e7c961 100644 --- a/tests/e2e/cases/onboarding-regressions.yaml +++ b/tests/e2e/cases/onboarding-regressions.yaml @@ -217,8 +217,7 @@ cases: tags: [shard-1, provider] steps: - fixture.prepare: { mode: voxtype-settings } - - aiTap: Settings button in the Doubao Say header - - aiAct: Scroll down in the Settings window until Voxtype status and its two action buttons are fully visible. + - aiAssert: The Doubao Say Settings window is open with the Voxtype status and action buttons visible. - aiAssert: The Voxtype status shows CLI version 1.0.1, daemon version 1.0.1, idle state, whisper engine, synthetic-english-model, CI-only synthetic microphone, CPU backend, and config schema 1. The API Key field is hidden. - aiTap: Refresh Voxtype status button in the Settings window - aiAssert: The Voxtype status still shows idle and synthetic-english-model, and Open Voxtype configuration is available. diff --git a/tests/e2e/gtk_onboarding_fixture.py b/tests/e2e/gtk_onboarding_fixture.py index ac5d8f8..5e9d3ab 100644 --- a/tests/e2e/gtk_onboarding_fixture.py +++ b/tests/e2e/gtk_onboarding_fixture.py @@ -378,6 +378,14 @@ def complete_setup(): ) holder["control"] = control control.show() + if mode == "voxtype-settings": + def show_voxtype_settings(): + open_settings() + # Focus the real control so GTK scrolls its containing pane into view. + holder["settings_window"].voxtype_refresh.grab_focus() + return GLib.SOURCE_REMOVE + + GLib.idle_add(show_voxtype_settings) def cleanup(): if holder["settings_window"]: From bde42ff76eadc014a87d505dddb982cd6889bf4c Mon Sep 17 00:00:00 2001 From: quanru Date: Wed, 23 Sep 2026 11:48:26 +0800 Subject: [PATCH 4/6] test: isolate Voxtype settings visual verification --- .github/workflows/midscene-omarchy-4.0.3.yml | 1 + .github/workflows/midscene-ubuntu-22.04.yml | 1 + tests/e2e/cases/onboarding-regressions.yaml | 2 +- tests/e2e/midscene.config.ts | 14 ++++++++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/midscene-omarchy-4.0.3.yml b/.github/workflows/midscene-omarchy-4.0.3.yml index afcfb76..60bf7b0 100644 --- a/.github/workflows/midscene-omarchy-4.0.3.yml +++ b/.github/workflows/midscene-omarchy-4.0.3.yml @@ -50,6 +50,7 @@ on: - omarchy-shard-4 - omarchy-shell - omarchy-providers + - omarchy-voxtype-settings permissions: contents: read diff --git a/.github/workflows/midscene-ubuntu-22.04.yml b/.github/workflows/midscene-ubuntu-22.04.yml index 4097ce5..2299198 100644 --- a/.github/workflows/midscene-ubuntu-22.04.yml +++ b/.github/workflows/midscene-ubuntu-22.04.yml @@ -41,6 +41,7 @@ on: - ubuntu-shard-4 - ubuntu-polishing - ubuntu-providers + - ubuntu-voxtype-settings permissions: contents: read diff --git a/tests/e2e/cases/onboarding-regressions.yaml b/tests/e2e/cases/onboarding-regressions.yaml index 7e7c961..93341cc 100644 --- a/tests/e2e/cases/onboarding-regressions.yaml +++ b/tests/e2e/cases/onboarding-regressions.yaml @@ -214,7 +214,7 @@ cases: - wait: { duration: 1250 } - aiAssert: The voice test shows Synthetic Voxtype English transcript, the Listening overlay is gone, and feedback says the synthetic Voxtype CLI completed with transcript files removed and nothing pasted or sent. - name: Inspect Voxtype configuration in Settings - tags: [shard-1, provider] + tags: [shard-1, provider, voxtype-settings] steps: - fixture.prepare: { mode: voxtype-settings } - aiAssert: The Doubao Say Settings window is open with the Voxtype status and action buttons visible. diff --git a/tests/e2e/midscene.config.ts b/tests/e2e/midscene.config.ts index d820be9..60b95ee 100644 --- a/tests/e2e/midscene.config.ts +++ b/tests/e2e/midscene.config.ts @@ -334,6 +334,20 @@ export default defineTestProject({ files: { include: productCaseFiles }, tags: { include: ['provider'] }, }, + { + name: 'ubuntu-voxtype-settings', + retry: 2, + setup, + files: { include: productCaseFiles }, + tags: { include: ['voxtype-settings'] }, + }, + { + name: 'omarchy-voxtype-settings', + retry: 2, + setup, + files: { include: productCaseFiles }, + tags: { include: ['voxtype-settings'] }, + }, { name: 'omarchy-shell', retry: 2, From 937ca874bcb27d817b0c3d3aeb48a5ef9c04f3ec Mon Sep 17 00:00:00 2001 From: quanru Date: Wed, 23 Sep 2026 12:23:35 +0800 Subject: [PATCH 5/6] test: allow provider projects in Omarchy runner --- tests/e2e/run-omarchy-midscene.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/run-omarchy-midscene.sh b/tests/e2e/run-omarchy-midscene.sh index 4da2c69..7e83c57 100755 --- a/tests/e2e/run-omarchy-midscene.sh +++ b/tests/e2e/run-omarchy-midscene.sh @@ -197,7 +197,7 @@ for _xvfb_attempt in {1..200}; do done case "$MIDSCENE_PROJECT" in - omarchy-shard-[1-4]) + omarchy-shard-[1-4]|omarchy-providers|omarchy-voxtype-settings) npm --prefix tests/e2e test -- --project "$MIDSCENE_PROJECT" ;; omarchy-shell) From d38f476bf397f73cdeb6e67eeb5f0975a9b731a6 Mon Sep 17 00:00:00 2001 From: quanru Date: Fri, 25 Sep 2026 14:44:01 +0800 Subject: [PATCH 6/6] fix(settings): read Voxtype status without blocking GTK --- src/doubao_input/ui/settings_window.py | 40 ++++++++++++++++++---- tests/unit/test_setup_actions.py | 46 ++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/doubao_input/ui/settings_window.py b/src/doubao_input/ui/settings_window.py index 2267234..b24ad20 100644 --- a/src/doubao_input/ui/settings_window.py +++ b/src/doubao_input/ui/settings_window.py @@ -1,5 +1,7 @@ """Grouped preferences, safe hardware-key capture and allowlisted diagnostics.""" from dataclasses import replace +import subprocess +import threading from gi.repository import GLib, Gtk, Gdk from doubao_input.ui.trigger_picker import TriggerPicker from doubao_input.i18n import LANGUAGES, tr @@ -39,6 +41,7 @@ def __init__(self, parent, settings, apply, *, capture_key=None, self._test_asr = test_asr self._voxtype_details = voxtype_details self._configure_voxtype = configure_voxtype + self._voxtype_request_id = 0 self._asr_save_source = 0 self._asr_testing = False self._diagnostic_report = diagnostic_report or (lambda: report(self._settings)) @@ -494,18 +497,40 @@ def _sync_provider_details(self): self.voxtype_details.set_visible(provider.is_local) if provider.is_local: self._refresh_voxtype_details() + else: + self._voxtype_request_id += 1 + self.voxtype_refresh.set_sensitive(True) self.privacy_copy.set_text(provider.privacy) def _refresh_voxtype_details(self, *_): - try: - details = self._voxtype_details() - if details is None: - raise RuntimeError("Voxtype details are unavailable") - except (OSError, RuntimeError) as error: + self._voxtype_request_id += 1 + request_id = self._voxtype_request_id + self.voxtype_refresh.set_sensitive(False) + self.voxtype_summary.set_text(tr( + "Reading Voxtype status…", "正在读取 Voxtype 状态…")) + + def read_details(): + try: + details = self._voxtype_details() + if details is None: + raise RuntimeError("Voxtype details are unavailable") + error = None + except (OSError, RuntimeError, subprocess.SubprocessError) as exc: + details, error = None, str(exc) + GLib.idle_add(self._show_voxtype_details, request_id, details, error) + + threading.Thread(target=read_details, name="voxtype-status", + daemon=True).start() + + def _show_voxtype_details(self, request_id, details, error): + if request_id != self._voxtype_request_id: + return GLib.SOURCE_REMOVE + self.voxtype_refresh.set_sensitive(True) + if error: self.voxtype_summary.set_text(tr( "Voxtype status unavailable: ", - "无法读取 Voxtype 状态:") + str(error)) - return + "无法读取 Voxtype 状态:") + error) + return GLib.SOURCE_REMOVE self.voxtype_summary.set_text("\n".join(( tr("CLI version: ", "CLI 版本:") + details.cli_version, tr("Daemon version: ", "Daemon 版本:") + details.daemon_version, @@ -517,6 +542,7 @@ def _refresh_voxtype_details(self, *_): tr("Config schema: ", "配置 Schema:") + str(details.schema_version), tr("Config file: ", "配置文件:") + details.config_path, ))) + return GLib.SOURCE_REMOVE def _open_voxtype_configuration(self, *_): try: diff --git a/tests/unit/test_setup_actions.py b/tests/unit/test_setup_actions.py index b293200..5ac25f8 100644 --- a/tests/unit/test_setup_actions.py +++ b/tests/unit/test_setup_actions.py @@ -1,4 +1,6 @@ """Setup interaction contracts without a desktop, microphone or account.""" +import threading +import time from types import SimpleNamespace import unittest from unittest.mock import Mock, patch @@ -265,17 +267,47 @@ def test_voxtype_details_are_rendered_without_editing_config(self): config_path="/home/test/.config/voxtype/config.toml", ) view = SimpleNamespace( - _voxtype_details=Mock(return_value=details), + _voxtype_request_id=1, + voxtype_refresh=Mock(), voxtype_summary=Mock(), ) - SettingsWindow._refresh_voxtype_details(view) + SettingsWindow._show_voxtype_details(view, 1, details, None) rendered = view.voxtype_summary.set_text.call_args.args[0] self.assertIn("large-v3-turbo", rendered) self.assertIn("Vulkan", rendered) self.assertIn("config.toml", rendered) + def test_voxtype_status_request_does_not_block_settings(self): + started = threading.Event() + release = threading.Event() + delivered = threading.Event() + + def read_details(): + started.set() + release.wait(2) + return None + + view = SimpleNamespace( + _voxtype_details=read_details, + _voxtype_request_id=0, + _show_voxtype_details=Mock(), + voxtype_refresh=Mock(), + voxtype_summary=Mock(), + ) + try: + with patch("doubao_input.ui.settings_window.GLib.idle_add", + side_effect=lambda *args: delivered.set()): + start = time.monotonic() + SettingsWindow._refresh_voxtype_details(view) + self.assertLess(time.monotonic() - start, 0.2) + self.assertTrue(started.wait(1)) + release.set() + self.assertTrue(delivered.wait(1)) + finally: + release.set() + def test_voxtype_configure_failure_stays_in_settings(self): view = SimpleNamespace( _configure_voxtype=Mock(side_effect=ValueError("no terminal")), @@ -285,3 +317,13 @@ def test_voxtype_configure_failure_stays_in_settings(self): SettingsWindow._open_voxtype_configuration(view) view.voxtype_summary.set_text.assert_called_once_with("no terminal") + + def test_stale_voxtype_status_does_not_replace_newer_selection(self): + view = SimpleNamespace( + _voxtype_request_id=2, + voxtype_refresh=Mock(), + voxtype_summary=Mock(), + ) + SettingsWindow._show_voxtype_details(view, 1, None, "old failure") + view.voxtype_summary.set_text.assert_not_called() + view.voxtype_refresh.set_sensitive.assert_not_called()