Compare commits

..

9 Commits

Author SHA1 Message Date
coolbot100s
909250188b add Description clarity button 2025-08-01 13:15:18 -07:00
coolbot100s
240e8a6a93 reorder link text to match the settings page 2025-07-31 21:31:09 -07:00
coolbot100s
b625ed11b7 Update license messages to use flink 2025-07-31 21:15:53 -07:00
coolbot100s
9bf031d013 Tweak summary formatting msg 2025-07-30 18:28:35 -07:00
coolbot
9ec3d8ea3c Update versions.ts
remove unnecessary import

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
2025-07-30 00:19:40 -07:00
coolbot100s
e3105b3f98 redist libs message altered, and now shows on plugins too 2025-07-29 23:59:42 -07:00
coolbot100s
85e47e60e4 Move status alerts to top of message, getting rid of separators. 2025-07-29 23:06:41 -07:00
coolbot100s
8c00665d94 Only show slug stuff when needed. 2025-07-29 22:15:13 -07:00
coolbot100s
e15347e7c7 oop, all commas! 2025-07-29 21:26:03 -07:00
38 changed files with 245 additions and 878 deletions

3
.idea/code.iml generated
View File

@@ -10,10 +10,11 @@
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" /> <sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

View File

@@ -9,7 +9,7 @@
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit" "test": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {

View File

@@ -21,11 +21,14 @@ const props = defineProps({
}) })
const featuredCategory = computed(() => { const featuredCategory = computed(() => {
if (props.project.display_categories.includes('optimization')) { if (props.project.categories.includes('optimization')) {
return 'optimization' return 'optimization'
} }
return props.project.display_categories[0] ?? props.project.categories[0] if (props.project.categories.length > 0) {
return props.project.categories[0]
}
return undefined
}) })
const toColor = computed(() => { const toColor = computed(() => {

View File

@@ -76,10 +76,10 @@ const installing = ref(false)
const onInstall = ref(() => {}) const onInstall = ref(() => {})
defineExpose({ defineExpose({
show: (instanceVal, projectVal, projectVersions, selected, callback) => { show: (instanceVal, projectVal, projectVersions, callback) => {
instance.value = instanceVal instance.value = instanceVal
versions.value = projectVersions versions.value = projectVersions
selectedVersion.value = selected ?? projectVersions[0] selectedVersion.value = projectVersions[0]
project.value = projectVal project.value = projectVal

View File

@@ -6,8 +6,9 @@ import type {
ServerWorld, ServerWorld,
SingleplayerWorld, SingleplayerWorld,
World, World,
set_world_display_status,
getWorldIdentifier,
} from '@/helpers/worlds.ts' } from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils' import { formatNumber, getPingLevel } from '@modrinth/utils'
import { import {
useRelativeTime, useRelativeTime,
@@ -60,8 +61,7 @@ const props = withDefaults(
playingInstance?: boolean playingInstance?: boolean
playingWorld?: boolean playingWorld?: boolean
startingInstance?: boolean startingInstance?: boolean
supportsServerQuickPlay?: boolean supportsQuickPlay?: boolean
supportsWorldQuickPlay?: boolean
currentProtocol?: ProtocolVersion | null currentProtocol?: ProtocolVersion | null
highlighted?: boolean highlighted?: boolean
@@ -85,8 +85,7 @@ const props = withDefaults(
playingInstance: false, playingInstance: false,
playingWorld: false, playingWorld: false,
startingInstance: false, startingInstance: false,
supportsServerQuickPlay: true, supportsQuickPlay: false,
supportsWorldQuickPlay: false,
currentProtocol: null, currentProtocol: null,
refreshing: false, refreshing: false,
@@ -129,13 +128,9 @@ const messages = defineMessages({
id: 'instance.worlds.a_minecraft_server', id: 'instance.worlds.a_minecraft_server',
defaultMessage: 'A Minecraft Server', defaultMessage: 'A Minecraft Server',
}, },
noServerQuickPlay: { noQuickPlay: {
id: 'instance.worlds.no_server_quick_play', id: 'instance.worlds.no_quick_play',
defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+', defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
},
noSingleplayerQuickPlay: {
id: 'instance.worlds.no_singleplayer_quick_play',
defaultMessage: 'You can only jump straight into singleplayer worlds on Minecraft 1.20+',
}, },
gameAlreadyOpen: { gameAlreadyOpen: {
id: 'instance.worlds.game_already_open', id: 'instance.worlds.game_already_open',
@@ -157,6 +152,10 @@ const messages = defineMessages({
id: 'instance.worlds.view_instance', id: 'instance.worlds.view_instance',
defaultMessage: 'View instance', defaultMessage: 'View instance',
}, },
playAnyway: {
id: 'instance.worlds.play_anyway',
defaultMessage: 'Play anyway',
},
playInstance: { playInstance: {
id: 'instance.worlds.play_instance', id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance', defaultMessage: 'Play instance',
@@ -331,24 +330,17 @@ const messages = defineMessages({
<ButtonStyled v-else> <ButtonStyled v-else>
<button <button
v-tooltip=" v-tooltip="
world.type == 'server' && !supportsServerQuickPlay !serverStatus
? formatMessage(messages.noServerQuickPlay) ? formatMessage(messages.noContact)
: world.type == 'singleplayer' && !supportsWorldQuickPlay : serverIncompatible
? formatMessage(messages.noSingleplayerQuickPlay) ? formatMessage(messages.incompatibleServer)
: playingOtherWorld || locked : !supportsQuickPlay
? formatMessage(messages.gameAlreadyOpen) ? formatMessage(messages.noQuickPlay)
: !serverStatus : playingOtherWorld || locked
? formatMessage(messages.noContact) ? formatMessage(messages.gameAlreadyOpen)
: serverIncompatible : null
? formatMessage(messages.incompatibleServer)
: null
"
:disabled="
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
" "
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')" @click="emit('play')"
> >
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" /> <SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
@@ -365,6 +357,11 @@ const messages = defineMessages({
disabled: playingInstance, disabled: playingInstance,
action: () => emit('play-instance'), action: () => emit('play-instance'),
}, },
{
id: 'play-anyway',
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
action: () => emit('play'),
},
{ {
id: 'open-instance', id: 'open-instance',
shown: !!instancePath, shown: !!instancePath,
@@ -430,6 +427,10 @@ const messages = defineMessages({
<PlayIcon aria-hidden="true" /> <PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }} {{ formatMessage(messages.playInstance) }}
</template> </template>
<template #play-anyway>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }}
</template>
<template #open-instance> <template #open-instance>
<EyeIcon aria-hidden="true" /> <EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }} {{ formatMessage(messages.viewInstance) }}

View File

@@ -311,24 +311,15 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
return worlds ?? [] return worlds ?? []
} }
export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) { const FIRST_QUICK_PLAY_VERSION = '23w14a'
if (!gameVersions.length) {
return true
}
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion) export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
const targetIndex = gameVersions.findIndex((v) => v.version === 'a1.0.5_01')
return versionIndex === -1 || targetIndex === -1 || versionIndex <= targetIndex
}
export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) { if (!gameVersions.length) {
return false return false
} }
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion) const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a') const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
} }

View File

@@ -383,11 +383,11 @@
"instance.worlds.no_contact": { "instance.worlds.no_contact": {
"message": "Server couldn't be contacted" "message": "Server couldn't be contacted"
}, },
"instance.worlds.no_server_quick_play": { "instance.worlds.no_quick_play": {
"message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+" "message": "You can only jump straight into worlds on Minecraft 1.20+"
}, },
"instance.worlds.no_singleplayer_quick_play": { "instance.worlds.play_anyway": {
"message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+" "message": "Play anyway"
}, },
"instance.worlds.play_instance": { "instance.worlds.play_instance": {
"message": "Play instance" "message": "Play instance"

View File

@@ -67,8 +67,7 @@
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`" :key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
:world="world" :world="world"
:highlighted="highlightedWorld === getWorldIdentifier(world)" :highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-server-quick-play="supportsServerQuickPlay" :supports-quick-play="supportsQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
:current-protocol="protocolVersion" :current-protocol="protocolVersion"
:playing-instance="playing" :playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)" :playing-world="worldsMatch(world, worldPlaying)"
@@ -151,11 +150,10 @@ import {
refreshWorld, refreshWorld,
sortWorlds, sortWorlds,
refreshServers, refreshServers,
hasWorldQuickPlaySupport, hasQuickPlaySupport,
refreshWorlds, refreshWorlds,
handleDefaultProfileUpdateEvent, handleDefaultProfileUpdateEvent,
showWorldInFolder, showWorldInFolder,
hasServerQuickPlaySupport,
} from '@/helpers/worlds.ts' } from '@/helpers/worlds.ts'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue' import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue' import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
@@ -357,11 +355,8 @@ function worldsMatch(world: World, other: World | undefined) {
} }
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => [])) const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
const supportsServerQuickPlay = computed(() => const supportsQuickPlay = computed(() =>
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version), hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const supportsWorldQuickPlay = computed(() =>
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
) )
const filterOptions = computed(() => { const filterOptions = computed(() => {

View File

@@ -29,8 +29,8 @@ export const useInstall = defineStore('installStore', {
setIncompatibilityWarningModal(ref) { setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref this.incompatibilityWarningModal = ref
}, },
showIncompatibilityWarningModal(instance, project, versions, selected, onInstall) { showIncompatibilityWarningModal(instance, project, versions, onInstall) {
this.incompatibilityWarningModal.show(instance, project, versions, selected, onInstall) this.incompatibilityWarningModal.show(instance, project, versions, onInstall)
}, },
setModInstallModal(ref) { setModInstallModal(ref) {
this.modInstallModal = ref this.modInstallModal = ref
@@ -133,13 +133,7 @@ export const install = async (
callback(version.id) callback(version.id)
} else { } else {
const install = useInstall() const install = useInstall()
install.showIncompatibilityWarningModal( install.showIncompatibilityWarningModal(instance, project, projectVersions, callback)
instance,
project,
projectVersions,
version,
callback,
)
} }
} else { } else {
const versions = (await get_version_many(project.versions).catch(handleError)).sort( const versions = (await get_version_many(project.versions).catch(handleError)).sort(

View File

@@ -250,7 +250,7 @@ pub async fn profile_get_pack_export_candidates(
// invoke('plugin:profile|profile_run', path) // invoke('plugin:profile|profile_run', path)
#[tauri::command] #[tauri::command]
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> { pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
let process = profile::run(path, QuickPlayType::None).await?; let process = profile::run(path, &QuickPlayType::None).await?;
Ok(process) Ok(process)
} }

View File

@@ -4,7 +4,6 @@ use enumset::EnumSet;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata; use theseus::prelude::ProcessMetadata;
use theseus::profile::{QuickPlayType, get_full_path}; use theseus::profile::{QuickPlayType, get_full_path};
use theseus::server_address::ServerAddress;
use theseus::worlds::{ use theseus::worlds::{
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World, DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
WorldType, WorldWithProfile, WorldType, WorldWithProfile,
@@ -204,7 +203,7 @@ pub async fn start_join_singleplayer_world(
world: String, world: String,
) -> Result<ProcessMetadata> { ) -> Result<ProcessMetadata> {
let process = let process =
profile::run(path, QuickPlayType::Singleplayer(world)).await?; profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
Ok(process) Ok(process)
} }
@@ -214,11 +213,8 @@ pub async fn start_join_server(
path: &str, path: &str,
address: &str, address: &str,
) -> Result<ProcessMetadata> { ) -> Result<ProcessMetadata> {
let process = profile::run( let process =
path, profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
)
.await?;
Ok(process) Ok(process)
} }

View File

@@ -50,35 +50,13 @@
</div> </div>
<div v-else-if="generatedMessage"> <div v-else-if="generatedMessage">
<div> <div>
<ButtonStyled>
<button class="mb-2" @click="useSimpleEditor = !useSimpleEditor">
<template v-if="!useSimpleEditor">
<ToggleLeftIcon aria-hidden="true" />
Use simple mode
</template>
<template v-else>
<ToggleRightIcon aria-hidden="true" />
Use advanced mode
</template>
</button>
</ButtonStyled>
<MarkdownEditor <MarkdownEditor
v-if="!useSimpleEditor"
v-model="message" v-model="message"
:max-height="400" :max-height="400"
placeholder="No message generated." placeholder="No message generated."
:disabled="false" :disabled="false"
:heading-buttons="false" :heading-buttons="false"
/> />
<textarea
v-else
v-model="message"
type="text"
class="bg-bg-input h-[400px] w-full rounded-lg border border-solid border-divider px-3 py-2 font-mono text-base"
placeholder="No message generated."
autocomplete="off"
@input="persistState"
/>
</div> </div>
</div> </div>
<div v-else-if="isModpackPermissionsStage"> <div v-else-if="isModpackPermissionsStage">
@@ -281,7 +259,7 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="green"> <ButtonStyled color="green">
<button @click="sendMessage(project.requested_status ?? 'approved')"> <button @click="sendMessage('approved')">
<CheckIcon aria-hidden="true" /> <CheckIcon aria-hidden="true" />
Approve Approve
</button> </button>
@@ -346,8 +324,6 @@ import {
CheckIcon, CheckIcon,
KeyboardIcon, KeyboardIcon,
EyeOffIcon, EyeOffIcon,
ToggleLeftIcon,
ToggleRightIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { import {
checklist, checklist,
@@ -379,7 +355,6 @@ import {
renderHighlightedString, renderHighlightedString,
type ModerationJudgements, type ModerationJudgements,
type ModerationModpackItem, type ModerationModpackItem,
type ProjectStatus,
} from "@modrinth/utils"; } from "@modrinth/utils";
import { computedAsync, useLocalStorage } from "@vueuse/core"; import { computedAsync, useLocalStorage } from "@vueuse/core";
import { import {
@@ -392,6 +367,7 @@ import {
type Stage, type Stage,
finalPermissionMessages, finalPermissionMessages,
} from "@modrinth/moderation"; } from "@modrinth/moderation";
import * as prettier from "prettier";
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue"; import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
import KeybindsModal from "./ChecklistKeybindsModal.vue"; import KeybindsModal from "./ChecklistKeybindsModal.vue";
import { useModerationStore } from "~/store/moderation.ts"; import { useModerationStore } from "~/store/moderation.ts";
@@ -415,7 +391,6 @@ const isModpackPermissionsStage = computed(() => {
return currentStageObj.value.id === "modpack-permissions"; return currentStageObj.value.id === "modpack-permissions";
}); });
const useSimpleEditor = ref(false);
const message = ref(""); const message = ref("");
const generatedMessage = ref(false); const generatedMessage = ref(false);
const loadingMessage = ref(false); const loadingMessage = ref(false);
@@ -552,7 +527,7 @@ function handleKeybinds(event: KeyboardEvent) {
tryResetProgress: resetProgress, tryResetProgress: resetProgress,
tryExitModeration: () => emit("exit"), tryExitModeration: () => emit("exit"),
tryApprove: () => sendMessage(props.project.requested_status), tryApprove: () => sendMessage("approved"),
tryReject: () => sendMessage("rejected"), tryReject: () => sendMessage("rejected"),
tryWithhold: () => sendMessage("withheld"), tryWithhold: () => sendMessage("withheld"),
tryEditMessage: goBackToStages, tryEditMessage: goBackToStages,
@@ -1142,7 +1117,19 @@ async function generateMessage() {
} }
} }
message.value = fullMessage; try {
const formattedMessage = await prettier.format(fullMessage, {
parser: "markdown",
printWidth: 80,
proseWrap: "always",
tabWidth: 2,
useTabs: false,
});
message.value = formattedMessage;
} catch (formattingError) {
console.warn("Failed to format markdown, using original:", formattingError);
message.value = fullMessage;
}
generatedMessage.value = true; generatedMessage.value = true;
} catch (error) { } catch (error) {
@@ -1221,7 +1208,7 @@ function generateModpackMessage(allFiles: {
} }
const hasNextProject = ref(false); const hasNextProject = ref(false);
async function sendMessage(status: ProjectStatus) { async function sendMessage(status: "approved" | "rejected" | "withheld") {
try { try {
await useBaseFetch(`project/${props.project.id}`, { await useBaseFetch(`project/${props.project.id}`, {
method: "PATCH", method: "PATCH",

View File

@@ -2,10 +2,7 @@
<div class="static w-full grid-cols-1 md:relative md:flex"> <div class="static w-full grid-cols-1 md:relative md:flex">
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4"> <div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]"> <div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
<div <div v-for="link in navLinks" :key="link.label">
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
:key="link.label"
>
<NuxtLink <NuxtLink
:to="link.href" :to="link.href"
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg" class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
@@ -43,7 +40,7 @@ import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const emit = defineEmits(["reinstall"]); const emit = defineEmits(["reinstall"]);
defineProps<{ defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[]; navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
route: RouteLocationNormalized; route: RouteLocationNormalized;
server: ModrinthServer; server: ModrinthServer;
backupInProgress?: BackupInProgressReason; backupInProgress?: BackupInProgressReason;

View File

@@ -6,7 +6,6 @@ import { ServerModule } from "./base.ts";
export class GeneralModule extends ServerModule implements ServerGeneral { export class GeneralModule extends ServerModule implements ServerGeneral {
server_id!: string; server_id!: string;
name!: string; name!: string;
owner_id!: string;
net!: { ip: string; port: number; domain: string }; net!: { ip: string; port: number; domain: string };
game!: string; game!: string;
backup_quota!: number; backup_quota!: number;

View File

@@ -147,7 +147,7 @@ export async function useServersFetch<T>(
404: "Not Found", 404: "Not Found",
405: "Method Not Allowed", 405: "Method Not Allowed",
408: "Request Timeout", 408: "Request Timeout",
429: "You're making requests too quickly. Please wait a moment and try again.", 429: "Too Many Requests",
500: "Internal Server Error", 500: "Internal Server Error",
502: "Bad Gateway", 502: "Bad Gateway",
503: "Service Unavailable", 503: "Service Unavailable",
@@ -167,17 +167,11 @@ export async function useServersFetch<T>(
console.error("Fetch error:", error); console.error("Fetch error:", error);
const fetchError = new ModrinthServersFetchError( const fetchError = new ModrinthServersFetchError(
`[Modrinth Servers] ${error.message}`, `[Modrinth Servers] ${message}`,
statusCode, statusCode,
error, error,
); );
throw new ModrinthServerError( throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
`[Modrinth Servers] ${message}`,
statusCode,
fetchError,
module,
v1Error,
);
} }
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000; const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;

View File

@@ -16,15 +16,12 @@ import {
CardIcon, CardIcon,
UserIcon, UserIcon,
WrenchIcon, WrenchIcon,
ModrinthIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { isAdmin as isUserAdmin, type User } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts"; import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue"; import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const route = useRoute(); const route = useRoute();
const serverId = route.params.id as string; const serverId = route.params.id as string;
const auth = await useAuth();
const props = defineProps<{ const props = defineProps<{
server: ModrinthServer; server: ModrinthServer;
@@ -35,11 +32,7 @@ useHead({
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`, title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
}); });
const ownerId = computed(() => props.server.general?.owner_id ?? "Ghost"); const navLinks = [
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value);
const isAdmin = computed(() => isUserAdmin(auth.value?.user));
const navLinks = computed(() => [
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` }, { icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` }, { icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` }, { icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
@@ -55,15 +48,7 @@ const navLinks = computed(() => [
label: "Billing", label: "Billing",
href: `/settings/billing#server-${serverId}`, href: `/settings/billing#server-${serverId}`,
external: true, external: true,
shown: isOwner.value,
},
{
icon: ModrinthIcon,
label: "Admin Billing",
href: `/admin/billing/${ownerId.value}`,
external: true,
shown: isAdmin.value,
}, },
{ icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` }, { icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` },
]); ];
</script> </script>

View File

@@ -42,7 +42,7 @@
</label> </label>
<ButtonStyled> <ButtonStyled>
<button <button
:disabled="invocation === originalInvocation" :disabled="invocation === startupSettings?.original_invocation"
class="!w-full sm:!w-auto" class="!w-full sm:!w-auto"
@click="resetToDefault" @click="resetToDefault"
> >
@@ -120,9 +120,8 @@ const props = defineProps<{
server: ModrinthServer; server: ModrinthServer;
}>(); }>();
await props.server.startup.fetch();
const data = computed(() => props.server.general); const data = computed(() => props.server.general);
const startupSettings = computed(() => props.server.startup);
const showAllVersions = ref(false); const showAllVersions = ref(false);
const jdkVersionMap = [ const jdkVersionMap = [
@@ -138,15 +137,33 @@ const jdkBuildMap = [
{ value: "graal", label: "GraalVM" }, { value: "graal", label: "GraalVM" },
]; ];
const invocation = ref(props.server.startup.invocation); const invocation = ref("");
const jdkVersion = ref( const jdkVersion = ref("");
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label, const jdkBuild = ref("");
);
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label);
const originalInvocation = ref(invocation.value); const originalInvocation = ref("");
const originalJdkVersion = ref(jdkVersion.value); const originalJdkVersion = ref("");
const originalJdkBuild = ref(jdkBuild.value); const originalJdkBuild = ref("");
watch(
startupSettings,
(newSettings) => {
if (newSettings) {
invocation.value = newSettings.invocation;
originalInvocation.value = newSettings.invocation;
const jdkVersionLabel =
jdkVersionMap.find((v) => v.value === newSettings.jdk_version)?.label || "";
jdkVersion.value = jdkVersionLabel;
originalJdkVersion.value = jdkVersionLabel;
const jdkBuildLabel = jdkBuildMap.find((v) => v.value === newSettings.jdk_build)?.label || "";
jdkBuild.value = jdkBuildLabel;
originalJdkBuild.value = jdkBuildLabel;
}
},
{ immediate: true },
);
const hasUnsavedChanges = computed( const hasUnsavedChanges = computed(
() => () =>
@@ -178,7 +195,7 @@ const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value; return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
}); });
async function saveStartup() { const saveStartup = async () => {
try { try {
isUpdating.value = true; isUpdating.value = true;
const invocationValue = invocation.value ?? ""; const invocationValue = invocation.value ?? "";
@@ -215,17 +232,17 @@ async function saveStartup() {
} finally { } finally {
isUpdating.value = false; isUpdating.value = false;
} }
} };
function resetStartup() { const resetStartup = () => {
invocation.value = originalInvocation.value; invocation.value = originalInvocation.value;
jdkVersion.value = originalJdkVersion.value; jdkVersion.value = originalJdkVersion.value;
jdkBuild.value = originalJdkBuild.value; jdkBuild.value = originalJdkBuild.value;
} };
function resetToDefault() { const resetToDefault = () => {
invocation.value = originalInvocation.value ?? ""; invocation.value = startupSettings.value?.original_invocation ?? "";
} };
</script> </script>
<style scoped> <style scoped>

View File

@@ -53,7 +53,6 @@ fn build_java_jars() {
.arg("build") .arg("build")
.arg("--no-daemon") .arg("--no-daemon")
.arg("--console=rich") .arg("--console=rich")
.arg("--info")
.current_dir(dunce::canonicalize("java").unwrap()) .current_dir(dunce::canonicalize("java").unwrap())
.status() .status()
.expect("Failed to wait on Gradle build"); .expect("Failed to wait on Gradle build");

View File

@@ -1,7 +1,6 @@
plugins { plugins {
java java
id("com.diffplug.spotless") version "7.0.4" id("com.diffplug.spotless") version "7.0.4"
id("com.gradleup.shadow") version "9.0.0-rc2"
} }
repositories { repositories {
@@ -9,9 +8,6 @@ repositories {
} }
dependencies { dependencies {
implementation("org.ow2.asm:asm:9.8")
implementation("org.ow2.asm:asm-tree:9.8")
testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter)
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
} }
@@ -35,17 +31,7 @@ spotless {
} }
tasks.jar { tasks.jar {
enabled = false
}
tasks.shadowJar {
archiveFileName = "theseus.jar" archiveFileName = "theseus.jar"
manifest {
attributes["Premain-Class"] = "com.modrinth.theseus.agent.TheseusAgent"
}
enableRelocation = true
relocationPrefix = "com.modrinth.theseus.shadow"
} }
tasks.named<Test>("test") { tasks.named<Test>("test") {

View File

@@ -1,45 +0,0 @@
package com.modrinth.theseus.agent;
import java.util.ListIterator;
import java.util.function.Predicate;
import org.objectweb.asm.Type;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.FieldInsnNode;
public interface InsnPattern extends Predicate<AbstractInsnNode> {
/**
* Advances past the first match of all instructions in the pattern.
* @return {@code true} if the pattern was found, {@code false} if not
*/
static boolean findAndSkip(ListIterator<AbstractInsnNode> iterator, InsnPattern... pattern) {
if (pattern.length == 0) {
return true;
}
int patternIndex = 0;
while (iterator.hasNext()) {
final AbstractInsnNode insn = iterator.next();
if (insn.getOpcode() == -1) continue;
if (pattern[patternIndex].test(insn) && ++patternIndex == pattern.length) {
return true;
} else {
patternIndex = 0;
}
}
return false;
}
static InsnPattern opcode(int opcode) {
return insn -> insn.getOpcode() == opcode;
}
static InsnPattern field(int opcode, Type fieldType) {
final String typeDescriptor = fieldType.getDescriptor();
return insn -> {
if (insn.getOpcode() != opcode || !(insn instanceof FieldInsnNode)) {
return false;
}
final FieldInsnNode fieldInsn = (FieldInsnNode) insn;
return typeDescriptor.equals(fieldInsn.desc);
};
}
}

View File

@@ -1,12 +0,0 @@
package com.modrinth.theseus.agent;
// Must be kept up-to-date with quick_play_version.rs
public enum QuickPlayServerVersion {
BUILTIN,
BUILTIN_LEGACY,
INJECTED,
UNSUPPORTED;
public static final QuickPlayServerVersion CURRENT =
valueOf(System.getProperty("modrinth.internal.quickPlay.serverVersion"));
}

View File

@@ -1,85 +0,0 @@
package com.modrinth.theseus.agent;
import com.modrinth.theseus.agent.transformers.ClassTransformer;
import com.modrinth.theseus.agent.transformers.MinecraftTransformer;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.instrument.Instrumentation;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
import java.util.Map;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
@SuppressWarnings({"NullableProblems", "CallToPrintStackTrace"})
public final class TheseusAgent {
private static final boolean DEBUG_AGENT = Boolean.getBoolean("modrinth.debugAgent");
public static void premain(String args, Instrumentation instrumentation) {
final Path debugPath = Paths.get("ModrinthDebugTransformed");
if (DEBUG_AGENT) {
System.out.println(
"===== Theseus agent debugging enabled. Dumping transformed classes to " + debugPath + " =====");
if (Files.exists(debugPath)) {
try {
Files.walkFileTree(debugPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
new UncheckedIOException("Failed to delete " + debugPath, e).printStackTrace();
}
}
System.out.println("===== Quick play server version: " + QuickPlayServerVersion.CURRENT + " =====");
}
final Map<String, ClassTransformer> transformers = new HashMap<>();
transformers.put("net/minecraft/client/Minecraft", new MinecraftTransformer());
instrumentation.addTransformer((loader, className, classBeingRedefined, protectionDomain, classData) -> {
final ClassTransformer transformer = transformers.get(className);
if (transformer == null) {
return null;
}
final ClassReader reader = new ClassReader(classData);
final ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS);
try {
if (!transformer.transform(reader, writer)) {
if (DEBUG_AGENT) {
System.out.println("Not writing " + className + " as its transformer returned false");
}
return null;
}
} catch (Throwable t) {
new IllegalStateException("Failed to transform " + className, t).printStackTrace();
return null;
}
final byte[] result = writer.toByteArray();
if (DEBUG_AGENT) {
try {
final Path path = debugPath.resolve(className + ".class");
Files.createDirectories(path.getParent());
Files.write(path, result);
System.out.println("Dumped class to " + path.toAbsolutePath());
} catch (IOException e) {
new UncheckedIOException("Failed to dump class " + className, e).printStackTrace();
}
}
return result;
});
}
}

View File

@@ -1,20 +0,0 @@
package com.modrinth.theseus.agent.transformers;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.tree.ClassNode;
public abstract class ClassNodeTransformer extends ClassTransformer {
protected abstract boolean transform(ClassNode classNode);
@Override
public final boolean transform(ClassReader reader, ClassWriter writer) {
final ClassNode classNode = new ClassNode();
reader.accept(classNode, 0);
if (!transform(classNode)) {
return false;
}
classNode.accept(writer);
return true;
}
}

View File

@@ -1,14 +0,0 @@
package com.modrinth.theseus.agent.transformers;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.ClassNode;
public abstract class ClassTransformer {
public abstract boolean transform(ClassReader reader, ClassWriter writer);
protected static boolean needsStackMap(ClassNode classNode) {
return (classNode.version & 0xffff) >= Opcodes.V1_6;
}
}

View File

@@ -1,99 +0,0 @@
package com.modrinth.theseus.agent.transformers;
import com.modrinth.theseus.agent.InsnPattern;
import com.modrinth.theseus.agent.QuickPlayServerVersion;
import java.util.ListIterator;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FrameNode;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.JumpInsnNode;
import org.objectweb.asm.tree.LabelNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.VarInsnNode;
public final class MinecraftTransformer extends ClassNodeTransformer {
private static final String SET_SERVER_NAME_DESC = "(Ljava/lang/String;I)V";
private static final InsnPattern[] INITIALIZE_THIS_PATTERN = {InsnPattern.opcode(Opcodes.INVOKESPECIAL)};
@Override
protected boolean transform(ClassNode classNode) {
if (QuickPlayServerVersion.CURRENT == QuickPlayServerVersion.INJECTED) {
return addServerJoinSupport(classNode);
}
return false;
}
private static boolean addServerJoinSupport(ClassNode classNode) {
String setServerName = null;
MethodNode constructor = null;
for (final MethodNode method : classNode.methods) {
if (constructor == null && method.name.equals("<init>")) {
constructor = method;
} else if (method.desc.equals(SET_SERVER_NAME_DESC) && method.name.indexOf('$') == -1) {
// Check for $ is because Mixin-injected methods should have $ in it
if (setServerName == null) {
setServerName = method.name;
} else {
// Already found a setServer method, but we found another one? Since we can't
// know which is real, just return so we don't call something we shouldn't.
// Note this can't happen unless some other mod is adding a method with this
// same descriptor.
return false;
}
}
}
if (constructor == null) {
return false;
}
final ListIterator<AbstractInsnNode> it = constructor.instructions.iterator();
if (!InsnPattern.findAndSkip(it, INITIALIZE_THIS_PATTERN)) {
return true;
}
final LabelNode noQuickPlayLabel = new LabelNode();
final LabelNode doneQuickPlayLabel = new LabelNode();
it.add(new LdcInsnNode("modrinth.internal.quickPlay.host"));
// String
it.add(new MethodInsnNode(
Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;"));
// String
it.add(new InsnNode(Opcodes.DUP));
// String String
it.add(new JumpInsnNode(Opcodes.IFNULL, noQuickPlayLabel));
// String
it.add(new VarInsnNode(Opcodes.ALOAD, 0));
// String Minecraft
it.add(new InsnNode(Opcodes.SWAP));
// Minecraft String
it.add(new LdcInsnNode("modrinth.internal.quickPlay.port"));
// Minecraft String String
it.add(new MethodInsnNode(
Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;"));
// Minecraft String String
it.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Integer", "parseInt", "(Ljava/lang/String;)I"));
// Minecraft String int
it.add(new MethodInsnNode(
Opcodes.INVOKEVIRTUAL, "net/minecraft/client/Minecraft", setServerName, SET_SERVER_NAME_DESC));
//
it.add(new JumpInsnNode(Opcodes.GOTO, doneQuickPlayLabel));
it.add(noQuickPlayLabel);
if (needsStackMap(classNode)) {
it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
}
// String
it.add(new InsnNode(Opcodes.POP));
//
it.add(doneQuickPlayLabel);
if (needsStackMap(classNode)) {
it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
}
//
return true;
}
}

View File

@@ -11,7 +11,6 @@ pub mod mr_auth;
pub mod pack; pub mod pack;
pub mod process; pub mod process;
pub mod profile; pub mod profile;
pub mod server_address;
pub mod settings; pub mod settings;
pub mod tags; pub mod tags;
pub mod worlds; pub mod worlds;

View File

@@ -23,7 +23,6 @@ use serde_json::json;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use crate::data::Settings; use crate::data::Settings;
use crate::server_address::ServerAddress;
use dashmap::DashMap; use dashmap::DashMap;
use std::iter::FromIterator; use std::iter::FromIterator;
use std::{ use std::{
@@ -41,7 +40,7 @@ pub mod update;
pub enum QuickPlayType { pub enum QuickPlayType {
None, None,
Singleplayer(String), Singleplayer(String),
Server(ServerAddress), Server(String),
} }
/// Remove a profile /// Remove a profile
@@ -631,7 +630,7 @@ fn pack_get_relative_path(
#[tracing::instrument] #[tracing::instrument]
pub async fn run( pub async fn run(
path: &str, path: &str,
quick_play_type: QuickPlayType, quick_play_type: &QuickPlayType,
) -> crate::Result<ProcessMetadata> { ) -> crate::Result<ProcessMetadata> {
let state = State::get().await?; let state = State::get().await?;
@@ -647,7 +646,7 @@ pub async fn run(
async fn run_credentials( async fn run_credentials(
path: &str, path: &str,
credentials: &Credentials, credentials: &Credentials,
quick_play_type: QuickPlayType, quick_play_type: &QuickPlayType,
) -> crate::Result<ProcessMetadata> { ) -> crate::Result<ProcessMetadata> {
let state = State::get().await?; let state = State::get().await?;
let settings = Settings::get(&state.pool).await?; let settings = Settings::get(&state.pool).await?;

View File

@@ -1,166 +0,0 @@
use crate::{Error, ErrorKind, Result};
use std::fmt::Display;
use std::mem;
use std::net::{Ipv4Addr, Ipv6Addr};
use tokio::sync::Semaphore;
#[derive(Debug, Clone)]
pub enum ServerAddress {
Unresolved(String),
Resolved {
original_host: String,
original_port: u16,
resolved_host: String,
resolved_port: u16,
},
}
impl ServerAddress {
pub async fn resolve(&mut self) -> Result<()> {
match self {
Self::Unresolved(address) => {
let (host, port) = parse_server_address(address)?;
let (resolved_host, resolved_port) =
resolve_server_address(host, port).await?;
*self = Self::Resolved {
original_host: if host.len() == address.len() {
mem::take(address)
} else {
host.to_owned()
},
original_port: port,
resolved_host,
resolved_port,
}
}
Self::Resolved { .. } => {}
}
Ok(())
}
pub fn require_resolved(&self) -> Result<(&str, u16)> {
match self {
Self::Resolved {
resolved_host,
resolved_port,
..
} => Ok((resolved_host, *resolved_port)),
Self::Unresolved(address) => Err(ErrorKind::InputError(format!(
"Unexpected unresolved server address: {address}"
))
.into()),
}
}
}
impl Display for ServerAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unresolved(address) => write!(f, "{address}"),
Self::Resolved {
resolved_host,
resolved_port,
..
} => {
if resolved_host.contains(':') {
write!(f, "[{resolved_host}]:{resolved_port}")
} else {
write!(f, "{resolved_host}:{resolved_port}")
}
}
}
}
}
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
parse_server_address_inner(address)
.map_err(|e| Error::from(ErrorKind::InputError(e)))
}
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
fn parse_server_address_inner(
address: &str,
) -> std::result::Result<(&str, u16), String> {
let (host, port_str) = if address.starts_with("[") {
let colon_index = address.find(':');
let close_bracket_index = address.rfind(']');
if colon_index.is_none() || close_bracket_index.is_none() {
return Err(format!("Invalid bracketed host/port: {address}"));
}
let close_bracket_index = close_bracket_index.unwrap();
let host = &address[1..close_bracket_index];
if close_bracket_index + 1 == address.len() {
(host, "")
} else {
if address.as_bytes().get(close_bracket_index).copied()
!= Some(b':')
{
return Err(format!(
"Only a colon may follow a close bracket: {address}"
));
}
let port_str = &address[close_bracket_index + 2..];
for c in port_str.chars() {
if !c.is_ascii_digit() {
return Err(format!("Port must be numeric: {address}"));
}
}
(host, port_str)
}
} else {
let colon_pos = address.find(':');
if let Some(colon_pos) = colon_pos {
(&address[..colon_pos], &address[colon_pos + 1..])
} else {
(address, "")
}
};
let mut port = None;
if !port_str.is_empty() {
if port_str.starts_with('+') {
return Err(format!("Unparseable port number: {port_str}"));
}
port = port_str.parse::<u16>().ok();
if port.is_none() {
return Err(format!("Unparseable port number: {port_str}"));
}
}
Ok((host, port.unwrap_or(25565)))
}
pub async fn resolve_server_address(
host: &str,
port: u16,
) -> Result<(String, u16)> {
static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24);
if port != 25565
|| host.parse::<Ipv4Addr>().is_ok()
|| host.parse::<Ipv6Addr>().is_ok()
{
return Ok((host.to_owned(), port));
}
let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?;
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
Ok(
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
Err(e)
if e.proto()
.filter(|x| x.kind().is_no_records_found())
.is_some() =>
{
None
}
Err(e) => return Err(e.into()),
Ok(lookup) => lookup
.into_iter()
.next()
.map(|r| (r.target().to_string(), r.port())),
}
.unwrap_or_else(|| (host.to_owned(), port)),
)
}

View File

@@ -1,7 +1,6 @@
use crate::data::ModLoader; use crate::data::ModLoader;
use crate::launcher::get_loader_version_from_profile; use crate::launcher::get_loader_version_from_profile;
use crate::profile::get_full_path; use crate::profile::get_full_path;
use crate::server_address::{parse_server_address, resolve_server_address};
use crate::state::attached_world_data::AttachedWorldData; use crate::state::attached_world_data::AttachedWorldData;
use crate::state::{ use crate::state::{
Profile, ProfileInstallStage, attached_world_data, server_join_log, Profile, ProfileInstallStage, attached_world_data, server_join_log,
@@ -12,7 +11,7 @@ pub use crate::util::server_ping::{
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion, ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
}; };
use crate::util::{io, server_ping}; use crate::util::{io, server_ping};
use crate::{ErrorKind, Result, State, launcher}; use crate::{Error, ErrorKind, Result, State, launcher};
use async_walkdir::WalkDir; use async_walkdir::WalkDir;
use async_zip::{Compression, ZipEntryBuilder}; use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Local, TimeZone, Utc}; use chrono::{DateTime, Local, TimeZone, Utc};
@@ -25,9 +24,11 @@ use regex::{Regex, RegexBuilder};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::cmp::Reverse; use std::cmp::Reverse;
use std::io::Cursor; use std::io::Cursor;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::LazyLock; use std::sync::LazyLock;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::sync::Semaphore;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tokio_util::compat::FuturesAsyncWriteCompatExt; use tokio_util::compat::FuturesAsyncWriteCompatExt;
use url::Url; use url::Url;
@@ -432,9 +433,9 @@ async fn get_server_worlds_in_profile(
let mut futures = JoinSet::new(); let mut futures = JoinSet::new();
for (index, world) in worlds.iter().enumerate().skip(first_server_index) for (index, world) in worlds.iter().enumerate().skip(first_server_index)
{ {
// We can't check for the profile already having a last_played, in case the user joined if world.last_played.is_some() {
// the target address directly more recently. This is often the case when using continue;
// quick-play before 1.20. }
if let WorldDetails::Server { address, .. } = &world.details if let WorldDetails::Server { address, .. } = &world.details
&& let Ok((host, port)) = parse_server_address(address) && let Ok((host, port)) = parse_server_address(address)
{ {
@@ -916,3 +917,93 @@ pub async fn get_server_status(
) )
.await .await
} }
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
parse_server_address_inner(address)
.map_err(|e| Error::from(ErrorKind::InputError(e)))
}
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
fn parse_server_address_inner(
address: &str,
) -> std::result::Result<(&str, u16), String> {
let (host, port_str) = if address.starts_with("[") {
let colon_index = address.find(':');
let close_bracket_index = address.rfind(']');
if colon_index.is_none() || close_bracket_index.is_none() {
return Err(format!("Invalid bracketed host/port: {address}"));
}
let close_bracket_index = close_bracket_index.unwrap();
let host = &address[1..close_bracket_index];
if close_bracket_index + 1 == address.len() {
(host, "")
} else {
if address.as_bytes().get(close_bracket_index).copied()
!= Some(b':')
{
return Err(format!(
"Only a colon may follow a close bracket: {address}"
));
}
let port_str = &address[close_bracket_index + 2..];
for c in port_str.chars() {
if !c.is_ascii_digit() {
return Err(format!("Port must be numeric: {address}"));
}
}
(host, port_str)
}
} else {
let colon_pos = address.find(':');
if let Some(colon_pos) = colon_pos {
(&address[..colon_pos], &address[colon_pos + 1..])
} else {
(address, "")
}
};
let mut port = None;
if !port_str.is_empty() {
if port_str.starts_with('+') {
return Err(format!("Unparseable port number: {port_str}"));
}
port = port_str.parse::<u16>().ok();
if port.is_none() {
return Err(format!("Unparseable port number: {port_str}"));
}
}
Ok((host, port.unwrap_or(25565)))
}
async fn resolve_server_address(
host: &str,
port: u16,
) -> Result<(String, u16)> {
static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24);
if host.parse::<Ipv4Addr>().is_ok() || host.parse::<Ipv6Addr>().is_ok() {
return Ok((host.to_owned(), port));
}
let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?;
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
Ok(
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
Err(e)
if e.proto()
.filter(|x| x.kind().is_no_records_found())
.is_some() =>
{
None
}
Err(e) => return Err(e.into()),
Ok(lookup) => lookup
.into_iter()
.next()
.map(|r| (r.target().to_string(), r.port())),
}
.unwrap_or_else(|| (host.to_owned(), port)),
)
}

View File

@@ -1,6 +1,5 @@
//! Minecraft CLI argument logic //! Minecraft CLI argument logic
use crate::launcher::quick_play_version::QuickPlayServerVersion; use crate::launcher::parse_rules;
use crate::launcher::{QuickPlayVersion, parse_rules};
use crate::profile::QuickPlayType; use crate::profile::QuickPlayType;
use crate::state::Credentials; use crate::state::Credentials;
use crate::{ use crate::{
@@ -116,13 +115,11 @@ pub fn get_jvm_arguments(
libraries_path: &Path, libraries_path: &Path,
log_configs_path: &Path, log_configs_path: &Path,
class_paths: &str, class_paths: &str,
agent_path: &Path,
version_name: &str, version_name: &str,
memory: MemorySettings, memory: MemorySettings,
custom_args: Vec<String>, custom_args: Vec<String>,
java_arch: &str, java_arch: &str,
quick_play_type: &QuickPlayType, quick_play_type: &QuickPlayType,
quick_play_version: QuickPlayVersion,
log_config: Option<&LoggingConfiguration>, log_config: Option<&LoggingConfiguration>,
) -> crate::Result<Vec<String>> { ) -> crate::Result<Vec<String>> {
let mut parsed_arguments = Vec::new(); let mut parsed_arguments = Vec::new();
@@ -158,45 +155,13 @@ pub fn get_jvm_arguments(
parsed_arguments.push("-cp".to_string()); parsed_arguments.push("-cp".to_string());
parsed_arguments.push(class_paths.to_string()); parsed_arguments.push(class_paths.to_string());
} }
parsed_arguments.push(format!("-Xmx{}M", memory.maximum)); parsed_arguments.push(format!("-Xmx{}M", memory.maximum));
if let Some(LoggingConfiguration::Log4j2Xml { argument, file }) = log_config if let Some(LoggingConfiguration::Log4j2Xml { argument, file }) = log_config
{ {
let full_path = log_configs_path.join(&file.id); let full_path = log_configs_path.join(&file.id);
let full_path = full_path.to_string_lossy(); let full_path = full_path.to_string_lossy();
parsed_arguments.push(argument.replace("${path}", &full_path)); parsed_arguments.push(argument.replace("${path}", &full_path));
} }
parsed_arguments.push(format!(
"-javaagent:{}",
canonicalize(agent_path)
.map_err(|_| {
crate::ErrorKind::LauncherError(format!(
"Specified Java Agent path {} does not exist",
libraries_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
));
parsed_arguments.push(format!(
"-Dmodrinth.internal.quickPlay.serverVersion={}",
serde_json::to_value(quick_play_version.server)?
.as_str()
.unwrap()
));
if let QuickPlayType::Server(server) = quick_play_type
&& quick_play_version.server == QuickPlayServerVersion::Injected
{
let (host, port) = server.require_resolved()?;
parsed_arguments.extend_from_slice(&[
format!("-Dmodrinth.internal.quickPlay.host={host}"),
format!("-Dmodrinth.internal.quickPlay.port={port}"),
]);
}
for arg in custom_args { for arg in custom_args {
if !arg.is_empty() { if !arg.is_empty() {
parsed_arguments.push(arg); parsed_arguments.push(arg);
@@ -260,13 +225,13 @@ pub async fn get_minecraft_arguments(
resolution: WindowSize, resolution: WindowSize,
java_arch: &str, java_arch: &str,
quick_play_type: &QuickPlayType, quick_play_type: &QuickPlayType,
quick_play_version: QuickPlayVersion,
) -> crate::Result<Vec<String>> { ) -> crate::Result<Vec<String>> {
let access_token = credentials.access_token.clone(); let access_token = credentials.access_token.clone();
let profile = credentials.maybe_online_profile().await; let profile = credentials.maybe_online_profile().await;
let mut parsed_arguments = Vec::new();
if let Some(arguments) = arguments { if let Some(arguments) = arguments {
let mut parsed_arguments = Vec::new();
parse_arguments( parse_arguments(
arguments, arguments,
&mut parsed_arguments, &mut parsed_arguments,
@@ -288,7 +253,10 @@ pub async fn get_minecraft_arguments(
java_arch, java_arch,
quick_play_type, quick_play_type,
)?; )?;
Ok(parsed_arguments)
} else if let Some(legacy_arguments) = legacy_arguments { } else if let Some(legacy_arguments) = legacy_arguments {
let mut parsed_arguments = Vec::new();
for x in legacy_arguments.split(' ') { for x in legacy_arguments.split(' ') {
parsed_arguments.push(parse_minecraft_argument( parsed_arguments.push(parse_minecraft_argument(
&x.replace(' ', TEMPORARY_REPLACE_CHAR), &x.replace(' ', TEMPORARY_REPLACE_CHAR),
@@ -304,21 +272,10 @@ pub async fn get_minecraft_arguments(
quick_play_type, quick_play_type,
)?); )?);
} }
Ok(parsed_arguments)
} else {
Ok(Vec::new())
} }
if let QuickPlayType::Server(server) = quick_play_type
&& quick_play_version.server == QuickPlayServerVersion::BuiltinLegacy
{
let (host, port) = server.require_resolved()?;
parsed_arguments.extend_from_slice(&[
"--server".to_string(),
host.to_string(),
"--port".to_string(),
port.to_string(),
]);
}
Ok(parsed_arguments)
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -397,9 +354,9 @@ fn parse_minecraft_argument(
) )
.replace( .replace(
"${quickPlayMultiplayer}", "${quickPlayMultiplayer}",
&match quick_play_type { match quick_play_type {
QuickPlayType::Server(address) => address.to_string(), QuickPlayType::Server(address) => address,
_ => "".to_string(), _ => "",
}, },
)) ))
} }

View File

@@ -4,9 +4,6 @@ use crate::event::emit::{emit_loading, init_or_edit_loading};
use crate::event::{LoadingBarId, LoadingBarType}; use crate::event::{LoadingBarId, LoadingBarType};
use crate::launcher::download::download_log_config; use crate::launcher::download::download_log_config;
use crate::launcher::io::IOError; use crate::launcher::io::IOError;
use crate::launcher::quick_play_version::{
QuickPlayServerVersion, QuickPlayVersion,
};
use crate::profile::QuickPlayType; use crate::profile::QuickPlayType;
use crate::state::{ use crate::state::{
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage, Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
@@ -28,7 +25,6 @@ use tokio::process::Command;
mod args; mod args;
pub mod download; pub mod download;
pub mod quick_play_version;
// All nones -> disallowed // All nones -> disallowed
// 1+ true -> allowed // 1+ true -> allowed
@@ -461,7 +457,7 @@ pub async fn launch_minecraft(
credentials: &Credentials, credentials: &Credentials,
post_exit_hook: Option<String>, post_exit_hook: Option<String>,
profile: &Profile, profile: &Profile,
mut quick_play_type: QuickPlayType, quick_play_type: &QuickPlayType,
) -> crate::Result<ProcessMetadata> { ) -> crate::Result<ProcessMetadata> {
if profile.install_stage == ProfileInstallStage::PackInstalling if profile.install_stage == ProfileInstallStage::PackInstalling
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling || profile.install_stage == ProfileInstallStage::MinecraftInstalling
@@ -593,18 +589,6 @@ pub async fn launch_minecraft(
io::create_dir_all(&natives_dir).await?; io::create_dir_all(&natives_dir).await?;
} }
let quick_play_version =
QuickPlayVersion::find_version(version_index, &minecraft.versions);
tracing::debug!(
"Found QuickPlayVersion for {}: {quick_play_version:?}",
profile.game_version
);
if let QuickPlayType::Server(address) = &mut quick_play_type
&& quick_play_version.server >= QuickPlayServerVersion::BuiltinLegacy
{
address.resolve().await?;
}
let (main_class_keep_alive, main_class_path) = let (main_class_keep_alive, main_class_path) =
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?; get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
@@ -622,13 +606,11 @@ pub async fn launch_minecraft(
&java_version.architecture, &java_version.architecture,
minecraft_updated, minecraft_updated,
)?, )?,
&main_class_path,
&version_jar, &version_jar,
*memory, *memory,
Vec::from(java_args), Vec::from(java_args),
&java_version.architecture, &java_version.architecture,
&quick_play_type, quick_play_type,
quick_play_version,
version_info version_info
.logging .logging
.as_ref() .as_ref()
@@ -664,8 +646,7 @@ pub async fn launch_minecraft(
&version.type_, &version.type_,
*resolution, *resolution,
&java_version.architecture, &java_version.architecture,
&quick_play_type, quick_play_type,
quick_play_version,
) )
.await? .await?
.into_iter(), .into_iter(),

View File

@@ -1,102 +0,0 @@
use daedalus::minecraft::Version;
use serde::{Deserialize, Serialize};
// If modified, also update QuickPlayServerVersion.java
#[derive(
Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum QuickPlayServerVersion {
Builtin,
BuiltinLegacy,
Injected,
Unsupported,
}
impl QuickPlayServerVersion {
pub fn min_version(&self) -> Option<&'static str> {
match self {
Self::Builtin => Some("23w14a"),
Self::BuiltinLegacy => Some("13w17a"),
Self::Injected => Some("a1.0.5_01"),
Self::Unsupported => None,
}
}
pub fn older_version(&self) -> Option<Self> {
match self {
Self::Builtin => Some(Self::BuiltinLegacy),
Self::BuiltinLegacy => Some(Self::Injected),
Self::Injected => Some(Self::Unsupported),
Self::Unsupported => None,
}
}
}
// If modified, also update QuickPlaySingleplayerVersion.java
#[derive(
Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum QuickPlaySingleplayerVersion {
Builtin,
Unsupported,
}
impl QuickPlaySingleplayerVersion {
pub fn min_version(&self) -> Option<&'static str> {
match self {
Self::Builtin => Some("23w14a"),
Self::Unsupported => None,
}
}
pub fn older_version(&self) -> Option<Self> {
match self {
Self::Builtin => Some(Self::Unsupported),
Self::Unsupported => None,
}
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub struct QuickPlayVersion {
pub server: QuickPlayServerVersion,
pub singleplayer: QuickPlaySingleplayerVersion,
}
impl QuickPlayVersion {
pub fn find_version(version_index: usize, versions: &[Version]) -> Self {
let mut server = QuickPlayServerVersion::Builtin;
let mut server_version = server.min_version();
let mut singleplayer = QuickPlaySingleplayerVersion::Builtin;
let mut singleplayer_version = singleplayer.min_version();
for version in versions.iter().take(version_index - 1) {
if let Some(check_version) = server_version
&& version.id == check_version
{
// Safety: older_version will always be Some when min_version is Some
server = server.older_version().unwrap();
server_version = server.min_version();
}
if let Some(check_version) = singleplayer_version
&& version.id == check_version
{
singleplayer = singleplayer.older_version().unwrap();
singleplayer_version = singleplayer.min_version();
}
if server_version.is_none() && singleplayer_version.is_none() {
break;
}
}
Self {
server,
singleplayer,
}
}
}

View File

@@ -2,7 +2,7 @@ use crate::event::emit::{emit_process, emit_profile};
use crate::event::{ProcessPayloadType, ProfilePayloadType}; use crate::event::{ProcessPayloadType, ProfilePayloadType};
use crate::profile; use crate::profile;
use crate::util::io::IOError; use crate::util::io::IOError;
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; use chrono::{DateTime, TimeZone, Utc};
use dashmap::DashMap; use dashmap::DashMap;
use quick_xml::Reader; use quick_xml::Reader;
use quick_xml::events::Event; use quick_xml::events::Event;
@@ -493,16 +493,6 @@ impl Process {
if let Err(e) = Self::append_to_log_file(&log_path, &line) { if let Err(e) = Self::append_to_log_file(&log_path, &line) {
tracing::warn!("Failed to write to log file: {}", e); tracing::warn!("Failed to write to log file: {}", e);
} }
if let Err(e) = Self::maybe_handle_old_server_join_logging(
profile_path,
line.trim_ascii_end(),
)
.await
{
tracing::error!(
"Failed to handle old server join logging: {e}"
);
}
} }
line.clear(); line.clear();
@@ -550,6 +540,17 @@ impl Process {
timestamp: &str, timestamp: &str,
message: &str, message: &str,
) -> crate::Result<()> { ) -> crate::Result<()> {
let Some(host_port_string) = message.strip_prefix("Connecting to ")
else {
return Ok(());
};
let Some((host, port_string)) = host_port_string.rsplit_once(", ")
else {
return Ok(());
};
let Some(port) = port_string.parse::<u16>().ok() else {
return Ok(());
};
let timestamp = timestamp let timestamp = timestamp
.parse::<i64>() .parse::<i64>()
.map(|x| x / 1000) .map(|x| x / 1000)
@@ -565,46 +566,6 @@ impl Process {
) )
}) })
})?; })?;
Self::parse_and_insert_server_join(profile_path, message, timestamp)
.await
}
async fn maybe_handle_old_server_join_logging(
profile_path: &str,
line: &str,
) -> crate::Result<()> {
if let Some((timestamp, message)) = line.split_once(" [CLIENT] [INFO] ")
{
let timestamp =
NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S")?
.and_local_timezone(chrono::Local)
.map(|x| x.to_utc())
.single()
.unwrap_or_else(Utc::now);
Self::parse_and_insert_server_join(profile_path, message, timestamp)
.await
} else {
Self::parse_and_insert_server_join(profile_path, line, Utc::now())
.await
}
}
async fn parse_and_insert_server_join(
profile_path: &str,
message: &str,
timestamp: DateTime<Utc>,
) -> crate::Result<()> {
let Some(host_port_string) = message.strip_prefix("Connecting to ")
else {
return Ok(());
};
let Some((host, port_string)) = host_port_string.rsplit_once(", ")
else {
return Ok(());
};
let Some(port) = port_string.parse::<u16>().ok() else {
return Ok(());
};
let state = crate::State::get().await?; let state = crate::State::get().await?;
crate::state::server_join_log::JoinLogEntry { crate::state::server_join_log::JoinLogEntry {

View File

@@ -169,8 +169,6 @@ import _TerminalSquareIcon from './icons/terminal-square.svg?component'
import _TestIcon from './icons/test.svg?component' import _TestIcon from './icons/test.svg?component'
import _TextQuoteIcon from './icons/text-quote.svg?component' import _TextQuoteIcon from './icons/text-quote.svg?component'
import _TimerIcon from './icons/timer.svg?component' import _TimerIcon from './icons/timer.svg?component'
import _ToggleLeftIcon from './icons/toggle-left.svg?component'
import _ToggleRightIcon from './icons/toggle-right.svg?component'
import _TransferIcon from './icons/transfer.svg?component' import _TransferIcon from './icons/transfer.svg?component'
import _TrashIcon from './icons/trash.svg?component' import _TrashIcon from './icons/trash.svg?component'
import _TriangleAlertIcon from './icons/triangle-alert.svg?component' import _TriangleAlertIcon from './icons/triangle-alert.svg?component'
@@ -364,8 +362,6 @@ export const TerminalSquareIcon = _TerminalSquareIcon
export const TestIcon = _TestIcon export const TestIcon = _TestIcon
export const TextQuoteIcon = _TextQuoteIcon export const TextQuoteIcon = _TextQuoteIcon
export const TimerIcon = _TimerIcon export const TimerIcon = _TimerIcon
export const ToggleLeftIcon = _ToggleLeftIcon
export const ToggleRightIcon = _ToggleRightIcon
export const TransferIcon = _TransferIcon export const TransferIcon = _TransferIcon
export const TrashIcon = _TrashIcon export const TrashIcon = _TrashIcon
export const TriangleAlertIcon = _TriangleAlertIcon export const TriangleAlertIcon = _TriangleAlertIcon

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-left-icon lucide-toggle-left"><circle cx="9" cy="12" r="3"/><rect width="20" height="14" x="2" y="5" rx="7"/></svg>

Before

Width:  |  Height:  |  Size: 324 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right-icon lucide-toggle-right"><circle cx="15" cy="12" r="3"/><rect width="20" height="14" x="2" y="5" rx="7"/></svg>

Before

Width:  |  Height:  |  Size: 327 B

View File

@@ -4,7 +4,7 @@ Per section 5.1 of %RULES%, it is important that the metadata of your projects i
For a brief rundown of how this works: For a brief rundown of how this works:
- Some modpacks can be client-side, usually aimed at providing utility and optimization while allowing the player to join an unmodded server, for instance, [Fabulously Optimized](https://modrinth.com/project/1KVo5zza). - Some modpacks can be client-side, usually aimed at providing utility and optimization while allowing the player to join an unmodded server, for instance, [Fabulously Optimized](https://modrinth.com/modpack/fabulously-optimized).
- Most other modpacks that change how the game is played are going to be required on both the client and server, like the modpack [Aged](https://modrinth.com/project/i4XHCd7Q). - Most other modpacks that change how the game is played are going to be required on both the client and server, like the modpack [Dying Light](https://modrinth.com/modpack/dying-light).
When in doubt, test for yourself or check the requirements of the mods in your pack. When in doubt, test for yourself or check the requirements of the mods in your pack.

View File

@@ -10,23 +10,6 @@ export type VersionEntry = {
} }
const VERSIONS: VersionEntry[] = [ const VERSIONS: VersionEntry[] = [
{
date: `2025-08-01T21:30:00-04:00`,
product: 'web',
body: `### Improvements
- Fixed issues with the newsletter subscription checkbox & buttons on news pages. ([#4072](https://github.com/modrinth/code/pull/4072), [#4073](https://github.com/modrinth/code/pull/4073))
- You can now access the "Moderation" tab on project pages again even if your project is approved. ([#4067](https://github.com/modrinth/code/pull/4067))
- Fixed issues with collection visibility. ([#4070](https://github.com/modrinth/code/pull/4070))
- Fixed text issue on collection icon upload dropdown. ([#4069](https://github.com/modrinth/code/pull/4069))`,
},
{
date: `2025-08-01T21:30:00-04:00`,
product: 'servers',
body: `### Improvements
- Server status information is now correctly displayed in the 'My Servers' page. ([#4071](https://github.com/modrinth/code/pull/4071))
- Fixed an error with displaying startup settings.
- Improved ratelimit error message.`,
},
{ {
date: `2025-07-19T15:20:00-07:00`, date: `2025-07-19T15:20:00-07:00`,
product: 'web', product: 'web',