Compare commits
10 Commits
prospector
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d22c9e24f4 | ||
|
|
e31197f649 | ||
|
|
0dee21814d | ||
|
|
0657e4466f | ||
|
|
13dbb4c57e | ||
|
|
99493b9917 | ||
|
|
72a52eb7b1 | ||
|
|
b33e12c71d | ||
|
|
82d86839c7 | ||
|
|
3a20e15340 |
3
.idea/code.iml
generated
3
.idea/code.iml
generated
@ -10,11 +10,10 @@
|
||||
<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$/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" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
</module>
|
||||
@ -9,7 +9,7 @@
|
||||
"tsc:check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -21,14 +21,11 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const featuredCategory = computed(() => {
|
||||
if (props.project.categories.includes('optimization')) {
|
||||
if (props.project.display_categories.includes('optimization')) {
|
||||
return 'optimization'
|
||||
}
|
||||
|
||||
if (props.project.categories.length > 0) {
|
||||
return props.project.categories[0]
|
||||
}
|
||||
return undefined
|
||||
return props.project.display_categories[0] ?? props.project.categories[0]
|
||||
})
|
||||
|
||||
const toColor = computed(() => {
|
||||
|
||||
@ -76,10 +76,10 @@ const installing = ref(false)
|
||||
const onInstall = ref(() => {})
|
||||
|
||||
defineExpose({
|
||||
show: (instanceVal, projectVal, projectVersions, callback) => {
|
||||
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
|
||||
instance.value = instanceVal
|
||||
versions.value = projectVersions
|
||||
selectedVersion.value = projectVersions[0]
|
||||
selectedVersion.value = selected ?? projectVersions[0]
|
||||
|
||||
project.value = projectVal
|
||||
|
||||
|
||||
@ -6,9 +6,8 @@ import type {
|
||||
ServerWorld,
|
||||
SingleplayerWorld,
|
||||
World,
|
||||
set_world_display_status,
|
||||
getWorldIdentifier,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
|
||||
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
||||
import {
|
||||
useRelativeTime,
|
||||
@ -61,7 +60,8 @@ const props = withDefaults(
|
||||
playingInstance?: boolean
|
||||
playingWorld?: boolean
|
||||
startingInstance?: boolean
|
||||
supportsQuickPlay?: boolean
|
||||
supportsServerQuickPlay?: boolean
|
||||
supportsWorldQuickPlay?: boolean
|
||||
currentProtocol?: ProtocolVersion | null
|
||||
highlighted?: boolean
|
||||
|
||||
@ -85,7 +85,8 @@ const props = withDefaults(
|
||||
playingInstance: false,
|
||||
playingWorld: false,
|
||||
startingInstance: false,
|
||||
supportsQuickPlay: false,
|
||||
supportsServerQuickPlay: true,
|
||||
supportsWorldQuickPlay: false,
|
||||
currentProtocol: null,
|
||||
|
||||
refreshing: false,
|
||||
@ -128,9 +129,13 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.a_minecraft_server',
|
||||
defaultMessage: 'A Minecraft Server',
|
||||
},
|
||||
noQuickPlay: {
|
||||
id: 'instance.worlds.no_quick_play',
|
||||
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
|
||||
noServerQuickPlay: {
|
||||
id: 'instance.worlds.no_server_quick_play',
|
||||
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: {
|
||||
id: 'instance.worlds.game_already_open',
|
||||
@ -152,10 +157,6 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.view_instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
playAnyway: {
|
||||
id: 'instance.worlds.play_anyway',
|
||||
defaultMessage: 'Play anyway',
|
||||
},
|
||||
playInstance: {
|
||||
id: 'instance.worlds.play_instance',
|
||||
defaultMessage: 'Play instance',
|
||||
@ -330,17 +331,24 @@ const messages = defineMessages({
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
!serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: !supportsQuickPlay
|
||||
? formatMessage(messages.noQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: null
|
||||
world.type == 'server' && !supportsServerQuickPlay
|
||||
? formatMessage(messages.noServerQuickPlay)
|
||||
: world.type == 'singleplayer' && !supportsWorldQuickPlay
|
||||
? formatMessage(messages.noSingleplayerQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: !serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: null
|
||||
"
|
||||
:disabled="
|
||||
playingOtherWorld ||
|
||||
startingInstance ||
|
||||
(world.type == 'server' && !supportsServerQuickPlay) ||
|
||||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
|
||||
"
|
||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
@ -357,11 +365,6 @@ const messages = defineMessages({
|
||||
disabled: playingInstance,
|
||||
action: () => emit('play-instance'),
|
||||
},
|
||||
{
|
||||
id: 'play-anyway',
|
||||
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
|
||||
action: () => emit('play'),
|
||||
},
|
||||
{
|
||||
id: 'open-instance',
|
||||
shown: !!instancePath,
|
||||
@ -427,10 +430,6 @@ const messages = defineMessages({
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playInstance) }}
|
||||
</template>
|
||||
<template #play-anyway>
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playAnyway) }}
|
||||
</template>
|
||||
<template #open-instance>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.viewInstance) }}
|
||||
|
||||
@ -311,15 +311,24 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
|
||||
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) {
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -383,11 +383,11 @@
|
||||
"instance.worlds.no_contact": {
|
||||
"message": "Server couldn't be contacted"
|
||||
},
|
||||
"instance.worlds.no_quick_play": {
|
||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||
"instance.worlds.no_server_quick_play": {
|
||||
"message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
|
||||
},
|
||||
"instance.worlds.play_anyway": {
|
||||
"message": "Play anyway"
|
||||
"instance.worlds.no_singleplayer_quick_play": {
|
||||
"message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
|
||||
},
|
||||
"instance.worlds.play_instance": {
|
||||
"message": "Play instance"
|
||||
|
||||
@ -67,7 +67,8 @@
|
||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||
:world="world"
|
||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||
:supports-quick-play="supportsQuickPlay"
|
||||
:supports-server-quick-play="supportsServerQuickPlay"
|
||||
:supports-world-quick-play="supportsWorldQuickPlay"
|
||||
:current-protocol="protocolVersion"
|
||||
:playing-instance="playing"
|
||||
:playing-world="worldsMatch(world, worldPlaying)"
|
||||
@ -150,10 +151,11 @@ import {
|
||||
refreshWorld,
|
||||
sortWorlds,
|
||||
refreshServers,
|
||||
hasQuickPlaySupport,
|
||||
hasWorldQuickPlaySupport,
|
||||
refreshWorlds,
|
||||
handleDefaultProfileUpdateEvent,
|
||||
showWorldInFolder,
|
||||
hasServerQuickPlaySupport,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.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 supportsQuickPlay = computed(() =>
|
||||
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
const supportsServerQuickPlay = computed(() =>
|
||||
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
const supportsWorldQuickPlay = computed(() =>
|
||||
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
|
||||
@ -29,8 +29,8 @@ export const useInstall = defineStore('installStore', {
|
||||
setIncompatibilityWarningModal(ref) {
|
||||
this.incompatibilityWarningModal = ref
|
||||
},
|
||||
showIncompatibilityWarningModal(instance, project, versions, onInstall) {
|
||||
this.incompatibilityWarningModal.show(instance, project, versions, onInstall)
|
||||
showIncompatibilityWarningModal(instance, project, versions, selected, onInstall) {
|
||||
this.incompatibilityWarningModal.show(instance, project, versions, selected, onInstall)
|
||||
},
|
||||
setModInstallModal(ref) {
|
||||
this.modInstallModal = ref
|
||||
@ -133,7 +133,13 @@ export const install = async (
|
||||
callback(version.id)
|
||||
} else {
|
||||
const install = useInstall()
|
||||
install.showIncompatibilityWarningModal(instance, project, projectVersions, callback)
|
||||
install.showIncompatibilityWarningModal(
|
||||
instance,
|
||||
project,
|
||||
projectVersions,
|
||||
version,
|
||||
callback,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const versions = (await get_version_many(project.versions).catch(handleError)).sort(
|
||||
|
||||
@ -250,7 +250,7 @@ pub async fn profile_get_pack_export_candidates(
|
||||
// invoke('plugin:profile|profile_run', path)
|
||||
#[tauri::command]
|
||||
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)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ use enumset::EnumSet;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use theseus::prelude::ProcessMetadata;
|
||||
use theseus::profile::{QuickPlayType, get_full_path};
|
||||
use theseus::server_address::ServerAddress;
|
||||
use theseus::worlds::{
|
||||
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
|
||||
WorldType, WorldWithProfile,
|
||||
@ -203,7 +204,7 @@ pub async fn start_join_singleplayer_world(
|
||||
world: String,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let process =
|
||||
profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
|
||||
profile::run(path, QuickPlayType::Singleplayer(world)).await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
@ -213,8 +214,11 @@ pub async fn start_join_server(
|
||||
path: &str,
|
||||
address: &str,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let process =
|
||||
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
|
||||
let process = profile::run(
|
||||
path,
|
||||
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
@ -143,8 +143,13 @@ export default defineNuxtConfig({
|
||||
state.lastGenerated &&
|
||||
new Date(state.lastGenerated).getTime() + TTL > new Date().getTime() &&
|
||||
// ...but only if the API URL is the same
|
||||
state.apiUrl === API_URL
|
||||
state.apiUrl === API_URL &&
|
||||
// ...and if no errors were caught during the last generation
|
||||
(state.errors ?? []).length === 0
|
||||
) {
|
||||
console.log(
|
||||
"Tags already recently generated. Delete apps/frontend/generated/state.json to force regeneration.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -259,7 +259,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button @click="sendMessage('approved')">
|
||||
<button @click="sendMessage(project.requested_status ?? 'approved')">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Approve
|
||||
</button>
|
||||
@ -355,6 +355,7 @@ import {
|
||||
renderHighlightedString,
|
||||
type ModerationJudgements,
|
||||
type ModerationModpackItem,
|
||||
type ProjectStatus,
|
||||
} from "@modrinth/utils";
|
||||
import { computedAsync, useLocalStorage } from "@vueuse/core";
|
||||
import {
|
||||
@ -527,7 +528,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
||||
tryResetProgress: resetProgress,
|
||||
tryExitModeration: () => emit("exit"),
|
||||
|
||||
tryApprove: () => sendMessage("approved"),
|
||||
tryApprove: () => sendMessage(props.project.requested_status),
|
||||
tryReject: () => sendMessage("rejected"),
|
||||
tryWithhold: () => sendMessage("withheld"),
|
||||
tryEditMessage: goBackToStages,
|
||||
@ -1208,7 +1209,7 @@ function generateModpackMessage(allFiles: {
|
||||
}
|
||||
|
||||
const hasNextProject = ref(false);
|
||||
async function sendMessage(status: "approved" | "rejected" | "withheld") {
|
||||
async function sendMessage(status: ProjectStatus) {
|
||||
try {
|
||||
await useBaseFetch(`project/${props.project.id}`, {
|
||||
method: "PATCH",
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
<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="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
|
||||
:to="link.href"
|
||||
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"]);
|
||||
|
||||
defineProps<{
|
||||
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
||||
navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[];
|
||||
route: RouteLocationNormalized;
|
||||
server: ModrinthServer;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
|
||||
@ -6,6 +6,7 @@ import { ServerModule } from "./base.ts";
|
||||
export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
server_id!: string;
|
||||
name!: string;
|
||||
owner_id!: string;
|
||||
net!: { ip: string; port: number; domain: string };
|
||||
game!: string;
|
||||
backup_quota!: number;
|
||||
|
||||
@ -147,7 +147,7 @@ export async function useServersFetch<T>(
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
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",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
@ -167,11 +167,17 @@ export async function useServersFetch<T>(
|
||||
console.error("Fetch error:", error);
|
||||
|
||||
const fetchError = new ModrinthServersFetchError(
|
||||
`[Modrinth Servers] ${message}`,
|
||||
`[Modrinth Servers] ${error.message}`,
|
||||
statusCode,
|
||||
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;
|
||||
|
||||
@ -16,12 +16,15 @@ import {
|
||||
CardIcon,
|
||||
UserIcon,
|
||||
WrenchIcon,
|
||||
ModrinthIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { isAdmin as isUserAdmin, type User } from "@modrinth/utils";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const route = useRoute();
|
||||
const serverId = route.params.id as string;
|
||||
const auth = await useAuth();
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
@ -32,7 +35,11 @@ useHead({
|
||||
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: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
|
||||
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
|
||||
@ -48,7 +55,15 @@ const navLinks = [
|
||||
label: "Billing",
|
||||
href: `/settings/billing#server-${serverId}`,
|
||||
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` },
|
||||
];
|
||||
]);
|
||||
</script>
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
</label>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="invocation === startupSettings?.original_invocation"
|
||||
:disabled="invocation === originalInvocation"
|
||||
class="!w-full sm:!w-auto"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
@ -120,8 +120,9 @@ const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
}>();
|
||||
|
||||
await props.server.startup.fetch();
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
const startupSettings = computed(() => props.server.startup);
|
||||
const showAllVersions = ref(false);
|
||||
|
||||
const jdkVersionMap = [
|
||||
@ -137,33 +138,15 @@ const jdkBuildMap = [
|
||||
{ value: "graal", label: "GraalVM" },
|
||||
];
|
||||
|
||||
const invocation = ref("");
|
||||
const jdkVersion = ref("");
|
||||
const jdkBuild = ref("");
|
||||
|
||||
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 invocation = ref(props.server.startup.invocation);
|
||||
const jdkVersion = ref(
|
||||
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
|
||||
);
|
||||
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(
|
||||
() =>
|
||||
@ -195,7 +178,7 @@ const displayedJavaVersions = computed(() => {
|
||||
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
|
||||
});
|
||||
|
||||
const saveStartup = async () => {
|
||||
async function saveStartup() {
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
const invocationValue = invocation.value ?? "";
|
||||
@ -232,17 +215,17 @@ const saveStartup = async () => {
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const resetStartup = () => {
|
||||
function resetStartup() {
|
||||
invocation.value = originalInvocation.value;
|
||||
jdkVersion.value = originalJdkVersion.value;
|
||||
jdkBuild.value = originalJdkBuild.value;
|
||||
};
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
invocation.value = startupSettings.value?.original_invocation ?? "";
|
||||
};
|
||||
function resetToDefault() {
|
||||
invocation.value = originalInvocation.value ?? "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -53,6 +53,7 @@ fn build_java_jars() {
|
||||
.arg("build")
|
||||
.arg("--no-daemon")
|
||||
.arg("--console=rich")
|
||||
.arg("--info")
|
||||
.current_dir(dunce::canonicalize("java").unwrap())
|
||||
.status()
|
||||
.expect("Failed to wait on Gradle build");
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
java
|
||||
id("com.diffplug.spotless") version "7.0.4"
|
||||
id("com.gradleup.shadow") version "9.0.0-rc2"
|
||||
}
|
||||
|
||||
repositories {
|
||||
@ -8,6 +9,9 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.ow2.asm:asm:9.8")
|
||||
implementation("org.ow2.asm:asm-tree:9.8")
|
||||
|
||||
testImplementation(libs.junit.jupiter)
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
@ -31,7 +35,17 @@ spotless {
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
tasks.shadowJar {
|
||||
archiveFileName = "theseus.jar"
|
||||
manifest {
|
||||
attributes["Premain-Class"] = "com.modrinth.theseus.agent.TheseusAgent"
|
||||
}
|
||||
|
||||
enableRelocation = true
|
||||
relocationPrefix = "com.modrinth.theseus.shadow"
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ pub mod mr_auth;
|
||||
pub mod pack;
|
||||
pub mod process;
|
||||
pub mod profile;
|
||||
pub mod server_address;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
pub mod worlds;
|
||||
|
||||
@ -23,6 +23,7 @@ use serde_json::json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::data::Settings;
|
||||
use crate::server_address::ServerAddress;
|
||||
use dashmap::DashMap;
|
||||
use std::iter::FromIterator;
|
||||
use std::{
|
||||
@ -40,7 +41,7 @@ pub mod update;
|
||||
pub enum QuickPlayType {
|
||||
None,
|
||||
Singleplayer(String),
|
||||
Server(String),
|
||||
Server(ServerAddress),
|
||||
}
|
||||
|
||||
/// Remove a profile
|
||||
@ -630,7 +631,7 @@ fn pack_get_relative_path(
|
||||
#[tracing::instrument]
|
||||
pub async fn run(
|
||||
path: &str,
|
||||
quick_play_type: &QuickPlayType,
|
||||
quick_play_type: QuickPlayType,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
let state = State::get().await?;
|
||||
|
||||
@ -646,7 +647,7 @@ pub async fn run(
|
||||
async fn run_credentials(
|
||||
path: &str,
|
||||
credentials: &Credentials,
|
||||
quick_play_type: &QuickPlayType,
|
||||
quick_play_type: QuickPlayType,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
let state = State::get().await?;
|
||||
let settings = Settings::get(&state.pool).await?;
|
||||
|
||||
166
packages/app-lib/src/api/server_address.rs
Normal file
166
packages/app-lib/src/api/server_address.rs
Normal 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)),
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
use crate::data::ModLoader;
|
||||
use crate::launcher::get_loader_version_from_profile;
|
||||
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::{
|
||||
Profile, ProfileInstallStage, attached_world_data, server_join_log,
|
||||
@ -11,7 +12,7 @@ pub use crate::util::server_ping::{
|
||||
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
|
||||
};
|
||||
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_zip::{Compression, ZipEntryBuilder};
|
||||
use chrono::{DateTime, Local, TimeZone, Utc};
|
||||
@ -24,11 +25,9 @@ use regex::{Regex, RegexBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Reverse;
|
||||
use std::io::Cursor;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||
use url::Url;
|
||||
@ -433,9 +432,9 @@ async fn get_server_worlds_in_profile(
|
||||
let mut futures = JoinSet::new();
|
||||
for (index, world) in worlds.iter().enumerate().skip(first_server_index)
|
||||
{
|
||||
if world.last_played.is_some() {
|
||||
continue;
|
||||
}
|
||||
// We can't check for the profile already having a last_played, in case the user joined
|
||||
// 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
|
||||
&& let Ok((host, port)) = parse_server_address(address)
|
||||
{
|
||||
@ -917,93 +916,3 @@ pub async fn get_server_status(
|
||||
)
|
||||
.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)),
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
//! 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::state::Credentials;
|
||||
use crate::{
|
||||
@ -115,11 +116,13 @@ pub fn get_jvm_arguments(
|
||||
libraries_path: &Path,
|
||||
log_configs_path: &Path,
|
||||
class_paths: &str,
|
||||
agent_path: &Path,
|
||||
version_name: &str,
|
||||
memory: MemorySettings,
|
||||
custom_args: Vec<String>,
|
||||
java_arch: &str,
|
||||
quick_play_type: &QuickPlayType,
|
||||
quick_play_version: QuickPlayVersion,
|
||||
log_config: Option<&LoggingConfiguration>,
|
||||
) -> crate::Result<Vec<String>> {
|
||||
let mut parsed_arguments = Vec::new();
|
||||
@ -155,13 +158,45 @@ pub fn get_jvm_arguments(
|
||||
parsed_arguments.push("-cp".to_string());
|
||||
parsed_arguments.push(class_paths.to_string());
|
||||
}
|
||||
|
||||
parsed_arguments.push(format!("-Xmx{}M", memory.maximum));
|
||||
|
||||
if let Some(LoggingConfiguration::Log4j2Xml { argument, file }) = log_config
|
||||
{
|
||||
let full_path = log_configs_path.join(&file.id);
|
||||
let full_path = full_path.to_string_lossy();
|
||||
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 {
|
||||
if !arg.is_empty() {
|
||||
parsed_arguments.push(arg);
|
||||
@ -225,13 +260,13 @@ pub async fn get_minecraft_arguments(
|
||||
resolution: WindowSize,
|
||||
java_arch: &str,
|
||||
quick_play_type: &QuickPlayType,
|
||||
quick_play_version: QuickPlayVersion,
|
||||
) -> crate::Result<Vec<String>> {
|
||||
let access_token = credentials.access_token.clone();
|
||||
let profile = credentials.maybe_online_profile().await;
|
||||
let mut parsed_arguments = Vec::new();
|
||||
|
||||
if let Some(arguments) = arguments {
|
||||
let mut parsed_arguments = Vec::new();
|
||||
|
||||
parse_arguments(
|
||||
arguments,
|
||||
&mut parsed_arguments,
|
||||
@ -253,10 +288,7 @@ pub async fn get_minecraft_arguments(
|
||||
java_arch,
|
||||
quick_play_type,
|
||||
)?;
|
||||
|
||||
Ok(parsed_arguments)
|
||||
} else if let Some(legacy_arguments) = legacy_arguments {
|
||||
let mut parsed_arguments = Vec::new();
|
||||
for x in legacy_arguments.split(' ') {
|
||||
parsed_arguments.push(parse_minecraft_argument(
|
||||
&x.replace(' ', TEMPORARY_REPLACE_CHAR),
|
||||
@ -272,10 +304,21 @@ pub async fn get_minecraft_arguments(
|
||||
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)]
|
||||
@ -354,9 +397,9 @@ fn parse_minecraft_argument(
|
||||
)
|
||||
.replace(
|
||||
"${quickPlayMultiplayer}",
|
||||
match quick_play_type {
|
||||
QuickPlayType::Server(address) => address,
|
||||
_ => "",
|
||||
&match quick_play_type {
|
||||
QuickPlayType::Server(address) => address.to_string(),
|
||||
_ => "".to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@ -4,6 +4,9 @@ use crate::event::emit::{emit_loading, init_or_edit_loading};
|
||||
use crate::event::{LoadingBarId, LoadingBarType};
|
||||
use crate::launcher::download::download_log_config;
|
||||
use crate::launcher::io::IOError;
|
||||
use crate::launcher::quick_play_version::{
|
||||
QuickPlayServerVersion, QuickPlayVersion,
|
||||
};
|
||||
use crate::profile::QuickPlayType;
|
||||
use crate::state::{
|
||||
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
||||
@ -25,6 +28,7 @@ use tokio::process::Command;
|
||||
mod args;
|
||||
|
||||
pub mod download;
|
||||
pub mod quick_play_version;
|
||||
|
||||
// All nones -> disallowed
|
||||
// 1+ true -> allowed
|
||||
@ -457,7 +461,7 @@ pub async fn launch_minecraft(
|
||||
credentials: &Credentials,
|
||||
post_exit_hook: Option<String>,
|
||||
profile: &Profile,
|
||||
quick_play_type: &QuickPlayType,
|
||||
mut quick_play_type: QuickPlayType,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
if profile.install_stage == ProfileInstallStage::PackInstalling
|
||||
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
|
||||
@ -589,6 +593,18 @@ pub async fn launch_minecraft(
|
||||
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) =
|
||||
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
|
||||
|
||||
@ -606,11 +622,13 @@ pub async fn launch_minecraft(
|
||||
&java_version.architecture,
|
||||
minecraft_updated,
|
||||
)?,
|
||||
&main_class_path,
|
||||
&version_jar,
|
||||
*memory,
|
||||
Vec::from(java_args),
|
||||
&java_version.architecture,
|
||||
quick_play_type,
|
||||
&quick_play_type,
|
||||
quick_play_version,
|
||||
version_info
|
||||
.logging
|
||||
.as_ref()
|
||||
@ -646,7 +664,8 @@ pub async fn launch_minecraft(
|
||||
&version.type_,
|
||||
*resolution,
|
||||
&java_version.architecture,
|
||||
quick_play_type,
|
||||
&quick_play_type,
|
||||
quick_play_version,
|
||||
)
|
||||
.await?
|
||||
.into_iter(),
|
||||
|
||||
102
packages/app-lib/src/launcher/quick_play_version.rs
Normal file
102
packages/app-lib/src/launcher/quick_play_version.rs
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ use crate::event::emit::{emit_process, emit_profile};
|
||||
use crate::event::{ProcessPayloadType, ProfilePayloadType};
|
||||
use crate::profile;
|
||||
use crate::util::io::IOError;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
|
||||
use dashmap::DashMap;
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
@ -493,6 +493,16 @@ impl Process {
|
||||
if let Err(e) = Self::append_to_log_file(&log_path, &line) {
|
||||
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();
|
||||
@ -540,17 +550,6 @@ impl Process {
|
||||
timestamp: &str,
|
||||
message: &str,
|
||||
) -> 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
|
||||
.parse::<i64>()
|
||||
.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?;
|
||||
crate::state::server_join_log::JoinLogEntry {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
**Discord:** %PROJECT_DISCORD_URL% \
|
||||
**Issues:** %PROJECT_ISSUES_URL% \
|
||||
**Source:** %PROJECT_SOURCE_URL% \
|
||||
**Wiki:** %PROJECT_WIKI_URL%
|
||||
**Wiki:** %PROJECT_WIKI_URL% \
|
||||
**Discord:** %PROJECT_DISCORD_URL%
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
**Slug:** `%PROJECT_SLUG%` </br>
|
||||
|
||||
**Title issues?**
|
||||
@ -0,0 +1 @@
|
||||
**Title:** %PROJECT_TITLE% </br>
|
||||
7
packages/moderation/data/messages/description/clarity.md
Normal file
7
packages/moderation/data/messages/description/clarity.md
Normal file
@ -0,0 +1,7 @@
|
||||
## Description Clarity
|
||||
|
||||
Per section 2 of %RULES% It's important that your Description accurately and honestly represents the content of your project.
|
||||
Currently, some elements in your Description may be confusing or misleading.
|
||||
Please edit your description to ensure it accurately represents the current functionality of your project.
|
||||
Avoid making hyperbolic claims that could misrepresent the facts of your project.
|
||||
Ensure that your Description is accurate and not likely to confuse users.
|
||||
@ -1,6 +1,6 @@
|
||||
## Description Accessibility
|
||||
|
||||
In accordance with section 2.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) we request that `# header`s not be used as body text.
|
||||
In accordance with section 2.2 of %RULES%, we request that `# header`s not be used as body text.
|
||||
|
||||
Headers are interpreted differently by screen-readers and thus should generally only be used for things like separating sections of your Description.
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
## Image Descriptions
|
||||
|
||||
In accordance with section 2.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) we ask that you provide a text alternative to your current Description.
|
||||
In accordance with section 2.2 of %RULES%, we ask that you provide a text alternative to your current Description.
|
||||
|
||||
It is important that your Description contains enough detail about your project that a user can have a full understanding of it from text alone.
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
## Insufficient Description
|
||||
|
||||
Per section 2.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#general-expectations) your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
|
||||
Per section 2.1 of %RULES%, your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
|
||||
|
||||
Currently, it looks like there are some missing details.
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
## Insufficient Description
|
||||
|
||||
Per section 2.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#general-expectations) your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
|
||||
Per section 2.1 of %RULES%, your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
|
||||
|
||||
Currently, it looks like there are some missing details.
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
## Insufficient Description
|
||||
|
||||
Per section 2.1 of %RULES% your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
|
||||
Per section 2.1 of %RULES%, your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
|
||||
|
||||
Currently, it looks like there are some missing details.
|
||||
%EXPLAINER%
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
## No English Description
|
||||
|
||||
Per section 2.2 of %RULES% a project's [Summary](%PROJECT_SETTINGS_LINK%) and %PROJECT_DESCRIPTION_FLINK% must be in English, unless meant exclusively for non-English use, such as translations.
|
||||
Per section 2.2 of %RULES%, a project's [Summary](%PROJECT_SETTINGS_LINK%) and %PROJECT_DESCRIPTION_FLINK% must be in English, unless meant exclusively for non-English use, such as translations.
|
||||
|
||||
You may include your non-English Description if you would like but we ask that you also add an English translation of the Description to your project page, if you would like to use an online translator to do this, we recommend [DeepL](https://www.deepl.com/translator).
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
## Description Accessibility
|
||||
|
||||
Per section 2 of %RULES% your description must be plainly readable and accessible.
|
||||
Per section 2 of %RULES%, your description must be plainly readable and accessible.
|
||||
|
||||
Using non-standard text characters like Zalgo or "fancy text" in place of text anywhere in your project, including the Description, Summary, or Title can make your project pages inaccessible.
|
||||
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
## Unrelated Gallery Images
|
||||
|
||||
Per section 5.5 of %RULES% any images in your project's Gallery must be relevant to the project and also include a Title.
|
||||
Per section 5.5 of %RULES%, any images in your project's Gallery must be relevant to the project and also include a Title.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
## Invalid License Link
|
||||
|
||||
It's important that your project's License link is accurate and leads directly to a valid license for this content.
|
||||
Your current link: `%PROJECT_LICENSE_URL%` does not appear to lead to a valid license for this project, or it is not publicly accessable.
|
||||
It's important that your project's %PROJECT_LICENSE_FLINK% link is accurate and leads directly to a valid license for this content.
|
||||
Your current link: `%PROJECT_LICENSE_URL%` does not appear to lead to a valid license for this project, or it is not publicly accessible.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
## No Source Code Provided
|
||||
|
||||
Your project's license of `%PROJECT_LICENSE_NAME%`, requires source disclosure.
|
||||
Your project's %PROJECT_LICENSE_FLINK% of `%PROJECT_LICENSE_NAME%`, requires source disclosure.
|
||||
Consider adding a Source link to your project's repository, or including a Sources file for each version as an Additional File.
|
||||
Keep in mind this may be a requirement of the source work's licensing, which must be abided per section 4 of %RULES%.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
## No Source Code Provided
|
||||
|
||||
Your project's license of `%PROJECT_LICENSE_NAME%`, requires source disclosure.
|
||||
Your project's %PROJECT_LICENSE_FLINK% of `%PROJECT_LICENSE_NAME%`, requires source disclosure.
|
||||
Consider adding a Source link to your project's repository, or including a Sources file for each version as an Additional File. You may also want to refer to %LICENSING_GUIDE% if you wish to select a different License, remember to make sure your selected License is consistent with the license in your project's files as well.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
## Misuse of Links
|
||||
|
||||
Per section 5.4 of %RULES% all %PROJECT_LINKS_FLINK% must lead to correctly labeled publicly available resources that are directly related to your project.
|
||||
Per section 5.4 of %RULES%, all %PROJECT_LINKS_FLINK% must lead to correctly labeled publicly available resources that are directly related to your project.
|
||||
Currently it looks like your %MISUSED_LINKS% link(s) are misused or incorrectly labeled.
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
## Unreachable Links
|
||||
|
||||
Per section 5.4 of %RULES% all %PROJECT_LINKS_FLINK% must lead to correctly labeled publicly available resources that are directly related to your project.
|
||||
Per section 5.4 of %RULES%, all %PROJECT_LINKS_FLINK% must lead to correctly labeled publicly available resources that are directly related to your project.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
## Reuploads are forbidden
|
||||
|
||||
This project appears to contain content from %ORIGINAL_PROJECT% by %ORIGINAL_AUTHOR%.
|
||||
Per section 4 of %RULES% this is strictly forbidden.
|
||||
Per section 4 of %RULES%, this is strictly forbidden.
|
||||
If you believe this is an error, or you can verify you are the creator and rightful owner of this content please let us know. Otherwise, we ask that you **do not resubmit this project**.
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
---
|
||||
|
||||
## Account Issues Indicated
|
||||
|
||||
We're sorry to hear you're having trouble accessing your accounts, unfortunately, our moderation team is unable to assist with account-related issues.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
---
|
||||
## Warnings from AutoMod
|
||||
|
||||
Unfortunately, our AutoMod cannot read your project's Description or your messages to moderation.
|
||||
AutoMod will warn both you and our Moderation Staff about potential issues, but if you've already followed the necessary steps these warnings can safely be ignored.
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
---
|
||||
|
||||
## Corrections Applied
|
||||
|
||||
I've gone ahead and corrected the issues listed above so your project can be Approved.
|
||||
Your submission contained some issues which may have prevented your project from being published.
|
||||
These have been corrected by our Moderation Team so your project can be Approved, be sure to read and understand each issue listed below to ensure a smooth review for your next submission.
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
---
|
||||
|
||||
## Private Use
|
||||
|
||||
Under normal circumstances, your project would be rejected due to the issues listed above.
|
||||
Under normal circumstances, your project would be rejected due to the issues listed below.
|
||||
However, since your project is not intended for public use, these requirements will be waived and your project will be unlisted. This means it will remain accessible through a direct link without appearing in public search results, allowing you to share it privately.
|
||||
If you're okay with this, or submitted your project to be unlisted already, than no further action is necessary.
|
||||
If you would like to publish your project publicly, please address all moderation concerns before resubmitting this project.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
## Insufficient Summary
|
||||
## Invalid Summary Formatting
|
||||
|
||||
Per section 5.3 of %RULES% your Summary can not include any extra formatting such as lists, or links.
|
||||
Per section 5.3 of %RULES%, your Summary can not include any extra formatting such as lists, or links.
|
||||
|
||||
Your project summary should provide a brief overview of your project that informs and entices users.
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
## Insufficient Summary
|
||||
|
||||
Per section 5.3 of %RULES% your project summary should provide a brief overview of your project that informs and entices users.
|
||||
Per section 5.3 of %RULES%, your project summary should provide a brief overview of your project that informs and entices users.
|
||||
|
||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
## No English Summary
|
||||
|
||||
Per section 2.2 of %RULES% a project's Summary and Description must be in English, unless meant exclusively for non-English use, such as translations.
|
||||
Per section 2.2 of %RULES%, a project's Summary and Description must be in English, unless meant exclusively for non-English use, such as translations.
|
||||
|
||||
You may include your non-English Summary but we ask that you also add an English translation.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
## Insufficient Summary
|
||||
|
||||
Per section 5.3 of %RULES% your Summary can not be the same as your project's Title.
|
||||
Per section 5.3 of %RULES%, your Summary can not be the same as your project's Title.
|
||||
|
||||
Your project summary should provide a brief overview of your project that informs and entices users.
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
## Unsupported Project
|
||||
|
||||
Per section 5.7 of [Modrinth's Content Rules](https://modrinth.com/legal/rules), Modrinth does not support uploading multiple variations of your project as Additional files.
|
||||
Per section 5.7 of %RULES%, Modrinth does not support uploading multiple variations of your project as Additional files.
|
||||
Having alternate versions of your content on the same project will hurt the functionality of the Modrinth App and other supported launchers as it would prevent users from updating your content, and may make it harder for your users to find the content they want.
|
||||
We ask that you upload each alternate version of your project as a new project, ensuring that all users will be able to access and easily find your content.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
## Incorrect Additional Files
|
||||
|
||||
Per section 5.7 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) the additional files section should only be used for specific designated purposes such as a `Sources.jar`.
|
||||
Per section 5.7 of %RULES%, the additional files section should only be used for specific designated purposes such as a `Sources.jar`.
|
||||
To ensure a smooth experience for you and your users, please upload each alternate version of your modpack as its own Modpack project, thank you.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
## Incorrect Additional Files
|
||||
|
||||
Per section 5.7 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) the additional files section should only be used for specific designated purposes such as a `Sources.jar`.
|
||||
Per section 5.7 of %RULES%, the additional files section should only be used for specific designated purposes such as a `Sources.jar`.
|
||||
Modrinth does not support the upload of modpacks in the `.zip` format, as this may cause issues for Modrinth users or distribute copyrighted content without the proper permissions.
|
||||
If you would like to upload a server-specific version of your modpack, consider creating a separate Modpack project.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
## Incorrect Use of Additional Files
|
||||
|
||||
It looks like you've uploaded multiple primary files to one Version as Additional Files. Per section 5.7 of %RULES% each Version of your project must include only one primary file that corresponds to its respective Minecraft and loader versions.
|
||||
It looks like you've uploaded multiple primary files to one Version as Additional Files. Per section 5.7 of %RULES%, each Version of your project must include only one primary file that corresponds to its respective Minecraft and loader versions.
|
||||
This allows users to easily find and download the content they need for their game profile with ease. The Additional Files feature can be used for things like a `Sources.jar`.
|
||||
Please upload each version of your project separately, thank you.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
## Excessive File Size
|
||||
## Unnecessary redistribution of dependencies
|
||||
|
||||
This project appears to include libs or dependencies, unnecessarily redistributing their entire contents.
|
||||
This is often due to an error in project structure or compilation, and in some cases, may violate the copyrights or licensing agreements of these libraries.
|
||||
|
||||
@ -94,6 +94,15 @@ const description: Stage = {
|
||||
message: async () =>
|
||||
(await import('../messages/description/non-standard-text.md?raw')).default,
|
||||
} as ButtonAction,
|
||||
{
|
||||
id: 'description_clarity',
|
||||
type: 'button',
|
||||
label: 'Unclear / Misleading',
|
||||
weight: 407,
|
||||
suggestedStatus: 'rejected',
|
||||
severity: 'high',
|
||||
message: async () => (await import('../messages/description/clarity.md?raw')).default,
|
||||
} as ButtonAction,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ const statusAlerts: Stage = {
|
||||
id: 'status_corrections_applied',
|
||||
type: 'button',
|
||||
label: 'Corrections applied',
|
||||
weight: 999999,
|
||||
weight: -999999,
|
||||
suggestedStatus: 'approved',
|
||||
disablesActions: ['status_private_use', 'status_account_issues'],
|
||||
message: async () => (await import('../messages/status-alerts/fixed.md?raw')).default,
|
||||
@ -24,7 +24,7 @@ const statusAlerts: Stage = {
|
||||
id: 'status_private_use',
|
||||
type: 'button',
|
||||
label: 'Private use',
|
||||
weight: 999999,
|
||||
weight: -999999,
|
||||
suggestedStatus: 'flagged',
|
||||
disablesActions: ['status_corrections_applied', 'status_account_issues'],
|
||||
message: async () => (await import('../messages/status-alerts/private.md?raw')).default,
|
||||
@ -33,7 +33,7 @@ const statusAlerts: Stage = {
|
||||
id: 'status_account_issues',
|
||||
type: 'button',
|
||||
label: 'Account issues',
|
||||
weight: 999999,
|
||||
weight: -999999,
|
||||
suggestedStatus: 'rejected',
|
||||
disablesActions: ['status_corrections_applied', 'status_private_use'],
|
||||
message: async () =>
|
||||
@ -78,7 +78,7 @@ const statusAlerts: Stage = {
|
||||
id: 'status_automod_confusion',
|
||||
type: 'button',
|
||||
label: `Automod confusion`,
|
||||
weight: 999999,
|
||||
weight: -999999,
|
||||
message: async () =>
|
||||
(await import('../messages/status-alerts/automod_confusion.md?raw')).default,
|
||||
} as ButtonAction,
|
||||
|
||||
@ -1,10 +1,28 @@
|
||||
import { BookOpenIcon } from '@modrinth/assets'
|
||||
import type { Stage } from '../../types/stage'
|
||||
import type { Project } from '@modrinth/utils'
|
||||
|
||||
function hasCustomSlug(project: Project): boolean {
|
||||
return (
|
||||
project.slug !==
|
||||
project.title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(' ', '-')
|
||||
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, '')
|
||||
.replaceAll(/--+/gm, '-')
|
||||
)
|
||||
}
|
||||
|
||||
const titleSlug: Stage = {
|
||||
title: 'Are the Name and URL accurate and appropriate?',
|
||||
id: 'title-&-slug',
|
||||
text: async () => (await import('../messages/checklist-text/title-slug.md?raw')).default,
|
||||
text: async (project) => {
|
||||
let text = (await import('../messages/checklist-text/title-slug/title.md?raw')).default
|
||||
if (hasCustomSlug(project))
|
||||
text += (await import('../messages/checklist-text/title-slug/slug.md?raw')).default
|
||||
return text
|
||||
},
|
||||
icon: BookOpenIcon,
|
||||
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
|
||||
actions: [
|
||||
@ -63,6 +81,7 @@ const titleSlug: Stage = {
|
||||
label: 'Slug issues?',
|
||||
suggestedStatus: 'rejected',
|
||||
severity: 'low',
|
||||
shouldShow: (project) => hasCustomSlug(project),
|
||||
options: [
|
||||
{
|
||||
label: 'Misused',
|
||||
|
||||
@ -135,11 +135,11 @@ const versions: Stage = {
|
||||
{
|
||||
id: 'versions_redist_libs',
|
||||
type: 'button',
|
||||
label: 'Oversized File',
|
||||
label: 'Packed Libs',
|
||||
suggestedStatus: `rejected`,
|
||||
severity: `medium`,
|
||||
weight: 1003,
|
||||
shouldShow: (project) => project.project_type === 'mod',
|
||||
shouldShow: (project) => project.project_type === 'mod' || project.project_type === 'plugin',
|
||||
message: async () => (await import('../messages/versions/redist_libs.md?raw')).default,
|
||||
} as ButtonAction,
|
||||
{
|
||||
|
||||
@ -259,7 +259,7 @@ export function flattenProjectVariables(project: Project): Record<string, string
|
||||
vars['PROJECT_CLIENT_SIDE'] = project.client_side
|
||||
vars['PROJECT_SERVER_SIDE'] = project.server_side
|
||||
|
||||
vars['PROJECT_TEAM'] = project.team
|
||||
vars['PROJECT_TEAM'] = project.team || 'None'
|
||||
vars['PROJECT_THREAD_ID'] = project.thread_id
|
||||
vars['PROJECT_ORGANIZATION'] = project.organization
|
||||
|
||||
@ -329,9 +329,10 @@ export function flattenProjectVariables(project: Project): Record<string, string
|
||||
vars[`PROJECT_DESCRIPTION_FLINK`] =
|
||||
`[Description](https://modrinth.com/project/${project.id}/settings/description)`
|
||||
vars[`PROJECT_LICENSE_LINK`] = `https://modrinth.com/project/${project.id}/license`
|
||||
vars[`PROJECT_LICENSE_FLINK`] = `[License](https://modrinth.com/project/${project.id}/license`
|
||||
vars[`PROJECT_LICENSE_FLINK`] = `[License](https://modrinth.com/project/${project.id}/license)`
|
||||
vars[`PROJECT_LINKS_LINK`] = `https://modrinth.com/project/${project.id}/settings/links`
|
||||
vars[`PROJECT_LINKS_FLINK`] = `[Links](https://modrinth.com/project/${project.id}/settings/links)`
|
||||
vars[`PROJECT_LINKS_FLINK`] =
|
||||
`[External Links](https://modrinth.com/project/${project.id}/settings/links)`
|
||||
vars[`PROJECT_GALLERY_LINK`] = `https://modrinth.com/project/${project.id}/gallery`
|
||||
vars[`PROJECT_GALLERY_FLINK`] = `[Gallery](https://modrinth.com/project/${project.id}/gallery)`
|
||||
vars[`PROJECT_VERSIONS_LINK`] = `https://modrinth.com/project/${project.id}/versions`
|
||||
|
||||
@ -10,6 +10,23 @@ export type 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`,
|
||||
product: 'web',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user