Compare commits
10 Commits
prod
...
cal/server
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
475ee69cfb | ||
|
|
ab48d4b144 | ||
|
|
0dee21814d | ||
|
|
0657e4466f | ||
|
|
13dbb4c57e | ||
|
|
3480448351 | ||
|
|
daae37806c | ||
|
|
6a4c140420 | ||
|
|
5d98b16270 | ||
|
|
6c452f86b6 |
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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -50,22 +50,27 @@ const container = ref(null);
|
||||
const showLabels = ref(false);
|
||||
|
||||
const locations = ref([
|
||||
// Active locations
|
||||
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false },
|
||||
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false },
|
||||
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false },
|
||||
{ name: "Spokane", lat: 47.667309, lng: -117.411922, active: true, clicked: false },
|
||||
{ name: "Dallas", lat: 32.78372, lng: -96.7947, active: true, 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: "Amsterdam", lat: 52.3676, lng: 4.9041, active: false, clicked: false },
|
||||
// { name: "Paris", lat: 48.8566, lng: 2.3522, active: false, clicked: false },
|
||||
// { name: "Singapore", lat: 1.3521, lng: 103.8198, active: false, clicked: false },
|
||||
// { name: "Tokyo", lat: 35.6762, lng: 139.6503, active: 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: "Vint Hill",
|
||||
lat: 38.74724876915715,
|
||||
lng: -77.67436507922152,
|
||||
active: true,
|
||||
clicked: false,
|
||||
},
|
||||
{
|
||||
name: "Coventry",
|
||||
lat: 52.39751276904742,
|
||||
lng: -1.5777183894453757,
|
||||
active: true,
|
||||
clicked: false,
|
||||
},
|
||||
{
|
||||
name: "Limburg",
|
||||
lat: 50.40863558430334,
|
||||
lng: 8.062427315007714,
|
||||
active: true,
|
||||
clicked: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const isLocationVisible = (location) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showProjectPageDownloadModalServersPromo: false,
|
||||
showProjectPageCreateServersTooltip: true,
|
||||
showProjectPageQuickServerButton: false,
|
||||
showModrinthServersGlobe: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -515,6 +515,98 @@
|
||||
</div>
|
||||
</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
|
||||
id="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 ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
|
||||
import OptionGroup from "~/components/ui/OptionGroup.vue";
|
||||
import Globe from "~/components/ui/servers/Globe.vue";
|
||||
|
||||
const { locale } = useVIntl();
|
||||
|
||||
@@ -842,6 +935,7 @@ async function fetchPaymentData() {
|
||||
const selectedProjectId = ref();
|
||||
|
||||
const route = useRoute();
|
||||
const flags = useFeatureFlags();
|
||||
const isAtCapacity = computed(
|
||||
() => isSmallAtCapacity.value && isMediumAtCapacity.value && isLargeAtCapacity.value,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
11
apps/frontend/src/pages/servers_new/manage/index.vue
Normal file
11
apps/frontend/src/pages/servers_new/manage/index.vue
Normal file
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
@@ -50,6 +50,7 @@ import _CubeIcon from './icons/cube.svg?component'
|
||||
import _CurrencyIcon from './icons/currency.svg?component'
|
||||
import _DashboardIcon from './icons/dashboard.svg?component'
|
||||
import _DatabaseIcon from './icons/database.svg?component'
|
||||
import _DotIcon from './icons/dot.svg?component'
|
||||
import _DownloadIcon from './icons/download.svg?component'
|
||||
import _DropdownIcon from './icons/dropdown.svg?component'
|
||||
import _EditIcon from './icons/edit.svg?component'
|
||||
@@ -243,6 +244,7 @@ export const CubeIcon = _CubeIcon
|
||||
export const CurrencyIcon = _CurrencyIcon
|
||||
export const DashboardIcon = _DashboardIcon
|
||||
export const DatabaseIcon = _DatabaseIcon
|
||||
export const DotIcon = _DotIcon
|
||||
export const DownloadIcon = _DownloadIcon
|
||||
export const DropdownIcon = _DropdownIcon
|
||||
export const EditIcon = _EditIcon
|
||||
|
||||
1
packages/assets/icons/dot.svg
Normal file
1
packages/assets/icons/dot.svg
Normal 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 |
@@ -12,6 +12,7 @@ import './omorphia.scss'
|
||||
import _ModrinthIcon from './branding/logo.svg?component'
|
||||
import _FourOhFourNotFound from './branding/404.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 _AnnoyedRinthbot from './branding/rinthbot/annoyed.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 FourOhFourNotFound = _FourOhFourNotFound
|
||||
export const ModrinthPlusIcon = _ModrinthPlusIcon
|
||||
export const ServersManageIllustration = _ServersManageIllustration
|
||||
export const AngryRinthbot = _AngryRinthbot
|
||||
export const AnnoyedRinthbot = _AnnoyedRinthbot
|
||||
export const ConfusedRinthbot = _ConfusedRinthbot
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './src/components'
|
||||
export * from './src/utils'
|
||||
export * from './src/composables'
|
||||
export * from './src/servers'
|
||||
|
||||
5
packages/ui/src/components/base/RaisedBadge.vue
Normal file
5
packages/ui/src/components/base/RaisedBadge.vue
Normal file
@@ -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>
|
||||
@@ -36,6 +36,7 @@ export { default as ProgressBar } from './base/ProgressBar.vue'
|
||||
export { default as ProjectCard } from './base/ProjectCard.vue'
|
||||
export { default as RadialHeader } from './base/RadialHeader.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 ServerNotice } from './base/ServerNotice.vue'
|
||||
export { default as SimpleBadge } from './base/SimpleBadge.vue'
|
||||
|
||||
0
packages/ui/src/servers/components/.gitkeep
Normal file
0
packages/ui/src/servers/components/.gitkeep
Normal file
141
packages/ui/src/servers/components/management/ServerCard.vue
Normal file
141
packages/ui/src/servers/components/management/ServerCard.vue
Normal file
@@ -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">
|
||||
• {{ 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>
|
||||
0
packages/ui/src/servers/composables/.gitkeep
Normal file
0
packages/ui/src/servers/composables/.gitkeep
Normal file
1
packages/ui/src/servers/index.ts
Normal file
1
packages/ui/src/servers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ServersManagePage } from './pages/manage.vue'
|
||||
0
packages/ui/src/servers/pages/.gitkeep
Normal file
0
packages/ui/src/servers/pages/.gitkeep
Normal file
130
packages/ui/src/servers/pages/manage.vue
Normal file
130
packages/ui/src/servers/pages/manage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user