feat: 支持 Linux 平台使用(CA 安装适配 + 静默启动),并修复 api_key 泄漏

CA 适配:
- 移除 CA 模块对 macOS/Windows 的硬性限制,Linux 走完整初始化流程
- 按发行版自动选择安装命令:Debian 系 update-ca-certificates,
  Fedora/RHEL/Arch 系 update-ca-trust extract;已安装检测比对信任锚文件
- 删除不可达的 CaState::Unsupported 分支及前端"请使用 macOS 或 Windows"提示

静默启动(参考 cc-switch 实现逻辑):
- 新增 DesktopSettings 持久化(service_settings 表)及 GET/PUT /api/settings/desktop
- 主窗口统一以 visible(false) 创建,启动时读取配置决定是否显示
- 应用设置页:开启"开机启动"后显示"静默启动"子开关

安全修复:
- ProviderEndpoint.api_key 增加 serde(skip_serializing),
  避免明文 API Key 随 JSON 响应泄漏(provider_console 测试恢复通过)

其他:
- Linux 补齐 open_terminal_with_command:按序探测 x-terminal-emulator /
  gnome-terminal / konsole 等并在新终端窗口执行命令
- "打开终端安装 CA"失败信息不再被前端静默吞掉
- 顺手修复上游遗留问题:desktop.rs 未使用导入、output.rs 测试模块位置、providers.rs 格式
This commit is contained in:
Linanwanttodo
2026-08-25 22:59:21 +08:00
parent 081e1f50e2
commit d95273c49b
18 changed files with 297 additions and 99 deletions
+4
View File
@@ -84,6 +84,10 @@ impl App {
self.harness.clone()
}
pub fn store(&self) -> Store {
self.store.clone()
}
pub async fn serve(self) -> Result<()> {
let listener = self.bind().await?;
let shutdown = CancellationToken::new();
+4
View File
@@ -164,6 +164,10 @@ pub fn api_router(service: ControlService) -> Router {
"/__byok-api__/api/settings/tab",
get(settings::get_tab).put(settings::update_tab),
)
.route(
"/__byok-api__/api/settings/desktop",
get(settings::get_desktop).put(settings::update_desktop),
)
.route(
"/__byok-api__/api/harness/cursor/status",
get(harness::status),
+10 -1
View File
@@ -22,7 +22,8 @@ use crate::{
},
provider::{ModelEvent, Provider},
store::{
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store, TabSettings,
DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store,
TabSettings,
},
Error, Result,
};
@@ -497,6 +498,14 @@ impl ControlService {
pub async fn set_tab_settings(&self, settings: TabSettings) -> Result<TabSettings> {
self.cursor_harness.set_tab_settings(settings).await
}
pub async fn desktop_settings(&self) -> Result<DesktopSettings> {
self.store.desktop_settings().await
}
pub async fn set_desktop_settings(&self, settings: DesktopSettings) -> Result<()> {
self.store.set_desktop_settings(settings).await
}
}
fn official_call(trace: CursorRunTraceSummary) -> CallSummary {
+14 -1
View File
@@ -2,7 +2,8 @@ use crate::Result;
use axum::{extract::State, Json};
use crate::store::{
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, TabSettings,
DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage,
TabSettings,
};
use super::{ControlService, ObservabilitySettings};
@@ -60,3 +61,15 @@ pub async fn update_tab(
) -> Result<Json<TabSettings>> {
Ok(Json(service.set_tab_settings(settings).await?))
}
pub async fn get_desktop(State(service): State<ControlService>) -> Result<Json<DesktopSettings>> {
Ok(Json(service.desktop_settings().await?))
}
pub async fn update_desktop(
State(service): State<ControlService>,
Json(settings): Json<DesktopSettings>,
) -> Result<Json<DesktopSettings>> {
service.set_desktop_settings(settings).await?;
get_desktop(State(service)).await
}
@@ -409,6 +409,10 @@ fn creates_subagent(call: &ToolCall) -> bool {
)
}
fn missing(name: &str) -> Error {
Error::Protocol(format!("{name} returned no result"))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
@@ -508,7 +512,3 @@ mod tests {
assert!(content.contains("cannot find value `name`"));
}
}
fn missing(name: &str) -> Error {
Error::Protocol(format!("{name} returned no result"))
}
+34 -11
View File
@@ -46,9 +46,6 @@ impl CaManager {
}
pub fn state(&self) -> Result<CaState> {
if !matches!(std::env::consts::OS, "macos" | "windows") {
return Ok(CaState::Unsupported);
}
let cert = fs::read_to_string(self.cert_path());
let key = fs::read_to_string(self.key_path());
match (cert, key) {
@@ -93,18 +90,21 @@ impl CaManager {
"certutil -addstore -f Root \"{}\"",
self.cert_path().display()
)),
"linux" => {
let anchor = linux_anchor_file();
Some(format!(
"sudo cp '{}' '{}' && sudo {}",
path,
anchor.display(),
linux_refresh_command()
))
}
_ => None,
}
}
pub fn initialize_local(&self) -> Result<()> {
match self.state()? {
CaState::Unsupported => {
return Err(Error::Config(format!(
"CA installation is not supported on {}",
std::env::consts::OS
)))
}
CaState::Invalid => {
return Err(Error::Config("CA files are incomplete or invalid".into()))
}
@@ -210,8 +210,31 @@ fn is_installed(cert: &str) -> Result<bool> {
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn is_installed(_cert: &str) -> Result<bool> {
Ok(false)
fn is_installed(cert: &str) -> Result<bool> {
match fs::read_to_string(linux_anchor_file()) {
Ok(installed) => Ok(installed.trim() == cert.trim()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
const LINUX_ANCHOR_NAME: &str = "cursor-byok-local-ca.crt";
fn linux_anchor_file() -> PathBuf {
if PathBuf::from("/etc/pki/ca-trust/source/anchors").is_dir() {
PathBuf::from("/etc/pki/ca-trust/source/anchors").join(LINUX_ANCHOR_NAME)
} else if PathBuf::from("/etc/ca-certificates/trust-source/anchors").is_dir() {
PathBuf::from("/etc/ca-certificates/trust-source/anchors").join(LINUX_ANCHOR_NAME)
} else {
PathBuf::from("/usr/local/share/ca-certificates").join(LINUX_ANCHOR_NAME)
}
}
fn linux_refresh_command() -> &'static str {
match linux_anchor_file().parent().and_then(|dir| dir.to_str()) {
Some("/usr/local/share/ca-certificates") => "update-ca-certificates",
_ => "update-ca-trust extract",
}
}
#[cfg(test)]
-1
View File
@@ -31,7 +31,6 @@ pub enum CaState {
Untrusted,
Ready,
Invalid,
Unsupported,
}
#[derive(Clone, Debug, Serialize)]
+1
View File
@@ -51,6 +51,7 @@ pub struct ProviderEndpoint {
pub name: String,
pub provider_type: ProviderType,
pub base_url: String,
#[serde(skip_serializing)]
pub api_key: Option<String>,
pub has_api_key: bool,
pub custom_headers: serde_json::Value,
+31
View File
@@ -8,6 +8,7 @@ const PORT_SETTINGS_KEY: &str = "network_ports";
const PROXY_SETTINGS_KEY: &str = "outbound_proxy";
const TAB_SETTINGS_KEY: &str = "cursor_tab";
const INSTALLATION_ID_KEY: &str = "installation_id";
const DESKTOP_SETTINGS_KEY: &str = "desktop_lifecycle";
pub const PUBLIC_TAB_SERVICE_URL: &str = "https://tab.leokun.cn";
@@ -46,6 +47,12 @@ pub struct TabSettings {
pub address: String,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub struct DesktopSettings {
#[serde(default)]
pub silent_start: bool,
}
impl TabSettings {
pub fn service_url(&self) -> Option<&str> {
match self.mode {
@@ -243,6 +250,30 @@ impl Store {
settings.proxy_port = port;
self.set_port_settings(settings).await
}
pub async fn desktop_settings(&self) -> Result<DesktopSettings> {
let value = sqlx::query_scalar::<_, String>(
"SELECT value_json FROM service_settings WHERE setting_key = ?",
)
.bind(DESKTOP_SETTINGS_KEY)
.fetch_optional(&self.pool)
.await?;
value
.map(|value| serde_json::from_str(&value).map_err(Into::into))
.unwrap_or_else(|| Ok(DesktopSettings::default()))
}
pub async fn set_desktop_settings(&self, settings: DesktopSettings) -> Result<()> {
sqlx::query(
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
)
.bind(DESKTOP_SETTINGS_KEY)
.bind(serde_json::to_string(&settings)?)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
}
#[cfg(test)]