Compare commits

..
Author SHA1 Message Date
Calum H. 475ee69cfb feat: "no servers found" impl 2025-08-06 16:58:36 +01:00
IMB11 ab48d4b144 Merge branch 'main' into cal/server-panel-refactor 2025-08-05 11:26:41 +01:00
Emma AlexiaandProspector 0dee21814d Change "Billing" link on dashboard for admins (#3951)
* Change "Billing" link on dashboard for admins

Requires an archon change before merging

* change order

* steal changes from prospector's old PR

supersedes #3234

Co-authored-by: Prospector <prospectordev@gmail.com>

* lint?

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-08-04 20:13:33 +00:00
Josiah Glosson 0657e4466f Allow direct joining servers on old instances (#4094)
* Implement direct server joining for 1.6.2 through 1.19.4

* Implement direct server joining for versions before 1.6.2

* Ignore methods with a $ in them

* Run intl:extract

* Improve code of MinecraftTransformer

* Support showing last played time for profiles before 1.7

* Reorganize QuickPlayVersion a bit to prepare for singleplayer

* Only inject quick play checking in versions where it's needed

* Optimize agent some and fix error on NeoForge

* Remove some code for quickplay singleplayer support before 1.20, as we can't reasonably support that with an agent

* Invert the default hasServerQuickPlaySupport return value

* Remove Play Anyway button

* Fix "Server couldn't be contacted" on singleplayer worlds

* Fix "Jump back in" section not working
2025-08-04 19:29:20 +00:00
Josiah Glosson 13dbb4c57e Fix most packs showing as "Optimization" on the app homepage (#4119) 2025-08-04 19:21:37 +00:00
IMB11 3480448351 Merge branch 'main' into cal/server-panel-refactor 2025-08-04 17:26:00 +01:00
Prospector 99493b9917 Updated changelog 2025-08-01 21:31:22 -04:00
IMB11andProspector 72a52eb7b1 fix: improve error message for rate limiting (#4101)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-08-01 21:27:25 +00:00
IMB11 b33e12c71d fix: startup settings not visible on hard page refresh/direct load (#4100)
* fix: startup settings not visible on hard page refresh/direct load

* refactor: const func => named
2025-08-01 21:22:22 +00:00
IMB11 82d86839c7 fix: approve status incorrect (#4104) 2025-08-01 20:24:40 +00:00
coolbot 3a20e15340 Coolbot/moderation updates aug1 (#4103)
* oop, all commas!

* Only show slug stuff when needed.

* Move status alerts to top of message, getting rid of separators.

* redist libs message altered, and now shows on plugins too

* Update versions.ts

remove unnecessary import

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>

* Tweak summary formatting msg

* Update license messages to use flink

* reorder link text to match the settings page

* add Description clarity button

---------

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
2025-08-01 20:21:28 +00:00
jade 1c89b84314 fix(moderation): Replace dead modpack link with a valid one in side-types message (#4095) 2025-07-31 17:50:33 +00:00
IMB11 daae37806c fix: update button size for new server action 2025-07-28 19:14:23 +01:00
IMB11 6a4c140420 refactor: remove mobile pull refresh logic 2025-07-28 18:47:53 +01:00
IMB11 5d98b16270 feat: manage page 2025-07-28 18:43:00 +01:00
IMB11 6c452f86b6 feat: hide globe behind flag 2025-07-28 17:30:22 +01:00
50 changed files with 1302 additions and 242 deletions

No files matched your search

+1 -2
View File
@@ -10,11 +10,10 @@
<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>
+1 -1
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/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", "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",
"test": "vue-tsc --noEmit" "test": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -21,14 +21,11 @@ const props = defineProps({
}) })
const featuredCategory = computed(() => { const featuredCategory = computed(() => {
if (props.project.categories.includes('optimization')) { if (props.project.display_categories.includes('optimization')) {
return 'optimization' return 'optimization'
} }
if (props.project.categories.length > 0) { return props.project.display_categories[0] ?? props.project.categories[0]
return props.project.categories[0]
}
return undefined
}) })
const toColor = computed(() => { const toColor = computed(() => {
@@ -6,9 +6,8 @@ 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,
@@ -61,7 +60,8 @@ const props = withDefaults(
playingInstance?: boolean playingInstance?: boolean
playingWorld?: boolean playingWorld?: boolean
startingInstance?: boolean startingInstance?: boolean
supportsQuickPlay?: boolean supportsServerQuickPlay?: boolean
supportsWorldQuickPlay?: boolean
currentProtocol?: ProtocolVersion | null currentProtocol?: ProtocolVersion | null
highlighted?: boolean highlighted?: boolean
@@ -85,7 +85,8 @@ const props = withDefaults(
playingInstance: false, playingInstance: false,
playingWorld: false, playingWorld: false,
startingInstance: false, startingInstance: false,
supportsQuickPlay: false, supportsServerQuickPlay: true,
supportsWorldQuickPlay: false,
currentProtocol: null, currentProtocol: null,
refreshing: false, refreshing: false,
@@ -128,9 +129,13 @@ const messages = defineMessages({
id: 'instance.worlds.a_minecraft_server', id: 'instance.worlds.a_minecraft_server',
defaultMessage: 'A Minecraft Server', defaultMessage: 'A Minecraft Server',
}, },
noQuickPlay: { noServerQuickPlay: {
id: 'instance.worlds.no_quick_play', id: 'instance.worlds.no_server_quick_play',
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+', defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+',
},
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',
@@ -152,10 +157,6 @@ 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',
@@ -330,17 +331,24 @@ const messages = defineMessages({
<ButtonStyled v-else> <ButtonStyled v-else>
<button <button
v-tooltip=" v-tooltip="
!serverStatus world.type == 'server' && !supportsServerQuickPlay
? formatMessage(messages.noContact) ? formatMessage(messages.noServerQuickPlay)
: serverIncompatible : world.type == 'singleplayer' && !supportsWorldQuickPlay
? formatMessage(messages.incompatibleServer) ? formatMessage(messages.noSingleplayerQuickPlay)
: !supportsQuickPlay : playingOtherWorld || locked
? formatMessage(messages.noQuickPlay) ? formatMessage(messages.gameAlreadyOpen)
: playingOtherWorld || locked : !serverStatus
? formatMessage(messages.gameAlreadyOpen) ? formatMessage(messages.noContact)
: null : serverIncompatible
? 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" />
@@ -357,11 +365,6 @@ 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,
@@ -427,10 +430,6 @@ 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) }}
+12 -3
View File
@@ -311,15 +311,24 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
return worlds ?? [] return worlds ?? []
} }
const FIRST_QUICK_PLAY_VERSION = '23w14a' export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return true
}
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) { const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
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 === FIRST_QUICK_PLAY_VERSION) const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
} }
@@ -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_quick_play": { "instance.worlds.no_server_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+" "message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
}, },
"instance.worlds.play_anyway": { "instance.worlds.no_singleplayer_quick_play": {
"message": "Play anyway" "message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
}, },
"instance.worlds.play_instance": { "instance.worlds.play_instance": {
"message": "Play instance" "message": "Play instance"
@@ -67,7 +67,8 @@
: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-quick-play="supportsQuickPlay" :supports-server-quick-play="supportsServerQuickPlay"
: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)"
@@ -150,10 +151,11 @@ import {
refreshWorld, refreshWorld,
sortWorlds, sortWorlds,
refreshServers, refreshServers,
hasQuickPlaySupport, hasWorldQuickPlaySupport,
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'
@@ -355,8 +357,11 @@ 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 supportsQuickPlay = computed(() => const supportsServerQuickPlay = computed(() =>
hasQuickPlaySupport(gameVersions.value, instance.value.game_version), hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const supportsWorldQuickPlay = computed(() =>
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
) )
const filterOptions = computed(() => { const filterOptions = computed(() => {
+1 -1
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)
} }
+7 -3
View File
@@ -4,6 +4,7 @@ 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,
@@ -203,7 +204,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)
} }
@@ -213,8 +214,11 @@ pub async fn start_join_server(
path: &str, path: &str,
address: &str, address: &str,
) -> Result<ProcessMetadata> { ) -> Result<ProcessMetadata> {
let process = let process = profile::run(
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?; path,
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
)
.await?;
Ok(process) Ok(process)
} }
@@ -259,7 +259,7 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="green"> <ButtonStyled color="green">
<button @click="sendMessage('approved')"> <button @click="sendMessage(project.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" /> <CheckIcon aria-hidden="true" />
Approve Approve
</button> </button>
@@ -355,6 +355,7 @@ 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 {
@@ -527,7 +528,7 @@ function handleKeybinds(event: KeyboardEvent) {
tryResetProgress: resetProgress, tryResetProgress: resetProgress,
tryExitModeration: () => emit("exit"), tryExitModeration: () => emit("exit"),
tryApprove: () => sendMessage("approved"), tryApprove: () => sendMessage(props.project.requested_status),
tryReject: () => sendMessage("rejected"), tryReject: () => sendMessage("rejected"),
tryWithhold: () => sendMessage("withheld"), tryWithhold: () => sendMessage("withheld"),
tryEditMessage: goBackToStages, tryEditMessage: goBackToStages,
@@ -1208,7 +1209,7 @@ function generateModpackMessage(allFiles: {
} }
const hasNextProject = ref(false); const hasNextProject = ref(false);
async function sendMessage(status: "approved" | "rejected" | "withheld") { async function sendMessage(status: ProjectStatus) {
try { try {
await useBaseFetch(`project/${props.project.id}`, { await useBaseFetch(`project/${props.project.id}`, {
method: "PATCH", method: "PATCH",
@@ -50,22 +50,27 @@ const container = ref(null);
const showLabels = ref(false); const showLabels = ref(false);
const locations = ref([ const locations = ref([
// Active locations {
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false }, name: "Vint Hill",
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false }, lat: 38.74724876915715,
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false }, lng: -77.67436507922152,
{ name: "Spokane", lat: 47.667309, lng: -117.411922, active: true, clicked: false }, active: true,
{ name: "Dallas", lat: 32.78372, lng: -96.7947, active: true, clicked: false }, clicked: false,
// Future Locations },
// { name: "London", lat: 51.5074, lng: -0.1278, active: false, clicked: false }, {
// { name: "Frankfurt", lat: 50.1109, lng: 8.6821, active: false, clicked: false }, name: "Coventry",
// { name: "Amsterdam", lat: 52.3676, lng: 4.9041, active: false, clicked: false }, lat: 52.39751276904742,
// { name: "Paris", lat: 48.8566, lng: 2.3522, active: false, clicked: false }, lng: -1.5777183894453757,
// { name: "Singapore", lat: 1.3521, lng: 103.8198, active: false, clicked: false }, active: true,
// { name: "Tokyo", lat: 35.6762, lng: 139.6503, active: false, clicked: false }, clicked: false,
// { name: "Sydney", lat: -33.8688, lng: 151.2093, active: false, clicked: false }, },
// { name: "São Paulo", lat: -23.5505, lng: -46.6333, active: false, clicked: false }, {
// { name: "Toronto", lat: 43.6532, lng: -79.3832, active: false, clicked: false }, name: "Limburg",
lat: 50.40863558430334,
lng: 8.062427315007714,
active: true,
clicked: false,
},
]); ]);
const isLocationVisible = (location) => { const isLocationVisible = (location) => {
@@ -2,7 +2,10 @@
<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 v-for="link in navLinks" :key="link.label"> <div
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"
@@ -40,7 +43,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 }[]; navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[];
route: RouteLocationNormalized; route: RouteLocationNormalized;
server: ModrinthServer; server: ModrinthServer;
backupInProgress?: BackupInProgressReason; backupInProgress?: BackupInProgressReason;
@@ -34,6 +34,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showProjectPageDownloadModalServersPromo: false, showProjectPageDownloadModalServersPromo: false,
showProjectPageCreateServersTooltip: true, showProjectPageCreateServersTooltip: true,
showProjectPageQuickServerButton: false, showProjectPageQuickServerButton: false,
showModrinthServersGlobe: false,
// advancedRendering: true, // advancedRendering: true,
// externalLinksNewTab: true, // externalLinksNewTab: true,
// notUsingBlockers: false, // notUsingBlockers: false,
@@ -6,6 +6,7 @@ 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;
@@ -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: "Too Many Requests", 429: "You're making requests too quickly. Please wait a moment and try again.",
500: "Internal Server Error", 500: "Internal Server Error",
502: "Bad Gateway", 502: "Bad Gateway",
503: "Service Unavailable", 503: "Service Unavailable",
@@ -167,11 +167,17 @@ 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] ${message}`, `[Modrinth Servers] ${error.message}`,
statusCode, statusCode,
error, error,
); );
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error); throw new ModrinthServerError(
`[Modrinth Servers] ${message}`,
statusCode,
fetchError,
module,
v1Error,
);
} }
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000; const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;
+94
View File
@@ -515,6 +515,98 @@
</div> </div>
</section> </section>
<section
v-if="flags.showModrinthServersGlobe"
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
>
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="mx-auto flex w-full max-w-7xl flex-col gap-8">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Server Locations
</div>
<h1 class="relative m-0 max-w-2xl text-4xl leading-[120%] md:text-7xl">
Global Coverage
</h1>
</div>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="grid size-8 place-content-center rounded-full bg-highlight-green">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-brand"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
Strategic Locations
</h2>
</div>
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
With servers strategically placed in Vint Hill (USA), Coventry (UK), and Limburg
(Germany), we provide excellent coverage across North America and Europe. Each
location features high-performance hardware and comprehensive DDoS protection.
</p>
</div>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="grid size-8 place-content-center rounded-full bg-highlight-blue">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-blue"
>
<path d="M12 2a10 10 0 1 0 10 10" />
<path d="M18 13a6 6 0 0 0-6-6" />
<path d="M13 2.05a10 10 0 0 1 2 2" />
<path d="M19.5 8.5a10 10 0 0 1 2 2" />
</svg>
</div>
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
Low Latency Connectivity
</h2>
</div>
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
Our three carefully chosen locations ensure optimal ping times and reliable
connections for players across multiple continents. Choose the region closest to
you for the best gaming experience.
</p>
</div>
</div>
</div>
<Globe />
</div>
</div>
</section>
<section <section
id="plan" id="plan"
pyro-hash="plan" pyro-hash="plan"
@@ -651,6 +743,7 @@ import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue"; import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue"; import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
import OptionGroup from "~/components/ui/OptionGroup.vue"; import OptionGroup from "~/components/ui/OptionGroup.vue";
import Globe from "~/components/ui/servers/Globe.vue";
const { locale } = useVIntl(); const { locale } = useVIntl();
@@ -842,6 +935,7 @@ async function fetchPaymentData() {
const selectedProjectId = ref(); const selectedProjectId = ref();
const route = useRoute(); const route = useRoute();
const flags = useFeatureFlags();
const isAtCapacity = computed( const isAtCapacity = computed(
() => isSmallAtCapacity.value && isMediumAtCapacity.value && isLargeAtCapacity.value, () => isSmallAtCapacity.value && isMediumAtCapacity.value && isLargeAtCapacity.value,
); );
@@ -16,12 +16,15 @@ 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;
@@ -32,7 +35,11 @@ useHead({
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`, title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
}); });
const navLinks = [ const ownerId = computed(() => props.server.general?.owner_id ?? "Ghost");
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` },
@@ -48,7 +55,15 @@ const navLinks = [
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>
@@ -42,7 +42,7 @@
</label> </label>
<ButtonStyled> <ButtonStyled>
<button <button
:disabled="invocation === startupSettings?.original_invocation" :disabled="invocation === originalInvocation"
class="!w-full sm:!w-auto" class="!w-full sm:!w-auto"
@click="resetToDefault" @click="resetToDefault"
> >
@@ -120,8 +120,9 @@ 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 = [
@@ -137,33 +138,15 @@ const jdkBuildMap = [
{ value: "graal", label: "GraalVM" }, { value: "graal", label: "GraalVM" },
]; ];
const invocation = ref(""); const invocation = ref(props.server.startup.invocation);
const jdkVersion = ref(""); const jdkVersion = ref(
const jdkBuild = ref(""); jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
const originalInvocation = ref("");
const originalJdkVersion = ref("");
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 jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label);
const originalInvocation = ref(invocation.value);
const originalJdkVersion = ref(jdkVersion.value);
const originalJdkBuild = ref(jdkBuild.value);
const hasUnsavedChanges = computed( const hasUnsavedChanges = computed(
() => () =>
@@ -195,7 +178,7 @@ const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value; return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
}); });
const saveStartup = async () => { async function saveStartup() {
try { try {
isUpdating.value = true; isUpdating.value = true;
const invocationValue = invocation.value ?? ""; const invocationValue = invocation.value ?? "";
@@ -232,17 +215,17 @@ const saveStartup = async () => {
} finally { } finally {
isUpdating.value = false; isUpdating.value = false;
} }
}; }
const resetStartup = () => { function 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;
}; }
const resetToDefault = () => { function resetToDefault() {
invocation.value = startupSettings.value?.original_invocation ?? ""; invocation.value = originalInvocation.value ?? "";
}; }
</script> </script>
<style scoped> <style scoped>
@@ -0,0 +1,11 @@
<template>
<div
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<ServersManagePage />
</div>
</template>
<script lang="ts" setup>
import { ServersManagePage } from "@modrinth/ui";
</script>
+1
View File
@@ -53,6 +53,7 @@ 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");
+14
View File
@@ -1,6 +1,7 @@
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 {
@@ -8,6 +9,9 @@ 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")
} }
@@ -31,7 +35,17 @@ 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") {
@@ -0,0 +1,45 @@
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);
};
}
}
@@ -0,0 +1,12 @@
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"));
}
@@ -0,0 +1,85 @@
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;
});
}
}
@@ -0,0 +1,20 @@
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;
}
}
@@ -0,0 +1,14 @@
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;
}
}
@@ -0,0 +1,99 @@
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;
}
}
+1
View File
@@ -11,6 +11,7 @@ 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;
+4 -3
View File
@@ -23,6 +23,7 @@ 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::{
@@ -40,7 +41,7 @@ pub mod update;
pub enum QuickPlayType { pub enum QuickPlayType {
None, None,
Singleplayer(String), Singleplayer(String),
Server(String), Server(ServerAddress),
} }
/// Remove a profile /// Remove a profile
@@ -630,7 +631,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?;
@@ -646,7 +647,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?;
+166
View File
@@ -0,0 +1,166 @@
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)),
)
}
+5 -96
View File
@@ -1,6 +1,7 @@
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,
@@ -11,7 +12,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::{Error, ErrorKind, Result, State, launcher}; use crate::{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};
@@ -24,11 +25,9 @@ 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;
@@ -433,9 +432,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)
{ {
if world.last_played.is_some() { // We can't check for the profile already having a last_played, in case the user joined
continue; // the target address directly more recently. This is often the case when using
} // 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)
{ {
@@ -917,93 +916,3 @@ 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)),
)
}
+55 -12
View File
@@ -1,5 +1,6 @@
//! Minecraft CLI argument logic //! Minecraft CLI argument logic
use crate::launcher::parse_rules; use crate::launcher::quick_play_version::QuickPlayServerVersion;
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::{
@@ -115,11 +116,13 @@ 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();
@@ -155,13 +158,45 @@ 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);
@@ -225,13 +260,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,
@@ -253,10 +288,7 @@ 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),
@@ -272,10 +304,21 @@ 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)]
@@ -354,9 +397,9 @@ fn parse_minecraft_argument(
) )
.replace( .replace(
"${quickPlayMultiplayer}", "${quickPlayMultiplayer}",
match quick_play_type { &match quick_play_type {
QuickPlayType::Server(address) => address, QuickPlayType::Server(address) => address.to_string(),
_ => "", _ => "".to_string(),
}, },
)) ))
} }
+22 -3
View File
@@ -4,6 +4,9 @@ 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,
@@ -25,6 +28,7 @@ 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
@@ -457,7 +461,7 @@ pub async fn launch_minecraft(
credentials: &Credentials, credentials: &Credentials,
post_exit_hook: Option<String>, post_exit_hook: Option<String>,
profile: &Profile, profile: &Profile,
quick_play_type: &QuickPlayType, mut 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
@@ -589,6 +593,18 @@ 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")?;
@@ -606,11 +622,13 @@ 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()
@@ -646,7 +664,8 @@ 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(),
@@ -0,0 +1,102 @@
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,
}
}
}
+51 -12
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, TimeZone, Utc}; use chrono::{DateTime, NaiveDateTime, 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,6 +493,16 @@ 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();
@@ -540,17 +550,6 @@ 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)
@@ -566,6 +565,46 @@ 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 {
@@ -0,0 +1,56 @@
<svg viewBox="0 0 592 384" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M0 3C0 1.34315 1.34315 0 3 0H93C94.6569 0 96 1.34315 96 3V93C96 94.6569 94.6569 96 93 96H3C1.34315 96 0 94.6569 0 93V3Z"
fill="#F3F4F6" />
<path
d="M120 18C120 16.8954 120.895 16 122 16H590C591.105 16 592 16.8954 592 18V38C592 39.1046 591.105 40 590 40H122C120.895 40 120 39.1046 120 38V18Z"
fill="#F3F4F6" />
<path
d="M120 62C120 60.8954 120.895 60 122 60H142C143.105 60 144 60.8954 144 62V82C144 83.1046 143.105 84 142 84H122C120.895 84 120 83.1046 120 82V62Z"
fill="#F3F4F6" />
<path
d="M160 62C160 60.8954 160.895 60 162 60H298C299.105 60 300 60.8954 300 62V82C300 83.1046 299.105 84 298 84H162C160.895 84 160 83.1046 160 82V62Z"
fill="#F3F4F6" />
<path
d="M324 62C324 60.8954 324.895 60 326 60H346C347.105 60 348 60.8954 348 62V82C348 83.1046 347.105 84 346 84H326C324.895 84 324 83.1046 324 82V62Z"
fill="#F3F4F6" />
<path
d="M364 62C364 60.8954 364.895 60 366 60H466C467.105 60 468 60.8954 468 62V82C468 83.1046 467.105 84 466 84H366C364.895 84 364 83.1046 364 82V62Z"
fill="#F3F4F6" />
<path
d="M0 147C0 145.343 1.34315 144 3 144H93C94.6569 144 96 145.343 96 147V237C96 238.657 94.6569 240 93 240H3C1.34315 240 0 238.657 0 237V147Z"
fill="#F3F4F6" />
<path
d="M120 162C120 160.895 120.895 160 122 160H590C591.105 160 592 160.895 592 162V182C592 183.105 591.105 184 590 184H122C120.895 184 120 183.105 120 182V162Z"
fill="#F3F4F6" />
<path
d="M120 206C120 204.895 120.895 204 122 204H142C143.105 204 144 204.895 144 206V226C144 227.105 143.105 228 142 228H122C120.895 228 120 227.105 120 226V206Z"
fill="#F3F4F6" />
<path
d="M160 206C160 204.895 160.895 204 162 204H298C299.105 204 300 204.895 300 206V226C300 227.105 299.105 228 298 228H162C160.895 228 160 227.105 160 226V206Z"
fill="#F3F4F6" />
<path
d="M324 206C324 204.895 324.895 204 326 204H346C347.105 204 348 204.895 348 206V226C348 227.105 347.105 228 346 228H326C324.895 228 324 227.105 324 226V206Z"
fill="#F3F4F6" />
<path
d="M364 206C364 204.895 364.895 204 366 204H466C467.105 204 468 204.895 468 206V226C468 227.105 467.105 228 466 228H366C364.895 228 364 227.105 364 226V206Z"
fill="#F3F4F6" />
<path
d="M0 291C0 289.343 1.34315 288 3 288H93C94.6569 288 96 289.343 96 291V381C96 382.657 94.6569 384 93 384H3C1.34315 384 0 382.657 0 381V291Z"
fill="#F3F4F6" />
<path
d="M120 306C120 304.895 120.895 304 122 304H590C591.105 304 592 304.895 592 306V326C592 327.105 591.105 328 590 328H122C120.895 328 120 327.105 120 326V306Z"
fill="#F3F4F6" />
<path
d="M120 350C120 348.895 120.895 348 122 348H142C143.105 348 144 348.895 144 350V370C144 371.105 143.105 372 142 372H122C120.895 372 120 371.105 120 370V350Z"
fill="#F3F4F6" />
<path
d="M160 350C160 348.895 160.895 348 162 348H298C299.105 348 300 348.895 300 350V370C300 371.105 299.105 372 298 372H162C160.895 372 160 371.105 160 370V350Z"
fill="#F3F4F6" />
<path
d="M324 350C324 348.895 324.895 348 326 348H346C347.105 348 348 348.895 348 350V370C348 371.105 347.105 372 346 372H326C324.895 372 324 371.105 324 370V350Z"
fill="#F3F4F6" />
<path
d="M364 350C364 348.895 364.895 348 366 348H466C467.105 348 468 348.895 468 350V370C468 371.105 467.105 372 466 372H366C364.895 372 364 371.105 364 370V350Z"
fill="#F3F4F6" />
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+2
View File
@@ -50,6 +50,7 @@ import _CubeIcon from './icons/cube.svg?component'
import _CurrencyIcon from './icons/currency.svg?component' import _CurrencyIcon from './icons/currency.svg?component'
import _DashboardIcon from './icons/dashboard.svg?component' import _DashboardIcon from './icons/dashboard.svg?component'
import _DatabaseIcon from './icons/database.svg?component' import _DatabaseIcon from './icons/database.svg?component'
import _DotIcon from './icons/dot.svg?component'
import _DownloadIcon from './icons/download.svg?component' import _DownloadIcon from './icons/download.svg?component'
import _DropdownIcon from './icons/dropdown.svg?component' import _DropdownIcon from './icons/dropdown.svg?component'
import _EditIcon from './icons/edit.svg?component' import _EditIcon from './icons/edit.svg?component'
@@ -243,6 +244,7 @@ export const CubeIcon = _CubeIcon
export const CurrencyIcon = _CurrencyIcon export const CurrencyIcon = _CurrencyIcon
export const DashboardIcon = _DashboardIcon export const DashboardIcon = _DashboardIcon
export const DatabaseIcon = _DatabaseIcon export const DatabaseIcon = _DatabaseIcon
export const DotIcon = _DotIcon
export const DownloadIcon = _DownloadIcon export const DownloadIcon = _DownloadIcon
export const DropdownIcon = _DropdownIcon export const DropdownIcon = _DropdownIcon
export const EditIcon = _EditIcon export const EditIcon = _EditIcon
+1
View File
@@ -0,0 +1 @@
<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-dot-icon lucide-dot"><circle cx="12.1" cy="12.1" r="1"/></svg>

After

Width:  |  Height:  |  Size: 264 B

+2
View File
@@ -12,6 +12,7 @@ import './omorphia.scss'
import _ModrinthIcon from './branding/logo.svg?component' import _ModrinthIcon from './branding/logo.svg?component'
import _FourOhFourNotFound from './branding/404.svg?component' import _FourOhFourNotFound from './branding/404.svg?component'
import _ModrinthPlusIcon from './branding/modrinth-plus.svg?component' import _ModrinthPlusIcon from './branding/modrinth-plus.svg?component'
import _ServersManageIllustration from './branding/illustrations/servers-background.svg?component'
import _AngryRinthbot from './branding/rinthbot/angry.webp' import _AngryRinthbot from './branding/rinthbot/angry.webp'
import _AnnoyedRinthbot from './branding/rinthbot/annoyed.webp' import _AnnoyedRinthbot from './branding/rinthbot/annoyed.webp'
import _ConfusedRinthbot from './branding/rinthbot/confused.webp' import _ConfusedRinthbot from './branding/rinthbot/confused.webp'
@@ -50,6 +51,7 @@ import _YouTubeIcon from './external/youtube.svg?component'
export const ModrinthIcon = _ModrinthIcon export const ModrinthIcon = _ModrinthIcon
export const FourOhFourNotFound = _FourOhFourNotFound export const FourOhFourNotFound = _FourOhFourNotFound
export const ModrinthPlusIcon = _ModrinthPlusIcon export const ModrinthPlusIcon = _ModrinthPlusIcon
export const ServersManageIllustration = _ServersManageIllustration
export const AngryRinthbot = _AngryRinthbot export const AngryRinthbot = _AngryRinthbot
export const AnnoyedRinthbot = _AnnoyedRinthbot export const AnnoyedRinthbot = _AnnoyedRinthbot
export const ConfusedRinthbot = _ConfusedRinthbot export const ConfusedRinthbot = _ConfusedRinthbot
@@ -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/modpack/fabulously-optimized). - 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).
- 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). - 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).
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.
+1
View File
@@ -1,3 +1,4 @@
export * from './src/components' export * from './src/components'
export * from './src/utils' export * from './src/utils'
export * from './src/composables' export * from './src/composables'
export * from './src/servers'
@@ -0,0 +1,5 @@
<template>
<div class="flex items-center gap-2 w-fit px-3 py-1 bg-button-bg rounded-full text-sm">
<slot />
</div>
</template>
+1
View File
@@ -36,6 +36,7 @@ export { default as ProgressBar } from './base/ProgressBar.vue'
export { default as ProjectCard } from './base/ProjectCard.vue' export { default as ProjectCard } from './base/ProjectCard.vue'
export { default as RadialHeader } from './base/RadialHeader.vue' export { default as RadialHeader } from './base/RadialHeader.vue'
export { default as RadioButtons } from './base/RadioButtons.vue' export { default as RadioButtons } from './base/RadioButtons.vue'
export { default as RaisedBadge } from './base/RaisedBadge.vue'
export { default as ScrollablePanel } from './base/ScrollablePanel.vue' export { default as ScrollablePanel } from './base/ScrollablePanel.vue'
export { default as ServerNotice } from './base/ServerNotice.vue' export { default as ServerNotice } from './base/ServerNotice.vue'
export { default as SimpleBadge } from './base/SimpleBadge.vue' export { default as SimpleBadge } from './base/SimpleBadge.vue'
Whitespace-only changes.
@@ -0,0 +1,141 @@
<template>
<Card>
<div class="server-card-grid">
<div class="header-section flex gap-4 items-center mb-4">
<Avatar size="4rem" />
<div class="flex flex-col gap-2">
<span class="text-xl text-contrast font-bold">{{ server_name }}</span>
<span class="text-md text-secondary" v-tooltip="server_created.toLocaleString()">
Created {{ formatRelativeTime(server_created) }}
</span>
</div>
</div>
<div class="badges-section flex gap-2 items-center mb-4">
<RaisedBadge>{{ server_plan }}</RaisedBadge>
<RaisedBadge class="text-lg" :color="serverStatusColor">
&bull; {{ formattedServerStatus }}
</RaisedBadge>
</div>
<div class="content-section flex flex-col gap-2 mb-4">
<div class="flex flex-row gap-2">
<UsersIcon class="size-4 my-auto" />
<span class="text-secondary">
{{ players_online }} / {{ max_players_online }} players
</span>
</div>
<div class="flex flex-row gap-2">
<GlobeIcon class="size-4 my-auto" />
<span class="text-secondary">{{ world_name }}</span>
</div>
<div class="flex flex-row gap-2">
<LinkIcon class="size-4 my-auto" />
<CopyCode :text="ip" />
</div>
</div>
<div class="actions-section flex gap-2">
<ButtonStyled color="brand">
<RouterLink :to="`/servers/manage/${id}`">
<EditIcon class="size-4" />
Manage
</RouterLink>
</ButtonStyled>
<ButtonStyled>
<RouterLink :to="`/servers/manage/${id}`">
<CurrencyIcon class="size-4" />
Billing
</RouterLink>
</ButtonStyled>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { CurrencyIcon, EditIcon, GlobeIcon, LinkIcon, UsersIcon } from '@modrinth/assets'
import { Avatar, Card, RaisedBadge, useRelativeTime, CopyCode, ButtonStyled } from '@modrinth/ui'
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
const props = defineProps<{
server_name: string
server_created: Date
server_plan: string
server_status: string
players_online: number
max_players_online: number
world_name: string
ip: string
id: string
}>()
const formatRelativeTime = useRelativeTime()
const serverStatusColor = computed(() => {
switch (props.server_status) {
case 'online':
return 'green'
case 'restarting':
return 'orange'
case 'offline':
return undefined
default:
return undefined
}
})
const formattedServerStatus = computed(() => {
return props.server_status.slice(0, 1).toUpperCase() + props.server_status.slice(1)
})
</script>
<style scoped>
.server-card-grid {
display: grid;
grid-template-areas:
'header badges'
'content content'
'actions actions';
grid-template-columns: 1fr auto;
align-items: start;
}
@media (max-width: 768px) {
.server-card-grid {
grid-template-areas:
'header'
'badges'
'content'
'actions';
grid-template-columns: 1fr;
}
.badges-section {
justify-self: start;
}
}
@media (min-width: 769px) {
.badges-section {
justify-self: end;
}
}
.header-section {
grid-area: header;
}
.badges-section {
grid-area: badges;
}
.content-section {
grid-area: content;
}
.actions-section {
grid-area: actions;
}
</style>
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
export { default as ServersManagePage } from './pages/manage.vue'
Whitespace-only changes.
+130
View File
@@ -0,0 +1,130 @@
<template>
<div v-if="servers.length + sharedServers.length === 0" class="text-center py-24 relative">
<ServersManageIllustration class="servers-manage-illustration" />
<ServerIcon class="size-24 mx-auto text-contrast mb-4" />
<h3 class="text-3xl font-medium text-contrast mb-2">No servers found</h3>
<p class="text-gray-500 mb-6 px-4">Get started by creating your first server</p>
<ButtonStyled color="green" size="large" type="outlined">
<button class="flex items-center justify-center gap-2 mx-auto">
<PlusIcon class="size-4" /> New server
</button>
</ButtonStyled>
</div>
<div v-else class="flex flex-col sm:flex-row gap-4 sm:gap-0 my-4">
<div class="flex flex-col gap-2">
<span class="text-3xl text-contrast font-bold">Servers</span>
<span class="text-sm sm:text-base text-secondary">View and manage all your servers</span>
</div>
<div class="sm:ml-auto">
<ButtonStyled color="green" size="large" class="w-full sm:w-auto">
<button class="flex items-center justify-center gap-2">
<PlusIcon class="size-4" />
<span>New server</span>
</button>
</ButtonStyled>
</div>
</div>
<template v-if="servers.length > 0">
<span class="text-xl text-contrast font-bold mb-4 flex flex-row gap-2"> Your servers </span>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 gap-y-2">
<ServerCard
v-for="server in servers"
:id="server.id"
:key="server.id"
:server_name="server.server_name"
:server_created="server.server_created"
:server_plan="server.server_plan"
:server_status="server.server_status"
:players_online="server.players_online"
:max_players_online="server.max_players_online"
:world_name="server.world_name"
:ip="server.ip"
/>
</div>
</template>
<template v-if="sharedServers.length > 0">
<span class="text-xl text-contrast font-bold mb-4 flex flex-row gap-2"> Shared servers </span>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 gap-y-2">
<ServerCard
v-for="server in sharedServers"
:id="server.id"
:key="server.id"
:server_name="server.server_name"
:server_created="server.server_created"
:server_plan="server.server_plan"
:server_status="server.server_status"
:players_online="server.players_online"
:max_players_online="server.max_players_online"
:world_name="server.world_name"
:ip="server.ip"
/>
</div>
</template>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { ButtonStyled } from '@modrinth/ui'
import ServerCard from '../components/management/ServerCard.vue'
import { PlusIcon, ServerIcon, ServersManageIllustration } from '@modrinth/assets'
const sharedServers = ref([
{
id: 'server-1',
server_name: 'Rinth SMP',
server_created: new Date('2023-10-01T12:00:00Z'),
server_plan: 'Large',
server_status: 'online',
players_online: 5,
max_players_online: 20,
world_name: 'Example World',
ip: 'valiant-apple.modrinth.gg',
},
])
// const sharedServers = ref([])
// const servers = ref([])
const servers = ref([
{
id: 'server-1',
server_name: 'Rinth SMP',
server_created: new Date('2023-10-01T12:00:00Z'),
server_plan: 'Large',
server_status: 'online',
players_online: 5,
max_players_online: 20,
world_name: 'Example World',
ip: 'valiant-apple.modrinth.gg',
},
{
id: 'server-1',
server_name: 'Rinth SMP',
server_created: new Date('2023-10-01T12:00:00Z'),
server_plan: 'Large',
server_status: 'online',
players_online: 5,
max_players_online: 20,
world_name: 'Example World',
ip: 'valiant-apple.modrinth.gg',
},
])
</script>
<style scoped lang="scss">
.servers-manage-illustration {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: -1;
width: 500px;
height: 500px;
opacity: 0.05;
mask: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%);
-webkit-mask: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%);
}
</style>
+17
View File
@@ -10,6 +10,23 @@ 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',