mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-18 05:16:35 +08:00
Add README, plugin citations, and multi-platform release CI.
Ship screenshots and third-party notices for the desktop shell, and package Windows, macOS, deb, rpm, and Flatpak from GitHub Releases. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
379
crates/dsh-core/src/preset.rs
Normal file
379
crates/dsh-core/src/preset.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::paths::{copy_tree, dsh_home, BundledPaths};
|
||||
|
||||
pub const PRESET_ID: &str = "anchored-standard";
|
||||
pub const ZERO_PRESET_ID: &str = "zero-anchored-standard";
|
||||
pub const SOURCE_MARKER: &str = ".dsh-desktop-source";
|
||||
pub const DEFAULT_BLOCK: &str = "agent-presets:\n default: anchored-standard\n";
|
||||
pub const PRESET_NAME_ZH: &str = "锚定式标准(实验)";
|
||||
pub const PRESET_DESCRIPTION_ZH: &str =
|
||||
"首轮使用 Minimal 的真实工具对(持久 bash + str_replace_editor),不自动注入工作区或技能上下文;首次工具调用或回复后开放完整 Standard 工具。";
|
||||
pub const ZERO_PRESET_NAME_ZH: &str = "零工具锚定式标准(实验)";
|
||||
pub const ZERO_PRESET_DESCRIPTION_ZH: &str =
|
||||
"先插入一轮无工具的锚定对话(固定提示),从下一轮起开放完整 Standard 工具。";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BundledPreset {
|
||||
pub preset_id: &'static str,
|
||||
pub name_zh: &'static str,
|
||||
pub description_zh: &'static str,
|
||||
}
|
||||
|
||||
pub const BUNDLED_PRESETS: &[BundledPreset] = &[
|
||||
BundledPreset {
|
||||
preset_id: PRESET_ID,
|
||||
name_zh: PRESET_NAME_ZH,
|
||||
description_zh: PRESET_DESCRIPTION_ZH,
|
||||
},
|
||||
BundledPreset {
|
||||
preset_id: ZERO_PRESET_ID,
|
||||
name_zh: ZERO_PRESET_NAME_ZH,
|
||||
description_zh: ZERO_PRESET_DESCRIPTION_ZH,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PresetEnsureResult {
|
||||
pub status: &'static str,
|
||||
pub version: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub fn preset_install_dir(preset_id: &str) -> PathBuf {
|
||||
dsh_home().join(".agent-presets").join(preset_id)
|
||||
}
|
||||
|
||||
fn is_preset_dir(path: &Path) -> bool {
|
||||
path.is_dir() && path.join("preset.yml").is_file()
|
||||
}
|
||||
|
||||
pub fn read_source_version(path: &Path) -> Option<String> {
|
||||
let version = std::fs::read_to_string(path.join(SOURCE_MARKER)).ok()?;
|
||||
let version = version.trim();
|
||||
if version.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(version.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bundled_preset_dir(paths: &BundledPaths, preset_id: &str) -> Option<PathBuf> {
|
||||
paths
|
||||
.find_dir(preset_id, "preset.yml")
|
||||
.or_else(|| paths.find_dir(&format!("vendor/{preset_id}"), "preset.yml"))
|
||||
.filter(|p| is_preset_dir(p))
|
||||
}
|
||||
|
||||
pub fn ensure_default_preset(text: &str) -> String {
|
||||
let mut body = text.replace("\r\n", "\n");
|
||||
if !body.ends_with('\n') {
|
||||
body.push('\n');
|
||||
}
|
||||
if body.trim().is_empty() {
|
||||
return DEFAULT_BLOCK.to_string();
|
||||
}
|
||||
let re = Regex::new(r"(?m)^agent-presets:\n((?:[ \t]+.*\n)*)").unwrap();
|
||||
let Some(caps) = re.captures(&body) else {
|
||||
body.push('\n');
|
||||
body.push_str(DEFAULT_BLOCK);
|
||||
return body;
|
||||
};
|
||||
let inner = caps.get(1).unwrap().as_str();
|
||||
if Regex::new(r"(?m)^[ \t]+default:\s*\S+")
|
||||
.unwrap()
|
||||
.is_match(inner)
|
||||
{
|
||||
return body;
|
||||
}
|
||||
let insert = format!(" default: {PRESET_ID}\n");
|
||||
let start = caps.get(1).unwrap().start();
|
||||
let end = caps.get(1).unwrap().end();
|
||||
format!("{}{}{}{}", &body[..start], insert, inner, &body[end..])
|
||||
}
|
||||
|
||||
pub fn localize_preset_yml(text: &str, name: &str, description: &str) -> String {
|
||||
let mut body = text.replace("\r\n", "\n");
|
||||
if !body.ends_with('\n') {
|
||||
body.push('\n');
|
||||
}
|
||||
let name_line = format!("name: {}\n", serde_json::to_string(name).unwrap());
|
||||
let desc_line = format!(
|
||||
"description: {}\n",
|
||||
serde_json::to_string(description).unwrap()
|
||||
);
|
||||
let name_re = Regex::new(r"(?m)^name:.*\n").unwrap();
|
||||
if let Some(m) = name_re.find(&body) {
|
||||
body = format!("{}{}{}", &body[..m.start()], name_line, &body[m.end()..]);
|
||||
} else {
|
||||
body = format!("{name_line}{body}");
|
||||
}
|
||||
let desc_re = Regex::new(r"(?m)^description:(?:[ \t].*)?\n(?:[ \t].+\n)*").unwrap();
|
||||
if let Some(m) = desc_re.find(&body) {
|
||||
return format!("{}{}{}", &body[..m.start()], desc_line, &body[m.end()..]);
|
||||
}
|
||||
if let Some(m) = name_re.find(&body) {
|
||||
return format!("{}{}{}", &body[..m.end()], desc_line, &body[m.end()..]);
|
||||
}
|
||||
format!("{desc_line}{body}")
|
||||
}
|
||||
|
||||
fn write_localized_preset(dest: &Path, spec: &BundledPreset) -> std::io::Result<()> {
|
||||
let path = dest.join("preset.yml");
|
||||
let previous = std::fs::read_to_string(&path)?;
|
||||
let updated = localize_preset_yml(&previous, spec.name_zh, spec.description_zh);
|
||||
if updated != previous {
|
||||
std::fs::write(path, updated)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn short_version(version: Option<&str>) -> String {
|
||||
match version {
|
||||
None => "bundled".into(),
|
||||
Some(v) if v.len() > 12 => v[..12].into(),
|
||||
Some(v) => v.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_one(paths: &BundledPaths, spec: &BundledPreset) -> PresetEnsureResult {
|
||||
let src = bundled_preset_dir(paths, spec.preset_id);
|
||||
let dest = preset_install_dir(spec.preset_id);
|
||||
let bundled = src.as_ref().and_then(|p| read_source_version(p));
|
||||
let installed = if dest.is_dir() {
|
||||
read_source_version(&dest)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let version = bundled.clone().or_else(|| installed.clone());
|
||||
let label = spec.name_zh;
|
||||
if src.is_none() {
|
||||
if dest.is_dir() && dest.join("preset.yml").is_file() {
|
||||
let _ = write_localized_preset(&dest, spec);
|
||||
return PresetEnsureResult {
|
||||
status: "current",
|
||||
version: version.clone(),
|
||||
message: format!("已配置已安装的{label}({})", short_version(version.as_deref())),
|
||||
};
|
||||
}
|
||||
return PresetEnsureResult {
|
||||
status: "skipped",
|
||||
version: None,
|
||||
message: format!("未找到内置{label},跳过安装"),
|
||||
};
|
||||
}
|
||||
let src = src.unwrap();
|
||||
if let Err(exc) = (|| -> std::io::Result<()> {
|
||||
if installed != bundled || !dest.join("preset.yml").is_file() {
|
||||
copy_tree(&src, &dest, false)?;
|
||||
}
|
||||
write_localized_preset(&dest, spec)?;
|
||||
Ok(())
|
||||
})() {
|
||||
return PresetEnsureResult {
|
||||
status: "failed",
|
||||
version,
|
||||
message: format!("内置{label}安装失败:{exc}"),
|
||||
};
|
||||
}
|
||||
let sha = short_version(bundled.as_deref());
|
||||
if installed == bundled && dest.join("preset.yml").is_file() {
|
||||
return PresetEnsureResult {
|
||||
status: "current",
|
||||
version: bundled,
|
||||
message: format!("内置{label} {sha} 已就绪"),
|
||||
};
|
||||
}
|
||||
if installed.is_some() {
|
||||
return PresetEnsureResult {
|
||||
status: "updated",
|
||||
version: bundled,
|
||||
message: format!("已将内置{label}更新到 {sha}"),
|
||||
};
|
||||
}
|
||||
PresetEnsureResult {
|
||||
status: "installed",
|
||||
version: bundled,
|
||||
message: format!("已启用内置{label} {sha}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine(results: &[PresetEnsureResult]) -> PresetEnsureResult {
|
||||
if results.iter().any(|item| item.status == "failed") {
|
||||
let failed: Vec<_> = results
|
||||
.iter()
|
||||
.filter(|item| item.status == "failed")
|
||||
.map(|item| item.message.clone())
|
||||
.collect();
|
||||
return PresetEnsureResult {
|
||||
status: "failed",
|
||||
version: None,
|
||||
message: failed.join(";"),
|
||||
};
|
||||
}
|
||||
let active: Vec<_> = results
|
||||
.iter()
|
||||
.filter(|item| item.status != "skipped")
|
||||
.collect();
|
||||
if active.is_empty() {
|
||||
return PresetEnsureResult {
|
||||
status: "skipped",
|
||||
version: None,
|
||||
message: "未找到内置锚定预设,跳过安装".into(),
|
||||
};
|
||||
}
|
||||
let version = active.iter().find_map(|item| item.version.clone());
|
||||
let (status, lead) = if active.iter().any(|item| item.status == "updated") {
|
||||
("updated", "已更新内置锚定预设")
|
||||
} else if active.iter().any(|item| item.status == "installed") {
|
||||
("installed", "已启用内置锚定预设")
|
||||
} else {
|
||||
("current", "内置锚定预设已就绪")
|
||||
};
|
||||
let detail = active
|
||||
.iter()
|
||||
.map(|item| item.message.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
PresetEnsureResult {
|
||||
status,
|
||||
version,
|
||||
message: format!("{lead}。{detail}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_anchored_standard(paths: &BundledPaths) -> PresetEnsureResult {
|
||||
let results: Vec<_> = BUNDLED_PRESETS
|
||||
.iter()
|
||||
.map(|spec| ensure_one(paths, spec))
|
||||
.collect();
|
||||
let combined = combine(&results);
|
||||
if combined.status == "failed" {
|
||||
return combined;
|
||||
}
|
||||
let settings = dsh_home().join("settings.yaml");
|
||||
match (|| -> std::io::Result<()> {
|
||||
let previous = if settings.is_file() {
|
||||
std::fs::read_to_string(&settings)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let updated = ensure_default_preset(&previous);
|
||||
if updated != previous {
|
||||
if let Some(parent) = settings.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&settings, updated)?;
|
||||
}
|
||||
Ok(())
|
||||
})() {
|
||||
Ok(()) => combined,
|
||||
Err(exc) => PresetEnsureResult {
|
||||
status: "failed",
|
||||
version: combined.version,
|
||||
message: format!("写入默认 preset 失败:{exc}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::paths::BundledPaths;
|
||||
|
||||
#[test]
|
||||
fn empty_file_gets_default_block() {
|
||||
assert_eq!(
|
||||
ensure_default_preset(""),
|
||||
"agent-presets:\n default: anchored-standard\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_when_section_missing() {
|
||||
let updated = ensure_default_preset("ui-theme:\n preference: dark\n");
|
||||
assert!(updated.contains("preference: dark"));
|
||||
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_default_into_empty_section() {
|
||||
let updated = ensure_default_preset("agent-presets:\nui-theme:\n preference: dark\n");
|
||||
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
|
||||
assert!(updated.contains("preference: dark"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_existing_default() {
|
||||
let original = "agent-presets:\n default: standard\n";
|
||||
assert_eq!(ensure_default_preset(original), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_match_permission_default_preset() {
|
||||
let original = "permission:\n defaultPreset: danger-full-access\n";
|
||||
let updated = ensure_default_preset(original);
|
||||
assert!(updated.contains("defaultPreset: danger-full-access"));
|
||||
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_english_name_and_description() {
|
||||
let original = "\
|
||||
name: Anchored Standard (experimental)
|
||||
description: Bootstrap with the Minimal preset's real tool pair.
|
||||
order: 5
|
||||
";
|
||||
let updated = localize_preset_yml(original, PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH);
|
||||
assert!(updated.contains(PRESET_NAME_ZH));
|
||||
assert!(updated.contains(PRESET_DESCRIPTION_ZH));
|
||||
assert!(!updated.contains("Anchored Standard (experimental)"));
|
||||
assert!(!updated.contains("Bootstrap"));
|
||||
assert!(updated.contains("order: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_description_after_name() {
|
||||
let updated = localize_preset_yml("name: Anchored Standard\norder: 5\n", PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH);
|
||||
assert!(updated.contains(PRESET_NAME_ZH));
|
||||
assert!(!updated.contains("name: Anchored Standard\n"));
|
||||
assert!(updated.contains(PRESET_DESCRIPTION_ZH));
|
||||
assert!(updated.contains("order: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_from_bundle_and_sets_default() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let src = tmp.path().join("anchored-standard");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::write(src.join("preset.yml"), "name: Anchored Standard\n").unwrap();
|
||||
std::fs::write(src.join(SOURCE_MARKER), "abc123def456\n").unwrap();
|
||||
let home = tmp.path().join(".dsh");
|
||||
std::env::set_var("DSH_HOME", &home);
|
||||
let paths = BundledPaths::discover().with_resource_dir(tmp.path().to_path_buf());
|
||||
let result = ensure_anchored_standard(&paths);
|
||||
std::env::remove_var("DSH_HOME");
|
||||
assert_eq!(result.status, "installed");
|
||||
let dest = home.join(".agent-presets").join(PRESET_ID);
|
||||
assert!(dest.join("preset.yml").is_file());
|
||||
let yml = std::fs::read_to_string(dest.join("preset.yml")).unwrap();
|
||||
assert!(yml.contains(PRESET_DESCRIPTION_ZH));
|
||||
let settings = std::fs::read_to_string(home.join("settings.yaml")).unwrap();
|
||||
assert!(settings.contains("default: anchored-standard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localizes_zero_preset() {
|
||||
let original = "\
|
||||
name: Zero-Anchored Standard (experimental)
|
||||
description: Inject one zero-tool anchor turn.
|
||||
order: 6
|
||||
";
|
||||
let updated = localize_preset_yml(original, ZERO_PRESET_NAME_ZH, ZERO_PRESET_DESCRIPTION_ZH);
|
||||
assert!(updated.contains(ZERO_PRESET_NAME_ZH));
|
||||
assert!(updated.contains(ZERO_PRESET_DESCRIPTION_ZH));
|
||||
assert!(!updated.contains("Zero-Anchored"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user