From edc142e4caa27c801e67c1448f9e24a1ffe29c51 Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 00:08:12 +0800 Subject: [PATCH 1/6] feat(settings): gate unfinished features behind a local Beta switch --- docs/architecture.md | 4 +- docs/encrypted-cloud-sync.md | 2 + docs/linux-egui-backend-contract.md | 5 +- openless-all/app/contract/backend-2.0.json | 9 + .../app/crates/openless-core/src/api.rs | 173 +++++++++++++++--- .../src/cloud_sync_e2ee_documents/registry.rs | 7 + .../src/cloud_sync_e2ee_documents/tests.rs | 21 +++ .../src/cloud_sync_e2ee_store/native.rs | 7 + .../src/cloud_sync_e2ee_store/tests.rs | 7 +- .../crates/openless-core/src/coding_agent.rs | 130 +++++++++++-- .../app/crates/openless-core/src/domains.rs | 7 + .../crates/openless-core/src/less_computer.rs | 62 +++++++ .../app/crates/openless-core/src/settings.rs | 16 +- .../crates/openless-core/src/shared_types.rs | 35 ++++ .../tests/auxiliary_voice_lifecycle.rs | 1 + openless-all/app/e2e/testing-features.spec.ts | 45 +++++ .../app/linux-egui/examples/headless_host.rs | 1 + openless-all/app/linux-egui/src/i18n.rs | 50 ++++- .../linux-egui/src/linux_app/settings_save.rs | 3 + openless-all/app/linux-egui/src/main.rs | 24 +++ .../app/linux-egui/src/ui/frontend/mod.rs | 27 ++- .../linux-egui/src/ui/frontend/settings.rs | 46 ++++- .../linux-egui/src/ui/frontend/view_model.rs | 3 + .../app/linux-egui/tests/host_contract.rs | 1 + openless-all/app/scripts/sync-egui-i18n.mjs | 2 + openless-all/app/src-tauri/src/commands/qa.rs | 4 + .../src-tauri/src/coordinator/hotkey_loops.rs | 2 + openless-all/app/src-tauri/src/lib.rs | 19 ++ .../app/src-tauri/src/tauri_events.rs | 4 + .../app/src/components/SettingsModal.tsx | 25 ++- openless-all/app/src/i18n/de.ts | 11 +- openless-all/app/src/i18n/en.ts | 11 +- openless-all/app/src/i18n/es.ts | 12 +- openless-all/app/src/i18n/fr.ts | 12 +- openless-all/app/src/i18n/ja.ts | 11 +- openless-all/app/src/i18n/ko.ts | 11 +- openless-all/app/src/i18n/zh-CN.ts | 11 +- openless-all/app/src/i18n/zh-TW.ts | 11 +- openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/types.ts | 2 + .../src/pages/settings/ShortcutsSection.tsx | 2 +- .../app/src/pages/settings/navigation.test.ts | 26 ++- .../app/src/pages/settings/navigation.ts | 12 +- .../pages/settings/navigationLocale.test.ts | 20 +- openless-all/app/src/pages/settings/tabs.tsx | 118 ++++++++---- 45 files changed, 908 insertions(+), 105 deletions(-) create mode 100644 openless-all/app/e2e/testing-features.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index 04306b686..e6b2fc519 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # OpenLess 2.0 架构 -状态:canonical,当前实现说明;更新:2026-09-30。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 +状态:canonical,当前实现说明;更新:2026-10-03。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 ## 1. 分层与工作区 @@ -79,6 +79,8 @@ Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺 思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 +Less Computer 与 Claude 控制台收拢到「实验与扩展 → 正在测试中的功能(Beta)」。本设备的 `testingFeaturesEnabled` 默认关闭,旧版已开启 Less Computer 的配置也不会自动打开总开关。开启后按真实平台能力显示入口;React 的 Less Computer 仅 Windows/macOS、Claude 控制台仅 macOS,Linux egui 保留自己的 Less Computer 接入入口,Android 当前显示无可用测试功能。关闭总开关会隐藏入口、快捷键和独立窗口,Core 停止当前捕获与 Agent/控制台任务并拒绝新的运行请求;已有 Agent、模型、权限与路径配置保留。总开关不进入云同步,恢复保留接收设备的本机选择。 + Less Computer 面板将听写与直接语音提交分开:麦克风把转写填入草稿供编辑,语音模式和快捷键可直接提交给 Agent。Core 的 `voice_state` 事件携带会话 ID、模式、实时转写及收尾结果;波形使用实际音量采样。停止和取消均绑定指定会话,延迟请求不能结束下一段录音;开麦与文字发送互斥。工具过程默认折叠,运行状态只在真实活动步骤显示动效,右侧工作台汇总当前轮次;历史和多会话仍标为暂不可用。登录弹窗打开时,听写结果只更新草稿,不抢走弹窗或授权浏览器的焦点。 选区直接润色在捕获文字和原输入目标后显示处理中提示,重复快捷键的 Busy 返回不覆盖该提示。已有语音选区入口在松开快捷键后继续显示思考动画,直到处理/替换完成,或交给确认和预览面板。录音提示音的 Web Audio context 在恢复超时或音频时钟冻结时丢弃并最多重试一次,重试沿用原请求的取消和迟到边界。 diff --git a/docs/encrypted-cloud-sync.md b/docs/encrypted-cloud-sync.md index 1caa901eb..05aa90a91 100644 --- a/docs/encrypted-cloud-sync.md +++ b/docs/encrypted-cloud-sync.md @@ -33,6 +33,8 @@ 短暂的本机写入竞争最多触发 4 次有界延后重试;网络失败、结果未知、版本冲突不进入该重试。后续手动任务、锁定或停用会淘汰旧重试链。同步连接遵循当前系统代理偏好,策略变化后重建连接,回环测试始终直连。 +测试功能授权 `testingFeaturesEnabled` 不进入远端文档或设备档案;正常恢复和失败回滚均保留接收设备的本机值,不能通过同步打开 Less Computer 或 Claude 控制台。 + 系统自启动 `launchAtLogin` 不进入远端文档或设备档案;保留接收设备本机值,实际 OS 状态仍由现有 UI 自启动插件管理,恢复不宣称替用户重建自启动授权。 恢复必须绑定 `RestoreRuntimeEffects` 的真实 Host 适配器。所有仓库写入后,在同一加密 journal、独占写入门和运行态屏障内等待绝对目标设置生效;副作用失败按 before 镜像回滚。只保存在本地 journal 的 Agent 开关、provider 镜像和录音标记恢复后,再应用最终旧设置;若回滚副作用也失败,保留 recovery_required,不能提前放行录音或 Agent。未绑定适配器明确失败,不使用空实现代替平台确认。 diff --git a/docs/linux-egui-backend-contract.md b/docs/linux-egui-backend-contract.md index 83d60420e..56832a77e 100644 --- a/docs/linux-egui-backend-contract.md +++ b/docs/linux-egui-backend-contract.md @@ -1,6 +1,6 @@ # Linux egui 后端接口契约(2.0.0) -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-27。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-10-03。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。 ## 1. 合同文件 @@ -10,6 +10,7 @@ | --- | --- | | `startupSnapshot` | 启动快照结构与版本校验规则;UI 必须先消费快照再渲染 | | `backendEvent` | 语义事件清单、顺序与重放规则 | +| `testingFeatures` | 本设备 Beta 总开关、默认关闭、运行门禁与取消合同 | | `lessComputerVoice` | Less Computer 语音事件面 | | `androidJni` | Android JNI 合同(src-tauri android 桥接共用) | | `linuxFacade` | Linux 专用 facade 面(`LinuxHost` 公开方法对应) | @@ -33,6 +34,8 @@ ## 5. 行为约定(合同级) +- `UserPreferences.testingFeaturesEnabled` 默认 `false`,设备独立选择,不进入云同步。实验与扩展中的 Less Computer 移到 Beta 测试功能子页,开启总开关后才显示,运行还须开启原有 `codingAgentEnabled`。Core 在会话预约、录音与文字提交时验证有效开关,关闭返回 `PermissionDenied`;关闭总开关取消当前任务、注销热键并关闭独立弹窗,保留功能配置。 + - fcitx5 为生产启动硬依赖:持有单实例锁后检查插件和会话 D-Bus,按需刷新并在 15 秒就绪预算内等待,再启动监听器、注册必需热键、启动 Core 和 Remote Input。失败清理已启动资源并显示错误窗口;关闭后以非零状态退出,外层锁覆盖完整生命周期。 - 设置保存状态与动作处理位于 `linux_app/settings_save.rs`:按序列化字段生成补丁、串行提交、只重试明确 revision 冲突(最多三次),失败保留草稿并显式重试;Core 合同与持久化格式不变。 - 窗口生命周期与 client 位于 `linux_app/window.rs`,协议门禁位于 `ui/bridge.rs`。显式显示窗口请求创建窗口或发送 FocusMain;Wayland 是否激活由合成器决定。 diff --git a/openless-all/app/contract/backend-2.0.json b/openless-all/app/contract/backend-2.0.json index d9c176a9d..c62a33ce0 100644 --- a/openless-all/app/contract/backend-2.0.json +++ b/openless-all/app/contract/backend-2.0.json @@ -1,5 +1,14 @@ { "contractVersion": "2.0.0", + "testingFeatures": { + "preferenceField": "testingFeaturesEnabled", + "default": false, + "scope": "device_local", + "features": ["lessComputer", "claudeConsole"], + "disabledBehavior": ["hide_entries_and_windows", "unregister_less_computer_hotkeys", "reject_runtime_admission", "cancel_active_tasks", "preserve_feature_settings"], + "disabledErrorCode": "permission_denied", + "cloudSync": "excluded" + }, "startupSnapshot": { "fields": ["contractVersion", "backend"], "sample": { diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index a96c1cbe9..d461dbd03 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -1,4 +1,5 @@ mod cloud_note; +use futures_util::future::BoxFuture; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -2527,6 +2528,10 @@ impl OpenLessBackend { let history_revision = Arc::new(AtomicU64::new(0)); let vocabulary_revision = Arc::new(AtomicU64::new(0)); let settings_write_gate = Arc::new(Mutex::new(())); + deps.services.less_computer.bind_testing_features( + Arc::clone(&repositories.preferences), + Arc::clone(&settings_write_gate), + ); let voice_sessions = Arc::clone(&deps.services.voice_sessions); let runtime_start_work = Arc::new(crate::voice_session::RuntimeActivityGate::default()); deps.services.selection_voice = @@ -2555,6 +2560,8 @@ impl OpenLessBackend { process, Arc::clone(&deps.services.less_computer), BackendEventPublisher::new(Arc::clone(&events)), + Arc::clone(&repositories.preferences), + Arc::clone(&settings_write_gate), )); } if let Some(runtime) = deps.local_asr_runtime.take() { @@ -2929,7 +2936,7 @@ impl OpenLessBackend { /// [`Self::abort_less_computer_capture`]. pub fn begin_less_computer_capture(&self, session_id: SessionId) -> Result<(), BackendError> { let _runtime = self.runtime_start_work.acquire()?; - if !self.get_preferences().coding_agent_enabled { + if !self.get_preferences().less_computer_available() { return Err(BackendError::new( BackendErrorCode::PermissionDenied, "Less Computer is disabled", @@ -2946,6 +2953,11 @@ impl OpenLessBackend { self.deps.services.less_computer.begin_capture(session_id) } + /// Require this device's explicit opt-in for unfinished features. + pub fn ensure_testing_features_enabled(&self) -> Result<(), BackendError> { + crate::settings::ensure_testing_features_enabled(&self.get_preferences()) + } + /// Return the current Less Computer capture/run session, if any. pub fn less_computer_active_session(&self) -> Option { self.deps.services.less_computer.active_session() @@ -2990,7 +3002,7 @@ impl OpenLessBackend { ) -> Result { let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); - if !preferences.coding_agent_enabled { + if !preferences.less_computer_available() { return Err(BackendError::new( BackendErrorCode::PermissionDenied, "Less Computer is disabled", @@ -3564,7 +3576,7 @@ impl OpenLessBackend { ) -> Result { let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); - if !preferences.coding_agent_enabled { + if !preferences.less_computer_available() { return Err(BackendError::new( BackendErrorCode::PermissionDenied, "Less Computer is disabled", @@ -3616,6 +3628,17 @@ impl OpenLessBackend { let Some(session_id) = session_id.or_else(|| self.less_computer_active_session()) else { return Ok(()); }; + own_voice_effect( + &self.deps.task_spawner, + self.less_computer_cancellation(session_id), + ) + .await + } + + fn less_computer_cancellation( + &self, + session_id: SessionId, + ) -> BoxFuture<'static, Result<(), BackendError>> { let control = self .less_computer_voice_controls .lock() @@ -3624,26 +3647,22 @@ impl OpenLessBackend { .cloned(); let controls = Arc::clone(&self.less_computer_voice_controls); let less_computer = Arc::clone(&self.deps.services.less_computer); - own_voice_effect( - &self.deps.task_spawner, - Box::pin(async move { - let service_result = less_computer.cancel(Some(session_id)).await; - let resource_result = match control { - Some(control) => { - let _guard = VoiceControlGuard { - session_id, - control: Arc::clone(&control), - controls, - }; - control.cancel_resources().await - } - None => Ok(()), - }; - let _ = less_computer.abort_capture(session_id); - resource_result.and(service_result) - }), - ) - .await + Box::pin(async move { + let service_result = less_computer.cancel(Some(session_id)).await; + let resource_result = match control { + Some(control) => { + let _guard = VoiceControlGuard { + session_id, + control: Arc::clone(&control), + controls, + }; + control.cancel_resources().await + } + None => Ok(()), + }; + let _ = less_computer.abort_capture(session_id); + resource_result.and(service_result) + }) } pub async fn cancel_active_voice_session( @@ -4765,6 +4784,21 @@ impl OpenLessBackend { self.disarm_edit_observation(); self.dismiss_pending_corrections(); } + if previous.testing_features_enabled && !preferences.testing_features_enabled { + // Capture the old owners while settings admission is still locked. + // A later opt-in must not let cleanup cancel a replacement task. + let cancellation = self + .less_computer_active_session() + .map(|session_id| self.less_computer_cancellation(session_id)); + self.deps.services.less_computer.dismiss(); + let cancel_test = self.deps.services.coding_agent.cancel_test(); + self.deps.task_spawner.spawn(Box::pin(async move { + let _ = cancel_test.await; + if let Some(cancellation) = cancellation { + let _ = cancellation.await; + } + })); + } self.publish_preferences_changed(); Ok(crate::SettingsUpdateOutcome { preferences, @@ -7626,6 +7660,7 @@ mod tests { async fn voice_workflows_share_one_busy_lease_and_release_it_on_terminal_paths() { let (backend, _) = backend(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend .update_settings( @@ -7733,6 +7768,7 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; preferences.coding_agent_provider = "dsh-cli".into(); preferences.coding_agent_permission_mode = "bypassPermissions".into(); @@ -7785,6 +7821,24 @@ mod tests { .await .unwrap_err(); assert_eq!(error.code, BackendErrorCode::PermissionDenied); + let mut legacy = backend.get_preferences(); + legacy.coding_agent_enabled = true; + backend.set_preferences(legacy).unwrap(); + assert_eq!( + backend + .submit_less_computer("legacy".into()) + .await + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + assert_eq!( + backend + .begin_less_computer_capture(SessionId::new()) + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); assert!(runtime.request.lock().unwrap().is_none()); } @@ -7792,6 +7846,7 @@ mod tests { async fn less_computer_capture_facade_is_session_scoped_and_cancel_releases_it() { let (backend, _) = backend(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend.set_preferences(preferences).unwrap(); @@ -7812,10 +7867,74 @@ mod tests { assert_eq!(backend.less_computer_active_session(), None); } + #[tokio::test] + async fn disabling_testing_features_cancels_capture_and_preserves_configuration() { + let (backend, _) = backend(); + backend.start().await.unwrap(); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; + preferences.coding_agent_enabled = true; + preferences.coding_agent_model = Some("saved-model".into()); + backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + let owner = SessionId::new(); + backend.begin_less_computer_capture(owner).unwrap(); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = false; + let outcome = backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + let hotkeys = outcome.effects.hotkeys.unwrap(); + assert!(hotkeys.previous.coding_agent_enabled); + assert!(!hotkeys.next.coding_agent_enabled); + assert!(outcome.preferences.coding_agent_enabled); + assert_eq!( + outcome.preferences.coding_agent_model.as_deref(), + Some("saved-model") + ); + assert!(backend.less_computer_capture_cancelled(owner)); + assert!(backend.ensure_testing_features_enabled().is_err()); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while backend.less_computer_active_session().is_some() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!( + backend + .begin_less_computer_capture(SessionId::new()) + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; + backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + backend.begin_less_computer_capture(owner).unwrap(); + backend.cancel_less_computer(Some(owner)).await.unwrap(); + } + #[test] fn less_computer_hotkey_modes_share_hold_toggle_auto_and_combined_rules() { let (backend, _) = backend(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; preferences.hotkey.mode = crate::HotkeyMode::Hold; @@ -8072,6 +8191,7 @@ mod tests { .unwrap(), ); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend.set_preferences(preferences).unwrap(); let session_id = SessionId::new(); @@ -8140,6 +8260,7 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; preferences.stable_transcription_enabled = true; backend.set_preferences(preferences).unwrap(); @@ -8300,6 +8421,7 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend.set_preferences(preferences).unwrap(); (data_dir, backend, transcription, runtime) @@ -8879,6 +9001,10 @@ mod tests { BackendDependencies::unsupported(), ) .unwrap(); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; + preferences.coding_agent_enabled = true; + backend.set_preferences(preferences).unwrap(); let control = Arc::new(FakeRecordingControl::default()); let stop_session = SessionId::new(); @@ -14635,6 +14761,7 @@ mod tests { let backend = runtime_restore_backend(); backend.ensure_runtime_ready().unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend .update_settings( diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 3eedc0809..674c67856 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -410,6 +410,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::ObjectList, reason: "device_bound", }, + PreferenceField { + rust_name: "testing_features_enabled", + key: "testingFeaturesEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, PreferenceField { rust_name: "coding_agent_enabled", key: "codingAgentEnabled", diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs index c62a51ed8..0f7c258cf 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs @@ -118,6 +118,27 @@ fn all_current_preference_fields_have_an_explicit_registration() { assert_eq!(registered.len(), registry::PREFERENCE_FIELDS.len()); } +#[test] +fn testing_features_opt_in_is_never_exported_or_accepted_from_another_device() { + let mut data = snapshot(); + data.preferences = SecretJson::new(json!({"testingFeaturesEnabled": true})); + let exported = export_snapshot(data).unwrap(); + assert!(!serde_json::to_string(exported.documents.documents()) + .unwrap() + .contains("testingFeaturesEnabled")); + let mut invalid = sample().documents().clone(); + invalid.documents.push(LogicalDocument { + id: "testingFeaturesEnabled".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value: json!(true), + }); + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::ExcludedField + ); +} + #[test] fn controlled_export_keeps_real_service_keys_but_excludes_local_secrets_and_grants() { let mut data = channel_snapshot(); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs index 4a30c0940..bdfc466fd 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs @@ -89,6 +89,8 @@ pub(super) fn canonicalize_native_documents( #[serde(rename_all = "camelCase", deny_unknown_fields)] struct LocalRollbackState { schema_version: u32, + #[serde(default)] + testing_features_enabled: bool, coding_agent_enabled: bool, active_asr_provider: String, active_llm_provider: String, @@ -126,6 +128,7 @@ impl CoreSyncStore { } SecretJson::from_serializable(&LocalRollbackState { schema_version: 1, + testing_features_enabled: prefs.testing_features_enabled, coding_agent_enabled: prefs.coding_agent_enabled, active_asr_provider: prefs.active_asr_provider, active_llm_provider: prefs.active_llm_provider, @@ -177,6 +180,10 @@ impl CoreSyncStore { let object = current .as_object_mut() .ok_or(DocumentError::InvalidDocument)?; + object.insert( + "testingFeaturesEnabled".into(), + Value::Bool(local.testing_features_enabled), + ); object.insert( "codingAgentEnabled".into(), Value::Bool(local.coding_agent_enabled), diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs index 4cad49904..6cb137ff8 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -1553,7 +1553,10 @@ async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() fixture .repo .preferences - .update(|prefs| prefs.launch_at_login = true) + .update(|prefs| { + prefs.launch_at_login = true; + prefs.testing_features_enabled = true; + }) .unwrap(); let before = fixture .store @@ -1562,6 +1565,7 @@ async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() .unwrap(); let encoded = serde_json::to_string(before.documents.documents()).unwrap(); assert!(!encoded.contains("launchAtLogin")); + assert!(!encoded.contains("testingFeaturesEnabled")); let mut desired = before.documents.documents().clone(); set_doc( &mut desired, @@ -1575,6 +1579,7 @@ async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() .await .unwrap(); assert!(fixture.repo.preferences.get().launch_at_login); + assert!(fixture.repo.preferences.get().testing_features_enabled); assert!( fixture .effects diff --git a/openless-all/app/crates/openless-core/src/coding_agent.rs b/openless-all/app/crates/openless-core/src/coding_agent.rs index 77ff713e0..b1283e8ff 100644 --- a/openless-all/app/crates/openless-core/src/coding_agent.rs +++ b/openless-all/app/crates/openless-core/src/coding_agent.rs @@ -1644,6 +1644,8 @@ pub struct CodingAgentService { events: BackendEventPublisher, active_test: Arc>>>, request_counter: AtomicU64, + preferences: Arc, + settings_write_gate: Arc>, } impl CodingAgentService { @@ -1652,6 +1654,8 @@ impl CodingAgentService { process: Arc, less_computer: Arc, events: BackendEventPublisher, + preferences: Arc, + settings_write_gate: Arc>, ) -> Self { Self { runner, @@ -1660,6 +1664,8 @@ impl CodingAgentService { events, active_test: Arc::new(Mutex::new(None)), request_counter: AtomicU64::new(0), + preferences, + settings_write_gate, } } @@ -1757,7 +1763,9 @@ impl crate::domains::CodingAgentApi for CodingAgentService { request: CodingAgentDetectRequest, ) -> BoxFuture<'static, Result> { let process = Arc::clone(&self.process); + let preferences = Arc::clone(&self.preferences); Box::pin(async move { + crate::settings::ensure_testing_features_enabled(&preferences.get())?; let executable = normalize_coding_agent_executable(request.provider, request.executable)?; let probe = execute_capture( @@ -1809,7 +1817,9 @@ impl crate::domains::CodingAgentApi for CodingAgentService { request: CodingAgentModelsRequest, ) -> BoxFuture<'static, Result, BackendError>> { let process = Arc::clone(&self.process); + let preferences = Arc::clone(&self.preferences); Box::pin(async move { + crate::settings::ensure_testing_features_enabled(&preferences.get())?; if request.provider != CodingAgentProvider::OpenCodeCli { return Err(BackendError::new( BackendErrorCode::Unsupported, @@ -1864,10 +1874,16 @@ impl crate::domains::CodingAgentApi for CodingAgentService { let active_test = Arc::clone(&self.active_test); let events = self.events.clone(); let request_id = self.next_request_id(); + let preferences = Arc::clone(&self.preferences); + let settings_write_gate = Arc::clone(&self.settings_write_gate); Box::pin(async move { let normalized = normalized?; let cancel = Arc::new(AtomicBool::new(false)); { + let _admission = settings_write_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + crate::settings::ensure_testing_features_enabled(&preferences.get())?; let mut active = active_test.lock().expect("coding agent test lock poisoned"); if active.is_some() { return Err(BackendError::new( @@ -1918,17 +1934,15 @@ impl crate::domains::CodingAgentApi for CodingAgentService { } fn cancel_test(&self) -> BoxFuture<'static, Result<(), BackendError>> { - let active_test = Arc::clone(&self.active_test); - Box::pin(async move { - if let Some(cancel) = active_test - .lock() - .expect("coding agent test lock poisoned") - .clone() - { - cancel.store(true, Ordering::Release); - } - Ok(()) - }) + if let Some(cancel) = self + .active_test + .lock() + .expect("coding agent test lock poisoned") + .as_ref() + { + cancel.store(true, Ordering::Release); + } + Box::pin(async move { Ok(()) }) } fn approve( @@ -1944,6 +1958,100 @@ impl crate::domains::CodingAgentApi for CodingAgentService { mod tests { use super::*; + #[tokio::test] + async fn console_requires_device_opt_in_and_revocation_cancels_only_the_current_test() { + use crate::domains::CodingAgentApi; + let process = Arc::new(CancelAwareProcess); + let path = std::env::temp_dir().join(format!( + "openless-testing-console-{}.json", + uuid::Uuid::new_v4() + )); + let preferences = Arc::new(crate::PreferencesStore::fallback(path.clone())); + let events = BackendEventPublisher::new(Arc::new(crate::events::EventBus::new(32))); + let service = CodingAgentService::new( + Arc::new(CodingAgentRunner::new(process.clone())), + process, + Arc::new(crate::less_computer::LessComputerService::new()), + events, + preferences.clone(), + Arc::new(Mutex::new(())), + ); + assert_eq!( + service + .detect(CodingAgentDetectRequest { + provider: CodingAgentProvider::ClaudeCodeCli, + executable: None, + }) + .await + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + assert_eq!( + service + .list_models(CodingAgentModelsRequest { + provider: CodingAgentProvider::OpenCodeCli, + executable: None, + refresh: false, + }) + .await + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + let request = CodingAgentTestRequest { + provider: CodingAgentProvider::ClaudeCodeCli, + executable: None, + prompt: "probe".into(), + permission_mode: CodingAgentPermissionMode::default(), + workdir: None, + model: None, + max_budget_usd: None, + timeout_secs: 5, + }; + let mut queued = service.run_test(request.clone()); + preferences + .update(|prefs| prefs.testing_features_enabled = true) + .unwrap(); + assert!(futures_util::poll!(queued.as_mut()).is_pending()); + preferences + .update(|prefs| prefs.testing_features_enabled = false) + .unwrap(); + let cleanup = service.cancel_test(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(2), queued) + .await + .unwrap() + .unwrap_err() + .code, + BackendErrorCode::Cancelled + ); + assert_eq!( + service.run_test(request.clone()).await.unwrap_err().code, + BackendErrorCode::PermissionDenied + ); + + preferences + .update(|prefs| prefs.testing_features_enabled = true) + .unwrap(); + let mut replacement = service.run_test(request); + assert!(futures_util::poll!(replacement.as_mut()).is_pending()); + cleanup.await.unwrap(); + assert!(!service + .active_test + .lock() + .unwrap() + .as_ref() + .unwrap() + .load(Ordering::Acquire)); + service.cancel_test().await.unwrap(); + assert_eq!( + replacement.await.unwrap_err().code, + BackendErrorCode::Cancelled + ); + std::fs::remove_file(path).unwrap(); + } + struct ScriptedProcess(Vec, Result); impl CodingAgentProcessAdapter for ScriptedProcess { diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index 11e5f0116..8c07d425c 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -1468,6 +1468,13 @@ pub struct LessComputerRunResult { /// the typed event and return the user's decision; they must not maintain a /// second token registry or conversation flag. pub trait LessComputerApi: Send + Sync { + /// Bind admission to this backend's device-local settings transaction. + fn bind_testing_features( + &self, + _preferences: Arc, + _settings_write_gate: Arc>, + ) { + } #[doc(hidden)] fn bind_event_publisher(&self, _publisher: crate::events::BackendEventPublisher) {} #[doc(hidden)] diff --git a/openless-all/app/crates/openless-core/src/less_computer.rs b/openless-all/app/crates/openless-core/src/less_computer.rs index 157528278..dedf04a1c 100644 --- a/openless-all/app/crates/openless-core/src/less_computer.rs +++ b/openless-all/app/crates/openless-core/src/less_computer.rs @@ -23,6 +23,7 @@ const MAX_DSH_CONTINUATION_TURNS: usize = 2; pub(crate) const VOICE_CAPTURE_FAILED: &str = "Less Computer voice input failed. Please try again."; struct LessComputerState { + testing_gate: Mutex>, conversation_active: AtomicBool, approvals: Mutex>>, events: Mutex>, @@ -33,6 +34,12 @@ struct LessComputerState { voice_sessions: Arc, } +#[derive(Clone)] +struct TestingFeaturesGate { + preferences: Arc, + settings_write_gate: Arc>, +} + enum ActiveLease { Capture(ActiveCapture), Run(ActiveRun), @@ -85,6 +92,7 @@ impl LessComputerService { ) -> Self { Self { state: Arc::new(LessComputerState { + testing_gate: Mutex::new(None), conversation_active: AtomicBool::new(false), approvals: Mutex::new(HashMap::new()), events: Mutex::new(None), @@ -134,6 +142,26 @@ impl LessComputerService { } fn begin_capture_inner(&self, session_id: SessionId) -> Result<(), BackendError> { + let gate = self + .state + .testing_gate + .lock() + .expect("testing feature gate lock poisoned") + .clone(); + let _admission = gate.as_ref().map(|gate| { + gate.settings_write_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + }); + if gate + .as_ref() + .is_some_and(|gate| !gate.preferences.get().less_computer_available()) + { + return Err(BackendError::new( + BackendErrorCode::PermissionDenied, + "Less Computer is disabled", + )); + } let mut active = self .state .active_lease @@ -160,6 +188,26 @@ impl LessComputerService { &self, session_id: SessionId, ) -> Result, BackendError> { + let gate = self + .state + .testing_gate + .lock() + .expect("testing feature gate lock poisoned") + .clone(); + let _admission = gate.as_ref().map(|gate| { + gate.settings_write_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + }); + if gate + .as_ref() + .is_some_and(|gate| !gate.preferences.get().less_computer_available()) + { + return Err(BackendError::new( + BackendErrorCode::PermissionDenied, + "Less Computer is disabled", + )); + } let mut active = self .state .active_lease @@ -382,6 +430,20 @@ impl Drop for ApprovalLease { } impl LessComputerApi for LessComputerService { + fn bind_testing_features( + &self, + preferences: Arc, + settings_write_gate: Arc>, + ) { + *self + .state + .testing_gate + .lock() + .expect("testing feature gate lock poisoned") = Some(TestingFeaturesGate { + preferences, + settings_write_gate, + }); + } fn bind_event_publisher(&self, publisher: BackendEventPublisher) { *self .state diff --git a/openless-all/app/crates/openless-core/src/settings.rs b/openless-all/app/crates/openless-core/src/settings.rs index eb2100d1a..0f1a573a3 100644 --- a/openless-all/app/crates/openless-core/src/settings.rs +++ b/openless-all/app/crates/openless-core/src/settings.rs @@ -5,6 +5,20 @@ use crate::shared_types::{ HotkeyMode, ShortcutBinding, StylePackHotkey, UserPreferences, WindowsInsertionMode, }; +pub(crate) fn ensure_testing_features_enabled( + preferences: &UserPreferences, +) -> Result<(), BackendError> { + if preferences.testing_features_enabled { + Ok(()) + } else { + Err(BackendError::new( + crate::BackendErrorCode::PermissionDenied, + "Enable features under testing in Advanced settings first", + ) + .retryable(false)) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SettingsCollisionPolicy { @@ -71,7 +85,7 @@ impl From<&UserPreferences> for HotkeyRuntimeTarget { switch_style: preferences.switch_style_hotkey.clone(), open_app: preferences.open_app_hotkey.clone(), selection_polish: preferences.selection_polish_hotkey.clone(), - coding_agent_enabled: preferences.coding_agent_enabled, + coding_agent_enabled: preferences.less_computer_available(), coding_agent_voice: preferences.coding_agent_voice_hotkey.clone(), coding_agent_panel: preferences.coding_agent_panel_hotkey.clone(), coding_agent_quick: preferences.coding_agent_quick_hotkey.clone(), diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 9da6eb7a3..4982e809a 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -588,6 +588,9 @@ pub struct UserPreferences { /// a disabled pack auto-enables and activates it. #[serde(default)] pub style_pack_hotkeys: Vec, + /// Device-local opt-in for unfinished features. Hidden and unavailable by default. + #[serde(default)] + pub testing_features_enabled: bool, /// Less Computer: whether enabled. Default off; users must enable it in Advanced settings. #[serde(default)] pub coding_agent_enabled: bool, @@ -849,6 +852,10 @@ pub struct UserPreferences { } impl UserPreferences { + pub fn less_computer_available(&self) -> bool { + self.testing_features_enabled && self.coding_agent_enabled + } + pub fn preserve_style_preferences_from(&mut self, current: &Self) { self.default_mode = current.default_mode; self.enabled_modes = current.enabled_modes.clone(); @@ -1016,6 +1023,8 @@ struct UserPreferencesWire { #[serde(default)] style_pack_hotkeys: Vec, #[serde(default)] + testing_features_enabled: bool, + #[serde(default)] coding_agent_enabled: bool, #[serde(default = "default_coding_agent_provider")] coding_agent_provider: String, @@ -1228,6 +1237,7 @@ impl Default for UserPreferencesWire { switch_style_hotkey: prefs.switch_style_hotkey, open_app_hotkey: prefs.open_app_hotkey, style_pack_hotkeys: prefs.style_pack_hotkeys, + testing_features_enabled: prefs.testing_features_enabled, coding_agent_enabled: prefs.coding_agent_enabled, coding_agent_provider: prefs.coding_agent_provider, coding_agent_model: prefs.coding_agent_model, @@ -1425,6 +1435,7 @@ impl<'de> Deserialize<'de> for UserPreferences { selection_voice_edit_plan_format: wire.selection_voice_edit_plan_format, selection_voice_edit_system_prompt: wire.selection_voice_edit_system_prompt, qa_save_history: wire.qa_save_history, + testing_features_enabled: wire.testing_features_enabled, coding_agent_enabled: wire.coding_agent_enabled, coding_agent_provider: wire.coding_agent_provider, coding_agent_model: wire.coding_agent_model, @@ -1798,6 +1809,7 @@ impl Default for UserPreferences { switch_style_hotkey: default_switch_style_hotkey(), open_app_hotkey: default_open_app_hotkey(), style_pack_hotkeys: Vec::new(), + testing_features_enabled: false, coding_agent_enabled: false, coding_agent_provider: default_coding_agent_provider(), coding_agent_model: None, @@ -2806,6 +2818,29 @@ mod translation_effective_tests { mod tests { use super::*; + #[test] + fn legacy_less_computer_settings_remain_sealed_until_device_opt_in() { + let legacy: UserPreferences = serde_json::from_value(serde_json::json!({ + "codingAgentEnabled": true, + "codingAgentProvider": "codex-cli", + "codingAgentModel": "saved-model" + })) + .unwrap(); + assert!(!UserPreferences::default().testing_features_enabled); + assert!(!legacy.testing_features_enabled); + assert!(legacy.coding_agent_enabled); + assert!(!legacy.less_computer_available()); + let mut opted_in = legacy.clone(); + opted_in.testing_features_enabled = true; + let restored: UserPreferences = + serde_json::from_value(serde_json::to_value(&opted_in).unwrap()).unwrap(); + assert!(restored.less_computer_available()); + opted_in.testing_features_enabled = false; + assert!(!opted_in.less_computer_available()); + assert_eq!(opted_in.coding_agent_model, legacy.coding_agent_model); + assert_eq!(opted_in.coding_agent_provider, legacy.coding_agent_provider); + } + #[test] fn macos_newline_modes_round_trip_legacy_auto_and_line_feed() { assert_eq!(MacosNewlineMode::default(), MacosNewlineMode::Auto); diff --git a/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs b/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs index 7744a6171..c90c0b272 100644 --- a/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs +++ b/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs @@ -334,6 +334,7 @@ fn backend( ); let mut prefs = backend.get_preferences(); prefs.coding_agent_enabled = true; + prefs.testing_features_enabled = true; backend .update_settings(prefs, SettingsUpdateOptions::STRICT, &NoopSettingsRuntime) .unwrap(); diff --git a/openless-all/app/e2e/testing-features.spec.ts b/openless-all/app/e2e/testing-features.spec.ts new file mode 100644 index 000000000..6ad5f892c --- /dev/null +++ b/openless-all/app/e2e/testing-features.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; + +test('testing features require opt-in and retain their settings across revocation', async ({ + page, +}) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto('/'); + await page.getByRole('button', { name: '设置', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '设置', exact: true }); + await dialog.getByRole('button', { name: '实验与扩展', exact: true }).click(); + const beta = dialog.locator('[data-ol-advanced-entry="testingFeatures"]'); + const lessEntry = dialog.locator('[data-ol-advanced-entry="lessComputer"]'); + const consoleEntry = dialog.locator('[data-ol-advanced-entry="claudeConsole"]'); + const lessDetail = dialog.locator('[data-ol-advanced-page="lessComputer"]'); + await expect(beta).toBeVisible(); + await expect(lessEntry).toHaveCount(0); + await expect(consoleEntry).toHaveCount(0); + await expect(lessDetail).toHaveCount(0); + await beta.click(); + const toggle = dialog.getByRole('switch', { name: '启用正在测试中的功能', exact: true }); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await toggle.click(); + await expect(lessEntry).toBeVisible(); + await expect(consoleEntry).toBeVisible(); + await lessEntry.click(); + await expect(lessDetail).toBeVisible(); + await lessDetail.getByRole('switch').first().click(); + await expect(lessDetail.getByRole('switch').first()).toHaveAttribute('aria-checked', 'true'); + await dialog.getByRole('button', { name: '返回正在测试中的功能', exact: true }).click(); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await expect(lessDetail).toHaveCount(0); + await expect(lessEntry).toHaveCount(0); + await expect(consoleEntry).toHaveCount(0); + await dialog.getByRole('button', { name: '快捷键与选区', exact: true }).click(); + await expect(dialog.getByText('Less Computer', { exact: true })).toHaveCount(0); + await dialog.getByRole('button', { name: '实验与扩展', exact: true }).click(); + await beta.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await toggle.click(); + await lessEntry.click(); + await expect(lessDetail.getByRole('switch').first()).toHaveAttribute('aria-checked', 'true'); + expect(errors).toEqual([]); +}); diff --git a/openless-all/app/linux-egui/examples/headless_host.rs b/openless-all/app/linux-egui/examples/headless_host.rs index 7e7e6bf04..2ada52eb1 100644 --- a/openless-all/app/linux-egui/examples/headless_host.rs +++ b/openless-all/app/linux-egui/examples/headless_host.rs @@ -155,6 +155,7 @@ async fn main() -> Result<(), BackendError> { // requiring a coding-agent process. let mut less_computer_preferences = backend.get_preferences(); less_computer_preferences.coding_agent_enabled = true; + less_computer_preferences.testing_features_enabled = true; linux_host.update_settings_strict( less_computer_preferences, linux_host.snapshot().preferences_revision, diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index ec1ec21a2..2ce768e60 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -2757,6 +2757,46 @@ pub const CATALOG: &[Msg] = &[ "키를 누르고 말하면 선택한 Agent가 PC를 조작합니다. macOS 전용.", ), }, + Msg { + key: "settings.testing_features.title", + text: row( + "正在测试中的功能", + "正在測試中的功能", + "Features under testing", + "テスト中の機能", + "테스트 중인 기능", + ), + }, + Msg { + key: "settings.testing_features.enable", + text: row( + "启用正在测试中的功能", + "啟用正在測試中的功能", + "Enable features under testing", + "テスト中の機能を有効にする", + "테스트 중인 기능 활성화", + ), + }, + Msg { + key: "settings.testing_features.description", + text: row( + "开启后显示 Less Computer 和 Claude 控制台,可在支持的平台配置和使用。关闭会停止运行并隐藏入口,保留已有配置。仅在当前设备生效。", + "開啟後顯示 Less Computer 和 Claude 控制台,可在支援的平台設定和使用。關閉會停止執行並隱藏入口,保留既有設定。僅在目前裝置生效。", + "Show Less Computer and the Claude console for setup and use on supported platforms. Turning this off stops active tasks and hides their entries while keeping your settings. Applies only to this device.", + "対応するプラットフォームで Less Computer と Claude コンソールを表示し、設定・使用できます。オフにすると実行中のタスクを停止して項目を非表示にしますが、設定は保持されます。このデバイスのみに適用されます。", + "지원되는 플랫폼에서 Less Computer와 Claude 콘솔을 표시하고 설정 및 사용할 수 있습니다. 끄면 실행 중인 작업을 중지하고 항목을 숨기지만 설정은 유지됩니다. 이 기기에만 적용됩니다.", + ), + }, + Msg { + key: "modal.advanced_pages.testing_features", + text: row( + "按需开启尚在测试中的功能。", + "依需要開啟尚在測試中的功能。", + "Opt in to features that are still being tested.", + "テスト中の機能を必要に応じて有効にします。", + "필요할 때 테스트 중인 기능을 활성화합니다.", + ), + }, Msg { key: "settings.coding_agent.title", text: row( @@ -3756,11 +3796,11 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "modal.descriptions.advanced", text: row( - "按需配置 Less Computer、多模态与调试功能。", - "按需設定 Less Computer、多模態與除錯功能。", - "Configure Less Computer, multimodal processing and debugging as needed.", - "必要に応じて Less Computer、マルチモーダル処理、デバッグを設定します。", - "필요에 따라 Less Computer, 멀티모달 처리 및 디버깅을 설정합니다.", + "按需配置测试功能、多模态与调试工具。", + "依需要設定測試功能、多模態與除錯工具。", + "Configure testing features, multimodal processing, and debugging tools.", + "テスト機能、マルチモーダル処理、デバッグツールを設定します。", + "테스트 기능, 멀티모달 처리 및 디버깅 도구를 설정합니다.", ), }, Msg { diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index 3a4f3a2a6..65ad3cdec 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -177,6 +177,9 @@ impl OpenLessEguiApp { frontend::view_model::SettingsField::LessComputer => { preferences.coding_agent_enabled = !preferences.coding_agent_enabled; } + frontend::view_model::SettingsField::TestingFeatures => { + preferences.testing_features_enabled = !preferences.testing_features_enabled; + } frontend::view_model::SettingsField::SilenceAutoStop => { preferences.silence_auto_stop_enabled = !preferences.silence_auto_stop_enabled; } diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 799a50609..74bbb9865 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1086,6 +1086,12 @@ mod linux_app { } fn show_less_computer_popup(&mut self) { + if self + .backend() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + return; + } self.ensure_popup(PopupKind::LessComputer); let message = self.less_computer_snapshot(self.lang); self.send_popup(PopupKind::LessComputer, message); @@ -2408,6 +2414,12 @@ mod linux_app { } } BackendEventKind::LessComputerEvent(event) => { + if self + .backend() + .is_some_and(|backend| !backend.get_preferences().testing_features_enabled) + { + return; + } // Less Computer events may complete after a newer turn has // already started. Session ownership, not arrival time, // decides whether a delta/terminal may mutate this view. @@ -2538,6 +2550,17 @@ mod linux_app { } self.preferences = Some(self.settings_save.rebase(&latest)); } + if self + .backend() + .is_some_and(|backend| !backend.get_preferences().testing_features_enabled) + { + // Dropping the supervisor closes a feature that is no longer opted in. + self.less_computer_popup = None; + self.less_computer_working = false; + self.pending_approval = None; + self.less_computer_session = None; + self.less_computer_entries.clear(); + } self.load_remote_status(); self.load_library(); } @@ -3969,6 +3992,7 @@ mod linux_app { s.system_proxy = prefs.use_system_proxy; s.multimodal = prefs.multimodal_pipeline_enabled; s.less_computer = prefs.coding_agent_enabled; + s.testing_features = prefs.testing_features_enabled; s.coding_agent_provider = match prefs.coding_agent_provider.as_str() { "opencode-cli" => 1, "codex-cli" => 2, diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 88a17c578..91bd6c0e1 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -789,6 +789,7 @@ mod tests { ..Default::default() }; vm.settings.less_computer = enabled; + vm.settings.testing_features = true; let mut painted = String::new(); for _ in 0..2 { ctx.begin_pass(egui::RawInput { @@ -807,7 +808,31 @@ mod tests { } } - /// 渲染设置页并把这一帧画出的文字按行返回。 + #[test] + fn testing_features_hide_root_entries_and_revoke_an_open_detail_page() { + use super::view_model::SettingsSection; + let mut vm = FrontendViewModel::default(); + let root = painted_settings_lines(SettingsSection::Advanced, &mut vm); + assert!(root.iter().any(|line| line.contains("正在测试中的功能"))); + assert!(!root.iter().any(|line| line == "Less Computer")); + vm.advanced_open = 3; + let off = painted_settings_lines(SettingsSection::Advanced, &mut vm); + assert!(!off.iter().any(|line| line == "Less Computer")); + vm.settings.testing_features = true; + let on = painted_settings_lines(SettingsSection::Advanced, &mut vm); + assert!(on.iter().any(|line| line == "Less Computer")); + vm.advanced_open = 0; + vm.settings.less_computer = true; + vm.settings.testing_features = false; + let revoked = painted_settings_lines(SettingsSection::Advanced, &mut vm); + assert_eq!(vm.advanced_open, 3); + assert!(!revoked.iter().any(|line| line == "Less Computer")); + let shortcuts = painted_settings_lines(SettingsSection::Shortcuts, &mut vm); + let agent_voice = openless_linux_egui::tr_l10n(vm.lang, "settings.shortcuts.agent_voice"); + assert!(!shortcuts.iter().any(|line| line == agent_voice)); + } + + /// Render two frames so layout and text are settled. fn painted_settings_lines( section: super::view_model::SettingsSection, vm: &mut FrontendViewModel, diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index fb07a1d54..b3dbe8d73 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -338,6 +338,9 @@ fn panel(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec Option<(&'static s tr_l10n(lang, "settings.debug.title"), tr_l10n(lang, "modal.advanced_pages.debug"), )), + 3 => Some(( + tr_l10n(lang, "settings.testing_features.title"), + tr_l10n(lang, "modal.advanced_pages.testing_features"), + )), _ => None, } } @@ -927,6 +934,9 @@ fn shortcuts(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec( /// as QA). `macos` build only. #[cfg(target_os = "macos")] pub(crate) fn show_less_computer_window(app: &AppHandle) { + if app + .try_state::>() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + return; + } let Some(window) = ensure_less_computer_window(app) else { log::info!("[less-computer] show 跳过:窗口不存在"); return; }; let window_clone = window.clone(); + let app_for_show = app.clone(); let _ = app.run_on_main_thread(move || { + if app_for_show + .try_state::>() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + return; + } use objc2::msg_send; use objc2::runtime::AnyObject; // This helper is also called from the Tokio worker that executes a text or @@ -3108,6 +3121,12 @@ pub(crate) fn show_less_computer_window(app: &AppHandle) { #[cfg(target_os = "windows")] pub(crate) fn show_less_computer_window(app: &AppHandle) { + if app + .try_state::>() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + return; + } let Some(window) = ensure_less_computer_window(app) else { return; }; diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index cfb928f9c..dd67fe96a 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -527,6 +527,10 @@ fn map_dictation_state( fn emit_preferences(app: &AppHandle, backend: &OpenLessBackend) { let preferences = backend.get_preferences(); + if !preferences.testing_features_enabled { + crate::hide_less_computer_window(app); + crate::hide_less_computer_glow(app); + } // Cloud restore also changes preferences directly through Core. Keep native // capsule geometry and hit testing current for every source of a preference change. if let Some(coordinator) = app.try_state::>() { diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 0d8ded3ff..c5bdd15cc 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -1,4 +1,6 @@ // Settings categories reuse the existing consumers; complex experiments open in the right pane. +import { useHotkeySettings } from '../state/HotkeySettingsContext'; + import { useEffect, useLayoutEffect, @@ -29,6 +31,7 @@ import { searchSettingsSections, visibleSettingsSections, visibleAdvancedPages, + isTestingFeaturePage, type AdvancedPageId, type SettingsSectionId, } from '../pages/settings/navigation'; @@ -99,7 +102,9 @@ export function SettingsModal({ const searching = query.trim().length > 0; useContentMotion(scrollRef, `${section}:${searching ? 'search' : (advancedPage ?? 'root')}`); const results = searchSettingsSections(sections, query); - const advancedPages = visibleAdvancedPages(platformCaps?.platform, os); + const { prefs } = useHotkeySettings(); + const testingFeaturesEnabled = prefs?.testingFeaturesEnabled === true; + const advancedPages = visibleAdvancedPages(platformCaps?.platform, os, testingFeaturesEnabled); const activeAdvancedPage = section === 'advanced' && !searching ? advancedPages.find((page) => page.id === advancedPage) @@ -187,8 +192,8 @@ export function SettingsModal({ useEffect(() => { if (advancedPage && !advancedPages.some((page) => page.id === advancedPage)) - setAdvancedPage(null); - }, [advancedPage, platformCaps, os]); + setAdvancedPage(isTestingFeaturePage(advancedPage) ? 'testingFeatures' : null); + }, [advancedPage, platformCaps, os, testingFeaturesEnabled]); const openAdvancedPage = (page: AdvancedPageId) => { setAdvancedPage(page); @@ -197,7 +202,7 @@ export function SettingsModal({ const backToAdvanced = () => { const previousPage = advancedPage; - setAdvancedPage(null); + setAdvancedPage(isTestingFeaturePage(previousPage) ? 'testingFeatures' : null); window.requestAnimationFrame(() => { surfaceRef.current ?.querySelector(`[data-ol-advanced-entry="${previousPage}"]`) @@ -533,8 +538,16 @@ export function SettingsModal({ type="button" className="ol-settings-back" onClick={backToAdvanced} - aria-label={t('modal.backToAdvanced')} - title={t('modal.backToAdvanced')} + aria-label={t( + isTestingFeaturePage(advancedPage) + ? 'modal.backToTestingFeatures' + : 'modal.backToAdvanced', + )} + title={t( + isTestingFeaturePage(advancedPage) + ? 'modal.backToTestingFeatures' + : 'modal.backToAdvanced', + )} > diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ed9c03e2e..cc94ad24a 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1221,6 +1221,13 @@ export const de: typeof zhCN = { cancelledTitle: 'Aufnahme abgebrochen', }, settings: { + testingFeatures: { + title: 'Funktionen im Test', + enable: 'Funktionen im Test aktivieren', + description: + 'Less Computer und die Claude-Konsole auf unterstützten Plattformen anzeigen, einrichten und verwenden. Das Ausschalten stoppt laufende Aufgaben und blendet die Einträge aus. Ihre Einstellungen bleiben erhalten. Gilt nur für dieses Gerät.', + unavailable: 'Auf dieser Plattform sind noch keine Testfunktionen verfügbar.', + }, selectionWorkspace: { title: 'Assistent für Textauswahl', hint: 'Wähle Text aus und verwende einen Kurzbefehl: Ohne Sprachbearbeitung wird der Text direkt überarbeitet. Mit Sprachbearbeitung hältst du die Taste gedrückt und sprichst; danach wählst du „Fragen“ oder „Bearbeiten“.', @@ -2368,7 +2375,9 @@ export const de: typeof zhCN = { noResults: 'Keine passenden Kategorien. Versuche „Mikrofon“, „Modelle“ oder „Design“.', autoSaveHint: 'Änderungen werden automatisch gespeichert', backToAdvanced: 'Zurück zu Experimente und Erweiterungen', + backToTestingFeatures: 'Zurück zu Funktionen im Test', advancedPages: { + testingFeatures: 'Funktionen, die noch getestet werden, bei Bedarf aktivieren.', vocabularyLearning: 'Aus manuellen Korrekturen lernen; Beobachtung, Bestätigungszeit und Wortlänge einstellen.', lessComputer: @@ -2386,7 +2395,7 @@ export const de: typeof zhCN = { appearance: 'Design, Seitenlayout und Sprache der Oberfläche für angenehmes Lesen anpassen.', privacy: 'Systemberechtigungen und Verbindungen prüfen. Verlauf, Aufnahmen und lokale Daten verwalten.', - advanced: 'Less Computer und Diagnose nach Bedarf einrichten.', + advanced: 'Testfunktionen, Vokabellernen und Debugging-Werkzeuge konfigurieren.', about: 'Version, Updatekanal und automatische Updateeinstellungen anzeigen.', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 970ab81bc..8a3b039bf 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1199,6 +1199,13 @@ export const en: typeof zhCN = { cancelledTitle: 'Recording cancelled', }, settings: { + testingFeatures: { + title: 'Features under testing', + enable: 'Enable features under testing', + description: + 'Show Less Computer and the Claude console for setup and use on supported platforms. Turning this off stops active tasks and hides their entries while keeping your settings. Applies only to this device.', + unavailable: 'No testing features are available on this platform yet.', + }, selectionWorkspace: { title: 'Selection Assistant', hint: 'With a selection, one shortcut polishes (voice edit off) or takes a spoken instruction to ask or edit. With no selection but focus in a text field, speak to Help me write a draft or ask a question.', @@ -2306,7 +2313,9 @@ export const en: typeof zhCN = { noResults: 'No matching categories. Try “microphone”, “models” or “theme”.', autoSaveHint: 'Changes save automatically', backToAdvanced: 'Back to Experiments & extensions', + backToTestingFeatures: 'Back to features under testing', advancedPages: { + testingFeatures: 'Opt in to features that are still being tested.', vocabularyLearning: 'Learn from manual corrections after dictation; configure observation, confirmation time and phrase length.', lessComputer: 'Choose an agent and configure its model, permissions, and working directory.', @@ -2323,7 +2332,7 @@ export const en: typeof zhCN = { appearance: 'Adjust the theme, page layout and interface language for comfortable reading.', privacy: 'Check system permissions and connections. Manage history, recordings and local data.', - advanced: 'Configure Less Computer and debugging as needed.', + advanced: 'Configure testing features, vocabulary learning, and debugging tools.', about: 'View your version, update channel and automatic update settings.', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index c3ecec806..b1e37fbe9 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1214,6 +1214,13 @@ export const es: typeof zhCN = { cancelledTitle: 'Grabación cancelada', }, settings: { + testingFeatures: { + title: 'Funciones en fase de pruebas', + enable: 'Activar funciones en fase de pruebas', + description: + 'Mostrar Less Computer y la consola de Claude para configurarlos y usarlos en plataformas compatibles. Al desactivar esta opción, se detienen las tareas y se ocultan las entradas, pero se conservan los ajustes. Solo se aplica a este dispositivo.', + unavailable: 'Todavía no hay funciones de prueba disponibles en esta plataforma.', + }, selectionWorkspace: { title: 'Asistente de selección', hint: 'Selecciona texto y usa un solo atajo: mejora el texto si la edición por voz está desactivada; si está activada, mantén pulsado y habla, y luego elige Preguntar o Editar.', @@ -2350,7 +2357,9 @@ export const es: typeof zhCN = { noResults: 'No se encontraron categorías. Prueba «micrófono», «modelos» o «tema».', autoSaveHint: 'Los cambios se guardan automáticamente', backToAdvanced: 'Volver a Experimentos y extensiones', + backToTestingFeatures: 'Volver a funciones en fase de pruebas', advancedPages: { + testingFeatures: 'Activar opcionalmente las funciones que siguen en pruebas.', vocabularyLearning: 'Aprender de correcciones manuales; configurar observación, confirmación y longitud.', lessComputer: 'Elige un agente y configura su modelo, permisos y directorio de trabajo.', @@ -2368,7 +2377,8 @@ export const es: typeof zhCN = { appearance: 'Ajusta el tema, el diseño y el idioma de la interfaz para leer con comodidad.', privacy: 'Comprueba los permisos y las conexiones. Administra el historial, las grabaciones y los datos locales.', - advanced: 'Configura Less Computer y la depuración según tus necesidades.', + advanced: + 'Configurar funciones de prueba, aprendizaje de vocabulario y herramientas de depuración.', about: 'Consulta tu versión, el canal y los ajustes de actualización automática.', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index c9b2bc894..7d609f4b6 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1235,6 +1235,13 @@ export const fr: typeof zhCN = { cancelledTitle: 'Enregistrement annulé', }, settings: { + testingFeatures: { + title: 'Fonctionnalités en cours de test', + enable: 'Activer les fonctionnalités en cours de test', + description: + 'Afficher Less Computer et la console Claude pour les configurer et les utiliser sur les plateformes compatibles. La désactivation arrête les tâches en cours et masque les entrées tout en conservant vos réglages. Ce choix ne concerne que cet appareil.', + unavailable: 'Aucune fonctionnalité de test n’est encore disponible sur cette plateforme.', + }, selectionWorkspace: { title: 'Assistant de sélection', hint: 'Sélectionnez du texte, puis utilisez un seul raccourci : amélioration directe sans édition vocale ; sinon, maintenez et parlez, puis choisissez Question ou Modification.', @@ -2383,7 +2390,9 @@ export const fr: typeof zhCN = { noResults: 'Aucune catégorie correspondante. Essayez « microphone », « modèles » ou « thème ».', autoSaveHint: 'Les modifications sont enregistrées automatiquement', backToAdvanced: 'Retour à Expériences et extensions', + backToTestingFeatures: 'Retour aux fonctionnalités en cours de test', advancedPages: { + testingFeatures: 'Activer à la demande les fonctionnalités encore en test.', vocabularyLearning: 'Apprendre des corrections manuelles ; régler l’observation, la confirmation et la longueur.', lessComputer: @@ -2404,7 +2413,8 @@ export const fr: typeof zhCN = { 'Réglez le thème, la disposition et la langue de l’interface pour une lecture confortable.', privacy: 'Vérifiez les autorisations et les connexions. Gérez l’historique, les enregistrements et les données locales.', - advanced: 'Configurez Less Computer et le débogage selon vos besoins.', + advanced: + 'Configurer les fonctions de test, l’apprentissage du vocabulaire et les outils de débogage.', about: 'Consultez votre version, le canal et les réglages de mise à jour automatique.', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 64e9e0363..8ed1672bd 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1188,6 +1188,13 @@ export const ja: typeof zhCN = { cancelledTitle: '録音をキャンセルしました', }, settings: { + testingFeatures: { + title: 'テスト中の機能', + enable: 'テスト中の機能を有効にする', + description: + '対応するプラットフォームで Less Computer と Claude コンソールを表示し、設定・使用できます。オフにすると実行中のタスクを停止して項目を非表示にしますが、設定は保持されます。このデバイスのみに適用されます。', + unavailable: 'このプラットフォームでは、利用できるテスト機能はまだありません。', + }, selectionWorkspace: { title: '選択範囲アシスタント', hint: 'テキスト選択後、同じショートカットで:音声編集オフ時は推敲、オン時は押しながら話してから「質問」か「編集」を選択。', @@ -2275,7 +2282,9 @@ export const ja: typeof zhCN = { noResults: 'カテゴリが見つかりません。「マイク」「モデル」「テーマ」などをお試しください。', autoSaveHint: '変更は自動保存されます', backToAdvanced: '実験と拡張に戻る', + backToTestingFeatures: 'テスト中の機能に戻る', advancedPages: { + testingFeatures: 'テスト中の機能を必要に応じて有効にします。', vocabularyLearning: '音声入力後の手動修正から学習し、監視時間・確認時間・文字数を設定します。', lessComputer: 'Agent を選び、モデル・権限・作業ディレクトリを設定します。', @@ -2290,7 +2299,7 @@ export const ja: typeof zhCN = { services: '音声認識と文章処理のサービス、チャンネル、ローカルモデル、接続を管理します。', appearance: 'テーマ、レイアウト、表示言語を読みやすく調整します。', privacy: 'システム権限と接続を確認し、履歴、録音、ローカルデータを管理します。', - advanced: '必要に応じて Less Computer とデバッグを設定します。', + advanced: 'テスト機能、語彙学習、デバッグツールを設定します。', about: '現在のバージョン、更新チャンネル、自動更新を確認します。', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 371cc7e2d..63b0a1208 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1182,6 +1182,13 @@ export const ko: typeof zhCN = { cancelledTitle: '녹음이 취소됨', }, settings: { + testingFeatures: { + title: '테스트 중인 기능', + enable: '테스트 중인 기능 활성화', + description: + '지원되는 플랫폼에서 Less Computer와 Claude 콘솔을 표시하고 설정 및 사용할 수 있습니다. 끄면 실행 중인 작업을 중지하고 항목을 숨기지만 설정은 유지됩니다. 이 기기에만 적용됩니다.', + unavailable: '이 플랫폼에서는 아직 사용 가능한 테스트 기능이 없습니다.', + }, selectionWorkspace: { title: '선택 영역 도우미', hint: '텍스트 선택 후 같은 단축키: 음성 편집 끄면 바로 다듬기, 켜면 누른 채 말한 뒤 「질문」 또는 「편집」 선택.', @@ -2256,7 +2263,9 @@ export const ko: typeof zhCN = { noResults: '일치하는 카테고리가 없습니다. “마이크”, “모델” 또는 “테마”를 검색해 보세요.', autoSaveHint: '변경 사항이 자동 저장됩니다', backToAdvanced: '실험 및 확장으로 돌아가기', + backToTestingFeatures: '테스트 중인 기능으로 돌아가기', advancedPages: { + testingFeatures: '필요할 때 테스트 중인 기능을 활성화합니다.', vocabularyLearning: '음성 입력 후 수동 수정으로 학습하고 관찰 시간, 확인 시간, 글자 수를 설정합니다.', lessComputer: 'Agent를 선택하고 모델, 권한, 작업 디렉터리를 설정합니다.', @@ -2271,7 +2280,7 @@ export const ko: typeof zhCN = { services: '음성 인식과 텍스트 처리 서비스, 채널, 로컬 모델 및 연결을 관리합니다.', appearance: '테마, 페이지 배치, 인터페이스 언어를 편하게 읽도록 조정합니다.', privacy: '시스템 권한과 연결을 확인하고 기록, 녹음 및 로컬 데이터를 관리합니다.', - advanced: '필요에 따라 Less Computer와 디버깅을 설정합니다.', + advanced: '테스트 기능, 어휘 학습 및 디버깅 도구를 설정합니다.', about: '현재 버전, 업데이트 채널 및 자동 업데이트 설정을 확인합니다.', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ee582d872..5e05b0d2d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1152,6 +1152,13 @@ export const zhCN = { cancelledTitle: '已取消录音', }, settings: { + testingFeatures: { + title: '正在测试中的功能', + enable: '启用正在测试中的功能', + description: + '开启后显示 Less Computer 和 Claude 控制台,可在支持的平台配置和使用。关闭会停止运行并隐藏入口,保留已有配置。仅在当前设备生效。', + unavailable: '当前平台暂无可用的测试功能。', + }, selectionWorkspace: { title: '选区助手', hint: '有选区时按同一快捷键:关闭语音编辑则润色;开启后口述指令,可提问或编辑选区。无选区但光标在文本框时:可「帮我写」成稿或提问。', @@ -2175,7 +2182,9 @@ export const zhCN = { noResults: '没有找到相关分类。试试“麦克风”“模型”或“主题”。', autoSaveHint: '修改后自动保存', backToAdvanced: '返回实验与扩展', + backToTestingFeatures: '返回正在测试中的功能', advancedPages: { + testingFeatures: '按需开启尚在测试中的功能。', vocabularyLearning: '从听写后的手动改词中学习,配置观察时长、确认时间和词长。', lessComputer: '选择 Agent,配置模型、权限与工作目录。', claudeConsole: '检测 Claude Code,并查看测试任务的运行输出。', @@ -2189,7 +2198,7 @@ export const zhCN = { services: '选择语音识别与文字处理服务,管理渠道、本地模型和网络连接。', appearance: '调整主题、页面排版和界面语言,让阅读更舒服。', privacy: '检查系统权限与连接状态,管理历史、录音和本地数据。', - advanced: '按需配置 Less Computer 与调试功能。', + advanced: '配置测试功能、手改学词与调试工具。', about: '查看当前版本、更新渠道与自动更新设置。', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5b7cc7b3e..a0491e1bd 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1153,6 +1153,13 @@ export const zhTW: typeof zhCN = { cancelledTitle: '已取消錄音', }, settings: { + testingFeatures: { + title: '正在測試中的功能', + enable: '啟用正在測試中的功能', + description: + '開啟後顯示 Less Computer 和 Claude 控制台,可在支援的平台設定和使用。關閉會停止執行並隱藏入口,保留既有設定。僅在目前裝置生效。', + unavailable: '目前平台暫無可用的測試功能。', + }, selectionWorkspace: { title: '選區助手', hint: '選中文字後按同一快捷鍵:關閉語音編輯時直接潤色;開啟後口述指令,說完再選擇「提問」或「編輯選區」。', @@ -2175,7 +2182,9 @@ export const zhTW: typeof zhCN = { noResults: '找不到相關分類。試試「麥克風」「模型」或「主題」。', autoSaveHint: '修改後自動儲存', backToAdvanced: '返回實驗與擴充', + backToTestingFeatures: '返回正在測試中的功能', advancedPages: { + testingFeatures: '依需要開啟尚在測試中的功能。', vocabularyLearning: '從聽寫後的手動改詞中學習,設定觀察時長、確認時間和詞長。', lessComputer: '選擇 Agent,設定模型、權限與工作目錄。', claudeConsole: '偵測 Claude Code,並查看測試工作的執行輸出。', @@ -2189,7 +2198,7 @@ export const zhTW: typeof zhCN = { services: '選擇語音辨識與文字處理服務,管理管道、本機模型和網路連線。', appearance: '調整主題、頁面排版和介面語言,讓閱讀更舒服。', privacy: '檢查系統權限與連線狀態,管理歷史、錄音和本機資料。', - advanced: '按需設定 Less Computer 與除錯功能。', + advanced: '設定測試功能、手改學詞與除錯工具。', about: '查看目前版本、更新管道與自動更新設定。', }, searchKeywords: { diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 799121d82..81e0192d5 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -94,6 +94,7 @@ export let mockSettings: UserPreferences = { }, openAppHotkey: { primary: 'O', modifiers: defaultAppShortcutModifiers() }, stylePackHotkeys: [], + testingFeaturesEnabled: false, codingAgentEnabled: false, codingAgentProvider: 'claude-code-cli', codingAgentModel: null, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index d9cf11ab8..e8916aca7 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -438,6 +438,8 @@ export interface UserPreferences { openAppHotkey: ShortcutBinding | null; /** Style pack direct hotkeys: pressing one activates the corresponding style pack. Default empty list (issue #759). */ stylePackHotkeys: StylePackHotkey[]; + /** Local opt-in for features under testing. Defaults to false. */ + testingFeaturesEnabled: boolean; /** Less Computer: whether enabled. Off by default. */ codingAgentEnabled: boolean; /** Agent backend: claude-code-cli (default) / opencode-cli / codex-cli / dsh-cli. */ diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index f833fda63..a36d5cc34 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -326,7 +326,7 @@ export function ShortcutsSection() { }} /> - {(os === 'mac' || os === 'win') && ( + {prefs.testingFeaturesEnabled && (os === 'mac' || os === 'win') && ( item.id === 'lessComputer'), + visibleAdvancedPages('desktop', 'win', true).some((item) => item.id === 'lessComputer'), true, 'Windows retains Less Computer configuration', ); @@ -91,7 +91,7 @@ assert.equal( ); assert.deepEqual( visibleAdvancedPages('android', 'android').map((item) => item.id), - ['vocabularyLearning', 'debug'], + ['testingFeatures', 'vocabularyLearning', 'debug'], 'the pipeline switch lives in Services on every platform', ); console.log('settings navigation tests passed'); @@ -175,3 +175,25 @@ assert.deepEqual( 'mobile service tabs stay stable', ); console.log('settings service view tests passed'); + +for (const os of ['mac', 'win', 'android'] as const) { + assert.equal( + visibleAdvancedPages('desktop', os).some((item) => + ['lessComputer', 'claudeConsole'].includes(item.id), + ), + false, + `${os}: unfinished features are hidden without a device opt-in`, + ); + assert.equal( + visibleAdvancedPages('desktop', os).some((item) => item.id === 'testingFeatures'), + true, + `${os}: the Beta opt-in page remains reachable`, + ); +} +assert.deepEqual( + visibleAdvancedPages('desktop', 'mac', true) + .filter((item) => ['lessComputer', 'claudeConsole'].includes(item.id)) + .map((item) => item.id), + ['lessComputer', 'claudeConsole'], + 'macOS exposes both testing features after opt-in', +); diff --git a/openless-all/app/src/pages/settings/navigation.ts b/openless-all/app/src/pages/settings/navigation.ts index d24223c4e..07d897b6a 100644 --- a/openless-all/app/src/pages/settings/navigation.ts +++ b/openless-all/app/src/pages/settings/navigation.ts @@ -12,6 +12,7 @@ export type SettingsSectionId = | 'about'; export const ADVANCED_PAGES = [ + { id: 'testingFeatures', icon: 'sparkle', titleKey: 'settings.testingFeatures.title' }, { id: 'lessComputer', icon: 'mac', titleKey: 'settings.codingAgent.title' }, { id: 'claudeConsole', icon: 'chevLR', titleKey: 'settings.codingConsole.title' }, { id: 'vocabularyLearning', icon: 'sparkle', titleKey: 'settings.vocabularyLearning.title' }, @@ -21,8 +22,17 @@ export const ADVANCED_PAGES = [ export type AdvancedPage = (typeof ADVANCED_PAGES)[number]; export type AdvancedPageId = AdvancedPage['id']; -export function visibleAdvancedPages(platform: PlatformKind | undefined, os: OS): AdvancedPage[] { +export function isTestingFeaturePage(page: AdvancedPageId | null): boolean { + return page === 'lessComputer' || page === 'claudeConsole'; +} + +export function visibleAdvancedPages( + platform: PlatformKind | undefined, + os: OS, + enabled = false, +): AdvancedPage[] { return ADVANCED_PAGES.filter((page) => { + if (isTestingFeaturePage(page.id) && !enabled) return false; if (page.id === 'lessComputer') return platform === 'desktop' && (os === 'mac' || os === 'win'); if (page.id === 'claudeConsole') return platform === 'desktop' && os === 'mac'; if (page.id === 'vocabularyLearning') diff --git a/openless-all/app/src/pages/settings/navigationLocale.test.ts b/openless-all/app/src/pages/settings/navigationLocale.test.ts index b1b60f493..31ab35fbc 100644 --- a/openless-all/app/src/pages/settings/navigationLocale.test.ts +++ b/openless-all/app/src/pages/settings/navigationLocale.test.ts @@ -4,15 +4,31 @@ import { zhTW } from '../../i18n/zh-TW'; import { en } from '../../i18n/en'; import { ja } from '../../i18n/ja'; import { ko } from '../../i18n/ko'; +import { de } from '../../i18n/de'; +import { fr } from '../../i18n/fr'; +import { es } from '../../i18n/es'; import { ADVANCED_PAGES, SETTINGS_SECTIONS, searchSettingsSections } from './navigation'; // Resources must resolve where the dialog actually reads them, not in a nested // feature's unrelated "modal" object. Type-shape parity alone cannot detect this. -for (const [locale, translation] of Object.entries({ 'zh-CN': zhCN, 'zh-TW': zhTW, en, ja, ko })) { +for (const [locale, translation] of Object.entries({ + 'zh-CN': zhCN, + 'zh-TW': zhTW, + en, + ja, + ko, + de, + fr, + es, +})) { const i18n = createInstance(); await i18n.init({ lng: locale, fallbackLng: false, resources: { [locale]: { translation } } }); for (const key of [ 'modal.backToAdvanced', + 'modal.backToTestingFeatures', + ...['title', 'enable', 'description', 'unavailable'].map( + (key) => `settings.testingFeatures.${key}`, + ), ...ADVANCED_PAGES.flatMap((page) => [page.titleKey, `modal.advancedPages.${page.id}`]), ]) { if (!i18n.exists(key)) throw new Error(`${locale}: missing advanced navigation label ${key}`); @@ -47,4 +63,4 @@ for (const [locale, translation] of Object.entries({ 'zh-CN': zhCN, 'zh-TW': zhT if (!i18n.t('modal.searchCount', { count: 2 }).includes('2')) throw new Error(`${locale}: result count is not rendered`); } -console.log('settings localization and search routing passed in five languages'); +console.log('settings localization and search routing passed in eight languages'); diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index c04d6d5e1..661c504a1 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -32,11 +32,12 @@ import { listChannels } from '../../lib/ipc'; import type { PlatformCapabilities } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { emitSaved } from '../../lib/savedEvent'; -import { SettingRow, segmentedTrackStyle } from './shared'; +import { SettingRow, Toggle, ExperimentalSectionTitle, segmentedTrackStyle } from './shared'; import { Card } from '../_atoms'; import { useContentMotion, useSelectionMotion } from '../../lib/motion'; import { availableServiceViews, + isTestingFeaturePage, isServiceViewInactive, resolveServiceView, type ServiceViewId, @@ -357,46 +358,87 @@ export function AdvancedTab({ onOpenPage: (page: AdvancedPageId) => void; }) { const { t } = useTranslation(); - return ( - <> - - {pages.map((item) => ( -
+ page === 'testingFeatures' + ? testing && isTestingFeaturePage(item.id) + : !isTestingFeaturePage(item.id), + ); + const list = ( +
+ {entries.map((item) => ( +
+ + + + + {t(item.titleKey)} + + {t(`modal.advancedPages.${item.id}`)} + + + {item.id === 'testingFeatures' && ( + Beta + )} + + ))} + + ); + return ( + <> + {page === null && list} + {page === 'testingFeatures' && ( + + + {t('settings.testingFeatures.title')} + + + { + void updatePrefs((current) => ({ ...current, testingFeaturesEnabled: next })).catch( + () => emitSaved('failed', t('common.operationFailed')), + ); + }} + /> + + {testing && list} + {testing && entries.length === 0 && ( +

+ {t('settings.testingFeatures.unavailable')} +

+ )} +
+ )} + {pages + .filter((item) => item.id !== 'testingFeatures') + .map((item) => ( + + ))} ); } From f7ea09371e4716ddd4389cbfa0d3212a7d175a56 Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 00:27:09 +0800 Subject: [PATCH 2/6] fix(settings): await Beta opt-in before mounting testing tools --- .../pages/settings/ClaudeConsoleSection.tsx | 3 +++ openless-all/app/src/pages/settings/tabs.tsx | 27 ++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx b/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx index 154eb2923..438396431 100644 --- a/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx +++ b/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx @@ -60,6 +60,9 @@ export function ClaudeConsoleSection() { setDetecting(true); try { setDetection(await codingAgentDetect(exe.trim() || undefined)); + } catch (error) { + setDetection(null); + setSummary(`✗ ${String(error)}`); } finally { setDetecting(false); } diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 661c504a1..a39057ace 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -360,9 +360,11 @@ export function AdvancedTab({ const { t } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); const testing = prefs?.testingFeaturesEnabled === true; + const [savingTesting, setSavingTesting] = useState(false); + const testingReady = testing && !savingTesting; const entries = pages.filter((item) => page === 'testingFeatures' - ? testing && isTestingFeaturePage(item.id) + ? testingReady && isTestingFeaturePage(item.id) : !isTestingFeaturePage(item.id), ); const list = ( @@ -406,17 +408,22 @@ export function AdvancedTab({ > { - void updatePrefs((current) => ({ ...current, testingFeaturesEnabled: next })).catch( - () => emitSaved('failed', t('common.operationFailed')), - ); + onToggle={async (next) => { + setSavingTesting(true); + try { + await updatePrefs((current) => ({ ...current, testingFeaturesEnabled: next })); + } catch { + emitSaved('failed', t('common.operationFailed')); + } finally { + setSavingTesting(false); + } }} />
- {testing && list} - {testing && entries.length === 0 && ( + {testingReady && list} + {testingReady && entries.length === 0 && (

{t('settings.testingFeatures.unavailable')}

@@ -433,8 +440,8 @@ export function AdvancedTab({ aria-label={t(item.titleKey)} data-ol-advanced-page={item.id} > - {testing && item.id === 'lessComputer' && } - {testing && item.id === 'claudeConsole' && } + {testingReady && item.id === 'lessComputer' && } + {testingReady && item.id === 'claudeConsole' && } {item.id === 'vocabularyLearning' && } {item.id === 'debug' && } From 24a0d30ef2d56b6955ff58e3e8eb4ec0c76ef915 Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 00:35:15 +0800 Subject: [PATCH 3/6] test(linux): cover console opt-in before unavailable CLI detection --- openless-all/app/linux-egui/src/backend.rs | 37 ++++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index d01c3c4d1..0f42f1837 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -609,20 +609,37 @@ mod tests { .unwrap() .build() .unwrap(); + let request = CodingAgentTestRequest { + provider: CodingAgentProvider::ClaudeCodeCli, + executable: Some("openless-command-that-does-not-exist".into()), + prompt: "test".into(), + permission_mode: openless_core::CodingAgentPermissionMode::Plan, + workdir: None, + model: None, + max_budget_usd: Some(0.5), + timeout_secs: 5, + }; + let denied = runtime + .backend + .services() + .coding_agent + .run_test(request.clone()) + .await + .unwrap_err(); + assert_eq!(denied.code, BackendErrorCode::PermissionDenied); + let mut preferences = runtime.backend.get_preferences(); + preferences.testing_features_enabled = true; + runtime + .backend + .repositories() + .preferences + .set(preferences) + .unwrap(); let error = runtime .backend .services() .coding_agent - .run_test(CodingAgentTestRequest { - provider: CodingAgentProvider::ClaudeCodeCli, - executable: Some("openless-command-that-does-not-exist".into()), - prompt: "test".into(), - permission_mode: openless_core::CodingAgentPermissionMode::Plan, - workdir: None, - model: None, - max_budget_usd: Some(0.5), - timeout_secs: 5, - }) + .run_test(request) .await .expect_err("missing coding agent executable must be explicit"); assert_eq!(error.code, BackendErrorCode::Unsupported); From bc1234331fa44acbe293417fde249bc3ae7f8187 Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 01:11:30 +0800 Subject: [PATCH 4/6] test(linux): opt in before recording testing-feature shortcuts --- openless-all/app/linux-egui/src/main.rs | 1 + openless-all/app/linux-egui/src/ui/frontend/mod.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 74bbb9865..eabcfbdab 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -7912,6 +7912,7 @@ Internal flags (set by OpenLess itself, not for regular use): settings_section: frontend::view_model::SettingsSection::Shortcuts, ..Default::default() }; + vm.settings.testing_features = true; let _ = render(&mut vm, Vec::new()); let painted = render(&mut vm, Vec::new()); for key in [ diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 91bd6c0e1..efa1ca87d 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -932,6 +932,7 @@ mod tests { shortcut_recording: Some(ShortcutField::CodingAgentVoice), ..Default::default() }; + vm.settings.testing_features = true; let held = egui::Modifiers { ctrl: true, command: true, From d17c338a4aec955265b35102ec5f35be698c6885 Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 01:51:21 +0800 Subject: [PATCH 5/6] fix(ui): serialize testing-feature window visibility with opt-out --- openless-all/app/linux-egui/src/main.rs | 7 +++++ openless-all/app/src-tauri/src/lib.rs | 27 ++++++++++++------- .../app/src-tauri/src/tauri_events.rs | 12 +++++++-- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index eabcfbdab..4250aef1b 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1093,6 +1093,13 @@ mod linux_app { return; } self.ensure_popup(PopupKind::LessComputer); + if self + .backend() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + self.less_computer_popup = None; + return; + } let message = self.less_computer_snapshot(self.lang); self.send_popup(PopupKind::LessComputer, message); } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 27167f572..a720de9cf 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -3113,10 +3113,10 @@ pub(crate) fn show_less_computer_window(app: &AppHandle) { let _ = window_clone.show(); } } + // Keep the entrance epoch ordered with preference-driven hides on the UI thread. + LESS_COMPUTER_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst); + let _ = app_for_show.emit_to("less-computer", "chat-panel:shown", serde_json::json!({})); }); - // Cancel the pending exit-hide (fast close-open) and replay the entrance animation. - LESS_COMPUTER_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst); - let _ = app.emit_to("less-computer", "chat-panel:shown", serde_json::json!({})); } #[cfg(target_os = "windows")] @@ -3130,12 +3130,21 @@ pub(crate) fn show_less_computer_window(app: &AppHandle) { let Some(window) = ensure_less_computer_window(app) else { return; }; - if let Err(error) = window.show() { - log::warn!("[less-computer] show failed: {error}"); - return; - } - LESS_COMPUTER_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst); - let _ = app.emit_to("less-computer", "chat-panel:shown", serde_json::json!({})); + let app_for_show = app.clone(); + let _ = app.run_on_main_thread(move || { + if app_for_show + .try_state::>() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + return; + } + if let Err(error) = window.show() { + log::warn!("[less-computer] show failed: {error}"); + return; + } + LESS_COMPUTER_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst); + let _ = app_for_show.emit_to("less-computer", "chat-panel:shown", serde_json::json!({})); + }); } #[cfg(not(any(target_os = "macos", target_os = "windows")))] diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index dd67fe96a..190912f50 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -528,8 +528,16 @@ fn map_dictation_state( fn emit_preferences(app: &AppHandle, backend: &OpenLessBackend) { let preferences = backend.get_preferences(); if !preferences.testing_features_enabled { - crate::hide_less_computer_window(app); - crate::hide_less_computer_glow(app); + let app_for_hide = app.clone(); + let _ = app.run_on_main_thread(move || { + if app_for_hide + .try_state::>() + .is_some_and(|backend| backend.ensure_testing_features_enabled().is_err()) + { + crate::hide_less_computer_window(&app_for_hide); + crate::hide_less_computer_glow(&app_for_hide); + } + }); } // Cloud restore also changes preferences directly through Core. Keep native // capsule geometry and hit testing current for every source of a preference change. From 3cbf9a65a3d978072f0802750762b39799622dbb Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 02:12:54 +0800 Subject: [PATCH 6/6] test(linux): resolve Beta title through the localization catalog --- openless-all/app/linux-egui/src/ui/frontend/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index efa1ca87d..2c8716e57 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -813,7 +813,11 @@ mod tests { use super::view_model::SettingsSection; let mut vm = FrontendViewModel::default(); let root = painted_settings_lines(SettingsSection::Advanced, &mut vm); - assert!(root.iter().any(|line| line.contains("正在测试中的功能"))); + let title = openless_linux_egui::tr_l10n( + openless_linux_egui::Lang::ZhCn, + "settings.testing_features.title", + ); + assert!(root.iter().any(|line| line.contains(title))); assert!(!root.iter().any(|line| line == "Less Computer")); vm.advanced_open = 3; let off = painted_settings_lines(SettingsSection::Advanced, &mut vm);