Compare commits

...

14 Commits

Author SHA1 Message Date
Josiah Glosson
ee9d4fad33 Add quotes around $env:JAVA_HOME_11_X64
Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Signed-off-by: Josiah Glosson <soujournme@gmail.com>
2025-06-27 10:29:10 -05:00
Josiah Glosson
140b4060e6 Set JAVA_HOME to JAVA_HOME_11_X64 on Windows for theseus-release 2025-06-26 16:51:46 -05:00
Emma Alexia
6f03fae233 Clear owner's project cache after deleting organization (#3794)
* Clear owner's project cache after deleting organization

Fixes an issue where people would think their projects were deleted along with their organization, when this isn't actually the case.

* address PR review

* Update apps/labrinth/src/routes/v3/organizations.rs

Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>

* Fix lint

* actually fix lint

---------

Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>
2025-06-26 19:30:58 +00:00
Emma Alexia
22fc0c994d Remove new-projects channel emojis (#3843)
* Remove new-projects channel emojis

With new loaders, this functionality has become unwieldy. We don't have enough emoji slots in the server for the number of emojis we'd need for the loaders. It is easiest to simply format them in the same way knossos does.

Note: I forgor how the borrow checker works, this compiles but I'm sure it's gore to anyone who actually knows the difference between a string slice and a String, I come from Javaland though so pls forgive

* Rename func accordingly
2025-06-26 18:54:22 +00:00
Josiah Glosson
a1ccbc5757 Update Java to 11 with --release 8 (#3846) 2025-06-26 18:38:58 +00:00
Jai A
053cf10198 Update ads.txt again
(cherry picked from commit 67304fab25ed37ed3e39766dc9378ae3a760cb06)
2025-06-26 11:08:44 -07:00
Jai A
257efd8ad7 Update ads.txt
(cherry picked from commit 01037c184efad97e4b9f1a11c3e7e1eb21d44510)
2025-06-26 11:08:44 -07:00
Alejandro González
b75cfc063b Sign Windows Theseus binaries with DigiCert KeyLocker's cloud HSM (#3838)
* feat(ci): sign Windows Theseus bins with DigiCert KeyLocker cloud HSM

* perf(ci): speed up Jsign installation

* fix(ci): use absolute path to DigiCert client certificate

This should avoid errors related to Jsign not being able to find it
we've seen on CI.

* fix(ci): trim strange characters out from DigiCert credentials

* ci: another attempt at fixing Jsign errors

* chore: add comment mentioning why `jsign` choco deps are ignored

* tweak: move KeyLocker signing config to CI release Tauri config file

This prevents casual local builds from attempting to use a signing
command they really can't use, improving developer experience.

* tweak(ci/windows): do not waste time and signatures with MSIs

We aren't distributing these anyway. This should reduce the signing
operations required for building the app from 5 (one for the binary,
another for the MSI installer, two for WiX extension DLLs and one for
the NSIS installer) to 2.

* feat(ci): make Windows code signing toggleable, do not sign non-final builds

* chore(ci): tweak `sign-windows-binaries` input wording

* fix(ci): deal with usual Powershell syntax shenanigans

* fix(ci): work around more Powershell syntax shenanigans

Who thought it'd be a good idea to make a comma a synonymous of a space
for separating command line arguments? Why have to characters for the
same thing?

* perf(ci): do not run app build workflow on Labrinth changes

Labrinth is not related to the app at all, so this is just a waste of CI
minutes.

* ci(theseus): enable Windows code signing by default for manual triggers

These are expected to be not that common, so defaulting to what causes
the least human errors when it comes to publishing a release makes most
sense.
2025-06-26 17:43:20 +00:00
Prospector
2d8420131d Update changelog 2025-06-26 10:45:48 -07:00
Prospector
c793b68aed Add quick server button, dynamic price preview for custom server modal (#3815)
* Add quick server creation button, and dynamic pricing to custom server selection

* Remove test in compatibility card

* Lint + remove duplicate file

* Adjust z-index of popup

* $6 -> $5

* Dismiss prompt if the button is clicked

* Make "Create a server" disabled for now

* Use existing loaders type
2025-06-26 15:38:42 +00:00
Josiah Glosson
47af459f24 Migrate Java code to Gradle (#3833)
* Create get_resource_file macro to get an embedded resource

If the tauri feature is enabled, the resource will be loaded from Tauri resources.
If the tauri feature is disabled, the resource will be extracted to a temp directory.

* Wrap process execution to inject system properties through stdin

* Pass the time values as ISO 8601 datetimes

* Remove entirely internal modrinth.process.uuid

* Redo Java version checking somewhat and fix a few bugs with it

* Fix game launch with early access versions of Java

* Format Java code

* Use Gradle to build Java code

* Make Gradle build reproducible

* Fix constant rebuilds

* Get rid of unnecessary rebuilds

* Fix modrinth.profile.modified being the same as modrinth.profile.created

* Make javac use all lints and treat them as errors

* Force Gradle color output

* Add Java formatting config

* Make gradlew executable

* Revert to manually extracting class files

* Switch to using update resource macro

* fix: make `app-lib` build again

---------

Co-authored-by: Alejandro González <me@alegon.dev>
2025-06-26 14:56:35 +00:00
Josiah Glosson
f10e0f2bf1 Pass system properties into Minecraft (+ some launch code cleanup) (#3822)
* Create get_resource_file macro to get an embedded resource

If the tauri feature is enabled, the resource will be loaded from Tauri resources.
If the tauri feature is disabled, the resource will be extracted to a temp directory.

* Wrap process execution to inject system properties through stdin

* Pass the time values as ISO 8601 datetimes

* Remove entirely internal modrinth.process.uuid

* Redo Java version checking somewhat and fix a few bugs with it

* Fix game launch with early access versions of Java

* Format Java code

* Fix modrinth.profile.modified being the same as modrinth.profile.created

* Revert to manually extracting class files
2025-06-26 13:23:14 +00:00
Alejandro González
569d60cb57 tweak(labrinth): return proper error message for invalid password change flows (#3839) 2025-06-25 16:51:08 +00:00
Ken
74d36a6a2d Update config.toml (#3832)
Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>
2025-06-23 22:40:16 +00:00
55 changed files with 1510 additions and 1749 deletions

View File

@@ -1,6 +1,6 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220"]
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
[build]
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -14,5 +14,5 @@ max_line_length = 100
max_line_length = off
trim_trailing_whitespace = false
[*.rs]
indent_size = 4
[*.{rs,java,kts}]
indent_size = 4

View File

@@ -9,14 +9,18 @@ on:
- .github/workflows/theseus-release.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'apps/labrinth/src/common/**'
- 'apps/labrinth/Cargo.toml'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
- 'packages/ui/**'
- 'packages/utils/**'
workflow_dispatch:
inputs:
sign-windows-binaries:
description: Sign Windows binaries
type: boolean
default: true
required: false
jobs:
build:
@@ -103,11 +107,21 @@ jobs:
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev pkg-config libayatana-appindicator3-dev librsvg2-dev
- name: Install code signing client (Windows only)
if: startsWith(matrix.platform, 'windows')
run: choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
- name: Install frontend dependencies
run: pnpm install
- name: Disable Windows code signing for non-final release builds
if: ${{ startsWith(matrix.platform, 'windows') && !startsWith(github.ref, 'refs/tags/v') && !inputs.sign-windows-binaries }}
run: |
jq 'del(.bundle.windows.signCommand)' apps/app/tauri-release.conf.json > apps/app/tauri-release.conf.json.new
Move-Item -Path apps/app/tauri-release.conf.json.new -Destination apps/app/tauri-release.conf.json -Force
- name: build app (macos)
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config "tauri-release.conf.json"
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
if: startsWith(matrix.platform, 'macos')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -121,15 +135,30 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: build app
run: pnpm --filter=@modrinth/app run tauri build --config "tauri-release.conf.json"
id: build_os
if: "!startsWith(matrix.platform, 'macos')"
- name: build app (Linux)
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
if: startsWith(matrix.platform, 'ubuntu')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: build app (Windows)
run: |
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
Remove-Item -Path signer-client-cert.p12
if: startsWith(matrix.platform, 'windows')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }}
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
- name: upload ${{ matrix.platform }}
uses: actions/upload-artifact@v4
with:

2
.idea/code.iml generated
View File

@@ -17,4 +17,4 @@
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
</module>

2
.idea/modules.xml generated
View File

@@ -5,4 +5,4 @@
<module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" />
</modules>
</component>
</project>
</project>

1
Cargo.lock generated
View File

@@ -8889,6 +8889,7 @@ dependencies = [
"flate2",
"fs4",
"futures",
"hashlink",
"hickory-resolver",
"indicatif",
"notify",

View File

@@ -60,6 +60,7 @@ flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
hashlink = "0.10.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"

View File

@@ -127,7 +127,7 @@ async function handleJavaFileInput() {
const filePath = await open()
if (filePath) {
let result = await get_jre(filePath.path ?? filePath)
let result = await get_jre(filePath.path ?? filePath).catch(handleError)
if (!result) {
result = {
path: filePath.path ?? filePath,

View File

@@ -41,8 +41,8 @@ pub async fn jre_find_filtered_jres(
// Validates JRE at a given path
// Returns None if the path is not a valid JRE
#[tauri::command]
pub async fn jre_get_jre(path: PathBuf) -> Result<Option<JavaVersion>> {
jre::check_jre(path).await.map_err(|e| e.into())
pub async fn jre_get_jre(path: PathBuf) -> Result<JavaVersion> {
Ok(jre::check_jre(path).await?)
}
// Tests JRE of a certain version

View File

@@ -1,6 +1,24 @@
{
"bundle": {
"createUpdaterArtifacts": "v1Compatible"
"createUpdaterArtifacts": "v1Compatible",
"windows": {
"signCommand": {
"cmd": "jsign",
"args": [
"sign",
"--verbose",
"--storetype",
"DIGICERTONE",
"--keystore",
"https://clientauth.one.digicert.com",
"--storepass",
"env:DIGICERT_ONE_SIGNER_CREDENTIALS",
"--tsaurl",
"https://timestamp.sectigo.com,http://timestamp.digicert.com",
"%1"
]
}
}
},
"build": {
"features": ["updater"]

View File

@@ -14,9 +14,6 @@
"externalBin": [],
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com",
"nsis": {
"installMode": "perMachine",
"installerHooks": "./nsis/hooks.nsi"
@@ -30,7 +27,6 @@
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"linux": {
"deb": {

View File

@@ -31,6 +31,9 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
projectBackground: false,
searchBackground: false,
advancedDebugInfo: false,
showProjectPageDownloadModalServersPromo: true,
showProjectPageCreateServersTooltip: true,
showProjectPageQuickServerButton: false,
// advancedRendering: true,
// externalLinksNewTab: true,
// notUsingBlockers: false,

View File

@@ -452,6 +452,16 @@
{{ formatCategory(currentPlatform) }}.
</p>
</AutomaticAccordion>
<ServersPromo
v-if="flags.showProjectPageDownloadModalServersPromo"
:link="`/servers#plan`"
@close="
() => {
flags.showProjectPageDownloadModalServersPromo = false;
saveFeatureFlags();
}
"
/>
</div>
</template>
</NewModal>
@@ -495,6 +505,64 @@
</button>
</ButtonStyled>
</div>
<Tooltip
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
theme="dismissable-prompt"
:triggers="[]"
:shown="flags.showProjectPageCreateServersTooltip"
:auto-hide="false"
placement="bottom-start"
>
<ButtonStyled size="large" circular>
<nuxt-link
v-tooltip="'Create a server'"
:to="`/servers?project=${project.id}#plan`"
@click="
() => {
flags.showProjectPageCreateServersTooltip = false;
saveFeatureFlags();
}
"
>
<ServerPlusIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<template #popper>
<div class="experimental-styles-within flex max-w-60 flex-col gap-1">
<div class="flex items-center justify-between gap-4">
<h3 class="m-0 flex items-center gap-2 text-base font-bold text-contrast">
Create a server
<TagItem
:style="{
'--_color': 'var(--color-brand)',
'--_bg-color': 'var(--color-brand-highlight)',
}"
>New</TagItem
>
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="`Don't show again`"
@click="
() => {
flags.showProjectPageCreateServersTooltip = false;
saveFeatureFlags();
}
"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
Modrinth Servers is the easiest way to play with your friends without hassle!
</p>
<p class="m-0 text-wrap text-sm font-bold text-primary">
Starting at $5<span class="text-xs"> / month</span>
</p>
</div>
</template>
</Tooltip>
<ClientOnly>
<ButtonStyled
size="large"
@@ -850,12 +918,14 @@ import {
ReportIcon,
ScaleIcon,
SearchIcon,
ServerPlusIcon,
SettingsIcon,
TagsIcon,
UsersIcon,
VersionIcon,
WrenchIcon,
ModrinthIcon,
XIcon,
} from "@modrinth/assets";
import {
Avatar,
@@ -872,6 +942,8 @@ import {
ProjectSidebarLinks,
ProjectStatusBadge,
ScrollablePanel,
TagItem,
ServersPromo,
useRelativeTime,
} from "@modrinth/ui";
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
@@ -885,6 +957,7 @@ import {
} from "@modrinth/utils";
import { navigateTo } from "#app";
import dayjs from "dayjs";
import { Tooltip } from "floating-vue";
import Accordion from "~/components/ui/Accordion.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
@@ -898,6 +971,7 @@ import NavTabs from "~/components/ui/NavTabs.vue";
import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
import { userCollectProject } from "~/composables/user.js";
import { reportProject } from "~/utils/report-helpers.ts";
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
const data = useNuxtApp();
const route = useNativeRoute();
@@ -1311,6 +1385,10 @@ const description = computed(
} by ${members.value.find((x) => x.is_owner)?.user?.username || "a Creator"} on Modrinth`,
);
const canCreateServerFrom = computed(() => {
return project.value.project_type === "modpack" && project.value.server_side !== "unsupported";
});
if (!route.name.startsWith("type-id-settings")) {
useSeoMeta({
title: () => title.value,
@@ -1679,4 +1757,33 @@ const navLinks = computed(() => {
display: none;
}
}
.servers-popup {
box-shadow:
0 0 12px 1px rgba(0, 175, 92, 0.6),
var(--shadow-floating);
&::before {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid var(--color-button-bg);
content: " ";
position: absolute;
top: -7px;
left: 17px;
}
&::after {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid var(--color-raised-bg);
content: " ";
position: absolute;
top: -5px;
left: 18px;
}
}
</style>

View File

@@ -500,6 +500,7 @@
<section
id="plan"
pyro-hash="plan"
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>
@@ -603,7 +604,9 @@
<RightArrowIcon class="shrink-0" />
</button>
</ButtonStyled>
<p class="m-0 text-sm">Starting at $3/GB RAM</p>
<p v-if="lowestPrice" class="m-0 text-sm">
Starting at {{ formatPrice(locale, lowestPrice, selectedCurrency, true) }} / month
</p>
</div>
</div>
</div>
@@ -622,20 +625,34 @@ import {
VersionIcon,
ServerIcon,
} from "@modrinth/assets";
import { computed } from "vue";
import { monthsInInterval } from "@modrinth/ui/src/utils/billing.ts";
import { formatPrice } from "@modrinth/utils";
import { useVIntl } from "@vintl/vintl";
import { products } from "~/generated/state.json";
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";
const { locale } = useVIntl();
const billingPeriods = ref(["monthly", "quarterly"]);
const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly");
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
const pyroProducts = products
.filter((p) => p.metadata.type === "pyro")
.sort((a, b) => a.metadata.ram - b.metadata.ram);
const pyroPlanProducts = pyroProducts.filter(
(p) => p.metadata.ram === 4096 || p.metadata.ram === 6144 || p.metadata.ram === 8192,
);
pyroPlanProducts.sort((a, b) => a.metadata.ram - b.metadata.ram);
const lowestPrice = computed(() => {
const amount = pyroProducts[0]?.prices?.find(
(price) => price.currency_code === selectedCurrency.value,
)?.prices?.intervals?.[billingPeriod.value];
return amount ? amount / monthsInInterval[billingPeriod.value] : undefined;
});
const title = "Modrinth Servers";
const description =
@@ -799,6 +816,8 @@ async function fetchPaymentData() {
}
}
const selectedProjectId = ref();
const route = useRoute();
const isAtCapacity = computed(
() => isSmallAtCapacity.value && isMediumAtCapacity.value && isLargeAtCapacity.value,
@@ -817,7 +836,12 @@ const scrollToFaq = () => {
}
};
onMounted(scrollToFaq);
onMounted(() => {
scrollToFaq();
if (route.query?.project) {
selectedProjectId.value = route.query?.project;
}
});
watch(() => route.hash, scrollToFaq);
@@ -876,9 +900,9 @@ const selectProduct = async (product) => {
await nextTick();
if (product === "custom") {
purchaseModal.value?.show(billingPeriod.value);
purchaseModal.value?.show(billingPeriod.value, undefined, selectedProjectId.value);
} else {
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value);
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value, selectedProjectId.value);
}
};

View File

@@ -10,6 +10,10 @@ export default defineNuxtPlugin((nuxtApp) => {
instantMove: true,
distance: 8,
},
"dismissable-prompt": {
$extend: "dropdown",
placement: "bottom-start",
},
},
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,84 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Modrinth App Ad</title>
<script type="module" src="//js.rev.iq" data-domain="modrinth.com"></script>
<style>
* {
margin: 0;
padding: 0;
overflow: hidden;
cursor: pointer;
}
.ads-container {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
}
#plus-link {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
#modrinth-rail-1 {
border-radius: 1rem;
position: absolute;
left: 0;
top: 0;
z-index: 2;
}
</style>
</head>
<body>
<div class="ads-container">
<div id="plus-link"></div>
<div id="modrinth-rail-1" data-ad="app-rail" />
</div>
<script>
// window.addEventListener(
// "message",
// (event) => {
// if (event.data.modrinthAdClick && window.__TAURI_INTERNALS__) {
// window.__TAURI_INTERNALS__.invoke("plugin:ads|record_ads_click", {});
// }
//
// if (event.data.modrinthOpenUrl && window.__TAURI_INTERNALS__) {
// window.__TAURI_INTERNALS__.invoke("plugin:ads|open_link", {
// path: event.data.modrinthOpenUrl,
// origin: event.origin,
// });
// }
// },
// false,
// );
window.addEventListener("mousewheel", (event) => {
if (window.__TAURI_INTERNALS__) {
window.__TAURI_INTERNALS__.invoke("plugin:ads|scroll_ads_window", {
scroll: event.deltaY,
});
}
});
document.addEventListener("contextmenu", (event) => event.preventDefault());
const plusLink = document.getElementById("plus-link");
plusLink.addEventListener("click", function () {
window.__TAURI_INTERNALS__.invoke("plugin:ads|record_ads_click", {});
window.__TAURI_INTERNALS__.invoke("plugin:ads|open_link", {
path: "https://modrinth.com/plus",
origin: "https://modrinth.com",
});
});
</script>
</body>
</html>

View File

@@ -2031,7 +2031,9 @@ pub async fn change_password(
Some(user)
} else {
None
return Err(ApiError::CustomAuthentication(
"The password change flow code is invalid or has expired. Did you copy it promptly and correctly?".to_string(),
));
}
} else {
None

View File

@@ -667,8 +667,13 @@ pub async fn organization_delete(
)
.await?;
for team_id in organization_project_teams {
database::models::DBTeamMember::clear_cache(team_id, &redis).await?;
for team_id in &organization_project_teams {
database::models::DBTeamMember::clear_cache(*team_id, &redis).await?;
}
if !organization_project_teams.is_empty() {
database::models::DBUser::clear_project_cache(&[owner_id], &redis)
.await?;
}
if result.is_some() {

View File

@@ -45,7 +45,6 @@ async fn get_webhook_metadata(
project_id: ProjectId,
pool: &PgPool,
redis: &RedisPool,
emoji: bool,
) -> Result<Option<WebhookMetadata>, ApiError> {
let project = crate::database::models::project_item::DBProject::get_id(
project_id.into(),
@@ -159,56 +158,13 @@ async fn get_webhook_metadata(
categories_formatted: project
.categories
.into_iter()
.map(|mut x| format!("{}{x}", x.remove(0).to_uppercase()))
.collect::<Vec<_>>(),
.map(format_category_or_loader)
.collect(),
loaders_formatted: project
.inner
.loaders
.into_iter()
.map(|loader| {
let mut x = if &*loader == "datapack" {
"Data Pack".to_string()
} else if &*loader == "mrpack" {
"Modpack".to_string()
} else {
loader.clone()
};
if emoji {
let emoji_id: i64 = match &*loader {
"bukkit" => 1049793345481883689,
"bungeecord" => 1049793347067314220,
"canvas" => 1107352170656968795,
"datapack" => 1057895494652788866,
"fabric" => 1049793348719890532,
"folia" => 1107348745571537018,
"forge" => 1049793350498275358,
"iris" => 1107352171743281173,
"liteloader" => 1049793351630733333,
"minecraft" => 1049793352964526100,
"modloader" => 1049793353962762382,
"neoforge" => 1140437823783190679,
"optifine" => 1107352174415052901,
"paper" => 1049793355598540810,
"purpur" => 1140436034505674762,
"quilt" => 1049793857681887342,
"rift" => 1049793359373414502,
"spigot" => 1049793413886779413,
"sponge" => 1049793416969605231,
"vanilla" => 1107350794178678855,
"velocity" => 1049793419108700170,
"waterfall" => 1049793420937412638,
_ => 1049805243866681424,
};
format!(
"<:{loader}:{emoji_id}> {}{x}",
x.remove(0).to_uppercase()
)
} else {
format!("{}{x}", x.remove(0).to_uppercase())
}
})
.map(format_category_or_loader)
.collect(),
versions_formatted: formatted_game_versions,
gallery_image: project
@@ -229,7 +185,7 @@ pub async fn send_slack_webhook(
webhook_url: String,
message: Option<String>,
) -> Result<(), ApiError> {
let metadata = get_webhook_metadata(project_id, pool, redis, false).await?;
let metadata = get_webhook_metadata(project_id, pool, redis).await?;
if let Some(metadata) = metadata {
let mut blocks = vec![];
@@ -400,7 +356,7 @@ pub async fn send_discord_webhook(
webhook_url: String,
message: Option<String>,
) -> Result<(), ApiError> {
let metadata = get_webhook_metadata(project_id, pool, redis, true).await?;
let metadata = get_webhook_metadata(project_id, pool, redis).await?;
if let Some(project) = metadata {
let mut fields = vec![];
@@ -619,3 +575,35 @@ fn get_gv_range(
output
}
// Converted from knossos
// See: packages/utils/utils.ts
// https://github.com/modrinth/code/blob/47af459f24e541a844b42b1c8427af6a7b86381e/packages/utils/utils.ts#L147-L196
fn format_category_or_loader(mut x: String) -> String {
match &*x {
"modloader" => "Risugami's ModLoader".to_string(),
"bungeecord" => "BungeeCord".to_string(),
"liteloader" => "LiteLoader".to_string(),
"neoforge" => "NeoForge".to_string(),
"game-mechanics" => "Game Mechanics".to_string(),
"worldgen" => "World Generation".to_string(),
"core-shaders" => "Core Shaders".to_string(),
"gui" => "GUI".to_string(),
"8x-" => "8x or lower".to_string(),
"512x+" => "512x or higher".to_string(),
"kitchen-sink" => "Kitchen Sink".to_string(),
"path-tracing" => "Path Tracing".to_string(),
"pbr" => "PBR".to_string(),
"datapack" => "Data Pack".to_string(),
"colored-lighting" => "Colored Lighting".to_string(),
"optifine" => "OptiFine".to_string(),
"bta-babric" => "BTA (Babric)".to_string(),
"legacy-fabric" => "Legacy Fabric".to_string(),
"java-agent" => "Java Agent".to_string(),
"nilloader" => "NilLoader".to_string(),
"mrpack" => "Modpack".to_string(),
"minecraft" => "Resource Pack".to_string(),
"vanilla" => "Vanilla Shader".to_string(),
_ => format!("{}{x}", x.remove(0).to_uppercase()),
}
}

View File

@@ -23,6 +23,7 @@ quick-xml = { workspace = true, features = ["async-tokio"] }
enumset.workspace = true
chardetng.workspace = true
encoding_rs.workspace = true
hashlink.workspace = true
chrono = { workspace = true, features = ["serde"] }
daedalus.workspace = true
@@ -75,6 +76,9 @@ ariadne.workspace = true
[target.'cfg(windows)'.dependencies]
winreg.workspace = true
[build-dependencies]
dunce.workspace = true
[features]
tauri = ["dep:tauri"]
cli = ["dep:indicatif"]

44
packages/app-lib/build.rs Normal file
View File

@@ -0,0 +1,44 @@
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::{Command, exit};
use std::{env, fs};
fn main() {
println!("cargo::rerun-if-changed=java/gradle");
println!("cargo::rerun-if-changed=java/src");
println!("cargo::rerun-if-changed=java/build.gradle.kts");
println!("cargo::rerun-if-changed=java/settings.gradle.kts");
println!("cargo::rerun-if-changed=java/gradle.properties");
let out_dir =
dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap()))
.unwrap();
println!(
"cargo::rustc-env=JAVA_JARS_DIR={}",
out_dir.join("java/libs").display()
);
let gradle_path = fs::canonicalize(
#[cfg(target_os = "windows")]
"java\\gradlew.bat",
#[cfg(not(target_os = "windows"))]
"java/gradlew",
)
.unwrap();
let mut build_dir_str = OsString::from("-Dorg.gradle.project.buildDir=");
build_dir_str.push(out_dir.join("java"));
let exit_status = Command::new(gradle_path)
.arg(build_dir_str)
.arg("build")
.arg("--no-daemon")
.arg("--console=rich")
.current_dir(dunce::canonicalize("java").unwrap())
.status()
.expect("Failed to wait on Gradle build");
if !exit_status.success() {
println!("cargo::error=Gradle build failed with {exit_status}");
exit(exit_status.code().unwrap_or(1));
}
}

12
packages/app-lib/java/.gitattributes vendored Normal file
View File

@@ -0,0 +1,12 @@
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# Linux start script should use lf
/gradlew text eol=lf
# These are Windows script files and should use crlf
*.bat text eol=crlf
# Binary files should be left untouched
*.jar binary

5
packages/app-lib/java/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# Ignore Gradle project-specific cache directory
.gradle
# Ignore Gradle build output directory
build

View File

@@ -0,0 +1,44 @@
plugins {
java
id("com.diffplug.spotless") version "7.0.4"
}
repositories {
mavenCentral()
}
dependencies {
testImplementation(libs.junit.jupiter)
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(11)
}
}
tasks.withType<JavaCompile>().configureEach {
options.release = 8
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
}
spotless {
java {
palantirJavaFormat()
removeUnusedImports()
}
}
tasks.jar {
archiveFileName = "theseus.jar"
}
tasks.named<Test>("test") {
useJUnitPlatform()
}
tasks.withType<AbstractArchiveTask>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}

View File

@@ -0,0 +1,5 @@
# This file was generated by the Gradle 'init' task.
# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties
org.gradle.configuration-cache=true

View File

@@ -0,0 +1,5 @@
[versions]
junit-jupiter = "5.12.1"
[libraries]
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
packages/app-lib/java/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
packages/app-lib/java/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,6 @@
plugins {
// Apply the foojay-resolver plugin to allow automatic download of JDKs
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}
rootProject.name = "theseus"

View File

@@ -1,8 +1,7 @@
package com.modrinth.theseus;
public final class JavaInfo {
private static final String[] CHECKED_PROPERTIES = new String[] {
"os.arch",
"java.version"
};
private static final String[] CHECKED_PROPERTIES = new String[] {"os.arch", "java.version"};
public static void main(String[] args) {
int returnCode = 0;
@@ -19,4 +18,4 @@ public final class JavaInfo {
System.exit(returnCode);
}
}
}

View File

@@ -0,0 +1,118 @@
package com.modrinth.theseus;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
public final class MinecraftLaunch {
public static void main(String[] args) throws IOException, ReflectiveOperationException {
final String mainClass = args[0];
final String[] gameArgs = Arrays.copyOfRange(args, 1, args.length);
System.setProperty("modrinth.process.args", String.join("\u001f", gameArgs));
parseInput();
relaunch(mainClass, gameArgs);
}
private static void parseInput() throws IOException {
final ByteArrayOutputStream line = new ByteArrayOutputStream();
while (true) {
final int b = System.in.read();
if (b < 0) {
throw new IllegalStateException("Stdin terminated while parsing");
}
if (b != '\n') {
line.write(b);
continue;
}
if (handleLine(line.toString("UTF-8"))) {
break;
}
line.reset();
}
}
private static boolean handleLine(String line) {
final String[] parts = line.split("\t", 2);
switch (parts[0]) {
case "property": {
final String[] keyValue = parts[1].split("\t", 2);
System.setProperty(keyValue[0], keyValue[1]);
return false;
}
case "launch":
return true;
}
System.err.println("Unknown input line " + line);
return false;
}
private static void relaunch(String mainClassName, String[] args) throws ReflectiveOperationException {
final int javaVersion = getJavaVersion();
final Class<?> mainClass = Class.forName(mainClassName);
if (javaVersion >= 25) {
Method mainMethod;
try {
mainMethod = findMainMethodJep512(mainClass);
} catch (ReflectiveOperationException e) {
System.err.println(
"[MODRINTH] Unable to call JDK findMainMethod. Falling back to pre-Java 25 main method finding.");
// If the above fails due to JDK implementation details changing
try {
mainMethod = findSimpleMainMethod(mainClass);
} catch (ReflectiveOperationException innerE) {
e.addSuppressed(innerE);
throw e;
}
}
if (mainMethod == null) {
throw new IllegalArgumentException("Could not find main() method");
}
Object thisObject = null;
if (!Modifier.isStatic(mainMethod.getModifiers())) {
thisObject = mainClass.getDeclaredConstructor().newInstance();
}
final Object[] parameters = mainMethod.getParameterCount() > 0 ? new Object[] {args} : new Object[] {};
mainMethod.invoke(thisObject, parameters);
} else {
findSimpleMainMethod(mainClass).invoke(null, new Object[] {args});
}
}
private static int getJavaVersion() {
String javaVersion = System.getProperty("java.version");
final int dotIndex = javaVersion.indexOf('.');
if (dotIndex != -1) {
javaVersion = javaVersion.substring(0, dotIndex);
}
final int dashIndex = javaVersion.indexOf('-');
if (dashIndex != -1) {
javaVersion = javaVersion.substring(0, dashIndex);
}
return Integer.parseInt(javaVersion);
}
private static Method findMainMethodJep512(Class<?> mainClass) throws ReflectiveOperationException {
// BEWARE BELOW: This code may break if JDK internals to find the main method
// change.
final Class<?> methodFinderClass = Class.forName("jdk.internal.misc.MethodFinder");
final Method methodFinderMethod = methodFinderClass.getDeclaredMethod("findMainMethod", Class.class);
final Object result = methodFinderMethod.invoke(null, mainClass);
return (Method) result;
}
private static Method findSimpleMainMethod(Class<?> mainClass) throws NoSuchMethodException {
return mainClass.getMethod("main", String[].class);
}
}

View File

@@ -9,7 +9,7 @@ use std::path::PathBuf;
use sysinfo::{MemoryRefreshKind, RefreshKind};
use crate::util::io;
use crate::util::jre::extract_java_majorminor_version;
use crate::util::jre::extract_java_version;
use crate::{
LoadingBarType, State,
util::jre::{self},
@@ -38,9 +38,9 @@ pub async fn find_filtered_jres(
Ok(if let Some(java_version) = java_version {
jres.into_iter()
.filter(|jre| {
let jre_version = extract_java_majorminor_version(&jre.version);
let jre_version = extract_java_version(&jre.version);
if let Ok(jre_version) = jre_version {
jre_version.1 == java_version
jre_version == java_version
} else {
false
}
@@ -157,8 +157,8 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
}
// Validates JRE at a given at a given path
pub async fn check_jre(path: PathBuf) -> crate::Result<Option<JavaVersion>> {
Ok(jre::check_java_at_filepath(&path).await)
pub async fn check_jre(path: PathBuf) -> crate::Result<JavaVersion> {
jre::check_java_at_filepath(&path).await
}
// Test JRE at a given path
@@ -166,11 +166,11 @@ pub async fn test_jre(
path: PathBuf,
major_version: u32,
) -> crate::Result<bool> {
let Some(jre) = jre::check_java_at_filepath(&path).await else {
let Ok(jre) = jre::check_java_at_filepath(&path).await else {
return Ok(false);
};
let (major, _) = extract_java_majorminor_version(&jre.version)?;
Ok(major == major_version)
let version = extract_java_version(&jre.version)?;
Ok(version == major_version)
}
// Gets maximum memory in KiB.

View File

@@ -13,7 +13,7 @@ use daedalus::{
modded::SidedDataEntry,
};
use dunce::canonicalize;
use std::collections::HashSet;
use hashlink::LinkedHashSet;
use std::io::{BufRead, BufReader};
use std::{collections::HashMap, path::Path};
use uuid::Uuid;
@@ -24,7 +24,7 @@ const TEMPORARY_REPLACE_CHAR: &str = "\n";
pub fn get_class_paths(
libraries_path: &Path,
libraries: &[Library],
client_path: &Path,
launcher_class_path: &[&Path],
java_arch: &str,
minecraft_updated: bool,
) -> crate::Result<String> {
@@ -48,20 +48,22 @@ pub fn get_class_paths(
Some(get_lib_path(libraries_path, &library.name, false))
})
.collect::<Result<HashSet<_>, _>>()?;
.collect::<Result<LinkedHashSet<_>, _>>()?;
cps.insert(
canonicalize(client_path)
.map_err(|_| {
crate::ErrorKind::LauncherError(format!(
"Specified class path {} does not exist",
client_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_string(),
);
for launcher_path in launcher_class_path {
cps.insert(
canonicalize(launcher_path)
.map_err(|_| {
crate::ErrorKind::LauncherError(format!(
"Specified class path {} does not exist",
launcher_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_string(),
);
}
Ok(cps
.into_iter()

View File

@@ -9,7 +9,7 @@ use crate::state::{
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
};
use crate::util::io;
use crate::{State, process, state as st};
use crate::{State, get_resource_file, process, state as st};
use chrono::Utc;
use daedalus as d;
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
@@ -19,6 +19,7 @@ use serde::Deserialize;
use st::Profile;
use std::fmt::Write;
use std::path::PathBuf;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
mod args;
@@ -124,12 +125,10 @@ pub async fn get_java_version_from_profile(
version_info: &VersionInfo,
) -> crate::Result<Option<JavaVersion>> {
if let Some(java) = profile.java_path.as_ref() {
let java = crate::api::jre::check_jre(std::path::PathBuf::from(java))
.await
.ok()
.flatten();
let java =
crate::api::jre::check_jre(std::path::PathBuf::from(java)).await;
if let Some(java) = java {
if let Ok(java) = java {
return Ok(Some(java));
}
}
@@ -289,13 +288,7 @@ pub async fn install_minecraft(
};
// Test jre version
let java_version = crate::api::jre::check_jre(java_version.clone())
.await?
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Java path invalid or non-functional: {java_version:?}"
))
})?;
let java_version = crate::api::jre::check_jre(java_version.clone()).await?;
if set_java {
java_version.upsert(&state.pool).await?;
@@ -560,14 +553,7 @@ pub async fn launch_minecraft(
// Test jre version
let java_version =
crate::api::jre::check_jre(java_version.path.clone().into())
.await?
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Java path invalid or non-functional: {}",
java_version.path
))
})?;
crate::api::jre::check_jre(java_version.path.clone().into()).await?;
let client_path = state
.directories
@@ -603,33 +589,43 @@ pub async fn launch_minecraft(
io::create_dir_all(&natives_dir).await?;
}
command
.args(
args::get_jvm_arguments(
args.get(&d::minecraft::ArgumentType::Jvm)
.map(|x| x.as_slice()),
&natives_dir,
let (main_class_keep_alive, main_class_path) =
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
command.args(
args::get_jvm_arguments(
args.get(&d::minecraft::ArgumentType::Jvm)
.map(|x| x.as_slice()),
&natives_dir,
&state.directories.libraries_dir(),
&state.directories.log_configs_dir(),
&args::get_class_paths(
&state.directories.libraries_dir(),
&state.directories.log_configs_dir(),
&args::get_class_paths(
&state.directories.libraries_dir(),
version_info.libraries.as_slice(),
&client_path,
&java_version.architecture,
minecraft_updated,
)?,
&version_jar,
*memory,
Vec::from(java_args),
version_info.libraries.as_slice(),
&[&main_class_path, &client_path],
&java_version.architecture,
quick_play_type,
version_info
.logging
.as_ref()
.and_then(|x| x.get(&LoggingSide::Client)),
)?
.into_iter(),
)
minecraft_updated,
)?,
&version_jar,
*memory,
Vec::from(java_args),
&java_version.architecture,
quick_play_type,
version_info
.logging
.as_ref()
.and_then(|x| x.get(&LoggingSide::Client)),
)?
.into_iter(),
);
// The java launcher code requires internal JDK code in Java 25+ in order to support JEP 512
if java_version.parsed_version >= 25 {
command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED");
}
command
.arg("com.modrinth.theseus.MinecraftLaunch")
.arg(version_info.main_class.clone())
.args(
args::get_minecraft_arguments(
@@ -744,6 +740,40 @@ pub async fn launch_minecraft(
post_exit_hook,
state.directories.profile_logs_dir(&profile.path),
version_info.logging.is_some(),
main_class_keep_alive,
async |process: &ProcessMetadata, stdin| {
let process_start_time = process.start_time.to_rfc3339();
let profile_created_time = profile.created.to_rfc3339();
let profile_modified_time = profile.modified.to_rfc3339();
let system_properties = [
("modrinth.process.startTime", Some(&process_start_time)),
("modrinth.profile.created", Some(&profile_created_time)),
("modrinth.profile.icon", profile.icon_path.as_ref()),
(
"modrinth.profile.link.project",
profile.linked_data.as_ref().map(|x| &x.project_id),
),
(
"modrinth.profile.link.version",
profile.linked_data.as_ref().map(|x| &x.version_id),
),
("modrinth.profile.modified", Some(&profile_modified_time)),
("modrinth.profile.name", Some(&profile.name)),
];
for (key, value) in system_properties {
let Some(value) = value else {
continue;
};
stdin.write_all(b"property\t").await?;
stdin.write_all(key.as_bytes()).await?;
stdin.write_u8(b'\t').await?;
stdin.write_all(value.as_bytes()).await?;
stdin.write_u8(b'\n').await?;
}
stdin.write_all(b"launch\n").await?;
stdin.flush().await?;
Ok(())
},
)
.await
}

View File

@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Clone)]
pub struct JavaVersion {
pub major_version: u32,
pub parsed_version: u32,
pub version: String,
pub architecture: String,
pub path: String,
@@ -30,7 +30,7 @@ impl JavaVersion {
.await?;
Ok(res.map(|x| JavaVersion {
major_version,
parsed_version: major_version,
version: x.full_version,
architecture: x.architecture,
path: x.path,
@@ -52,7 +52,7 @@ impl JavaVersion {
acc.insert(
x.major_version as u32,
JavaVersion {
major_version: x.major_version as u32,
parsed_version: x.major_version as u32,
version: x.full_version,
architecture: x.architecture,
path: x.path,
@@ -70,7 +70,7 @@ impl JavaVersion {
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let major_version = self.major_version as i32;
let major_version = self.parsed_version as i32;
sqlx::query!(
"

View File

@@ -83,7 +83,7 @@ where
settings.prev_custom_dir = Some(old_launcher_root_str.clone());
for (_, legacy_version) in legacy_settings.java_globals.0 {
if let Ok(Some(java_version)) =
if let Ok(java_version) =
check_jre(PathBuf::from(legacy_version.path)).await
{
java_version.upsert(exec).await?;

View File

@@ -8,12 +8,14 @@ use quick_xml::Reader;
use quick_xml::events::Event;
use serde::Deserialize;
use serde::Serialize;
use std::fmt::Debug;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::process::{Child, ChildStdin, Command};
use uuid::Uuid;
const LAUNCHER_LOG_PATH: &str = "launcher_log.txt";
@@ -35,6 +37,7 @@ impl ProcessManager {
}
}
#[allow(clippy::too_many_arguments)]
pub async fn insert_new_process(
&self,
profile_path: &str,
@@ -42,24 +45,42 @@ impl ProcessManager {
post_exit_command: Option<String>,
logs_folder: PathBuf,
xml_logging: bool,
main_class_keep_alive: TempDir,
post_process_init: impl AsyncFnOnce(
&ProcessMetadata,
&mut ChildStdin,
) -> crate::Result<()>,
) -> crate::Result<ProcessMetadata> {
mc_command.stdout(std::process::Stdio::piped());
mc_command.stderr(std::process::Stdio::piped());
mc_command.stdin(std::process::Stdio::piped());
let mut mc_proc = mc_command.spawn().map_err(IOError::from)?;
let stdout = mc_proc.stdout.take();
let stderr = mc_proc.stderr.take();
let process = Process {
let mut process = Process {
metadata: ProcessMetadata {
uuid: Uuid::new_v4(),
start_time: Utc::now(),
profile_path: profile_path.to_string(),
},
child: mc_proc,
_main_class_keep_alive: main_class_keep_alive,
};
if let Err(e) = post_process_init(
&process.metadata,
&mut process.child.stdin.as_mut().unwrap(),
)
.await
{
tracing::error!("Failed to run post-process init: {e}");
let _ = process.child.kill().await;
return Err(e);
}
let metadata = process.metadata.clone();
if !logs_folder.exists() {
@@ -193,6 +214,7 @@ pub struct ProcessMetadata {
struct Process {
metadata: ProcessMetadata,
child: Child,
_main_class_keep_alive: TempDir,
}
#[derive(Debug, Default)]

View File

@@ -2,7 +2,6 @@
// A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error.
use std::{io::Write, path::Path};
use tempfile::NamedTempFile;
use tokio::task::spawn_blocking;
@@ -299,3 +298,44 @@ pub async fn metadata(
path: path.to_string_lossy().to_string(),
})
}
/// Gets a resource file from the executable. Returns `theseus::Result<(TempDir, PathBuf)>`.
#[macro_export]
macro_rules! get_resource_file {
(directory: $relative_dir:expr, file: $file_name:expr) => {
'get_resource_file: {
let dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => {
break 'get_resource_file $crate::Result::Err(
$crate::util::io::IOError::from(e).into(),
);
}
};
let path = dir.path().join($file_name);
if let Err(e) = $crate::util::io::write(
&path,
include_bytes!(concat!($relative_dir, "/", $file_name)),
)
.await
{
break 'get_resource_file $crate::Result::Err(e.into());
}
let path = match $crate::util::io::canonicalize(path) {
Ok(path) => path,
Err(e) => {
break 'get_resource_file $crate::Result::Err(e.into());
}
};
$crate::Result::Ok((dir, path))
}
};
($relative_dir:literal / $file_name:literal) => {
get_resource_file!(directory: $relative_dir, file: $file_name)
};
(env $dir_env_name:literal / $file_name:literal) => {
get_resource_file!(directory: env!($dir_env_name), file: $file_name)
};
}

View File

@@ -7,7 +7,7 @@ use std::process::Command;
use std::{collections::HashSet, path::Path};
use tokio::task::JoinError;
use crate::State;
use crate::{State, get_resource_file};
#[cfg(target_os = "windows")]
use winreg::{
RegKey,
@@ -183,7 +183,6 @@ pub async fn get_all_jre() -> Result<Vec<JavaVersion>, JREError> {
// Gets all JREs from the PATH env variable
#[tracing::instrument]
async fn get_all_autoinstalled_jre_path() -> Result<HashSet<PathBuf>, JREError>
{
Box::pin(async move {
@@ -239,54 +238,49 @@ pub const JAVA_BIN: &str = if cfg!(target_os = "windows") {
pub async fn check_java_at_filepaths(
paths: HashSet<PathBuf>,
) -> HashSet<JavaVersion> {
let jres = stream::iter(paths.into_iter())
stream::iter(paths.into_iter())
.map(|p: PathBuf| {
tokio::task::spawn(async move { check_java_at_filepath(&p).await })
})
.buffer_unordered(64)
.collect::<Vec<_>>()
.await;
jres.into_iter().filter_map(|x| x.ok()).flatten().collect()
.filter_map(async |x| x.ok().and_then(Result::ok))
.collect()
.await
}
// For example filepath 'path', attempt to resolve it and get a Java version at this path
// If no such path exists, or no such valid java at this path exists, returns None
#[tracing::instrument]
pub async fn check_java_at_filepath(path: &Path) -> Option<JavaVersion> {
pub async fn check_java_at_filepath(path: &Path) -> crate::Result<JavaVersion> {
// Attempt to canonicalize the potential java filepath
// If it fails, this path does not exist and None is returned (no Java here)
let Ok(path) = io::canonicalize(path) else {
return None;
};
let path = io::canonicalize(path)?;
// Checks for existence of Java at this filepath
// Adds JAVA_BIN to the end of the path if it is not already there
let java = if path.file_name()?.to_str()? != JAVA_BIN {
let java = if path
.file_name()
.and_then(|x| x.to_str())
.is_some_and(|x| x != JAVA_BIN)
{
path.join(JAVA_BIN)
} else {
path
};
if !java.exists() {
return None;
return Err(JREError::NoExecutable(java).into());
};
let bytes = include_bytes!("../../library/JavaInfo.class");
let Ok(tempdir) = tempfile::tempdir() else {
return None;
};
let file_path = tempdir.path().join("JavaInfo.class");
io::write(&file_path, bytes).await.ok()?;
let (_temp, file_path) =
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
let output = Command::new(&java)
.arg("-cp")
.arg(file_path.parent().unwrap())
.arg("JavaInfo")
.arg(file_path)
.arg("com.modrinth.theseus.JavaInfo")
.env_remove("_JAVA_OPTIONS")
.output()
.ok()?;
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
@@ -308,64 +302,49 @@ pub async fn check_java_at_filepath(path: &Path) -> Option<JavaVersion> {
// Extract version info from it
if let Some(arch) = java_arch {
if let Some(version) = java_version {
if let Ok((_, major_version)) =
extract_java_majorminor_version(version)
{
if let Ok(version) = extract_java_version(version) {
let path = java.to_string_lossy().to_string();
return Some(JavaVersion {
major_version,
return Ok(JavaVersion {
parsed_version: version,
path,
version: version.to_string(),
architecture: arch.to_string(),
});
}
return Err(JREError::InvalidJREVersion(version.to_owned()).into());
}
}
None
Err(JREError::FailedJavaCheck(java).into())
}
/// Extract major/minor version from a java version string
/// Gets the minor version or an error, and assumes 1 for major version if it could not find
/// "1.8.0_361" -> (1, 8)
/// "20" -> (1, 20)
pub fn extract_java_majorminor_version(
version: &str,
) -> Result<(u32, u32), JREError> {
pub fn extract_java_version(version: &str) -> Result<u32, JREError> {
let mut split = version.split('.');
let major_opt = split.next();
let mut major;
// Try minor. If doesn't exist, in format like "20" so use major
let mut minor = if let Some(minor) = split.next() {
major = major_opt.unwrap_or("1").parse::<u32>()?;
minor.parse::<u32>()?
} else {
// Formatted like "20", only one value means that is minor version
major = 1;
major_opt
.ok_or_else(|| JREError::InvalidJREVersion(version.to_string()))?
.parse::<u32>()?
};
// Java start should always be 1. If more than 1, it is formatted like "17.0.1.2" and starts with minor version
if major > 1 {
minor = major;
major = 1;
let version = split.next().unwrap();
let version = version.split_once('-').map_or(version, |(x, _)| x);
let mut version = version.parse::<u32>()?;
if version == 1 {
version = split.next().map_or(Ok(1), |x| x.parse::<u32>())?;
}
Ok((major, minor))
Ok(version)
}
#[derive(thiserror::Error, Debug)]
pub enum JREError {
#[error("Command error : {0}")]
#[error("Command error: {0}")]
IOError(#[from] std::io::Error),
#[error("Env error: {0}")]
EnvError(#[from] env::VarError),
#[error("No JRE found for required version: {0}")]
NoJREFound(String),
#[error("No executable found at {0}")]
NoExecutable(PathBuf),
#[error("Could not check Java version at path {0}")]
FailedJavaCheck(PathBuf),
#[error("Invalid JRE version string: {0}")]
InvalidJREVersion(String),
@@ -376,9 +355,9 @@ pub enum JREError {
#[error("Join error: {0}")]
JoinError(#[from] JoinError),
#[error("No stored tag for Minecraft Version {0}")]
#[error("No stored tag for Minecraft version {0}")]
NoMinecraftVersionFound(String),
#[error("Error getting launcher sttae")]
#[error("Error getting launcher state")]
StateError,
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg>

After

Width:  |  Height:  |  Size: 441 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M22 12H2M11.1 4H7.2c-.8 0-1.5.4-1.8 1.1L2 12v6c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-6l-1.5-3M6 16h0M10 16h0M14.4 4h6M17.4 1v6"/></svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@@ -44,6 +44,7 @@ import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
import _AsteriskIcon from './icons/asterisk.svg?component'
import _BadgeCheckIcon from './icons/badge-check.svg?component'
import _BanIcon from './icons/ban.svg?component'
import _BellIcon from './icons/bell.svg?component'
import _BellRingIcon from './icons/bell-ring.svg?component'
@@ -163,6 +164,7 @@ import _ScanEyeIcon from './icons/scan-eye.svg?component'
import _SearchIcon from './icons/search.svg?component'
import _SendIcon from './icons/send.svg?component'
import _ServerIcon from './icons/server.svg?component'
import _ServerPlusIcon from './icons/server-plus.svg?component'
import _SettingsIcon from './icons/settings.svg?component'
import _ShareIcon from './icons/share.svg?component'
import _ShieldIcon from './icons/shield.svg?component'
@@ -264,6 +266,7 @@ export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
export const AsteriskIcon = _AsteriskIcon
export const BadgeCheckIcon = _BadgeCheckIcon
export const BanIcon = _BanIcon
export const BellIcon = _BellIcon
export const BellRingIcon = _BellRingIcon
@@ -383,6 +386,7 @@ export const ScanEyeIcon = _ScanEyeIcon
export const SearchIcon = _SearchIcon
export const SendIcon = _SendIcon
export const ServerIcon = _ServerIcon
export const ServerPlusIcon = _ServerPlusIcon
export const SettingsIcon = _SettingsIcon
export const ShareIcon = _ShareIcon
export const ShieldIcon = _ShieldIcon

View File

@@ -822,10 +822,69 @@ a,
// TOOLTIPS
.v-popper--theme-dropdown,
.v-popper--theme-dropdown.v-popper--theme-ribbit-popout {
.v-popper__inner {
border: 1px solid var(--color-button-bg) !important;
padding: var(--gap-sm) !important;
width: fit-content !important;
border-radius: var(--radius-md) !important;
background-color: var(--color-raised-bg) !important;
box-shadow: var(--shadow-floating) !important;
}
.v-popper__arrow-outer {
border-color: var(--color-button-bg) !important;
}
.v-popper__arrow-inner {
border-color: var(--color-raised-bg) !important;
}
}
.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper {
transform-origin: top right;
}
.v-popper__popper[data-popper-placement='top-end'] .v-popper__wrapper {
transform-origin: bottom right;
}
.v-popper__popper[data-popper-placement='bottom-start'] .v-popper__wrapper {
transform-origin: top left;
}
.v-popper__popper[data-popper-placement='top-start'] .v-popper__wrapper {
transform-origin: bottom left;
}
.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper {
transform: scale(0.85);
opacity: 0;
}
.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper {
transform: scale(1);
opacity: 1;
transition:
transform 0.125s ease-in-out,
opacity 0.125s ease-in-out;
}
.v-popper__popper.v-popper__popper--hide-from .v-popper__wrapper {
transform: none;
opacity: 1;
transition: transform 0.0625s;
}
.v-popper__popper.v-popper__popper--hide-to .v-popper__wrapper {
//transform: scale(.9);
}
.v-popper--theme-tooltip {
.v-popper__inner {
background: var(--color-tooltip-bg) !important;
color: var(--color-tooltip-text) !important;
color: initial !important;
padding: 0.5rem 0.5rem !important;
border-radius: var(--radius-sm) !important;
filter: drop-shadow(5px 5px 0.8rem rgba(0, 0, 0, 0.35));
@@ -840,6 +899,30 @@ a,
}
}
.v-popper--theme-dismissable-prompt {
z-index: 10;
.v-popper__inner {
background: var(--color-raised-bg) !important;
border: 1px solid var(--color-button-border);
color: var(--color-tooltip-text) !important;
padding: 0.75rem 1rem !important;
border-radius: 0.75rem !important;
filter: drop-shadow(5px 5px 0.8rem rgba(0, 0, 0, 0.35));
font-size: 0.9rem;
font-weight: bold;
line-height: 1;
}
.v-popper__arrow-outer {
border-color: var(--color-button-border);
}
.v-popper__arrow-inner {
border-color: var(--color-raised-bg);
}
}
// MARKDOWN
.markdown-body {
@@ -1205,65 +1288,6 @@ select {
border-top-right-radius: var(--radius-md) !important;
}
.v-popper--theme-dropdown,
.v-popper--theme-dropdown.v-popper--theme-ribbit-popout {
.v-popper__inner {
border: 1px solid var(--color-button-bg) !important;
padding: var(--gap-sm) !important;
width: fit-content !important;
border-radius: var(--radius-md) !important;
background-color: var(--color-raised-bg) !important;
box-shadow: var(--shadow-floating) !important;
}
.v-popper__arrow-outer {
border-color: var(--color-button-bg) !important;
}
.v-popper__arrow-inner {
border-color: var(--color-raised-bg) !important;
}
}
.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper {
transform-origin: top right;
}
.v-popper__popper[data-popper-placement='top-end'] .v-popper__wrapper {
transform-origin: bottom right;
}
.v-popper__popper[data-popper-placement='bottom-start'] .v-popper__wrapper {
transform-origin: top left;
}
.v-popper__popper[data-popper-placement='top-start'] .v-popper__wrapper {
transform-origin: bottom left;
}
.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper {
transform: scale(0.85);
opacity: 0;
}
.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper {
transform: scale(1);
opacity: 1;
transition:
transform 0.125s ease-in-out,
opacity 0.125s ease-in-out;
}
.v-popper__popper.v-popper__popper--hide-from .v-popper__wrapper {
transform: none;
opacity: 1;
transition: transform 0.0625s;
}
.v-popper__popper.v-popper__popper--hide-to .v-popper__wrapper {
//transform: scale(.9);
}
.preview-radio {
width: 100% !important;
border-radius: var(--radius-md);

View File

@@ -59,6 +59,7 @@ const selectedPlan = ref<ServerPlan>()
const selectedInterval = ref<ServerBillingInterval>('quarterly')
const loading = ref(false)
const selectedRegion = ref<string>()
const projectId = ref<string>()
const {
initializeStripe,
@@ -85,6 +86,7 @@ const {
selectedPlan,
selectedInterval,
selectedRegion,
projectId,
props.initiatePayment,
props.onError,
)
@@ -201,7 +203,7 @@ watch(selectedPlan, () => {
console.log(selectedPlan.value)
})
function begin(interval: ServerBillingInterval, plan?: ServerPlan) {
function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) {
loading.value = false
selectedPlan.value = plan
selectedInterval.value = interval
@@ -209,6 +211,7 @@ function begin(interval: ServerBillingInterval, plan?: ServerPlan) {
selectedPaymentMethod.value = undefined
currentStep.value = steps[0]
skipPaymentMethods.value = true
projectId.value = project
modal.value?.show()
}
@@ -253,6 +256,8 @@ defineExpose({
:pings="pings"
:custom="customServer"
:available-products="availableProducts"
:currency="currency"
:interval="selectedInterval"
:fetch-stock="fetchStock"
/>
<PaymentMethodSelector

View File

@@ -4,19 +4,28 @@ import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { onMounted, ref, computed, watch } from 'vue'
import type { RegionPing } from './ModrinthServersPurchaseModal.vue'
import type { ServerPlan, ServerRegion, ServerStockRequest } from '../../utils/billing'
import {
monthsInInterval,
type ServerBillingInterval,
type ServerPlan,
type ServerRegion,
type ServerStockRequest,
} from '../../utils/billing'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import Slider from '../base/Slider.vue'
import { SpinnerIcon, XIcon, InfoIcon } from '@modrinth/assets'
import ServersSpecs from './ServersSpecs.vue'
import { formatPrice } from '../../../../utils'
const { formatMessage } = useVIntl()
const { formatMessage, locale } = useVIntl()
const props = defineProps<{
regions: ServerRegion[]
pings: RegionPing[]
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
custom: boolean
currency: string
interval: ServerBillingInterval
availableProducts: ServerPlan[]
}>()
@@ -25,6 +34,12 @@ const checkingCustomStock = ref(false)
const selectedPlan = defineModel<ServerPlan>('plan')
const selectedRegion = defineModel<string>('region')
const selectedPrice = computed(() => {
const amount = selectedPlan.value?.prices?.find((price) => price.currency_code === props.currency)
?.prices?.intervals?.[props.interval]
return amount ? amount / monthsInInterval[props.interval] : undefined
})
const regionOrder: string[] = ['us-vin', 'eu-lim']
const sortedRegions = computed(() => {
@@ -216,7 +231,12 @@ onMounted(() => {
</h2>
<div>
<Slider v-model="selectedRam" :min="minRam" :max="maxRam" :step="2" unit="GB" />
<div class="bg-bg rounded-xl p-4 mt-4 text-secondary">
<p v-if="selectedPrice" class="mt-2 mb-0">
<span class="text-contrast text-lg font-bold"
>{{ formatPrice(locale, selectedPrice, currency, true) }} / month</span
><span v-if="interval !== 'monthly'">, billed {{ interval }}</span>
</p>
<div class="bg-bg rounded-xl p-4 mt-2 text-secondary">
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
</div>

View File

@@ -1,6 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ServerBillingInterval, ServerPlan, ServerRegion } from '../../utils/billing'
import {
monthsInInterval,
type ServerBillingInterval,
type ServerPlan,
type ServerRegion,
} from '../../utils/billing'
import TagItem from '../base/TagItem.vue'
import ServersSpecs from './ServersSpecs.vue'
import { formatPrice, getPingLevel } from '@modrinth/utils'
@@ -77,12 +82,6 @@ const period = computed(() => {
return '???'
})
const monthsInInterval: Record<ServerBillingInterval, number> = {
monthly: 1,
quarterly: 3,
yearly: 12,
}
function setInterval(newInterval: ServerBillingInterval) {
interval.value = newInterval
emit('reloadPaymentIntent')

View File

@@ -109,5 +109,6 @@ export { default as VersionSummary } from './version/VersionSummary.vue'
export { default as ThemeSelector } from './settings/ThemeSelector.vue'
// Servers
export { default as ServersPromo } from './servers/ServersPromo.vue'
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
export { default as ServersSpecs } from './billing/ServersSpecs.vue'

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { RightArrowIcon, ModrinthIcon, XIcon } from '@modrinth/assets'
import ButtonStyled from '../base/ButtonStyled.vue'
import AutoLink from '../base/AutoLink.vue'
const emit = defineEmits<{
(e: 'close'): void
}>()
withDefaults(
defineProps<{
link: string
closable?: boolean
}>(),
{
closable: true,
},
)
</script>
<template>
<div
class="brand-gradient-bg card-shadow bg-bg relative p-4 border-[1px] border-solid border-brand rounded-2xl grid grid-cols-[1fr_auto] overflow-hidden"
>
<ModrinthIcon
class="absolute -top-12 -right-12 size-48 text-brand-highlight opacity-25"
fill="none"
stroke="var(--color-brand)"
stroke-width="4"
/>
<div class="flex flex-col gap-2">
<span class="text-lg leading-tight font-extrabold text-contrast"
>Want to play with <br />
<span class="text-brand">your friends?</span></span
>
<span class="text-sm font-medium">Create a server with Modrinth in just a few clicks.</span>
</div>
<div class="flex flex-col items-end justify-end z-10">
<ButtonStyled color="brand">
<AutoLink :to="link"> View plans <RightArrowIcon /> </AutoLink>
</ButtonStyled>
</div>
<div class="absolute top-2 right-2 z-10">
<ButtonStyled v-if="closable" size="small" circular>
<button v-tooltip="`Don't show again`" @click="emit('close')">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</template>
<style scoped>
.brand-gradient-bg {
background-image: linear-gradient(
to top right,
var(--color-brand-highlight) -80%,
var(--color-bg)
);
--color-button-bg: var(--brand-gradient-button);
}
</style>

View File

@@ -31,6 +31,7 @@ export const useStripe = (
product: Ref<ServerPlan | undefined>,
interval: Ref<ServerBillingInterval>,
region: Ref<string | undefined>,
project: Ref<string | undefined>,
initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse>,
@@ -222,16 +223,22 @@ export const useStripe = (
let result: BasePaymentIntentResponse
const metadata: CreatePaymentIntentRequest['metadata'] = {
type: 'pyro',
server_region: region.value,
source: project.value
? {
project_id: project.value,
}
: {},
}
if (paymentIntentId.value) {
result = await updateIntent({
...requestType,
charge,
existing_payment_intent: paymentIntentId.value,
metadata: {
type: 'pyro',
server_region: region.value,
source: {},
},
metadata,
})
console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
} else {
@@ -242,11 +249,7 @@ export const useStripe = (
} = await createIntent({
...requestType,
charge,
metadata: {
type: 'pyro',
server_region: region.value,
source: {},
},
metadata: metadata,
}))
console.log(`Created payment intent: ${interval.value} for ${result.total}`)
}

View File

@@ -1,7 +1,14 @@
import type Stripe from 'stripe'
import type { Loaders } from '@modrinth/utils'
export type ServerBillingInterval = 'monthly' | 'yearly' | 'quarterly'
export const monthsInInterval: Record<ServerBillingInterval, number> = {
monthly: 1,
quarterly: 3,
yearly: 12,
}
export interface ServerPlan {
id: string
name: string
@@ -72,11 +79,18 @@ export type CreatePaymentIntentRequest = PaymentRequestType & {
type: 'pyro'
server_name?: string
server_region?: string
source: {
loader?: string
game_version?: string
loader_version?: string
}
source:
| {
loader: Loaders
game_version?: string
loader_version?: string
}
| {
project_id: string
version_id?: string
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
| {}
}
}

View File

@@ -10,6 +10,23 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2025-06-26T11:00:00-07:00`,
product: 'servers',
body: `### Improvements
- Fixed support bubble overlapping notifications sometimes.
- Fixed race condition when creating backups.`,
},
{
date: `2025-06-26T11:00:00-07:00`,
product: 'web',
body: `### Added
- Added a dismissable Modrinth Servers promotion to project Download interface to inform users of the service's availability.
### Improvements
- Added colors for the newly added legacy mod loaders
- Improved file upload error message in some places.`,
},
{
date: `2025-06-16T11:00:00-07:00`,
product: 'web',