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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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. 分层与工作区

Expand Down Expand Up @@ -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 在恢复超时或音频时钟冻结时丢弃并最多重试一次,重试沿用原请求的取消和迟到边界。
Expand Down
2 changes: 2 additions & 0 deletions docs/encrypted-cloud-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@

短暂的本机写入竞争最多触发 4 次有界延后重试;网络失败、结果未知、版本冲突不进入该重试。后续手动任务、锁定或停用会淘汰旧重试链。同步连接遵循当前系统代理偏好,策略变化后重建连接,回环测试始终直连。

测试功能授权 `testingFeaturesEnabled` 不进入远端文档或设备档案;正常恢复和失败回滚均保留接收设备的本机值,不能通过同步打开 Less Computer 或 Claude 控制台。

系统自启动 `launchAtLogin` 不进入远端文档或设备档案;保留接收设备本机值,实际 OS 状态仍由现有 UI 自启动插件管理,恢复不宣称替用户重建自启动授权。

恢复必须绑定 `RestoreRuntimeEffects` 的真实 Host 适配器。所有仓库写入后,在同一加密 journal、独占写入门和运行态屏障内等待绝对目标设置生效;副作用失败按 before 镜像回滚。只保存在本地 journal 的 Agent 开关、provider 镜像和录音标记恢复后,再应用最终旧设置;若回滚副作用也失败,保留 recovery_required,不能提前放行录音或 Agent。未绑定适配器明确失败,不使用空实现代替平台确认。
Expand Down
5 changes: 4 additions & 1 deletion docs/linux-egui-backend-contract.md
Original file line number Diff line number Diff line change
@@ -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. 合同文件

Expand All @@ -10,6 +10,7 @@
| --- | --- |
| `startupSnapshot` | 启动快照结构与版本校验规则;UI 必须先消费快照再渲染 |
| `backendEvent` | 语义事件清单、顺序与重放规则 |
| `testingFeatures` | 本设备 Beta 总开关、默认关闭、运行门禁与取消合同 |
| `lessComputerVoice` | Less Computer 语音事件面 |
| `androidJni` | Android JNI 合同(src-tauri android 桥接共用) |
| `linuxFacade` | Linux 专用 facade 面(`LinuxHost` 公开方法对应) |
Expand All @@ -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 是否激活由合成器决定。
Expand Down
9 changes: 9 additions & 0 deletions openless-all/app/contract/backend-2.0.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
173 changes: 150 additions & 23 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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",
Expand All @@ -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<SessionId> {
self.deps.services.less_computer.active_session()
Expand Down Expand Up @@ -2990,7 +3002,7 @@ impl OpenLessBackend {
) -> Result<LessComputerVoiceSession, BackendError> {
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",
Expand Down Expand Up @@ -3564,7 +3576,7 @@ impl OpenLessBackend {
) -> Result<LessComputerRunResult, BackendError> {
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",
Expand Down Expand Up @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -7785,13 +7821,32 @@ 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());
}

#[tokio::test]
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();

Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading