docs(api): inventory all SimAdmin operations

This commit is contained in:
chick
2026-07-16 05:33:50 +08:00
parent c789ec62e1
commit 9dffadbec2
100 changed files with 33233 additions and 0 deletions
@@ -0,0 +1,839 @@
//! OTA 更新模块
//!
//! 处理 OTA 更新包的上传、验证和应用
use crate::config::ConfigManager;
use crate::models::{
OtaLatestReleaseResponse, OtaMeta, OtaReleaseAsset, OtaStatusResponse, OtaUploadResponse,
OtaValidation, VersionUpdateEvent,
};
use crate::notification::NotificationSender;
use chrono::{DateTime, FixedOffset, NaiveTime, TimeZone, Utc};
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
/// OTA 相关路径
const OTA_STAGING_DIR: &str = "/tmp/ota_staging";
const OTA_BINARY_PATH: &str = "/opt/simadmin/simadmin";
const OTA_WWW_PATH: &str = "/opt/simadmin/www";
const OTA_META_PATH: &str = "/opt/simadmin/meta.json";
const OTA_SERVICE_NAME: &str = "simadmin.service";
const NM_CONF_DIR: &str = "/etc/NetworkManager/conf.d";
const NM_CONF_PATH: &str = "/etc/NetworkManager/conf.d/99-simadmin-unmanaged-modem.conf";
const NM_UNMANAGED_WWAN_CONFIG: &str = "[keyfile]\nunmanaged-devices=interface-name:wwan*\n";
const LATEST_RELEASE_API: &str = "https://api.github.com/repos/3899/SimAdmin/releases/latest";
const BEIJING_UTC_OFFSET_SECONDS: i32 = 8 * 60 * 60;
const UPDATE_CHECK_HOURS: [u32; 2] = [9, 18];
const OTA_HTTP_TIMEOUT_SECS: u64 = 30;
const BUILTIN_PROXY_PREFIXES: [&str; 3] = [
"https://gh-proxy.com/",
"https://ghproxy.net/",
"https://githubproxy.cc/",
];
pub const MAX_OTA_BYTES: u64 = 50 * 1024 * 1024;
/// 当前版本信息(编译时注入)
pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// 获取当前 commit(从环境变量或默认值)
pub fn get_current_commit() -> String {
option_env!("GIT_COMMIT").unwrap_or("unknown").to_string()
}
/// 获取 OTA 更新状态
pub fn get_ota_status() -> OtaStatusResponse {
let pending_meta = read_pending_meta();
OtaStatusResponse {
current_version: CURRENT_VERSION.to_string(),
current_commit: get_current_commit(),
pending_update: pending_meta.is_some(),
pending_meta,
}
}
pub fn duration_until_next_update_check() -> Duration {
duration_until_next_update_check_from(Utc::now())
}
fn duration_until_next_update_check_from(now_utc: DateTime<Utc>) -> Duration {
let beijing = beijing_offset();
let now = now_utc.with_timezone(&beijing);
let next_check = UPDATE_CHECK_HOURS
.iter()
.filter_map(|hour| {
let check_time = NaiveTime::from_hms_opt(*hour, 0, 0)?;
beijing
.from_local_datetime(&now.date_naive().and_time(check_time))
.single()
})
.find(|check| now <= *check)
.unwrap_or_else(|| {
let check_time = NaiveTime::from_hms_opt(UPDATE_CHECK_HOURS[0], 0, 0)
.expect("valid update check time");
beijing
.from_local_datetime(
&(now.date_naive() + chrono::Duration::days(1)).and_time(check_time),
)
.single()
.expect("fixed offset has a single local time")
});
(next_check - now)
.to_std()
.unwrap_or_else(|_| Duration::from_secs(0))
}
pub fn normalize_proxy_prefix(prefix: Option<String>) -> String {
let Some(prefix) = prefix else {
return String::new();
};
let prefix = prefix.trim();
if prefix.is_empty() {
return String::new();
}
if prefix.ends_with('/') {
prefix.to_string()
} else {
format!("{}/", prefix)
}
}
pub fn build_ota_http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent("SimAdmin OTA updater")
.timeout(Duration::from_secs(OTA_HTTP_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))
}
fn push_proxy_url(urls: &mut Vec<String>, proxy_prefix: &str, url: &str) {
let proxy_prefix = normalize_proxy_prefix(Some(proxy_prefix.to_string()));
if proxy_prefix.is_empty() {
return;
}
let proxied_url = format!("{}{}", proxy_prefix, url);
if !urls.iter().any(|existing| existing == &proxied_url) {
urls.push(proxied_url);
}
}
pub fn ota_request_urls(
url: &str,
proxy_prefix: &str,
include_builtin_proxies: bool,
) -> Vec<String> {
let mut urls = Vec::new();
push_proxy_url(&mut urls, proxy_prefix, url);
if include_builtin_proxies {
for builtin_proxy in BUILTIN_PROXY_PREFIXES {
push_proxy_url(&mut urls, builtin_proxy, url);
}
}
urls.push(url.to_string());
urls
}
pub fn is_supported_ota_asset(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.ends_with(".tar.gz") || lower.ends_with(".tgz") || lower.ends_with(".zip")
}
pub fn supported_release_asset(release: &OtaLatestReleaseResponse) -> Option<&OtaReleaseAsset> {
release
.assets
.iter()
.find(|asset| is_supported_ota_asset(&asset.name))
}
pub async fn fetch_latest_github_release(
client: &reqwest::Client,
proxy_prefix: &str,
include_builtin_proxies: bool,
) -> Result<OtaLatestReleaseResponse, String> {
let mut last_error = String::new();
for url in ota_request_urls(LATEST_RELEASE_API, proxy_prefix, include_builtin_proxies) {
match client
.get(&url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
{
Ok(response) => {
let status = response.status();
if !status.is_success() {
last_error = format!("GitHub Releases request failed: HTTP {}", status);
continue;
}
match response.json::<OtaLatestReleaseResponse>().await {
Ok(release) => return Ok(release),
Err(e) => {
last_error = format!("Failed to parse latest release: {}", e);
continue;
}
}
}
Err(e) => {
last_error = format!("Failed to request latest release: {}", e);
}
}
}
if last_error.is_empty() {
Err("GitHub Releases request failed".to_string())
} else {
Err(last_error)
}
}
pub async fn check_and_notify_version_update(
config_manager: Arc<ConfigManager>,
notification_sender: Arc<NotificationSender>,
) -> Result<(), String> {
let update_config = config_manager.get_version_update_notifications();
if !update_config.enabled || !notification_sender.has_version_update_targets() {
return Ok(());
}
let proxy_prefix = normalize_proxy_prefix(Some(update_config.proxy_prefix));
let client = build_ota_http_client()?;
let release = fetch_latest_github_release(&client, &proxy_prefix, true).await?;
if !compare_versions(&release.tag_name, CURRENT_VERSION) {
return Ok(());
}
let notify_version = normalize_version(&release.tag_name);
if update_config
.last_notified_version
.as_deref()
.map(normalize_version)
.as_deref()
== Some(notify_version.as_str())
{
return Ok(());
}
let asset = supported_release_asset(&release)
.ok_or_else(|| "No supported OTA asset found in latest release".to_string())?;
let own_number = notification_sender.get_own_number().await;
let current_time = chrono::Utc::now().to_rfc3339();
let event = VersionUpdateEvent {
asset_name: asset.name.clone(),
version: notify_version.clone(),
build_time: current_time.clone(),
release_url: release.html_url.clone().unwrap_or_default(),
timestamp: current_time,
own_number,
};
let result = notification_sender
.forward_version_update_event(&event)
.await?;
if result.delivered {
config_manager.set_last_notified_update_version(notify_version)?;
}
if !result.errors.is_empty() {
tracing::warn!(
errors = %result.errors.join("; "),
"Version update notification partially failed"
);
}
Ok(())
}
pub async fn download_ota_asset_bytes(
client: &reqwest::Client,
proxy_prefix: &str,
include_builtin_proxies: bool,
asset: &OtaReleaseAsset,
) -> Result<Vec<u8>, String> {
if asset.size > MAX_OTA_BYTES {
return Err(format!(
"OTA asset is too large: {} bytes exceeds {} bytes",
asset.size, MAX_OTA_BYTES
));
}
let mut last_error = String::new();
for download_url in ota_request_urls(
&asset.browser_download_url,
proxy_prefix,
include_builtin_proxies,
) {
let response = match client.get(&download_url).send().await {
Ok(response) => response,
Err(e) => {
last_error = format!("Failed to download OTA asset: {}", e);
continue;
}
};
let status = response.status();
if !status.is_success() {
last_error = format!("OTA asset download failed: HTTP {}", status);
continue;
}
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(e) => {
last_error = format!("Failed to read OTA asset: {}", e);
continue;
}
};
if bytes.len() as u64 > MAX_OTA_BYTES {
last_error = format!(
"OTA asset is too large: {} bytes exceeds {} bytes",
bytes.len(),
MAX_OTA_BYTES
);
continue;
}
return Ok(bytes.to_vec());
}
if last_error.is_empty() {
Err("OTA asset download failed".to_string())
} else {
Err(last_error)
}
}
/// 读取待安装的更新元数据
fn read_pending_meta() -> Option<OtaMeta> {
let meta_path = format!("{}/meta.json", OTA_STAGING_DIR);
if let Ok(content) = fs::read_to_string(&meta_path) {
serde_json::from_str(&content).ok()
} else {
None
}
}
/// 处理上传的 OTA 包(支持 tar.gz 和 zip 格式)
pub fn handle_ota_upload(data: &[u8]) -> Result<OtaUploadResponse, String> {
// 清理并创建临时目录
let _ = fs::remove_dir_all(OTA_STAGING_DIR);
fs::create_dir_all(OTA_STAGING_DIR)
.map_err(|e| format!("Failed to create staging dir: {}", e))?;
// 自动检测文件格式
let is_zip = detect_zip_format(data);
if is_zip {
// ZIP 格式处理
let zip_path = format!("{}/update.zip", OTA_STAGING_DIR);
let mut file =
fs::File::create(&zip_path).map_err(|e| format!("Failed to create zip file: {}", e))?;
file.write_all(data)
.map_err(|e| format!("Failed to write zip file: {}", e))?;
// 解压 ZIP(使用 unzip 命令)
let output = Command::new("unzip")
.args(["-o", &zip_path, "-d", OTA_STAGING_DIR])
.output()
.map_err(|e| {
format!(
"Failed to extract zip: {}. Make sure 'unzip' is installed.",
e
)
})?;
if !output.status.success() {
return Err(format!(
"Failed to extract zip: {}",
String::from_utf8_lossy(&output.stderr)
));
}
// 删除原始 zip 文件
let _ = fs::remove_file(&zip_path);
// ZIP 格式不保留 Unix 权限,需要手动设置
fix_file_permissions()?;
} else {
// TAR.GZ 格式处理(默认,保留 Unix 权限)
let tar_path = format!("{}/update.tar.gz", OTA_STAGING_DIR);
let mut file =
fs::File::create(&tar_path).map_err(|e| format!("Failed to create tar file: {}", e))?;
file.write_all(data)
.map_err(|e| format!("Failed to write tar file: {}", e))?;
// 解压 TAR.GZ
let output = Command::new("tar")
.args(["-xzf", &tar_path, "-C", OTA_STAGING_DIR])
.output()
.map_err(|e| format!("Failed to extract tar: {}", e))?;
if !output.status.success() {
return Err(format!(
"Failed to extract tar: {}",
String::from_utf8_lossy(&output.stderr)
));
}
// 删除原始 tar 文件
let _ = fs::remove_file(&tar_path);
}
// 读取 meta.json
let meta_path = format!("{}/meta.json", OTA_STAGING_DIR);
let meta_content = fs::read_to_string(&meta_path)
.map_err(|_| "meta.json not found in OTA package".to_string())?;
let meta: OtaMeta =
serde_json::from_str(&meta_content).map_err(|e| format!("Invalid meta.json: {}", e))?;
// 验证
let validation = validate_ota_package(&meta)?;
Ok(OtaUploadResponse { meta, validation })
}
/// 验证 OTA 包
fn validate_ota_package(meta: &OtaMeta) -> Result<OtaValidation, String> {
let binary_path = format!("{}/simadmin", OTA_STAGING_DIR);
let www_path = format!("{}/www", OTA_STAGING_DIR);
// 检查文件存在
if !Path::new(&binary_path).exists() {
return Ok(OtaValidation {
valid: false,
is_newer: false,
binary_md5_match: false,
frontend_md5_match: false,
arch_match: false,
error: Some("Binary file not found in package".to_string()),
});
}
if !Path::new(&www_path).exists() {
return Ok(OtaValidation {
valid: false,
is_newer: false,
binary_md5_match: false,
frontend_md5_match: false,
arch_match: false,
error: Some("Frontend directory not found in package".to_string()),
});
}
// 计算二进制 MD5(严格验证)
let binary_md5 = calculate_file_md5(&binary_path)?;
let binary_md5_match = binary_md5 == meta.binary_md5;
// 前端目录存在即可(MD5 跨平台难以保持一致)
let frontend_md5_match = true; // 跳过前端 MD5 验证
// 检查架构(只接受 musl
let arch_match = meta.arch == "aarch64-unknown-linux-musl";
// 比较版本
let is_newer = compare_versions(&meta.version, CURRENT_VERSION);
// 只验证二进制 MD5 和架构
let valid = binary_md5_match && arch_match;
// 生成详细的错误信息
let error = if !valid {
let mut errors = Vec::new();
if !binary_md5_match {
errors.push(format!(
"Binary MD5 mismatch: expected={}, actual={}",
meta.binary_md5, binary_md5
));
}
if !arch_match {
errors.push(format!(
"Arch mismatch: expected=aarch64-unknown-linux-musl, actual={}",
meta.arch
));
}
Some(errors.join("; "))
} else {
None
};
Ok(OtaValidation {
valid,
is_newer,
binary_md5_match,
frontend_md5_match,
arch_match,
error,
})
}
/// 计算文件 MD5
fn calculate_file_md5(path: &str) -> Result<String, String> {
let mut file = fs::File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
let mut contents = Vec::new();
file.read_to_end(&mut contents)
.map_err(|e| format!("Failed to read file: {}", e))?;
Ok(format!("{:x}", md5::compute(&contents)))
}
/// 比较版本号(返回 v1 > v2)
pub fn compare_versions(v1: &str, v2: &str) -> bool {
let parse = |v: &str| -> Vec<u32> {
normalize_version(v)
.split(['.', '-'])
.filter_map(|s| s.parse().ok())
.collect()
};
let v1_parts = parse(v1);
let v2_parts = parse(v2);
for i in 0..std::cmp::max(v1_parts.len(), v2_parts.len()) {
let p1 = v1_parts.get(i).unwrap_or(&0);
let p2 = v2_parts.get(i).unwrap_or(&0);
if p1 > p2 {
return true;
} else if p1 < p2 {
return false;
}
}
false
}
fn normalize_version(version: &str) -> String {
version.trim().trim_start_matches(['v', 'V']).to_string()
}
fn beijing_offset() -> FixedOffset {
FixedOffset::east_opt(BEIJING_UTC_OFFSET_SECONDS).expect("valid Beijing UTC offset")
}
/// 应用 OTA 更新
pub fn apply_ota_update(restart_now: bool) -> Result<String, String> {
let meta = read_pending_meta().ok_or_else(|| "No pending update".to_string())?;
let staging_binary = format!("{}/simadmin", OTA_STAGING_DIR);
let staging_www = format!("{}/www", OTA_STAGING_DIR);
install_binary_atomic(&staging_binary, OTA_BINARY_PATH)?;
// 复制前端文件(删除旧目录,复制新目录)
let _ = fs::remove_dir_all(OTA_WWW_PATH);
copy_dir_recursive(&staging_www, OTA_WWW_PATH)?;
chmod_www_tree(OTA_WWW_PATH)?;
install_meta_file()?;
let nm_result = configure_networkmanager_modem_unmanaged(restart_now);
// 清理暂存目录
let _ = fs::remove_dir_all(OTA_STAGING_DIR);
let message = format!(
"Update to version {} applied successfully; {}",
meta.version, nm_result
);
if restart_now {
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(1));
restart_service_no_block();
});
}
Ok(message)
}
fn configure_networkmanager_modem_unmanaged(restart_now: bool) -> String {
if !Path::new("/etc/NetworkManager").exists() {
return "NetworkManager not installed, unmanaged modem config skipped".to_string();
}
if let Ok(content) = fs::read_to_string(NM_CONF_PATH) {
if content == NM_UNMANAGED_WWAN_CONFIG {
return "NetworkManager already ignores wwan*".to_string();
}
}
if let Err(err) = fs::create_dir_all(NM_CONF_DIR) {
return format!("Failed to create NetworkManager conf.d: {err}");
}
if let Err(err) = fs::write(NM_CONF_PATH, NM_UNMANAGED_WWAN_CONFIG) {
return format!("Failed to write NetworkManager unmanaged modem config: {err}");
}
if !restart_now {
return "NetworkManager config written; service restart deferred".to_string();
}
match Command::new("systemctl")
.args(["is-active", "--quiet", "NetworkManager.service"])
.status()
{
Ok(status) if status.success() => {
match Command::new("systemctl")
.args(["restart", "NetworkManager.service"])
.output()
{
Ok(output) if output.status.success() => {
"NetworkManager configured to ignore wwan*, service restarted".to_string()
}
Ok(output) => format!(
"NetworkManager config written, restart failed: {}",
String::from_utf8_lossy(&output.stderr)
),
Err(err) => format!("NetworkManager config written, restart failed: {err}"),
}
}
_ => "NetworkManager configured to ignore wwan*, service not active".to_string(),
}
}
fn install_binary_atomic(src: &str, dst: &str) -> Result<(), String> {
let dst_path = Path::new(dst);
let dst_dir = dst_path
.parent()
.ok_or_else(|| format!("Invalid binary path: {}", dst))?;
fs::create_dir_all(dst_dir).map_err(|e| format!("Failed to create binary dir: {}", e))?;
let tmp_path = ota_temp_binary_path(dst_path);
let _ = fs::remove_file(&tmp_path);
fs::copy(src, &tmp_path).map_err(|e| format!("Failed to copy binary: {}", e))?;
let chmod_output = Command::new("chmod")
.arg("755")
.arg(&tmp_path)
.output()
.map_err(|e| {
let _ = fs::remove_file(&tmp_path);
format!("Failed to chmod: {}", e)
})?;
if !chmod_output.status.success() {
let _ = fs::remove_file(&tmp_path);
return Err(format!(
"Failed to chmod: {}",
String::from_utf8_lossy(&chmod_output.stderr)
));
}
fs::rename(&tmp_path, dst).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
format!("Failed to replace binary: {}", e)
})?;
Ok(())
}
fn ota_temp_binary_path(dst: &Path) -> PathBuf {
let file_name = dst
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("simadmin");
dst.with_file_name(format!(".{}.ota-new", file_name))
}
fn chmod_www_tree(path: &str) -> Result<(), String> {
let output = Command::new("chmod")
.args(["-R", "a+rX", path])
.output()
.map_err(|e| format!("Failed to chmod www: {}", e))?;
if !output.status.success() {
return Err(format!(
"Failed to chmod www: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
fn install_meta_file() -> Result<(), String> {
let staging_meta = format!("{}/meta.json", OTA_STAGING_DIR);
if !Path::new(&staging_meta).exists() {
return Ok(());
}
fs::copy(&staging_meta, OTA_META_PATH)
.map_err(|e| format!("Failed to copy meta.json: {}", e))?;
let output = Command::new("chmod")
.args(["644", OTA_META_PATH])
.output()
.map_err(|e| format!("Failed to chmod meta.json: {}", e))?;
if !output.status.success() {
return Err(format!(
"Failed to chmod meta.json: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
fn restart_service_no_block() {
let _ = Command::new("systemctl")
.args(["--no-block", "restart", OTA_SERVICE_NAME])
.output();
}
/// 递归复制目录
fn copy_dir_recursive(src: &str, dst: &str) -> Result<(), String> {
fs::create_dir_all(dst).map_err(|e| format!("Failed to create dir: {}", e))?;
let entries = fs::read_dir(src).map_err(|e| format!("Failed to read src dir: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let src_path = entry.path();
let dst_path = Path::new(dst).join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(
src_path.to_str().unwrap_or(""),
dst_path.to_str().unwrap_or(""),
)?;
} else {
fs::copy(&src_path, &dst_path).map_err(|e| format!("Failed to copy file: {}", e))?;
}
}
Ok(())
}
/// 取消待安装的更新
pub fn cancel_pending_update() -> Result<(), String> {
if Path::new(OTA_STAGING_DIR).exists() {
fs::remove_dir_all(OTA_STAGING_DIR)
.map_err(|e| format!("Failed to remove staging dir: {}", e))?;
}
Ok(())
}
/// 检测文件是否为 ZIP 格式(通过魔术字节)
fn detect_zip_format(data: &[u8]) -> bool {
// ZIP 文件魔术字节: PK\x03\x04 (0x504B0304)
// TAR.GZ 文件魔术字节: \x1f\x8b (gzip header)
if data.len() < 4 {
return false;
}
// 检查是否是 ZIP 格式
data[0] == 0x50 && data[1] == 0x4B && data[2] == 0x03 && data[3] == 0x04
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compares_release_tag_versions() {
assert!(compare_versions("v1.0.4", "1.0.3"));
assert!(!compare_versions("v1.0.3", "1.0.3"));
assert!(!compare_versions("v1.0.2", "1.0.3"));
}
#[test]
fn schedules_next_update_check_at_nine_and_eighteen_beijing_time() {
let before_nine = Utc.with_ymd_and_hms(2026, 5, 15, 0, 59, 0).unwrap();
assert_eq!(
duration_until_next_update_check_from(before_nine),
Duration::from_secs(60)
);
let before_eighteen = Utc.with_ymd_and_hms(2026, 5, 15, 9, 59, 0).unwrap();
assert_eq!(
duration_until_next_update_check_from(before_eighteen),
Duration::from_secs(60)
);
let after_eighteen = Utc.with_ymd_and_hms(2026, 5, 15, 10, 1, 0).unwrap();
assert_eq!(
duration_until_next_update_check_from(after_eighteen),
Duration::from_secs(14 * 60 * 60 + 59 * 60)
);
}
#[test]
fn ota_request_urls_prefer_configured_proxy_then_builtin_then_direct() {
assert_eq!(
ota_request_urls("https://example.com/release", "https://proxy.local/", true),
vec![
"https://proxy.local/https://example.com/release".to_string(),
"https://gh-proxy.com/https://example.com/release".to_string(),
"https://ghproxy.net/https://example.com/release".to_string(),
"https://githubproxy.cc/https://example.com/release".to_string(),
"https://example.com/release".to_string()
]
);
assert_eq!(
ota_request_urls("https://example.com/release", "https://gh-proxy.com", true),
vec![
"https://gh-proxy.com/https://example.com/release".to_string(),
"https://ghproxy.net/https://example.com/release".to_string(),
"https://githubproxy.cc/https://example.com/release".to_string(),
"https://example.com/release".to_string()
]
);
}
#[test]
fn ota_request_urls_can_skip_builtin_proxies() {
assert_eq!(
ota_request_urls("https://example.com/release", "", false),
vec!["https://example.com/release".to_string()]
);
assert_eq!(
ota_request_urls("https://example.com/release", "https://proxy.local", false),
vec![
"https://proxy.local/https://example.com/release".to_string(),
"https://example.com/release".to_string()
]
);
}
}
/// 修复文件权限(用于 ZIP 解压后)
fn fix_file_permissions() -> Result<(), String> {
let binary_path = format!("{}/simadmin", OTA_STAGING_DIR);
let www_path = format!("{}/www", OTA_STAGING_DIR);
// 设置二进制文件权限为 755(可执行)
if Path::new(&binary_path).exists() {
Command::new("chmod")
.args(["755", &binary_path])
.output()
.map_err(|e| format!("Failed to chmod binary: {}", e))?;
}
// 设置前端文件权限:目录 755,文件 644
if Path::new(&www_path).exists() {
// 所有目录设置为 755
let _ = Command::new("find")
.args([&www_path, "-type", "d", "-exec", "chmod", "755", "{}", "+"])
.output();
// 所有文件设置为 644
let _ = Command::new("find")
.args([&www_path, "-type", "f", "-exec", "chmod", "644", "{}", "+"])
.output();
}
Ok(())
}