feat: add mainland-reachable desktop distribution

This commit is contained in:
2026-08-16 23:00:54 +08:00
parent e8eeb4ac72
commit a3e1f11e85
42 changed files with 3661 additions and 191 deletions

View File

@@ -11,6 +11,7 @@ base64 = "0.22"
dirs = "6"
regex = "1"
serde = { version = "1", features = ["derive"] }
semver = "1"
serde_json = "1"
shlex = "1"
thiserror = "2"

View File

@@ -24,7 +24,12 @@ pub struct ClipboardFile {
}
pub fn is_image_mime(mime: &str) -> bool {
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase();
let mime = mime
.split(';')
.next()
.unwrap_or(mime)
.trim()
.to_ascii_lowercase();
let mime = if mime == "image/jpg" {
"image/jpeg"
} else {
@@ -34,7 +39,12 @@ pub fn is_image_mime(mime: &str) -> bool {
}
pub fn filename_for_mime(mime: &str, index: usize) -> String {
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase();
let mime = mime
.split(';')
.next()
.unwrap_or(mime)
.trim()
.to_ascii_lowercase();
let ext = match mime.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
@@ -84,9 +94,7 @@ fn url_parse_file(rest: &str) -> Result<String, ()> {
let decoded = percent_decode(&uri);
if let Some(idx) = decoded.find("://") {
let after = &decoded[idx + 3..];
let path = after
.strip_prefix("localhost")
.unwrap_or(after);
let path = after.strip_prefix("localhost").unwrap_or(after);
return Ok(path.to_string());
}
if let Some(path) = decoded.strip_prefix("file:") {
@@ -101,7 +109,8 @@ fn percent_decode(input: &str) -> String {
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(v) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
if let Ok(v) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
out.push(v);
i += 3;
@@ -220,7 +229,10 @@ mod tests {
detect_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 1]),
Some("image/png")
);
assert_eq!(detect_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
assert_eq!(
detect_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
Some("image/jpeg")
);
assert_eq!(detect_image_mime(b"not-an-image"), None);
}

View File

@@ -10,7 +10,7 @@ use regex::Regex;
use thiserror::Error;
use crate::paths::{home_dir, is_flatpak};
use crate::updater::update_dsh_bin;
use crate::updater::{configure_npm_registry, update_dsh_bin};
use crate::{ENV_BIN_OVERRIDE, ENV_CWD_OVERRIDE};
pub const DSH_DEFAULT_HOST: &str = "127.0.0.1";
@@ -117,8 +117,8 @@ impl DshLauncher {
}
}
if let Some(cli) = &self.dsh_bin_override {
let parts = shlex::split(cli)
.ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?;
let parts =
shlex::split(cli).ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?;
candidates.push(parts);
}
@@ -197,6 +197,7 @@ impl DshLauncher {
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.stdin(Stdio::null());
configure_npm_registry(&mut cmd);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
@@ -208,9 +209,9 @@ impl DshLauncher {
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
let mut child = cmd.spawn().map_err(|exc| {
DshNotFound::new(format!("无法启动 dsh web: {exc}"))
})?;
let mut child = cmd
.spawn()
.map_err(|exc| DshNotFound::new(format!("无法启动 dsh web: {exc}")))?;
let stdout = child.stdout.take();
Ok(DshProcess::new(child, stdout, self.in_flatpak))
}

View File

@@ -19,3 +19,4 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const ENV_BIN_OVERRIDE: &str = "DSH_DESKTOP_DSH_BIN";
pub const ENV_CWD_OVERRIDE: &str = "DSH_DESKTOP_CWD";
pub const ENV_NO_UPDATE: &str = "DSH_DESKTOP_NO_UPDATE";
pub const ENV_NPM_REGISTRY: &str = "DSH_DESKTOP_NPM_REGISTRY";

View File

@@ -8,7 +8,13 @@ use crate::paths::{copy_tree, dsh_home, replace_symlink, BundledPaths};
pub const PACKAGE: &str = "@liustack/modlens";
pub const VISION_PACKAGE: &str = "dsh-desktop-vision";
pub const VOICE_PACKAGE: &str = "dsh-desktop-voice";
pub const MARKET_PACKAGE: &str = "dshmarket";
pub const MODLENS_VERSION: &str = "3.16.6";
const VISION_PLUGIN_VERSION: &str = "0.1.4";
const VOICE_PLUGIN_VERSION: &str = "0.4.0";
const MARKET_PLUGIN_VERSION: &str = "1.9.0";
const MANAGED_BUNDLES_DIR: &str = ".dsh-desktop/bundles";
pub const HIDE_PLAIN_TWINS_JS: &str = include_str!("../../../ui/inject/hide-twins.js");
pub const MANAGED_OVERLAY: &str = "\
@@ -48,12 +54,42 @@ pub fn read_modlens_version(prefix: &Path) -> Option<String> {
.map(|s| s.to_string())
}
fn bundle_should_replace(installed: Option<&str>, bundled: &str) -> bool {
let Some(installed) = installed else {
return true;
};
if installed == bundled {
return false;
}
match (
semver::Version::parse(installed),
semver::Version::parse(bundled),
) {
(Ok(installed), Ok(bundled)) => installed < bundled,
_ => true,
}
}
fn bundled_plugin(paths: &BundledPaths, dirs: &[&str]) -> Option<PathBuf> {
dirs.iter().find_map(|rel| {
paths
.find_dir(rel, "package.json")
.filter(|p| p.join("client.js").is_file() && p.join("index.js").is_file())
})
}
pub fn bundled_vision_plugin(paths: &BundledPaths) -> Option<PathBuf> {
paths
.find_dir("vision", "package.json")
.or_else(|| paths.find_dir("dsh-desktop-vision", "package.json"))
.or_else(|| paths.find_dir("plugins/dsh-desktop-vision", "package.json"))
.filter(|p| p.join("client.js").is_file())
bundled_plugin(
paths,
&["vision", "dsh-desktop-vision", "plugins/dsh-desktop-vision"],
)
}
fn bundled_voice_plugin(paths: &BundledPaths) -> Option<PathBuf> {
bundled_plugin(
paths,
&["voice", "dsh-desktop-voice", "plugins/dsh-desktop-voice"],
)
}
pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
@@ -62,6 +98,13 @@ pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
.or_else(|| paths.find_dir("vendor/modlens", "node_modules/@liustack/modlens"))
}
pub fn bundled_market_prefix(paths: &BundledPaths) -> Option<PathBuf> {
paths
.find_dir("market", "node_modules/dshmarket/package.json")
.or_else(|| paths.find_dir("dshmarket", "node_modules/dshmarket/package.json"))
.or_else(|| paths.find_dir("vendor/dshmarket", "node_modules/dshmarket/package.json"))
}
fn install_into_profile(src_prefix: &Path, profile: &Path) -> std::io::Result<()> {
let dest_pkg = package_dir(profile);
copy_tree(&package_dir(src_prefix), &dest_pkg, true)?;
@@ -86,22 +129,92 @@ fn read_pkg_version(dir: &Path) -> Option<String> {
.map(|s| s.to_string())
}
fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool {
let Some(src) = bundled_vision_plugin(paths) else {
fn local_plugin_spec(pkg: &str) -> String {
format!("file:{MANAGED_BUNDLES_DIR}/{pkg}")
}
fn install_profile_plugin(
profile: &Path,
pkg: &str,
expected_version: &str,
src: Option<PathBuf>,
) -> bool {
let Some(src) = src else {
return false;
};
let dest = profile.join("node_modules").join(VISION_PACKAGE);
let up_to_date = dest.join("client.js").is_file()
&& dest.join("index.js").is_file()
&& read_pkg_version(&dest) == read_pkg_version(&src);
if !up_to_date && copy_tree(&src, &dest, true).is_err() {
if read_pkg_version(&src).as_deref() != Some(expected_version) {
return false;
}
let managed = profile.join(MANAGED_BUNDLES_DIR).join(pkg);
let managed_current = managed.join("client.js").is_file()
&& managed.join("index.js").is_file()
&& read_pkg_version(&managed) == read_pkg_version(&src);
if !managed_current && copy_tree(&src, &managed, true).is_err() {
return false;
}
let dest = profile.join("node_modules").join(pkg);
let installed_current = dest.join("client.js").is_file()
&& dest.join("index.js").is_file()
&& read_pkg_version(&dest) == read_pkg_version(&managed);
if !installed_current && copy_tree(&managed, &dest, true).is_err() {
return false;
}
let fallback = dsh_home().join("profiles/node_modules").join(pkg);
let _ = replace_symlink(&fallback, &dest);
true
}
fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool {
install_profile_plugin(
profile,
VISION_PACKAGE,
VISION_PLUGIN_VERSION,
bundled_vision_plugin(paths),
)
}
fn install_voice_plugin(paths: &BundledPaths, profile: &Path) -> bool {
install_profile_plugin(
profile,
VOICE_PACKAGE,
VOICE_PLUGIN_VERSION,
bundled_voice_plugin(paths),
)
}
fn install_market_plugin(paths: &BundledPaths, profile: &Path) -> std::io::Result<Option<String>> {
let Some(prefix) = bundled_market_prefix(paths) else {
return Ok(None);
};
let src = prefix.join("node_modules").join(MARKET_PACKAGE);
let Some(version) = read_pkg_version(&src) else {
return Ok(None);
};
if version != MARKET_PLUGIN_VERSION {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("bundled dshmarket version {version} does not match {MARKET_PLUGIN_VERSION}"),
));
}
let dest = profile.join("node_modules").join(MARKET_PACKAGE);
if read_pkg_version(&dest).as_deref() != Some(version.as_str()) {
copy_tree(&src, &dest, true)?;
}
for dep in [
"@deepseek-ai/cordis",
"@deepseek-ai/cosmokit",
"@standard-schema/spec",
] {
let src_dep = prefix.join("node_modules").join(dep);
if src_dep.is_dir() {
copy_tree(&src_dep, &profile.join("node_modules").join(dep), true)?;
}
}
let fallback = dsh_home()
.join("profiles/node_modules")
.join(VISION_PACKAGE);
.join(MARKET_PACKAGE);
let _ = replace_symlink(&fallback, &dest);
true
Ok(Some(version))
}
fn ensure_manifest(profile: &Path, packages: &BTreeMap<String, String>) -> std::io::Result<()> {
@@ -323,9 +436,19 @@ fn ensure_modlens_inner(
) -> std::io::Result<ModlensEnsureResult> {
std::fs::create_dir_all(profile)?;
let vision_ok = install_vision_plugin(paths, profile);
let voice_ok = install_voice_plugin(paths, profile);
let mut packages = BTreeMap::new();
if vision_ok {
packages.insert(VISION_PACKAGE.to_string(), "0.1.0".into());
packages.insert(
VISION_PACKAGE.to_string(),
local_plugin_spec(VISION_PACKAGE),
);
}
if voice_ok {
packages.insert(VOICE_PACKAGE.to_string(), local_plugin_spec(VOICE_PACKAGE));
}
if let Some(version) = install_market_plugin(paths, profile)? {
packages.insert(MARKET_PACKAGE.to_string(), version);
}
if src.is_none() && installed.is_none() {
if !packages.is_empty() {
@@ -337,8 +460,9 @@ fn ensure_modlens_inner(
message: "未找到内置 ModLens跳过插件安装".into(),
});
}
let replace_bundle = src.is_some() && bundle_should_replace(installed.as_deref(), version);
let installed_after = if let Some(src) = src {
if installed.as_deref() != Some(version) {
if replace_bundle {
install_into_profile(src, profile)?;
read_modlens_version(profile).unwrap_or_else(|| version.to_string())
} else {
@@ -374,11 +498,18 @@ fn ensure_modlens_inner(
message: format!("已配置已安装的 ModLens {installed_after}(纯文本自动套视觉桥)"),
});
}
if installed.as_deref() == Some(version) {
if !replace_bundle {
if installed.as_deref() == Some(version) {
return Ok(ModlensEnsureResult {
status: "current",
version: Some(version.to_string()),
message: format!("内置 ModLens {version} 已就绪"),
});
}
return Ok(ModlensEnsureResult {
status: "current",
version: Some(version.to_string()),
message: format!("内置 ModLens {version} 已就绪"),
version: Some(installed_after.clone()),
message: format!("已保留用户更新的 ModLens {installed_after}(内置版本 {version}"),
});
}
if installed.is_some() {
@@ -474,4 +605,80 @@ ui-theme:
assert!(HIDE_PLAIN_TWINS_JS.contains("(modlens vision)"));
assert!(HIDE_PLAIN_TWINS_JS.contains("MutationObserver"));
}
#[test]
fn bundled_market_is_added_to_profile() {
let tmp = tempfile::TempDir::new().unwrap();
let market = tmp.path().join("market/node_modules/dshmarket");
std::fs::create_dir_all(&market).unwrap();
std::fs::write(
market.join("package.json"),
r#"{"name":"dshmarket","version":"1.9.0"}"#,
)
.unwrap();
std::fs::write(market.join("index.js"), "export {}\n").unwrap();
let paths = BundledPaths::default().with_resource_dir(tmp.path().to_path_buf());
let profile = tmp.path().join("profile");
ensure_modlens_inner(&paths, None, &profile, None, MODLENS_VERSION).unwrap();
let manifest: Value =
serde_json::from_str(&std::fs::read_to_string(profile.join("package.json")).unwrap())
.unwrap();
assert_eq!(manifest["dependencies"]["dshmarket"], "1.9.0");
assert!(manifest["dsh"]["profile"]["bundles"]
.as_array()
.unwrap()
.iter()
.any(|item| item == "dshmarket"));
assert!(profile
.join("node_modules/dshmarket/package.json")
.is_file());
}
#[test]
fn bundled_local_plugins_use_file_dependencies() {
let tmp = tempfile::TempDir::new().unwrap();
for (dir, name, version) in [
("vision", VISION_PACKAGE, VISION_PLUGIN_VERSION),
("voice", VOICE_PACKAGE, VOICE_PLUGIN_VERSION),
] {
let plugin = tmp.path().join(dir);
std::fs::create_dir_all(&plugin).unwrap();
std::fs::write(
plugin.join("package.json"),
format!(r#"{{"name":"{name}","version":"{version}"}}"#),
)
.unwrap();
std::fs::write(plugin.join("client.js"), "export {}\n").unwrap();
std::fs::write(plugin.join("index.js"), "export {}\n").unwrap();
}
let paths = BundledPaths::default().with_resource_dir(tmp.path().to_path_buf());
let profile = tmp.path().join("profile");
ensure_modlens_inner(&paths, None, &profile, None, MODLENS_VERSION).unwrap();
let manifest: Value =
serde_json::from_str(&std::fs::read_to_string(profile.join("package.json")).unwrap())
.unwrap();
for name in [VISION_PACKAGE, VOICE_PACKAGE] {
assert_eq!(
manifest["dependencies"][name],
format!("file:.dsh-desktop/bundles/{name}")
);
assert!(profile
.join(".dsh-desktop/bundles")
.join(name)
.join("package.json")
.is_file());
}
}
#[test]
fn newer_marketplace_modlens_is_not_replaced_by_bundle() {
assert!(!bundle_should_replace(Some("3.17.3"), "3.16.6"));
assert!(!bundle_should_replace(Some("3.16.6"), "3.16.6"));
assert!(bundle_should_replace(Some("3.16.5"), "3.16.6"));
assert!(bundle_should_replace(None, "3.16.6"));
}
}

View File

@@ -42,7 +42,7 @@ pub fn is_flatpak() -> bool {
Path::new("/.flatpak-info").exists()
}
/// Roots that may contain bundled ModLens / presets / vision plugin.
/// Roots that may contain bundled ModLens / market / preset / vision / voice components.
///
/// Search order: extra roots (Tauri resource dir), `$XDG_DATA_HOME/dsh-desktop`,
/// `/app/share/dsh-desktop`, `/usr/share/dsh-desktop`, `~/.local/share/dsh-desktop`,

View File

@@ -155,7 +155,10 @@ fn ensure_one(paths: &BundledPaths, spec: &BundledPreset) -> PresetEnsureResult
return PresetEnsureResult {
status: "current",
version: version.clone(),
message: format!("已配置已安装的{label}{}", short_version(version.as_deref())),
message: format!(
"已配置已安装的{label}{}",
short_version(version.as_deref())
),
};
}
return PresetEnsureResult {
@@ -336,7 +339,11 @@ 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);
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));
@@ -371,7 +378,8 @@ 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);
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"));

View File

@@ -1,3 +1,4 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -6,13 +7,14 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::paths::{cache_home, data_home};
use crate::ENV_NO_UPDATE;
use crate::{ENV_NO_UPDATE, ENV_NPM_REGISTRY};
pub const DSH_PACKAGE: &str = "@deepseek-ai/dsh";
pub const VIEW_TIMEOUT_SECONDS: u64 = 20;
pub const INSTALL_TIMEOUT_SECONDS: u64 = 180;
pub const BUNDLED_PREFIX: &str = "/app";
pub const BUNDLED_DSH: &[&str] = &["/app/bin/dsh", "/app/node24/bin/dsh"];
pub const DEFAULT_NPM_REGISTRY: &str = "https://registry.npmmirror.com";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateResult {
@@ -83,9 +85,30 @@ fn find_node_dir() -> Option<PathBuf> {
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
}
fn selected_npm_registry<'a>(
desktop_override: Option<&'a OsStr>,
npm_override: Option<&'a OsStr>,
) -> &'a OsStr {
desktop_override
.filter(|value| !value.is_empty())
.or_else(|| npm_override.filter(|value| !value.is_empty()))
.unwrap_or_else(|| OsStr::new(DEFAULT_NPM_REGISTRY))
}
pub(crate) fn configure_npm_registry(command: &mut Command) {
let desktop_override = std::env::var_os(ENV_NPM_REGISTRY);
let npm_override =
std::env::var_os("npm_config_registry").or_else(|| std::env::var_os("NPM_CONFIG_REGISTRY"));
command.env(
"npm_config_registry",
selected_npm_registry(desktop_override.as_deref(), npm_override.as_deref()),
);
}
fn npm_command(npm: &Path) -> Command {
let mut cmd = Command::new(npm);
let cache = cache_home().join("dsh-desktop/npm");
configure_npm_registry(&mut cmd);
let _ = std::fs::create_dir_all(&cache);
cmd.env("npm_config_cache", &cache);
cmd.env("npm_config_update_notifier", "false");
@@ -169,8 +192,12 @@ pub fn mark_update_checked() {
}
pub fn fetch_latest_version(npm: &Path) -> Option<String> {
let out = run_npm(npm, &["view", DSH_PACKAGE, "version"], Duration::from_secs(VIEW_TIMEOUT_SECONDS))
.ok()?;
let out = run_npm(
npm,
&["view", DSH_PACKAGE, "version"],
Duration::from_secs(VIEW_TIMEOUT_SECONDS),
)
.ok()?;
if !out.status.success() {
return None;
}
@@ -213,12 +240,14 @@ fn env_skips_update() -> bool {
pub fn update_dsh(enabled: bool) -> UpdateResult {
if !enabled || env_skips_update() {
let version = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
let version =
read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
return UpdateResult::new("skipped", version, "已跳过 dsh 更新");
}
let npm = find_npm();
let current = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
let current =
read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
let Some(npm) = npm else {
let extra = current
.as_deref()
@@ -247,11 +276,7 @@ pub fn update_dsh(enabled: bool) -> UpdateResult {
};
if current.as_deref() == Some(latest.as_str()) {
return UpdateResult::new(
"current",
current,
format!("内置 dsh 已是最新({latest}"),
);
return UpdateResult::new("current", current, format!("内置 dsh 已是最新({latest}"));
}
let dest = update_prefix();
@@ -321,6 +346,32 @@ mod tests {
fn read_version_missing() {
assert_eq!(read_version(Path::new("/no/such/prefix")), None);
}
#[test]
fn npm_commands_default_to_mainland_reachable_registry() {
assert_eq!(
selected_npm_registry(None, None),
OsStr::new(DEFAULT_NPM_REGISTRY)
);
}
#[test]
fn desktop_registry_override_wins() {
assert_eq!(
selected_npm_registry(
Some(OsStr::new("https://registry.example.cn")),
Some(OsStr::new("https://registry.npmjs.org")),
),
OsStr::new("https://registry.example.cn")
);
}
#[test]
fn npm_registry_override_is_preserved() {
assert_eq!(
selected_npm_registry(None, Some(OsStr::new("https://packages.example.com/npm")),),
OsStr::new("https://packages.example.com/npm")
);
}
#[test]
fn skip_when_disabled() {