Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src-tauri/src/commands/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! 提供解压、增量/全量更新、文件移动等功能

use log::{info, warn};
use std::io::Read;

use super::file_ops::get_exe_dir;
use super::types::ChangesJson;
Expand All @@ -22,6 +23,83 @@ pub fn extract_zip(zip_path: String, dest_dir: String) -> Result<(), String> {
}
}

/// 读取更新包中的 interface.json
#[tauri::command]
pub fn read_update_package_interface(package_path: String) -> Result<String, String> {
info!("read_update_package_interface called: {}", package_path);

// 本地更新包支持格式需要和前端 SUPPORTED_UPDATE_PACKAGE_EXTENSIONS 保持同步。
let path_lower = package_path.to_lowercase();
if path_lower.ends_with(".zip") {
read_interface_from_zip(&package_path)
} else if path_lower.ends_with(".tar.gz") || path_lower.ends_with(".tgz") {
read_interface_from_tar_gz(&package_path)
} else {
Err("不支持的更新包格式".to_string())
}
}

fn is_interface_json_path(path: &str) -> bool {
path.replace('\\', "/")
.rsplit('/')
.next()
.is_some_and(|name| name.eq_ignore_ascii_case("interface.json"))
}

fn read_interface_from_zip(zip_path: &str) -> Result<String, String> {
let file = std::fs::File::open(zip_path)
.map_err(|e| format!("无法打开 ZIP 文件 [{}]: {}", zip_path, e))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|e| format!("无法解析 ZIP 文件: {}", e))?;

for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("无法读取 ZIP 条目 {}: {}", i, e))?;
if file.is_dir() || !is_interface_json_path(file.name()) {
continue;
}

let mut content = String::new();
file.read_to_string(&mut content)
.map_err(|e| format!("无法读取 interface.json: {}", e))?;
return Ok(content);
}

Err("更新包内未找到 interface.json".to_string())
}

fn read_interface_from_tar_gz(tar_path: &str) -> Result<String, String> {
use flate2::read::GzDecoder;
use tar::Archive;

let file = std::fs::File::open(tar_path)
.map_err(|e| format!("无法打开 tar.gz 文件 [{}]: {}", tar_path, e))?;
let gz = GzDecoder::new(file);
let mut archive = Archive::new(gz);
let entries = archive
.entries()
.map_err(|e| format!("无法读取 tar.gz 条目: {}", e))?;

for entry in entries {
let mut entry = entry.map_err(|e| format!("无法读取 tar.gz 条目: {}", e))?;
let path = entry
.path()
.map_err(|e| format!("无法读取 tar.gz 条目路径: {}", e))?;
if !is_interface_json_path(&path.to_string_lossy()) {
continue;
}

let mut content = String::new();
entry
.read_to_string(&mut content)
.map_err(|e| format!("无法读取 interface.json: {}", e))?;
return Ok(content);
}

Err("更新包内未找到 interface.json".to_string())
}

/// 解压 ZIP 文件
fn extract_zip_file(zip_path: &str, dest_dir: &str) -> Result<(), String> {
let file = std::fs::File::open(zip_path)
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ pub fn run() {
commands::state::clear_instance_logs,
// 更新安装命令
commands::update::extract_zip,
commands::update::read_update_package_interface,
commands::update::check_changes_json,
commands::update::apply_incremental_update,
commands::update::apply_full_update,
Expand Down
13 changes: 2 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
downloadUpdate,
getUpdateSavePath,
consumeUpdateCompleteInfo,
savePendingUpdateInfo,
saveCompletedUpdateInfo,
getPendingUpdateInfo,
clearPendingUpdateInfo,
isDebugVersion,
Expand Down Expand Up @@ -469,16 +469,7 @@ function App() {
log.info('更新下载完成');

// 保存待安装更新信息,以便下次启动时自动安装
savePendingUpdateInfo({
versionName: updateResult.versionName,
releaseNote: updateResult.releaseNote,
channel: updateResult.channel,
downloadSavePath: result.actualSavePath,
fileSize: updateResult.fileSize,
updateType: updateResult.updateType,
downloadSource: updateResult.downloadSource,
timestamp: Date.now(),
});
saveCompletedUpdateInfo(updateResult, result.actualSavePath);

// 尝试自动安装更新
tryAutoInstallUpdate();
Expand Down
14 changes: 2 additions & 12 deletions src/components/UpdatePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
downloadUpdate,
getUpdateSavePath,
MIRRORCHYAN_ERROR_CODES,
savePendingUpdateInfo,
saveCompletedUpdateInfo,
} from '@/services/updateService';
import { proxySettingsForUpdateDownload } from '@/services/proxyService';
import { DownloadProgressBar } from './UpdateInfoCard';
Expand Down Expand Up @@ -81,17 +81,7 @@ export function UpdatePanel({ onClose, anchorRef }: UpdatePanelProps) {
// 使用实际保存路径(可能与请求路径不同,如果从 302 重定向检测到正确文件名)
setDownloadSavePath(result.actualSavePath);
setDownloadStatus('completed');
// 保存待安装更新信息,以便下次启动时自动安装
savePendingUpdateInfo({
versionName: updateInfo.versionName,
releaseNote: updateInfo.releaseNote,
channel: updateInfo.channel,
downloadSavePath: result.actualSavePath,
fileSize: updateInfo.fileSize,
updateType: updateInfo.updateType,
downloadSource: updateInfo.downloadSource,
timestamp: Date.now(),
});
saveCompletedUpdateInfo(updateInfo, result.actualSavePath);
} else {
setDownloadStatus('failed');
}
Expand Down
43 changes: 42 additions & 1 deletion src/components/settings/UpdateSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
PackageCheck,
Bug,
Network,
FileUp,
} from 'lucide-react';
import clsx from 'clsx';

Expand All @@ -25,12 +26,15 @@ import {
cancelDownload,
MIRRORCHYAN_ERROR_CODES,
isDebugVersion,
saveCompletedUpdateInfo,
} from '@/services/updateService';
import { createProxySettings, proxySettingsForUpdateDownload } from '@/services/proxyService';
import { resolveI18nText } from '@/services/contentResolver';
import { getInterfaceLangKey } from '@/i18n';
import { loggers } from '@/utils/logger';
import { ReleaseNotes, DownloadProgressBar } from '../UpdateInfoCard';
import { useLocalUpdatePackageImport } from '@/hooks/useLocalUpdatePackageImport';
import { isTauri } from '@/utils/windowUtils';

export function UpdateSection() {
const { t } = useTranslation();
Expand Down Expand Up @@ -65,6 +69,11 @@ export function UpdateSection() {
const [proxyError, setProxyError] = useState(false);
const [checkFailed, setCheckFailed] = useState(false);
const [, setDebugLog] = useState<string[]>([]);
const {
importPackage,
supportedExtensions,
disabled: localPackageImportDisabled,
} = useLocalUpdatePackageImport();

const addDebugLog = useCallback((msg: string) => {
setDebugLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
Expand Down Expand Up @@ -152,6 +161,7 @@ export function UpdateSection() {
// 使用实际保存路径(可能与请求路径不同,如果从 302 重定向检测到正确文件名)
setDownloadSavePath(result.actualSavePath);
setDownloadStatus('completed');
saveCompletedUpdateInfo(info, result.actualSavePath);
} else {
setDownloadStatus('failed');
}
Expand Down Expand Up @@ -357,6 +367,22 @@ export function UpdateSection() {
}
};

const handleSelectLocalPackage = useCallback(async () => {
const { open } = await import('@tauri-apps/plugin-dialog');
const selected = await open({
multiple: false,
filters: [
{
name: t('mirrorChyan.localPackageFilter'),
extensions: supportedExtensions.map((ext) => ext.replace(/^\./, '')),
},
],
});

if (!selected || Array.isArray(selected)) return;
await importPackage(selected);
}, [importPackage, supportedExtensions, t]);

if (!projectInterface?.mirrorchyan_rid) {
return null;
}
Expand Down Expand Up @@ -525,7 +551,22 @@ export function UpdateSection() {
</button>
)}

{/* 更新状态显示 */}
{isTauri() && downloadStatus !== 'downloading' && installStatus !== 'installing' && (
<button
onClick={handleSelectLocalPackage}
disabled={updateCheckLoading || localPackageImportDisabled}
className={clsx(
'w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors border border-border',
updateCheckLoading || localPackageImportDisabled
? 'bg-bg-tertiary text-text-muted cursor-not-allowed'
: 'bg-bg-secondary text-text-secondary hover:bg-bg-hover',
)}
>
<FileUp className="w-4 h-4" />
{t('mirrorChyan.selectLocalPackage')}
</button>
)}

{updateInfo && !updateInfo.hasUpdate && !updateInfo.errorCode && (
<p className="text-xs text-center text-text-muted">
{t('mirrorChyan.upToDate', { version: updateInfo.versionName })}
Expand Down
105 changes: 105 additions & 0 deletions src/hooks/useLocalUpdatePackageImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useAppStore } from '@/stores/appStore';
import {
importLocalUpdatePackage,
LocalUpdatePackageError,
getSupportedUpdatePackageExtensions,
saveCompletedUpdateInfo,
isDebugVersion,
} from '@/services/updateService';
import { loggers } from '@/utils/logger';

export function useLocalUpdatePackageImport() {
const { t } = useTranslation();
const {
projectInterface,
downloadStatus,
installStatus,
updateCheckLoading,
setUpdateInfo,
setUpdateCheckLoading,
setDownloadStatus,
setDownloadProgress,
setDownloadSavePath,
setShowInstallConfirmModal,
} = useAppStore();

const getErrorMessage = useCallback(
(error: unknown) => {
if (error instanceof LocalUpdatePackageError) {
return t(`mirrorChyan.localPackageErrors.${error.code}`);
}
return error instanceof Error ? error.message : t('mirrorChyan.localPackageErrors.checkFailed');
},
[t],
);

const disabledReason =
!projectInterface?.mirrorchyan_rid || !projectInterface?.version || !projectInterface?.name
? 'missingProjectInfo'
: import.meta.env.DEV || isDebugVersion(projectInterface.version)
? 'debugMode'
: updateCheckLoading || downloadStatus === 'downloading' || installStatus === 'installing'
? 'busy'
: null;

const importPackage = useCallback(
async (filePath: string) => {
if (disabledReason) {
toast.error(t(`mirrorChyan.localPackageErrors.${disabledReason}`));
return;
}

const toastId = toast.loading(t('mirrorChyan.verifyingLocalPackage'));
setUpdateCheckLoading(true);

try {
const updateInfo = await importLocalUpdatePackage({
filePath,
projectInterface,
});

setUpdateInfo(updateInfo);
setDownloadSavePath(filePath);
setDownloadProgress({
downloadedSize: updateInfo.fileSize || 0,
totalSize: updateInfo.fileSize || 0,
speed: 0,
progress: 100,
});
setDownloadStatus('completed');
saveCompletedUpdateInfo(updateInfo, filePath);
setShowInstallConfirmModal(true);
toast.success(t('mirrorChyan.localPackageReady', { version: updateInfo.versionName }), {
id: toastId,
});
} catch (error) {
loggers.ui.error('Local update package import failed:', error);
toast.error(getErrorMessage(error), { id: toastId });
} finally {
setUpdateCheckLoading(false);
}
},
[
projectInterface,
setUpdateInfo,
setUpdateCheckLoading,
setDownloadStatus,
setDownloadProgress,
setDownloadSavePath,
setShowInstallConfirmModal,
getErrorMessage,
t,
disabledReason,
],
);

return {
importPackage,
supportedExtensions: getSupportedUpdatePackageExtensions(),
disabled: disabledReason !== null,
disabledReason,
};
}
14 changes: 14 additions & 0 deletions src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,20 @@ export default {
preparingDownload: 'Preparing download...',
downloadFromGitHub: 'Download from GitHub',
downloadFromMirrorChyan: 'Download via MirrorChyan CDN',
selectLocalPackage: 'Select Local Update Package',
localPackageFilter: 'Update Package',
verifyingLocalPackage: 'Verifying update package...',
localPackageReady: 'Update package verified. Ready to install {{version}}',
localPackageErrors: {
unsupportedFile: 'Unsupported update package format',
missingProjectInfo: 'This project has no update metadata',
debugMode: 'Updates are disabled for debug builds',
busy: 'An update is currently downloading or installing',
checkFailed: 'Failed to check for updates. Please try again later',
noUpdate: 'There is no new version to install',
missingPackageInterface: 'The update package does not contain interface.json',
projectMismatch: 'The update package does not belong to this project',
},
// Update installation
installing: 'Installing update...',
installComplete: 'Installation Complete',
Expand Down
14 changes: 14 additions & 0 deletions src/i18n/locales/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,20 @@ export default {
preparingDownload: 'ダウンロードを準備中...',
downloadFromGitHub: 'GitHub からダウンロード',
downloadFromMirrorChyan: 'Mirror醤 CDN からダウンロード',
selectLocalPackage: 'ローカル更新パッケージを選択',
localPackageFilter: '更新パッケージ',
verifyingLocalPackage: '更新パッケージを検証中...',
localPackageReady: '更新パッケージを検証しました。{{version}} をインストールできます',
localPackageErrors: {
unsupportedFile: 'サポートされていない更新パッケージ形式です',
missingProjectInfo: 'このプロジェクトには更新情報が設定されていません',
debugMode: 'デバッグビルドでは更新機能が無効です',
busy: '更新をダウンロードまたはインストール中です',
checkFailed: '更新の確認に失敗しました。後でもう一度お試しください',
noUpdate: 'インストール可能な新しいバージョンはありません',
missingPackageInterface: '更新パッケージに interface.json がありません',
projectMismatch: '更新パッケージは現在のプロジェクトのものではありません',
},
// アップデートインストール
installing: 'アップデートをインストール中...',
installComplete: 'インストール完了',
Expand Down
Loading
Loading