mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
feat: add mainland-reachable desktop distribution
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user