fix: app cache and other issues (#5460)

* fixes

* #[serde(untagged)] my BEHATED (still kinda broken)

* remove unused hasContent ref

* clean up code in fetch instance

* ping 3 times for average latency

* fix: pinging to be more accurate

TCP_NODELAY — Set on the TCP stream right after connect, preventing Nagle's algorithm from buffering the small ping packet (could save up to ~40ms)

Instant over Utc::now() — Switched to monotonic std::time::Instant for timing, which is more precise and designed for measuring elapsed time (still using chrono just for the ping magic value)

* delete useFetch util and just use native fetch

* rename worlds until functions for more clarity

* fix lint

* fix cache.rs logic

* make backend ping use both impls

* Add optional timeout to server ping

* fix gallery appearing in nav with no items

* remove EU countries and add EU option for server country

* add uk to europe

---------

Co-authored-by: aecsocket <aecsocket@tutanota.com>
This commit is contained in:
Truman Gao
2026-03-03 10:41:12 -08:00
committed by GitHub
parent 211ec20970
commit 0029a22569
17 changed files with 468 additions and 272 deletions

View File

@@ -85,7 +85,6 @@ import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { useFetch } from '@/helpers/fetch.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { list } from '@/helpers/profile.js'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
@@ -303,11 +302,7 @@ async function setupApp() {
}),
)
useFetch(
`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
'criticalAnnouncements',
true,
)
fetch(`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`)
.then((response) => response.json())
.then((res) => {
if (res && res.header && res.body) {
@@ -320,23 +315,21 @@ async function setupApp() {
)
})
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true)
fetch(`https://modrinth.com/news/feed/articles.json`)
.then((response) => response.json())
.then((res) => {
if (res && res.articles) {
// Format expected by NewsArticleCard component.
news.value = res.articles
.map((article) => ({
...article,
path: article.link,
thumbnail: article.thumbnail,
title: article.title,
summary: article.summary,
date: article.date,
}))
.slice(0, 4)
}
})
.catch((error) => {
console.error('Failed to fetch news articles', error)
})
get_opening_command().then(handleCommand)
fetchCredentials()

View File

@@ -33,6 +33,7 @@ async function purgeCache() {
'user',
'team',
'organization',
'file',
'loader_manifest',
'minecraft_manifest',
'categories',
@@ -40,8 +41,10 @@ async function purgeCache() {
'loaders',
'game_versions',
'donation_platforms',
'file_hash',
'file_update',
'search_results',
'search_results_v3',
]).catch(handleError)
}

View File

@@ -1,18 +0,0 @@
import { getVersion } from '@tauri-apps/api/app'
import { fetch } from '@tauri-apps/plugin-http'
export const useFetch = async (url, item, isSilent) => {
try {
const version = await getVersion()
return await fetch(url, {
method: 'GET',
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
})
} catch (err) {
if (!isSilent) {
throw err
} else {
console.error(err)
}
}
}

View File

@@ -231,6 +231,25 @@ export function isLinkedWorld(world: World): boolean {
return world.type === 'server' && !!world.linked_project_id
}
export async function getServerLatency(
address: string,
protocolVersion: ProtocolVersion | null = null,
): Promise<number | undefined> {
const pings: number[] = []
for (let i = 0; i < 3; i++) {
try {
const status = await get_server_status(address, protocolVersion)
if (status.ping != null) {
pings.push(status.ping)
}
} catch {
// Ignore individual ping failures
}
}
if (pings.length === 0) return undefined
return Math.round(pings.reduce((sum, p) => sum + p, 0) / pings.length)
}
export async function refreshServerData(
serverData: ServerData,
protocolVersion: ProtocolVersion | null,

View File

@@ -49,7 +49,7 @@ import {
} from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import { get_server_status } from '@/helpers/worlds'
import { getServerLatency } from '@/helpers/worlds'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { getServerAddress, playServerProject, useInstall } from '@/store/install.js'
@@ -287,17 +287,18 @@ const {
} = useServerSearch({ tags, query, maxResults, currentPage })
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
for (const hit of hits) {
const address = hit.minecraft_java_server?.address
if (!address) continue
get_server_status(address)
.then((status) => {
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
})
.catch((err) => {
const pingsToFetch = hits.filter((hit) => hit.minecraft_java_server?.address)
await Promise.all(
pingsToFetch.map(async (hit) => {
const address = hit.minecraft_java_server!.address!
try {
const latency = await getServerLatency(address)
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
} catch (err) {
console.error(`Failed to ping server ${address}:`, err)
})
}
}
}),
)
}
const previousFilterState = ref('')

View File

@@ -67,7 +67,7 @@
<ServerPing v-if="ping" :ping="ping" />
<div
v-if="modpackContentProjectV3 && (minecraftServer?.country || ping)"
v-if="minecraftServer?.country || ping"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
@@ -312,13 +312,13 @@ import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.v
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3, get_version, get_version_many } from '@/helpers/cache.js'
import { get_project_v3, get_version_many } from '@/helpers/cache.js'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, get_projects, kill, run } from '@/helpers/profile'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils.js'
import { get_server_status } from '@/helpers/worlds'
import { get_server_status, getServerLatency } from '@/helpers/worlds'
import { handleSevereError } from '@/store/error.js'
import { playServerProject } from '@/store/install.js'
import { useBreadcrumbs, useLoading } from '@/store/state'
@@ -348,9 +348,7 @@ const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
const isServerInstance = ref(false)
const hasContent = ref(true)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const modpackContentProjectV3 = ref<Labrinth.Projects.v3.Project | null>(null)
const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
@@ -362,9 +360,7 @@ const ping = ref<number | undefined>(undefined)
async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
modpackContentProjectV3.value = null
modrinthVersions.value = []
hasContent.value = true
ping.value = undefined
instance.value = await get(route.params.id as string).catch(handleError)
@@ -382,48 +378,31 @@ async function fetchInstance() {
(a: Labrinth.Versions.v2.Version, b: Labrinth.Versions.v2.Version) =>
dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
)
if (linkedProjectV3.value?.minecraft_server != null) {
isServerInstance.value = true
}
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (serverAddress) {
get_server_status(serverAddress)
.then((status) => {
if (status.ping != null) {
ping.value = status.ping
playersOnline.value = status.players?.online
}
})
.catch((err) => {
console.error(`Failed to ping server ${serverAddress}:`, err)
})
if (linkedProjectV3.value?.minecraft_server != null) {
isServerInstance.value = true
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (serverAddress) {
try {
const status = await get_server_status(serverAddress)
const latency = await getServerLatency(serverAddress)
ping.value = latency
playersOnline.value = status.players?.online
} catch (err) {
console.error(`Failed to ping server ${serverAddress}:`, err)
}
await fetchModpackContent()
const projects = await get_projects(instance.value!.path).catch(() => ({}))
hasContent.value = Object.keys(projects).length > 0
}
}
} catch (error: Error) {
handleError(error)
} catch (error) {
handleError(error as Error)
}
}
await updatePlayState()
}
async function fetchModpackContent() {
modpackContentProjectV3.value = null
const versionId = instance.value?.linked_data?.version_id
if (!versionId) return
const contentVersion = await get_version(versionId, 'must_revalidate')
const projectId = contentVersion?.project_id
if (projectId) {
modpackContentProjectV3.value = await get_project_v3(projectId, 'must_revalidate')
}
}
async function updatePlayState() {
const runningProcesses = await get_by_profile_path(route.params.id as string).catch(handleError)

View File

@@ -265,7 +265,7 @@ import {
list as listInstances,
} from '@/helpers/profile'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_server_status } from '@/helpers/worlds'
import { getServerLatency } from '@/helpers/worlds'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import {
getServerAddress,
@@ -398,15 +398,11 @@ async function fetchProjectData() {
serverStatusOnline.value = !!projectV3.value?.minecraft_java_server?.ping?.data
if (serverAddress) {
serverPing.value = undefined
get_server_status(serverAddress)
.then((status) => {
if (status.ping != null) {
serverPing.value = status.ping
}
})
.catch((err) => {
console.error(`Failed to ping server ${serverAddress}:`, err)
})
try {
serverPing.value = await getServerLatency(serverAddress)
} catch (error) {
console.error(`Failed to ping server ${serverAddress}:`, error)
}
}
// Fetch server sidebar data (modpack version + project)

View File

@@ -328,7 +328,7 @@ export const installServerProject = async (serverProjectId) => {
})
await edit_icon(profilePath, originalIconPath)
await syncServerAsWorld(profilePath, project.title, serverAddress, serverProjectId)
await syncServerProjectAsWorld(profilePath, project.title, serverAddress, serverProjectId)
}
export const getServerAddress = (javaServer) => {
@@ -337,7 +337,7 @@ export const getServerAddress = (javaServer) => {
return port !== 25565 ? `${address}:${port}` : address
}
const syncServerAsWorld = async (
const syncServerProjectAsWorld = async (
profilePath,
serverName,
serverAddress,
@@ -405,7 +405,7 @@ const findInstalledInstance = async (projectId) => {
return packs.find((pack) => pack.linked_data?.project_id === projectId) ?? null
}
const createVanillaInstance = async (project, gameVersion, serverAddress) => {
const createVanillaServerInstance = async (project, gameVersion, serverAddress) => {
const profilePath = await create(
project.title,
gameVersion,
@@ -420,7 +420,8 @@ const createVanillaInstance = async (project, gameVersion, serverAddress) => {
},
)
await syncServerAsWorld(profilePath, project.title, serverAddress, project.id)
//
await syncServerProjectAsWorld(profilePath, project.title, serverAddress, project.id)
return profilePath
}
@@ -514,6 +515,7 @@ export const playServerProject = async (projectId) => {
if (projectV3?.minecraft_server == null) {
console.warn('playServerProject failed: project is not a server project')
return
}
const content = projectV3?.minecraft_java_server?.content
@@ -529,7 +531,7 @@ export const playServerProject = async (projectId) => {
if (installStore.installingServerProjects.includes(projectId)) return
installStore.startInstallingServer(projectId)
try {
const path = await createVanillaInstance(project, recommendedGameVersion, serverAddress)
const path = await createVanillaServerInstance(project, recommendedGameVersion, serverAddress)
if (path) {
instance = await get(path)
showModpackInstallSuccess(installStore, instance, serverAddress)
@@ -543,8 +545,6 @@ export const playServerProject = async (projectId) => {
installStore.showInstallToPlayModal(projectV3, modpackVersionId, async () => {
const newInstance = await findInstalledInstance(project.id)
if (!newInstance) return
// Ensure the server is in the worlds list after modpack install
await syncServerAsWorld(newInstance.path, project.title, serverAddress, project.id)
showModpackInstallSuccess(installStore, newInstance, serverAddress)
})
return
@@ -552,7 +552,7 @@ export const playServerProject = async (projectId) => {
if (!instance) return
await syncServerAsWorld(instance.path, project.title, serverAddress, project.id)
await syncServerProjectAsWorld(instance.path, project.title, serverAddress, project.id)
// Update existing instance if needed
if (isModpack && instance.linked_data?.version_id !== modpackVersionId) {

View File

@@ -2523,6 +2523,13 @@ const navLinks = computed(() => {
const routeType = route.params.type || project.value.project_type
const projectUrl = `/${routeType}/${project.value.slug ? project.value.slug : project.value.id}`
const galleryCount =
routeType === 'server'
? project.value.gallery.filter((item) => item.name === '__mc_server_banner__').length
: project.value.gallery.length
console.log('galleryCount', galleryCount, !!currentMember.value)
return [
{
label: formatMessage(messages.descriptionTab),
@@ -2531,7 +2538,7 @@ const navLinks = computed(() => {
{
label: formatMessage(messages.galleryTab),
href: `${projectUrl}/gallery`,
shown: project.value.gallery.length > 0 || !!currentMember.value,
shown: galleryCount > 0 || !!currentMember.value,
},
{
label: formatMessage(messages.changelogTab),

View File

@@ -281,35 +281,46 @@ if (projectV3.value) {
const countryOptions = [
{ value: 'US', label: 'United States' },
{ value: 'CA', label: 'Canada' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'DE', label: 'Germany' },
{ value: 'FR', label: 'France' },
{ value: 'NL', label: 'Netherlands' },
{ value: 'FI', label: 'Finland' },
{ value: 'SE', label: 'Sweden' },
{
value: 'EU',
label: 'Europe',
searchTerms: [
'Germany',
'France',
'Netherlands',
'Finland',
'Sweden',
'Denmark',
'Poland',
'Czech Republic',
'Romania',
'Austria',
'Belgium',
'Ireland',
'Spain',
'Italy',
'Portugal',
'Lithuania',
'Latvia',
'Estonia',
'Bulgaria',
'Croatia',
'Hungary',
'Slovakia',
'Greece',
'Luxembourg',
'Malta',
'Cyprus',
'Slovenia',
'Great Britain',
'United Kingdom',
],
},
{ value: 'NO', label: 'Norway' },
{ value: 'DK', label: 'Denmark' },
{ value: 'PL', label: 'Poland' },
{ value: 'CZ', label: 'Czech Republic' },
{ value: 'RO', label: 'Romania' },
{ value: 'CH', label: 'Switzerland' },
{ value: 'AT', label: 'Austria' },
{ value: 'BE', label: 'Belgium' },
{ value: 'IE', label: 'Ireland' },
{ value: 'ES', label: 'Spain' },
{ value: 'IT', label: 'Italy' },
{ value: 'PT', label: 'Portugal' },
{ value: 'RU', label: 'Russia' },
{ value: 'UA', label: 'Ukraine' },
{ value: 'LT', label: 'Lithuania' },
{ value: 'LV', label: 'Latvia' },
{ value: 'EE', label: 'Estonia' },
{ value: 'BG', label: 'Bulgaria' },
{ value: 'HR', label: 'Croatia' },
{ value: 'HU', label: 'Hungary' },
{ value: 'SK', label: 'Slovakia' },
{ value: 'RS', label: 'Serbia' },
{ value: 'GR', label: 'Greece' },
{ value: 'TR', label: 'Turkey' },
{ value: 'IL', label: 'Israel' },
{ value: 'AE', label: 'United Arab Emirates' },

View File

@@ -54,7 +54,7 @@ impl ServerPingQueue {
let mut retries = ENV.SERVER_PING_RETRIES;
let result = loop {
match ping_server(&address, port).await {
match ping_server(&address, port, None).await {
Ok(ping) => {
info!(?ping, "Received successful ping");
break Ok(ping);
@@ -252,39 +252,15 @@ impl ServerPingQueue {
pub async fn ping_server(
address: &str,
port: u16,
timeout: Option<Duration>,
) -> eyre::Result<exp::minecraft::JavaServerPingData> {
let start = Instant::now();
let timeout = Duration::from_millis(ENV.SERVER_PING_TIMEOUT_MS);
let default_duration = Duration::from_millis(ENV.SERVER_PING_TIMEOUT_MS);
let timeout = timeout
.map(|duration| duration.min(default_duration))
.unwrap_or(default_duration);
let task1 = async move {
let conn = async_minecraft_ping::ConnectionConfig::build(address)
.with_port(port)
.connect()
.await
.wrap_err("failed to connect to server")?;
let status = conn
.status()
.await
.wrap_err("failed to get server status")?
.status;
debug!("Successful ping with `async_minecraft_ping`");
eyre::Ok(exp::minecraft::JavaServerPingData {
latency: start.elapsed(),
version_name: status.version.name,
version_protocol: status.version.protocol,
description: match status.description {
ServerDescription::Plain(text)
| ServerDescription::Object { text } => text,
},
players_online: status.players.online,
players_max: status.players.max,
})
};
let task1 = tokio::time::timeout(timeout, task1);
let task2 = async move {
let task_ep = async move {
fn map_component(c: elytra_ping::parse::TextComponent) -> String {
match c {
elytra_ping::parse::TextComponent::Plain(t) => t,
@@ -303,7 +279,6 @@ pub async fn ping_server(
elytra_ping::ping_or_timeout((address.to_string(), port), timeout)
.await?;
debug!("Successful ping with `elytra_ping`");
eyre::Ok(exp::minecraft::JavaServerPingData {
latency,
version_name: result
@@ -326,15 +301,54 @@ pub async fn ping_server(
})
};
async move {
if let Ok(t) = task1
.await
.wrap_err("failed to ping with `async_minecraft_ping`")?
{
return Ok(t);
}
let task_amp = async move {
let task = async move {
let conn = async_minecraft_ping::ConnectionConfig::build(address)
.with_port(port)
.connect()
.await
.wrap_err("failed to connect to server")?;
task2.await.wrap_err("failed to ping with `elytra_ping`")
let status = conn
.status()
.await
.wrap_err("failed to get server status")?
.status;
eyre::Ok(exp::minecraft::JavaServerPingData {
latency: start.elapsed(),
version_name: status.version.name,
version_protocol: status.version.protocol,
description: match status.description {
ServerDescription::Plain(text)
| ServerDescription::Object { text } => text,
},
players_online: status.players.online,
players_max: status.players.max,
})
};
tokio::time::timeout(timeout, task)
.await
.map_err(eyre::Error::new)
.flatten()
};
async move {
let (result_ep, result_amp) = (task_ep.await, task_amp.await);
let result_ep = result_ep
.inspect(|_| debug!("Successful ping with `elytra_ping`"))
.inspect_err(|err| {
debug!("Failed to ping with `elytra_ping`: {err:#}")
});
let result_amp = result_amp
.inspect(|_| debug!("Successful ping with `async_minecraft_ping`"))
.inspect_err(|err| {
debug!("Failed to ping with `async_minecraft_ping`: {err:#}")
});
result_ep.or(result_amp)
}
.await
}
@@ -359,11 +373,20 @@ mod tests {
#[actix_rt::test]
async fn test_ping_server_success() {
let _status = ping_server("mc.hypixel.net", 25565).await.unwrap();
let _status = ping_server("mc.hypixel.net", 25565, None).await.unwrap();
}
#[actix_rt::test]
async fn test_ping_server_invalid_address() {
_ = ping_server("invalid.invalid", 25565).await.unwrap_err();
_ = ping_server("invalid.invalid", 25565, None)
.await
.unwrap_err();
}
#[actix_rt::test]
async fn test_ping_zero_timeout() {
_ = ping_server("hypixel.net", 25565, Some(Duration::ZERO))
.await
.unwrap_err();
}
}

View File

@@ -1,3 +1,5 @@
use std::time::Duration;
use actix_web::{HttpRequest, post, web};
use serde::{Deserialize, Serialize};
@@ -18,6 +20,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
pub struct PingRequest {
pub address: String,
pub port: u16,
pub timeout_ms: Option<u64>,
}
#[utoipa::path]
@@ -38,7 +41,8 @@ pub async fn ping_minecraft_java(
)
.await?;
server_ping::ping_server(&request.address, request.port)
let timeout = request.timeout_ms.map(Duration::from_millis);
server_ping::ping_server(&request.address, request.port, timeout)
.await
.wrap_request_err("failed to ping server")?;