From 403a9f4be8b9168b608a1933bfa21017e37e4bbd Mon Sep 17 00:00:00 2001 From: Wyvest Date: Thu, 25 Dec 2025 21:18:07 +0900 Subject: [PATCH] fix mod cards, removing mods --- apps/oneclient/frontend/src/bindings.gen.ts | 15 ++-- .../src/components/Bundle/ModCard.tsx | 79 +++++++++++++++---- .../frontend/src/routes/app/cluster/mods.tsx | 6 +- packages/core/src/api/packages/dao.rs | 31 +++++++- packages/core/src/api/packages/mod.rs | 37 +++++++++ packages/core/src/api/tauri/commands.rs | 11 +++ 6 files changed, 154 insertions(+), 25 deletions(-) diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 79861cb2..89aa2521 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -301,8 +301,13 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'core':'{"getLogByName":["id","name"],"createSettingsProfile":["name"],"searchPackages":["provider","query"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"setDefaultUser":["uuid"],"writeSettings":["setting"],"updateClusterById":["id","request"],"getProfileOrDefault":["name"],"getDefaultUser":["fallback"],"getClusters":[],"getUsers":[],"createCluster":["options"],"removeCape":["access_token"],"launchCluster":["id","uuid"],"getLinkedPackages":["cluster_id"],"setDiscordRPCMessage":["message"],"getUsersFromAuthor":["provider","author"],"getPackage":["provider","slug"],"getWorlds":["id"],"getMultiplePackages":["provider","slugs"],"getLogs":["id"],"fetchMinecraftProfile":["uuid"],"installModpack":["modpack","cluster_id"],"convertUsernameUUID":["username_uuid"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"killProcess":["pid"],"openMsaLogin":[],"removeUser":["uuid"],"getPackageBody":["provider","body"],"getClusterById":["id"],"getRunningProcessesByClusterId":["cluster_id"],"isClusterRunning":["cluster_id"],"getGameVersions":[],"readSettings":[],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getGlobalProfile":[],"fetchLoggedInProfile":["access_token"],"changeCape":["access_token","cape_uuid"],"getLoadersForVersion":["mc_version"],"getUser":["uuid"],"getRunningProcesses":[],"getScreenshots":["id"],"updateClusterProfile":["name","profile"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"open":["input"],"removeCluster":["id"],"changeSkin":["access_token","skin_url","skin_variant"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'debug':'{"getPackageVersion":[],"getFamily":[],"getType":[],"getPlatform":[],"getGitCommitHash":[],"getLocale":[],"getArch":[],"getBuildTimestamp":[],"openDevTools":[],"isInDev":[],"getOsVersion":[]}', 'oneclient':'{"getVersions":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"]}' } -export type Router = { 'core': { getClusters: () => Promise, +const ARGS_MAP = { 'core':'{"launchCluster":["id","uuid"],"getGlobalProfile":[],"setDefaultUser":["uuid"],"getPackage":["provider","slug"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getPackageBody":["provider","body"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"writeSettings":["setting"],"removePackage":["cluster_id","package_hash"],"open":["input"],"killProcess":["pid"],"getLinkedPackages":["cluster_id"],"getLogs":["id"],"setDiscordRPCMessage":["message"],"removeUser":["uuid"],"readSettings":[],"getDefaultUser":["fallback"],"getLogByName":["id","name"],"openMsaLogin":[],"getMultiplePackages":["provider","slugs"],"getGameVersions":[],"createSettingsProfile":["name"],"updateClusterById":["id","request"],"changeSkin":["access_token","skin_url","skin_variant"],"getLoadersForVersion":["mc_version"],"isClusterRunning":["cluster_id"],"getRunningProcesses":[],"getProfileOrDefault":["name"],"createCluster":["options"],"getClusters":[],"getClusterById":["id"],"getScreenshots":["id"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"removeCluster":["id"],"getUsers":[],"changeCape":["access_token","cape_uuid"],"getWorlds":["id"],"searchPackages":["provider","query"],"removeCape":["access_token"],"fetchLoggedInProfile":["access_token"],"convertUsernameUUID":["username_uuid"],"getUser":["uuid"],"updateClusterProfile":["name","profile"],"getUsersFromAuthor":["provider","author"],"installModpack":["modpack","cluster_id"],"fetchMinecraftProfile":["uuid"],"getRunningProcessesByClusterId":["cluster_id"]}', 'events':'{"message":["event"],"process":["event"],"ingress":["event"]}', 'oneclient':'{"getVersions":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'debug':'{"getOsVersion":[],"getArch":[],"getPackageVersion":[],"getBuildTimestamp":[],"getFamily":[],"getLocale":[],"getPlatform":[],"getType":[],"isInDev":[],"getGitCommitHash":[],"openDevTools":[]}' } +export type Router = { 'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise }, +'oneclient': { getClustersGroupedByMajor: () => Promise>, +getBundlesFor: (clusterId: number) => Promise, +getVersions: () => Promise }, +'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, createCluster: (options: CreateCluster) => Promise, @@ -339,6 +344,7 @@ downloadPackage: (provider: Provider, packageId: string, versionId: string, clus downloadExternalPackage: (package: ExternalPackage, clusterId: number, force: boolean | null, skipCompatibility: boolean | null) => Promise, getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise, getLinkedPackages: (clusterId: number) => Promise, +removePackage: (clusterId: number, packageHash: string) => Promise, installModpack: (modpack: ModpackArchive, clusterId: number) => Promise, fetchMinecraftProfile: (uuid: string) => Promise, fetchLoggedInProfile: (accessToken: string) => Promise, @@ -349,11 +355,6 @@ removeCape: (accessToken: string) => Promise, convertUsernameUUID: (usernameUuid: string) => Promise, setDiscordRPCMessage: (message: string) => Promise, open: (input: string) => Promise }, -'oneclient': { getClustersGroupedByMajor: () => Promise>, -getBundlesFor: (clusterId: number) => Promise, -getVersions: () => Promise }, -'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise }, 'debug': { openDevTools: () => Promise, isInDev: () => Promise, getArch: () => Promise, diff --git a/apps/oneclient/frontend/src/components/Bundle/ModCard.tsx b/apps/oneclient/frontend/src/components/Bundle/ModCard.tsx index f8d628f5..9012576b 100644 --- a/apps/oneclient/frontend/src/components/Bundle/ModCard.tsx +++ b/apps/oneclient/frontend/src/components/Bundle/ModCard.tsx @@ -1,9 +1,11 @@ -import type { ClusterModel, ModpackFile, ModpackFileKind } from '@/bindings.gen'; +import type { ClusterModel, ModpackFile, ModpackFileKind, PackageModel } from '@/bindings.gen'; import MissingLogo from '@/assets/misc/missingLogo.svg'; -import { DownloadModButton, ModTag } from '@/components'; +import { ModTag } from '@/components'; import { useSettings } from '@/hooks/useSettings'; import { bindings } from '@/main'; -import { createContext, useContext, useEffect, useState } from 'react'; +import { useCommandMut } from '@onelauncher/common'; +import { useQueryClient } from '@tanstack/react-query'; +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { twMerge } from 'tailwind-merge'; export interface ModInfo { @@ -84,10 +86,11 @@ interface ModCardProps { export type onClickOnMod = (file: ModpackFile) => void; export interface ModCardContextApi { - showModDownloadButton?: boolean; + enableClickToDownload?: boolean; onClickOnMod?: onClickOnMod; useVerticalGridLayout?: boolean; mods?: Array; + installedPackages?: Array; } export const ModCardContext = createContext(null); @@ -101,20 +104,72 @@ export function useModCardContext() { } export function ModCard({ file, cluster }: ModCardProps) { - const { showModDownloadButton, onClickOnMod, useVerticalGridLayout, mods } = useModCardContext(); + const { enableClickToDownload, onClickOnMod, useVerticalGridLayout, mods, installedPackages } = useModCardContext(); + const queryClient = useQueryClient(); const [modMetadata, setModMetadata] = useState({ name: 'Loading...', description: null, author: null, iconURL: null, url: null, managed: false, packageSlug: null }); useEffect(() => { (async () => setModMetadata(await getModMetaData(file, useVerticalGridLayout)))(); }, [file, useVerticalGridLayout]); - const [isSelected, setSelected] = useState(mods?.includes(file) ?? false); + const kind = file.kind; + + const isInstalled = useMemo(() => { + if (installedPackages) + if ('Managed' in kind) { + const [pkg, _] = kind.Managed; + return installedPackages.some(p => p.package_id === pkg.id && p.provider === pkg.provider); + } + else { + return installedPackages.some(p => p.hash === kind.External.sha1); + } + + return mods?.includes(file) ?? false; + }, [installedPackages, mods, file]); + + const [isSelected, setSelected] = useState(isInstalled); useEffect(() => { - setSelected(mods?.includes(file) ?? false); - }, [mods, file]); + setSelected(isInstalled); + }, [isInstalled]); + + const download = useCommandMut(async () => { + if ('Managed' in kind) { + const [pkg, version] = kind.Managed; + await bindings.core.downloadPackage(pkg.provider, pkg.id, version.version_id, cluster.id, null); + } + else { + await bindings.core.downloadExternalPackage(kind.External, cluster.id, null, null); + } + }); + + const remove = useCommandMut(async () => { + let hash: string | undefined; + if ('Managed' in kind) { + const [_, version] = kind.Managed; + const primary = version.files.find(f => f.primary) ?? version.files[0]; + hash = primary?.sha1; + } + else { + hash = kind.External.sha1; + } + + if (hash) + await bindings.core.removePackage(cluster.id, hash); + }); const handleOnClick = () => { - if (onClickOnMod) + if (enableClickToDownload) + (async () => { + if (isInstalled) + await remove.mutateAsync(); + + else + await download.mutateAsync(); + + await queryClient.invalidateQueries({ queryKey: ['getLinkedPackages', cluster.id] }); + })(); + + else if (onClickOnMod) onClickOnMod(file); }; @@ -146,12 +201,6 @@ export function ModCard({ file, cluster }: ModCardProps) { {useVerticalGridLayout === true && modMetadata.description !== null &&

{modMetadata.description}

} - - {showModDownloadButton === true && ( -
- -
- )} ); } diff --git a/apps/oneclient/frontend/src/routes/app/cluster/mods.tsx b/apps/oneclient/frontend/src/routes/app/cluster/mods.tsx index 351f5440..db9fa257 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/mods.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/mods.tsx @@ -13,10 +13,12 @@ export const Route = createFileRoute('/app/cluster/mods')({ function RouteComponent() { const { cluster } = Route.useRouteContext(); const { data: bundles } = useCommandSuspense(['getBundlesFor', cluster.id], () => bindings.oneclient.getBundlesFor(cluster.id)); + const { data: installedPackages } = useCommandSuspense(['getLinkedPackages', cluster.id], () => bindings.core.getLinkedPackages(cluster.id)); const context = useMemo(() => ({ - showModDownloadButton: true, - }), []); + enableClickToDownload: true, + installedPackages, + }), [installedPackages]); if (bundles.length === 0) return

No bundles found {cluster.name}

; diff --git a/packages/core/src/api/packages/dao.rs b/packages/core/src/api/packages/dao.rs index ae0be132..3a7a6211 100644 --- a/packages/core/src/api/packages/dao.rs +++ b/packages/core/src/api/packages/dao.rs @@ -2,7 +2,8 @@ use onelauncher_entity::loader::GameLoader; use onelauncher_entity::package::PackageType; use onelauncher_entity::{cluster_packages, clusters, packages}; use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, TryInsertResult, + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, + TryInsertResult, }; use crate::error::{DaoError, LauncherResult}; @@ -105,6 +106,34 @@ pub async fn is_package_linked_to_cluster( Ok(linked_packages.is_some()) } +/// Checks if a package is linked to any cluster. +pub async fn is_package_used(package_hash: &str) -> LauncherResult { + let state = State::get().await?; + let db = &state.db; + + let count = cluster_packages::Entity::find() + .filter(cluster_packages::Column::PackageHash.eq(package_hash)) + .count(db) + .await?; + + Ok(count > 0) +} + +/// Unlinks a package from a cluster in database. +pub async fn unlink_package_from_cluster( + package_hash: &str, + cluster_id: i64, +) -> LauncherResult<()> { + let state = State::get().await?; + let db = &state.db; + + cluster_packages::Entity::delete_by_id((cluster_id, package_hash.to_string())) + .exec(db) + .await?; + + Ok(()) +} + /// Retrieves a package by its hash from the database. pub async fn get_package_by_hash(hash: PackageId) -> LauncherResult> { let state = State::get().await?; diff --git a/packages/core/src/api/packages/mod.rs b/packages/core/src/api/packages/mod.rs index ac530723..feb7a4c9 100644 --- a/packages/core/src/api/packages/mod.rs +++ b/packages/core/src/api/packages/mod.rs @@ -4,8 +4,10 @@ use onelauncher_entity::package::Provider; use onelauncher_entity::{clusters, packages}; use reqwest::Method; +use crate::api::cluster::dao::get_cluster_by_id; use crate::api::packages::data::{ManagedPackageBody, PackageAuthor}; use crate::error::{LauncherError, LauncherResult}; +use crate::send_error; use crate::store::Dirs; use crate::store::ingress::SubIngress; use crate::utils::crypto::HashAlgorithm; @@ -290,3 +292,38 @@ impl DatabaseModelExt for packages::Model { .join(&self.file_name)) } } + +/// Removes a package from a cluster. +/// It is also deleted from the filesystem and database. +pub async fn remove_package(cluster_id: i64, package_hash: String) -> LauncherResult<()> { + if let Some(package) = dao::get_package_by_hash(package_hash.clone()).await? { + dao::unlink_package_from_cluster(&package_hash, cluster_id).await?; + + // Remove the hard link from the cluster's folder + if let Some(cluster) = get_cluster_by_id(cluster_id).await? { + let cluster_dir = Dirs::get_clusters_dir() + .await? + .join(cluster.folder_name) + .join(package.package_type.folder_name()) + .join(&package.file_name); + + if cluster_dir.exists() { + if let Err(err) = io::remove_file(&cluster_dir).await { + send_error!("{}{}", "failed to remove file: ", err); + } + } + } + + if !dao::is_package_used(&package_hash).await? { + let path = package.path().await?; + if path.exists() { + if let Err(err) = io::remove_file(&path).await { + send_error!("{}{}", "failed to remove file: ", err); + } + } + dao::delete_package_by_id(package_hash).await?; + } + } + + Ok(()) +} diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index edc19ef8..7c972059 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -199,6 +199,9 @@ pub trait TauriLauncherApi { #[taurpc(alias = "getLinkedPackages")] async fn get_linked_packages(cluster_id: ClusterId) -> LauncherResult>; + + #[taurpc(alias = "removePackage")] + async fn remove_package(cluster_id: ClusterId, package_hash: String) -> LauncherResult<()>; // endregion: package // MARK: API: modpack @@ -753,6 +756,14 @@ impl TauriLauncherApi for TauriLauncherApiImpl { Ok(model) } + + async fn remove_package( + self, + cluster_id: ClusterId, + package_hash: String, + ) -> LauncherResult<()> { + api::packages::remove_package(cluster_id, package_hash).await + } // endregion: package // MARK: Impl: modpack