Compare commits
9 Commits
prod
...
alex/db-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b2362c794 | ||
|
|
86ea212373 | ||
|
|
ed8f176a52 | ||
|
|
35aeab98cd | ||
|
|
dcc563d7ad | ||
|
|
e31197f649 | ||
|
|
0dee21814d | ||
|
|
0657e4466f | ||
|
|
13dbb4c57e |
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)
|
||||
}
|
||||
|
||||
@@ -3,55 +3,37 @@ title: Labrinth (API)
|
||||
description: Guide for contributing to Modrinth's backend
|
||||
---
|
||||
|
||||
This project is part of our [monorepo](https://github.com/modrinth/code). You can find it in the `apps/labrinth` directory.
|
||||
This project is part of our [monorepo](https://github.com/modrinth/code). You can find it in the `apps/labrinth` directory. The instructions below assume that you have switched your working directory to the `apps/labrinth` subdirectory.
|
||||
|
||||
[labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432 and a MeiliSearch instance on port 7700. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
|
||||
[Labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432 and a MeiliSearch instance on port 7700. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
|
||||
|
||||
To get a basic configuration, copy the `.env.local` file to `.env`. Now, you'll have to install the sqlx CLI, which can be done with cargo:
|
||||
|
||||
```bash
|
||||
cargo install --git https://github.com/launchbadge/sqlx sqlx-cli --no-default-features --features postgres,rustls
|
||||
```sh
|
||||
cargo install sqlx-cli --no-default-features --features mysql,sqlite,postgres,rustls,completions
|
||||
```
|
||||
|
||||
From there, you can create the database and perform all database migrations with one simple command:
|
||||
From there, you can create the database and set up its schema with one simple command:
|
||||
|
||||
```bash
|
||||
sqlx database setup
|
||||
```sh
|
||||
cargo sqlx database setup
|
||||
```
|
||||
|
||||
To enable labrinth to create a project, you need to add two things.
|
||||
To enable Labrinth to create projects and serve useful metadata to the frontend build scripts, you'll need to seed the database with several key entities:
|
||||
|
||||
1. An entry in the `loaders` table.
|
||||
2. An entry in the `loaders_project_types` table.
|
||||
1. Categories, in the `categories` table.
|
||||
2. Loaders and their fields, in the `loaders`, `loader_fields`, `loader_field_enums`, `loader_field_enum_values`, and `loader_fields_loaders` tables.
|
||||
3. Project types and their allowed loaders and games, in the `project_types`, `loaders_project_types`, and `loaders_project_types_games` tables.
|
||||
4. Optionally, to moderate projects from the frontend, an admin user, in the `users` table.
|
||||
|
||||
A minimal setup can be done from the command line with [psql](https://www.postgresql.org/docs/current/app-psql.html):
|
||||
The most convenient way to do this seeding is with the [psql](https://www.postgresql.org/docs/current/app-psql.html) command line tool and the pre-existing seed data fixture. This fixture was generated by dumping the official staging environment database at a specific point in time, and defines an admin user with email `admin@modrinth.invalid` and password `admin`:
|
||||
|
||||
```bash
|
||||
psql --host=localhost --port=5432 -U <username, default is labrinth> -W
|
||||
```sh
|
||||
source .env
|
||||
psql "$DATABASE_URL" < fixtures/labrinth-seed-data-202508052143.sql
|
||||
```
|
||||
|
||||
The default password for the database is `labrinth`. Once you've connected, run
|
||||
|
||||
```sql
|
||||
INSERT INTO loaders VALUES (0, 'placeholder_loader');
|
||||
INSERT INTO loaders_project_types VALUES (0, 1); -- modloader id, supported type id
|
||||
INSERT INTO categories VALUES (0, 'placeholder_category', 1); -- category id, category, project type id
|
||||
```
|
||||
|
||||
This will initialize your database with a modloader called 'placeholder_loader', with id 0, and marked as supporting mods only. It will also create a category called 'placeholder_category' that is marked as supporting mods only
|
||||
If you would like 'placeholder_loader' to be marked as supporting modpacks too, run
|
||||
|
||||
```sql
|
||||
INSERT INTO loaders_project_types VALUES (0, 2); -- modloader id, supported type id
|
||||
```
|
||||
|
||||
If you would like 'placeholder_category' to be marked as supporting modpacks too, run
|
||||
|
||||
```sql
|
||||
INSERT INTO categories VALUES (0, 'placeholder_category', 2); -- modloader id, supported type id
|
||||
```
|
||||
|
||||
You can find more example SQL statements for seeding the database in the `apps/labrinth/tests/files/dummy_data.sql` file.
|
||||
You can find more example SQL statements for seeding the database in the `tests/files/dummy_data.sql` file.
|
||||
|
||||
The majority of configuration is done at runtime using [dotenvy](https://crates.io/crates/dotenvy) and the `.env` file. Each of the variables and what they do can be found in the dropdown below. Additionally, there are three command line options that can be used to specify to MeiliSearch what you want to do.
|
||||
|
||||
@@ -109,14 +91,13 @@ The OAuth configuration options are fairly self-explanatory. For help setting up
|
||||
|
||||
If you're prepared to contribute by submitting a pull request, ensure you have met the following criteria:
|
||||
|
||||
- `cargo fmt` has been run.
|
||||
- `cargo clippy` has been run.
|
||||
- `cargo check` has been run.
|
||||
- `cargo fmt --all` has been run.
|
||||
- `cargo clippy --all-targets` has been run.
|
||||
- `cargo sqlx prepare` has been run.
|
||||
|
||||
> Note: If you encounter issues with `sqlx` saying 'no queries found' after running `cargo sqlx prepare`, you may need to ensure the installed version of `sqlx-cli` matches the current version of `sqlx` used [in labrinth](https://github.com/modrinth/labrinth/blob/master/Cargo.toml).
|
||||
|
||||
[Discord]: https://discord.modrinth.com
|
||||
[GitHub]: https://github.com/modrinth
|
||||
[labrinth]: https://github.com/modrinth/labrinth
|
||||
[Labrinth]: https://github.com/modrinth/code/tree/main/apps/labrinth
|
||||
[Rust]: https://www.rust-lang.org/tools/install
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
1108
apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql
Normal file
1108
apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -292,7 +292,7 @@ pub async fn add_dummy_data(api: &ApiV3, db: TemporaryDatabase) -> DummyData {
|
||||
let pool = &db.pool.clone();
|
||||
|
||||
pool.execute(
|
||||
include_str!("../files/dummy_data.sql")
|
||||
include_str!("../fixtures/dummy_data.sql")
|
||||
.replace("$1", &Scopes::all().bits().to_string())
|
||||
.as_str(),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user