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
21 changes: 21 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ define_save_config_command!(
enum ComponentCommandAction {
Install,
Reinstall,
Uninstall,
}

async fn run_component_command(
Expand All @@ -321,6 +322,11 @@ async fn run_component_command(
ComponentCommandAction::Reinstall => {
component::reinstall_component(&client, id, Some(app_handle)).await
}
ComponentCommandAction::Uninstall => {
tokio::task::spawn_blocking(move || component::uninstall_component(id))
.await
.map_err(|e| AppError::process(format!("Uninstall task panicked: {}", e)))?
}
Comment on lines +325 to +329

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Uninstalling a core component (such as Python, Node.js, or uv) while there are active instances running can lead to application crashes, corrupted environments, or file lock errors (especially on Windows).\n\nIt is highly recommended to check if any instances are currently running and return an error if so, similar to how it is handled in other commands.

        ComponentCommandAction::Uninstall => {\n            if !state.process_manager.get_active_ids().is_empty() {\n                return Err(AppError::instance_running());\n            }\n            tokio::task::spawn_blocking(move || component::uninstall_component(id))\n                .await\n                .map_err(|e| AppError::process(format!(\"Uninstall task panicked: {}\", e)))?\n        }

}
}

Expand Down Expand Up @@ -354,6 +360,21 @@ pub async fn reinstall_component(
.await
}

#[tauri::command]
pub async fn uninstall_component(
app_handle: AppHandle,
state: State<'_, AppState>,
component_id: String,
) -> Result<String> {
run_component_command(
&app_handle,
&state,
&component_id,
ComponentCommandAction::Uninstall,
)
.await
}

// === GitHub ===

#[tauri::command]
Expand Down
17 changes: 17 additions & 0 deletions src-tauri/src/component/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ pub async fn install_component(
result
}

/// Uninstall a component by id, dispatching to the appropriate sub-module.
pub fn uninstall_component(id: ComponentId) -> Result<String> {
log::info!("Uninstalling component {:?}", id);
let result = match id {
ComponentId::Python => python::uninstall_component(),
ComponentId::Nodejs => nodejs::uninstall_nodejs(),
ComponentId::UV => uv::uninstall_uv(),
};

match &result {
Ok(msg) => log::info!("Component {:?} uninstalled: {}", id, msg),
Err(e) => log::error!("Failed to uninstall component {:?}: {}", id, e),
}

result
}

/// Reinstall a component by id, dispatching to the appropriate sub-module.
pub async fn reinstall_component(
client: &Client,
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/src/component/nodejs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ pub async fn install_nodejs(client: &Client, app_handle: Option<&AppHandle>) ->
Ok(format!("已安装 Node.js (LTS): {}", version))
}

/// Uninstall Node.js LTS.
pub fn uninstall_nodejs() -> Result<String> {
let dir = get_component_dir("nodejs");
if dir.exists() {
std::fs::remove_dir_all(&dir)
.map_err(|e| AppError::io(format!("Failed to remove Node.js: {}", e)))?;
Ok("已卸载 Node.js (LTS)".to_string())
} else {
Ok("Node.js (LTS) 组件未安装".to_string())
}
}

/// Reinstall Node.js LTS (always removes existing and re-downloads).
pub async fn reinstall_nodejs(client: &Client, app_handle: Option<&AppHandle>) -> Result<String> {
let version = do_install_nodejs(client, app_handle).await?;
Expand Down
26 changes: 26 additions & 0 deletions src-tauri/src/component/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,32 @@ pub(super) async fn install_component(
}
}

/// Uninstall unified Python component (3.10 + 3.12).
pub(super) fn uninstall_component() -> Result<String> {
let py310_dir = get_python_runtime_dir(RUNTIME_PY310);
let py312_dir = get_python_runtime_dir(RUNTIME_PY312);

let mut removed = Vec::new();
for dir in [&py310_dir, &py312_dir] {
if dir.exists() {
std::fs::remove_dir_all(dir)
.map_err(|e| AppError::io(format!("Failed to remove Python runtime: {}", e)))?;
removed.push(
dir.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
);
}
}
Comment on lines +78 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Include more specific context in Python uninstall error messages and logs

Since you already compute separate dirs for RUNTIME_PY310 and RUNTIME_PY312, this error should indicate which one failed. Instead of a generic "Failed to remove Python runtime: {}", include the directory or runtime identifier in the message (and logs), e.g. format!("Failed to remove Python runtime {:?}: {}", dir, e), so users can see which specific runtime uninstall failed.

Suggested change
let mut removed = Vec::new();
for dir in [&py310_dir, &py312_dir] {
if dir.exists() {
std::fs::remove_dir_all(dir)
.map_err(|e| AppError::io(format!("Failed to remove Python runtime: {}", e)))?;
removed.push(
dir.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
);
}
}
let mut removed = Vec::new();
for dir in [&py310_dir, &py312_dir] {
if dir.exists() {
std::fs::remove_dir_all(dir).map_err(|e| {
AppError::io(format!(
"Failed to remove Python runtime at {}: {}",
dir.display(),
e
))
})?;
removed.push(
dir.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
);
}
}


if removed.is_empty() {
Ok("Python 组件未安装".to_string())
} else {
Ok(format!("已卸载 Python: {}", removed.join(", ")))
}
}

/// Reinstall unified Python component (3.10 + 3.12).
pub(super) async fn reinstall_component(
client: &Client,
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/src/component/uv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ pub async fn install_uv(client: &Client, app_handle: Option<&AppHandle>) -> Resu
Ok(format!("已安装 uv: {}", version))
}

/// Uninstall uv.
pub fn uninstall_uv() -> Result<String> {
let dir = get_component_dir("uv");
if dir.exists() {
std::fs::remove_dir_all(&dir)
.map_err(|e| AppError::io(format!("Failed to remove uv: {}", e)))?;
Ok("已卸载 uv".to_string())
} else {
Ok("uv 组件未安装".to_string())
}
}

pub async fn reinstall_uv(client: &Client, app_handle: Option<&AppHandle>) -> Result<String> {
let version = do_install_uv(client, app_handle).await?;
Ok(format!("已重新安装 uv: {}", version))
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 @@ -178,6 +178,7 @@ pub fn run() {
// Components
commands::install_component,
commands::reinstall_component,
commands::uninstall_component,
// GitHub
commands::fetch_releases,
commands::fetch_launcher_release_notes,
Expand Down
2 changes: 2 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export const api = {
installComponent: (componentId: string) => invoke<string>('install_component', { componentId }),
reinstallComponent: (componentId: string) =>
invoke<string>('reinstall_component', { componentId }),
uninstallComponent: (componentId: string) =>
invoke<string>('uninstall_component', { componentId }),

// ========================================
// GitHub
Expand Down
1 change: 1 addition & 0 deletions src/constants/operationKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const OPERATION_KEYS = {
uninstallVersion: (version: string) => `uninstall:${version}`,
installComponent: (componentId: string) => `install-component:${componentId}`,
reinstallComponent: (componentId: string) => `reinstall-component:${componentId}`,
uninstallComponent: (componentId: string) => `uninstall-component:${componentId}`,
fetchReleases: 'fetch-releases',

backupCreate: 'backup:create',
Expand Down
62 changes: 62 additions & 0 deletions src/pages/Versions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export default function Versions() {
const [detailOpen, setDetailOpen] = useState(false);
const [uninstallOpen, setUninstallOpen] = useState(false);
const [versionToUninstall, setVersionToUninstall] = useState<InstalledVersion | null>(null);
const [componentUninstallOpen, setComponentUninstallOpen] = useState(false);
const [componentToUninstall, setComponentToUninstall] = useState<{
id: string;
display_name: string;
} | null>(null);
const [releases, setReleases] = useState<GitHubRelease[]>([]);
const releasesLoading = operations[OPERATION_KEYS.fetchReleases] || false;

Expand Down Expand Up @@ -145,6 +150,23 @@ export default function Versions() {
[runOperation, clearDownloadProgress]
);

const handleComponentUninstall = useCallback(async () => {
if (!componentToUninstall) return;
const key = OPERATION_KEYS.uninstallComponent(componentToUninstall.id);
await runOperation({
key,
task: () => api.uninstallComponent(componentToUninstall.id),
onSuccess: (result) => {
message.success(result);
},
onError: (error) => {
handleApiError(error);
},
});
setComponentUninstallOpen(false);
setComponentToUninstall(null);
}, [componentToUninstall, runOperation]);
Comment on lines +153 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Consider not closing the uninstall modal when the uninstall operation fails

Because setComponentUninstallOpen(false) and setComponentToUninstall(null) run after runOperation regardless of outcome, the modal always closes and the selection is cleared even on failure. This forces the user to reopen the modal to retry and can obscure the error context. Consider only resetting this state on success (e.g., in onSuccess) or otherwise distinguishing success vs failure to keep a smoother retry flow.

Suggested change
const handleComponentUninstall = useCallback(async () => {
if (!componentToUninstall) return;
const key = OPERATION_KEYS.uninstallComponent(componentToUninstall.id);
await runOperation({
key,
task: () => api.uninstallComponent(componentToUninstall.id),
onSuccess: (result) => {
message.success(result);
},
onError: (error) => {
handleApiError(error);
},
});
setComponentUninstallOpen(false);
setComponentToUninstall(null);
}, [componentToUninstall, runOperation]);
const handleComponentUninstall = useCallback(async () => {
if (!componentToUninstall) return;
const key = OPERATION_KEYS.uninstallComponent(componentToUninstall.id);
await runOperation({
key,
task: () => api.uninstallComponent(componentToUninstall.id),
onSuccess: (result) => {
message.success(result);
setComponentUninstallOpen(false);
setComponentToUninstall(null);
},
onError: (error) => {
handleApiError(error);
},
});
}, [componentToUninstall, runOperation]);


const isInstalled = (tagName: string) => versions.some((v) => v.version === tagName);
const availableReleases = releases.filter((r) => !isInstalled(r.tag_name));
const getInstalledRelease = (version: string) => releases.find((r) => r.tag_name === version);
Expand Down Expand Up @@ -211,6 +233,21 @@ export default function Versions() {
onClick={() => runComponentInstallAction(comp.id, 'reinstall')}
/>
</Tooltip>,
<Tooltip title="卸载" key="uninstall">
<Button
type="text"
danger
icon={<DeleteOutlined />}
disabled={isComponentOperating}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The isComponentOperating state only checks for isInstalling and isReinstalling. It does not include the isUninstalling state. As a result, the "Uninstall" button (and other action buttons) will not be disabled while the component is being uninstalled, which could allow users to trigger concurrent conflicting operations.\n\nTo prevent this, we should disable the button if an uninstallation is in progress. Ideally, you should also update the definition of isComponentOperating (around line 205) to include the uninstall operation:\n\ntypescript\nconst uninstallKey = OPERATION_KEYS.uninstallComponent(comp.id);\nconst isUninstalling = operations[uninstallKey] || false;\nconst isComponentOperating = isInstalling || isReinstalling || isUninstalling;\n

Suggested change
disabled={isComponentOperating}
disabled={isComponentOperating || (operations[OPERATION_KEYS.uninstallComponent(comp.id)] || false)}

onClick={() => {
setComponentToUninstall({
id: comp.id,
display_name: comp.display_name,
});
setComponentUninstallOpen(true);
}}
/>
</Tooltip>,
].filter(Boolean)
: [
downloading && (
Expand Down Expand Up @@ -437,6 +474,31 @@ export default function Versions() {
setVersionToUninstall(null);
}}
/>

{/* Component Uninstall Modal */}
<ConfirmModal
open={componentUninstallOpen}
title="确认卸载组件"
danger
content={
<>
<p>确定卸载此组件?卸载后需重新下载才能使用。</p>
{componentToUninstall && (
<Text type="secondary">组件: {componentToUninstall.display_name}</Text>
)}
</>
}
loading={
componentToUninstall
? operations[OPERATION_KEYS.uninstallComponent(componentToUninstall.id)] || false
: false
}
onConfirm={handleComponentUninstall}
onCancel={() => {
setComponentUninstallOpen(false);
setComponentToUninstall(null);
}}
/>
</>
);
}
15 changes: 2 additions & 13 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,7 @@ export interface DownloadProgress {
}

export type DeployStep =
| 'backup'
| 'extract'
| 'venv'
| 'deps'
| 'webui'
| 'restore'
| 'start'
| 'done'
| 'error';
'backup' | 'extract' | 'venv' | 'deps' | 'webui' | 'restore' | 'start' | 'done' | 'error';

export interface DeployProgress {
instance_id: string;
Expand All @@ -170,10 +162,7 @@ export interface DeployProgress {
export type DeployType = 'start' | 'upgrade' | 'downgrade' | null;

export type RepairPreserveScope =
| 'data_directory'
| 'config_and_data_files'
| 'core_config_and_data_files'
| 'database_only';
'data_directory' | 'config_and_data_files' | 'core_config_and_data_files' | 'database_only';

export interface DeployState {
instanceName: string;
Expand Down