refactor: Lots of stuff refactored

This commit is contained in:
LynithDev 2024-03-28 22:08:04 +01:00
parent 0dd1365fa4
commit fb14f96eea
16 changed files with 326 additions and 294 deletions

View file

@ -65,7 +65,7 @@ pub enum ErrorKind {
/// Wrapper around [`crate::game::client::ClientManagerError`] to handle directory errors.
#[error("failed to manage clients: {0}")]
ClientManagerError(#[from] crate::game::client::ClientManagerError),
ClientManagerError(#[from] crate::game::client_manager::ClientManagerError),
}
#[derive(Debug)]

View file

@ -1,27 +1,21 @@
use std::{collections::HashMap, fs, path::PathBuf};
use std::path::PathBuf;
use anyhow::anyhow;
use async_trait::async_trait;
use chrono::{DateTime, Local};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::utils::dirs;
use super::{
minecraft::{MinecraftManifest, ReleaseType},
vanilla::{self, VanillaClientProps},
};
use super::{clients::ClientType, minecraft::{MinecraftManifest, ReleaseType}};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Version {
id: String,
url: String,
pub struct MinecraftVersion {
pub id: String,
pub url: String,
#[serde(default)]
release_type: ReleaseType,
pub release_type: ReleaseType,
#[serde(default)]
release_time: chrono::DateTime<chrono::Utc>,
pub release_time: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -41,30 +35,6 @@ pub struct Cluster {
pub client: ClientType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ClientType {
Vanilla(VanillaClientProps),
}
impl ClientType {
pub async fn get_versions(&self, file: Option<&PathBuf>) -> crate::Result<Vec<Version>> {
match self {
ClientType::Vanilla(_) => vanilla::get_versions(file).await,
}
}
}
pub fn get_impl<'a>(
client: &'a ClientType,
cluster: &'a Cluster,
manifest: &'a Manifest,
) -> Box<dyn ClientTrait<'a> + 'a> {
Box::new(match client {
ClientType::Vanilla(_) => vanilla::VanillaClient::new(cluster, manifest),
})
}
#[async_trait]
pub trait ClientTrait<'a>: Send + Sync {
fn new(cluster: &'a Cluster, manifest: &'a Manifest) -> Self
@ -122,184 +92,4 @@ macro_rules! impl_game_client {
self.manifest
}
};
}
#[derive(Debug, thiserror::Error)]
pub enum ClientManagerError {
#[error("Cluster not found")]
ClusterNotFound,
#[error("Manifest not found")]
ManifestNotFound,
}
pub struct ClientManager {
clusters: HashMap<Uuid, Cluster>,
manifests: HashMap<Uuid, Manifest>,
}
impl ClientManager {
pub fn new() -> crate::Result<Self> {
let clusters = load_and_serialize::<Cluster>(&dirs::clusters_dir()?)?;
let manifests = load_and_serialize::<Manifest>(&dirs::manifests_dir()?)?;
Ok(Self {
clusters,
manifests,
})
}
pub fn get_impl_uuid<'a>(&'a self, uuid: Uuid) -> crate::Result<Box<dyn ClientTrait<'a> + 'a>> {
let cluster = self.get_cluster(uuid)?;
let manifest = self.get_manifest(uuid)?;
Ok(get_impl(&cluster.client, &cluster, &manifest))
}
pub fn get_clusters(&self) -> Vec<&Cluster> {
self.clusters.values().collect()
}
pub fn get_clusters_owned(&self) -> Vec<Cluster> {
self.clusters.values().cloned().collect()
}
pub fn get_cluster(&self, uuid: Uuid) -> crate::Result<&Cluster> {
self.clusters
.get(&uuid)
.ok_or(ClientManagerError::ClusterNotFound.into())
}
pub fn get_cluster_mut(&mut self, uuid: Uuid) -> crate::Result<&mut Cluster> {
self.clusters
.get_mut(&uuid)
.ok_or(ClientManagerError::ClusterNotFound.into())
}
pub fn get_manifest(&self, uuid: Uuid) -> crate::Result<&Manifest> {
self.manifests
.get(&uuid)
.ok_or(ClientManagerError::ManifestNotFound.into())
}
pub fn get_manifest_mut(&mut self, uuid: Uuid) -> crate::Result<&mut Manifest> {
self.manifests
.get_mut(&uuid)
.ok_or(ClientManagerError::ManifestNotFound.into())
}
pub async fn create_cluster(
&mut self,
name: String,
version: String,
cover: Option<String>,
group: Option<Uuid>,
client: ClientType,
) -> crate::Result<Uuid> {
let version_cache = dirs::app_config_dir()?.join("versions_cache.json");
let versions = client.get_versions(Some(&version_cache)).await?;
let url = &versions
.iter()
.find(|v| v.id == version)
.ok_or(anyhow!("Version not found"))?
.url;
let uuid = Uuid::new_v4();
// Save the manifest
let minecraft_manifest = vanilla::retrieve_version_manifest(url).await?;
let manifest = Manifest {
id: uuid,
manifest: minecraft_manifest,
};
save(
&dirs::manifests_dir()?.join(format!("{}.json", uuid)),
&manifest,
)?;
self.manifests.insert(uuid, manifest);
// Save the cluster
let cluster = Cluster {
id: uuid,
created_at: Local::now(),
name,
cover,
group,
client,
};
save(
&dirs::clusters_dir()?.join(format!("{}.json", uuid)),
&cluster,
)?;
self.clusters.insert(uuid, cluster);
Ok(uuid)
}
}
fn save<T>(path: &PathBuf, object: T) -> crate::Result<()>
where
T: Serialize,
{
if !path.exists() {
fs::create_dir_all(
path.parent()
.expect("Couldn't get parent of path whilst saving object. 'client.rs'"),
)?;
}
let content = serde_json::to_string(&object)?;
fs::write(path, content)?;
Ok(())
}
fn load_and_serialize<T>(dir: &PathBuf) -> crate::Result<HashMap<Uuid, T>>
where
T: DeserializeOwned,
{
let mut result = HashMap::<Uuid, T>::new();
if !dir.exists() {
fs::create_dir_all(dir)?;
return Ok(result);
}
for file in fs::read_dir(dir)? {
let file = file?;
let file_name = file.file_name();
let file_name = file_name
.to_str()
.ok_or(anyhow!("Couldn't convert OsStr to str"))?
.replace(".json", "");
let uuid = match Uuid::parse_str(&file_name) {
Ok(uuid) => uuid,
Err(_) => {
eprintln!("Couldn't parse file name as UUID: '{:?}'", file_name);
continue;
}
};
let path = file.path();
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => {
eprintln!("Couldn't read file: '{:?}'", path);
continue;
}
};
let parsed = match serde_json::from_str::<T>(&content) {
Ok(parsed) => parsed,
Err(_) => {
eprintln!("Couldn't parse file '{}' content as JSON", path.display());
continue;
}
};
result.insert(uuid, parsed);
}
Ok(result)
}
}

View file

@ -0,0 +1,203 @@
use std::{collections::HashMap, fs, path::PathBuf};
use anyhow::anyhow;
use chrono::Local;
use serde::{de::DeserializeOwned, Serialize};
use uuid::Uuid;
use crate::utils::dirs;
use super::{client::{ClientTrait, Cluster, Manifest}, clients::{self, vanilla, ClientType}};
#[derive(Debug, thiserror::Error)]
pub enum ClientManagerError {
#[error("Cluster not found")]
ClusterNotFound,
#[error("Manifest not found")]
ManifestNotFound,
}
pub struct ClientManager {
clusters: HashMap<Uuid, Cluster>,
manifests: HashMap<Uuid, Manifest>,
}
impl ClientManager {
pub fn new() -> crate::Result<Self> {
let clusters = load_and_serialize::<Cluster>(&dirs::clusters_dir()?)?;
let manifests = load_and_serialize::<Manifest>(&dirs::manifests_dir()?)?;
Ok(Self {
clusters,
manifests,
})
}
pub fn get_impl_uuid<'a>(&'a self, uuid: Uuid) -> crate::Result<Box<dyn ClientTrait<'a> + 'a>> {
let cluster = self.get_cluster(uuid)?;
let manifest = self.get_manifest(uuid)?;
Ok(clients::get_impl(&cluster.client, &cluster, &manifest))
}
pub fn get_clusters(&self) -> Vec<&Cluster> {
self.clusters.values().collect()
}
pub fn get_clusters_owned(&self) -> Vec<Cluster> {
self.clusters.values().cloned().collect()
}
pub fn get_cluster(&self, uuid: Uuid) -> crate::Result<&Cluster> {
self.clusters
.get(&uuid)
.ok_or(ClientManagerError::ClusterNotFound.into())
}
pub fn get_cluster_mut(&mut self, uuid: Uuid) -> crate::Result<&mut Cluster> {
self.clusters
.get_mut(&uuid)
.ok_or(ClientManagerError::ClusterNotFound.into())
}
pub fn get_manifest(&self, uuid: Uuid) -> crate::Result<&Manifest> {
self.manifests
.get(&uuid)
.ok_or(ClientManagerError::ManifestNotFound.into())
}
pub fn get_manifest_mut(&mut self, uuid: Uuid) -> crate::Result<&mut Manifest> {
self.manifests
.get_mut(&uuid)
.ok_or(ClientManagerError::ManifestNotFound.into())
}
}
impl ClientManager {
pub async fn launch_cluster(
&mut self,
uuid: Uuid,
) -> crate::Result<()> {
let client = self.get_impl_uuid(uuid)?;
client.setup().await?;
client.launch().await?;
Ok(())
}
pub async fn create_cluster(
&mut self,
name: String,
version: String,
cover: Option<String>,
group: Option<Uuid>,
client: ClientType,
) -> crate::Result<Uuid> {
let version_cache = dirs::app_config_dir()?.join("versions_cache.json");
let versions = clients::get_versions(&client, Some(&version_cache)).await?;
let url = &versions
.iter()
.find(|v| v.id == version)
.ok_or(anyhow!("Version not found"))?
.url;
let uuid = Uuid::new_v4();
// Save the manifest
// TODO: Is this correct?
let minecraft_manifest = vanilla::retrieve_version_manifest(url).await?;
let manifest = Manifest {
id: uuid,
manifest: minecraft_manifest,
};
save(
&dirs::manifests_dir()?.join(format!("{}.json", uuid)),
&manifest,
)?;
self.manifests.insert(uuid, manifest);
// Save the cluster
let cluster = Cluster {
id: uuid,
created_at: Local::now(),
name,
cover,
group,
client,
};
save(
&dirs::clusters_dir()?.join(format!("{}.json", uuid)),
&cluster,
)?;
self.clusters.insert(uuid, cluster);
Ok(uuid)
}
}
fn save<T>(path: &PathBuf, object: T) -> crate::Result<()>
where
T: Serialize,
{
if !path.exists() {
fs::create_dir_all(
path.parent()
.expect("Couldn't get parent of path whilst saving object. 'client.rs'"),
)?;
}
let content = serde_json::to_string(&object)?;
fs::write(path, content)?;
Ok(())
}
fn load_and_serialize<T>(dir: &PathBuf) -> crate::Result<HashMap<Uuid, T>>
where
T: DeserializeOwned,
{
let mut result = HashMap::<Uuid, T>::new();
if !dir.exists() {
fs::create_dir_all(dir)?;
return Ok(result);
}
for file in fs::read_dir(dir)? {
let file = file?;
let file_name = file.file_name();
let file_name = file_name
.to_str()
.ok_or(anyhow!("Couldn't convert OsStr to str"))?
.replace(".json", "");
let uuid = match Uuid::parse_str(&file_name) {
Ok(uuid) => uuid,
Err(_) => {
eprintln!("Couldn't parse file name as UUID: '{:?}'", file_name);
continue;
}
};
let path = file.path();
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => {
eprintln!("Couldn't read file: '{:?}'", path);
continue;
}
};
let parsed = match serde_json::from_str::<T>(&content) {
Ok(parsed) => parsed,
Err(_) => {
eprintln!("Couldn't parse file '{}' content as JSON", path.display());
continue;
}
};
result.insert(uuid, parsed);
}
Ok(result)
}

View file

@ -0,0 +1,27 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use self::vanilla::VanillaClientProps;
use super::client::{ClientTrait, Cluster, Manifest, MinecraftVersion};
pub mod vanilla;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ClientType {
Vanilla(VanillaClientProps),
}
pub async fn get_versions(client: &ClientType, file: Option<&PathBuf>) -> crate::Result<Vec<MinecraftVersion>> {
match client {
ClientType::Vanilla(_) => vanilla::get_versions(file).await,
}
}
pub fn get_impl<'a>(client: &'a ClientType, cluster: &'a Cluster, manifest: &'a Manifest) -> Box<dyn ClientTrait<'a> + 'a> {
Box::new(match client {
ClientType::Vanilla(_) => vanilla::VanillaClient::new(cluster, manifest),
})
}

View file

@ -1,11 +1,9 @@
use super::{
client::{ClientTrait, Cluster, Manifest, Version},
minecraft::MinecraftManifest,
};
use crate::{
constants::MINECRAFT_VERSIONS_MANIFEST,
create_game_client, impl_game_client,
utils::{dirs, file, http},
constants::MINECRAFT_VERSIONS_MANIFEST,
create_game_client,
game::{client::{ClientTrait, Cluster, Manifest, MinecraftVersion}, minecraft::MinecraftManifest},
impl_game_client,
utils::{dirs, http}
};
use anyhow::anyhow;
use async_trait::async_trait;
@ -31,6 +29,8 @@ impl<'a> ClientTrait<'a> for VanillaClient<'a> {
}
async fn setup(&self) -> crate::Result<()> {
let client_path = self.install_game().await?;
println!("{:?}", client_path);
Ok(())
}
@ -42,17 +42,7 @@ impl<'a> ClientTrait<'a> for VanillaClient<'a> {
fs::create_dir_all(&file)?;
let artifact = &manifest.downloads.client;
http::download_file(&artifact.url, &file).await?;
let file_hash = file::file_sha1(&file)?;
println!("Downloaded: '{}' | '{}'", file_hash, artifact.sha1);
if file_hash != artifact.sha1 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Hashes do not match",
)
.into());
}
http::download_file_sha1_check(&artifact.url, &file, &artifact.sha1).await?;
}
Ok(file)
@ -73,16 +63,16 @@ impl<'a> ClientTrait<'a> for VanillaClient<'a> {
#[derive(Clone, Serialize, Deserialize)]
struct VersionList {
versions: Vec<Version>,
versions: Vec<MinecraftVersion>,
}
#[derive(Serialize, Deserialize)]
struct CachedVersions {
last_updated: i64,
versions: Vec<Version>,
versions: Vec<MinecraftVersion>,
}
pub async fn get_versions(file: Option<&PathBuf>) -> crate::Result<Vec<Version>> {
pub async fn get_versions(file: Option<&PathBuf>) -> crate::Result<Vec<MinecraftVersion>> {
if let Some(file) = file {
if file.exists() && file.is_file() {
if let Some(versions) = get_cached_versions(file).await? {
@ -110,7 +100,7 @@ pub async fn get_versions(file: Option<&PathBuf>) -> crate::Result<Vec<Version>>
Ok(response.versions)
}
async fn get_cached_versions(file: &PathBuf) -> crate::Result<Option<Vec<Version>>> {
async fn get_cached_versions(file: &PathBuf) -> crate::Result<Option<Vec<MinecraftVersion>>> {
let cached = serde_json::from_str::<CachedVersions>(&fs::read_to_string(file)?)?;
let head_request = http::create_client()?
.head(MINECRAFT_VERSIONS_MANIFEST)

View file

@ -104,7 +104,7 @@ async fn download(
let latest = response.as_array().unwrap().first().unwrap();
let download_url = latest.get("download_url").unwrap().as_str().unwrap();
if let Err(err) = http::download_file(download_url, archive.as_path()).await {
if let Err(err) = http::download_file(download_url, archive).await {
return Err(ErrorKind::JavaError(JavaDownloadError::DownloadError(err.to_string())).into());
};

View file

@ -30,8 +30,8 @@ pub struct MinecraftManifest {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModernArguments {
game: Vec<ModernArgumentsItem>,
jvm: Vec<ModernArgumentsItem>,
pub game: Vec<ModernArgumentsItem>,
pub jvm: Vec<ModernArgumentsItem>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -46,11 +46,11 @@ pub enum ModernArgumentsItem {
#[serde(rename_all = "camelCase")]
pub struct ArgumentRule {
#[serde(skip_serializing_if = "Vec::is_empty")]
rules: Vec<Rule>,
pub rules: Vec<Rule>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(deserialize_with = "string_or_seq")]
value: Vec<String>,
pub value: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -1,4 +1,5 @@
pub mod client;
pub mod java;
pub mod minecraft;
pub mod vanilla;
pub mod clients;
pub mod client_manager;

View file

@ -1,34 +1,39 @@
use std::{
fs::File,
io::{self, Cursor},
path::Path,
path::PathBuf,
};
use reqwest::{Client, ClientBuilder};
use crate::{constants::USER_AGENT, ErrorKind};
use super::file::file_sha1;
pub fn create_client() -> crate::Result<Client> {
Ok(ClientBuilder::new()
.user_agent(USER_AGENT)
.build()
.map_err(|err| ErrorKind::HTTPError(err))?)
Ok(ClientBuilder::new().user_agent(USER_AGENT).build()?)
}
// can we not make mapping the errors automatic, isnt that the whole point
pub async fn download_file(url: &str, path: &Path) -> crate::Result<()> {
let response = create_client()?
.get(url)
.send()
.await
.map_err(|err| ErrorKind::HTTPError(err))?;
pub async fn download_file_sha1_check(url: &str, path: &PathBuf, sha1: &str) -> crate::Result<()> {
download_file(url, path).await?;
let file_hash = file_sha1(path)?;
if file_hash != sha1 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Hashes do not match",
).into());
}
Ok(())
}
pub async fn download_file(url: &str, path: &PathBuf) -> crate::Result<()> {
let response = create_client()?.get(url).send().await?;
let mut file = File::create(path).map_err(|err| ErrorKind::IOError(err))?;
let mut content = Cursor::new(
response
.bytes()
.await
.map_err(|err| ErrorKind::HTTPError(err))?,
);
let mut content = Cursor::new(response.bytes().await?);
io::copy(&mut content, &mut file).map_err(|err| ErrorKind::IOError(err))?;
Ok(())
}

View file

@ -6,6 +6,7 @@ fn main() {
InlinedPlugin::new().commands(&[
"login_msa",
"refresh_client_manager",
"launch_cluster",
"create_cluster",
"get_clusters",
"get_cluster",

View file

@ -2,6 +2,7 @@
description = "Default permissions for the plugin"
permissions = [
"allow-refresh-client-manager",
"allow-launch-cluster",
"allow-create-cluster",
"allow-get-clusters",
"allow-get-cluster",

View file

@ -1,4 +1,4 @@
use onelauncher::game::client::{ClientType, Cluster, Manifest};
use onelauncher::game::{client::{Cluster, Manifest}, clients::ClientType};
use tauri::State;
use tokio::sync::Mutex;
use uuid::Uuid;
@ -39,6 +39,16 @@ pub async fn get_cluster(
Ok(instance.clone())
}
#[tauri::command]
pub async fn launch_cluster(
state: State<'_, Mutex<GameManagerState>>,
uuid: Uuid,
) -> Result<(), String> {
let manager = &mut state.lock().await.client_manager;
manager.launch_cluster(uuid).await?; // TODO: Change how this works
Ok(())
}
#[tauri::command]
pub async fn get_manifest(
state: State<'_, Mutex<GameManagerState>>,

View file

@ -1,4 +1,4 @@
use onelauncher::game::client::ClientManager;
use onelauncher::game::client_manager::ClientManager;
use tauri::{plugin::TauriPlugin, Manager, State};
use tokio::sync::Mutex;
@ -27,6 +27,7 @@ pub fn init() -> TauriPlugin<tauri::Wry> {
game::get_clusters,
game::get_cluster,
game::get_manifest,
game::launch_cluster,
refresh_client_manager,
])
.build()

View file

@ -19,8 +19,3 @@ export async function getUuidHeadSrc(uuid: string): Promise<string> {
// TODO implement some kind of caching + fallback
return `https://crafatar.com/avatars/${uuid}?size=32`;
}
export default {
loginMicrosoft,
getUuidHeadSrc,
};

View file

@ -1,15 +1,17 @@
import { invoke } from '@tauri-apps/api/core';
export async function getCluster(uuid: string): Promise<Core.Cluster> {
return await invoke<Core.Cluster>('plugin:onelauncher|get_cluster', {
uuid,
});
return await invoke<Core.Cluster>(
'plugin:onelauncher|get_cluster',
{ uuid },
);
}
export async function getManifest(uuid: string): Promise<Core.Manifest> {
return await invoke<Core.Manifest>('plugin:onelauncher|get_manifest', {
uuid,
});
return await invoke<Core.Manifest>(
'plugin:onelauncher|get_manifest',
{ uuid },
);
}
export async function getClustersWithManifests(): Promise<Core.ClusterWithManifest[]> {
@ -58,3 +60,10 @@ export async function createCluster(cluster: Omit<Core.Cluster, 'id' | 'createdA
export async function refreshClientManager(): Promise<void> {
return await invoke('plugin:onelauncher|refresh_client_manager');
}
export async function launchCluster(uuid: string): Promise<number> {
return await invoke<number>(
'plugin:onelauncher|launch_cluster',
{ uuid },
);
}

View file

@ -10,12 +10,12 @@ import type { Accessor, Setter } from 'solid-js';
import { For, Show, createResource, createSignal } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import image from '../../assets/images/header.png';
import defaultCover from '../../assets/images/default_instance_cover.jpg';
import ContextMenu from '../components/overlay/ContextMenu';
import { createCluster, getClustersGrouped, launchCluster } from '../../bridge/game';
import Button from '../components/base/Button';
import Tag from '../components/base/Tag';
import TextField from '../components/base/TextField';
import defaultCover from '../../assets/images/default_instance_cover.jpg';
import ContextMenu from '../components/overlay/ContextMenu';
import { createCluster, getClustersGrouped } from '../../bridge/game';
// TODO: Replace this into it's own component
// possibly with the core ui lirbary with autogenerated and optimized svgs pls
@ -71,6 +71,14 @@ interface ClusterCardContextMenuProps {
function ClusterCardContextMenu(props: ClusterCardContextMenuProps) {
const navigate = useNavigate();
function viewCluster() {
navigate(`/clusters/?id=${props.id}`);
}
function launch() {
launchCluster(props.id);
}
return (
<ContextMenu
pos={props.pos}
@ -80,9 +88,7 @@ function ClusterCardContextMenu(props: ClusterCardContextMenuProps) {
<ContextMenu.Row
icon={<EyeIcon />}
text="View"
onClick={() => {
navigate(`/clusters/?id=${props.id}`);
}}
onClick={() => viewCluster()}
/>
<ContextMenu.Seperator />
@ -90,20 +96,12 @@ function ClusterCardContextMenu(props: ClusterCardContextMenuProps) {
<ContextMenu.Row
icon={<PlayIcon />}
text="Launch"
onClick={() => launch()}
/>
</ContextMenu>
);
}
declare module 'solid-js' {
// eslint-disable-next-line ts/no-namespace
namespace JSX {
interface Directives {
sortable: any;
}
}
}
function ClusterCard(props: Core.ClusterWithManifest) {
const navigate = useNavigate();
const [pos, setPos] = createSignal({ x: 0, y: 0 });
@ -160,6 +158,7 @@ function ClusterCard(props: Core.ClusterWithManifest) {
</Button>
</div>
</div>
<ClusterCardContextMenu
contextMenuVisible={contextMenuVisible}
setContextMenuVisible={setContextMenuVisible}