Compare commits

...
25 Commits
Author SHA1 Message Date
Prospector 14b2943dbe deploy moderation changes (#7169) 2026-08-15 17:24:14 -07:00
coolbot 5b16a774f9 Various moderation updates (#7160)
* Update status alerts, permission quick replies, utils, rules, disclosures.

* typo
2026-08-16 00:23:55 +00:00
François-Xavier Talbot b0ec93f8ab chore(labrinth): paypal webhook changes (#7157)
feat(labrinth): make paypal webhook de errors 500 instead of 400, make payout ID optional
2026-08-15 06:01:43 +00:00
Prospector 2070428080 changelog 2026-08-14 19:19:44 -07:00
Prospector 9582c1a6fd fix error page not having country provider (#7159) 2026-08-14 19:18:22 -07:00
Prospector 4897c2da94 changelog 2026-08-14 11:44:49 -07:00
Prospector 457254f210 Revert "Revert "feat: sync individual content installation states on … (#7155)
Revert "Revert "feat: sync individual content installation states on panel" (…"

This reverts commit 1dba3bfee8.
2026-08-14 11:42:19 -07:00
Prospector 283f38c936 changelog 2026-08-14 10:47:25 -07:00
Prospector 1dba3bfee8 Revert "feat: sync individual content installation states on panel" (#7154)
Revert "feat: sync individual content installation states on panel (#6909)"

This reverts commit 9628b4c269.
2026-08-14 10:47:08 -07:00
Prospector 37a5e14657 fix user returning null when falsy (#7153) 2026-08-14 10:44:51 -07:00
Prospector cc66d80165 changelog 2026-08-14 10:05:34 -07:00
Calum H. 9628b4c269 feat: sync individual content installation states on panel (#6909)
* feat: sync individual content installation states on panel

* feat: improve handling

* fix: lint

* fix: ws connection duplication + disconnecting during browse

* fix: sse feats

* fix: qa

* fix: qa

* fix: prepr

* fix: bug

* fix: lint
2026-08-14 16:38:36 +00:00
Truman GaoandCalum H. 99683f570a fix: environments in mrpack exports (#7141)
* fix: server_only single player mods excluded

* fix: align with mac

* fix: fmt

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-08-14 16:18:03 +00:00
Calum H. 762d9fe4f4 fix: ears regressions (#7148)
* temp: fix skins issues

* fix: lint
2026-08-14 16:17:32 +00:00
Calum H. f948b5d358 fix: medal upgrade broken in app in some situations (#7147) 2026-08-14 16:45:40 +01:00
Truman Gao 4dbea80e65 feat: show warning for broken mrpack in moderation (#7136)
* feat: show warning if modpack has a version during time when modpack export broke

* fix type

* fix: dont lazy load versions v3 for staff and convert times to utc

* pnpm prepr
2026-08-14 15:42:01 +00:00
Calum H. 74c672df49 fix: modpack content deleted from api breaks card (#7146) 2026-08-14 15:41:34 +00:00
Calum H. 3597bb4821 fix: missed eventbus typegen (#7143) 2026-08-14 14:54:03 +00:00
Michael H. cf50e28dda feat: email whitelist 2026-08-14 16:00:05 +02:00
Calum H. 4ca48e4589 feat: content upload session impl (#6992)
* feat: content upload session impl

* fix: fixes

* fix: prepr
2026-08-14 13:43:09 +00:00
Prospector 17f13097f2 adjust design -> functionality language in blog post 2026-08-13 11:28:53 -07:00
Prospector a9de420d57 update date + changelog 2026-08-13 11:09:05 -07:00
Prospector ab7359a4c5 minor fixes to disclosures, basic markdown support (#7133) 2026-08-13 10:58:47 -07:00
Truman GaoandCalum H. 97b5138672 fix: modpack export (#7119)
* fix: stale function call set_webview_visible

* fix: modpack export progress bar and add notification when done

* remove: force_no_zip64

* feat: switch to zip crate

* add typo exception

* fix: remove unit test (dont do)

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-08-13 16:37:39 +00:00
Calum H. 579109a478 refactor: app event bus (#6985)
* refactor: app event bus

* fix: use messagepack + cleanup

* fix: use postcard

* fix: lint

* fix: import gate

* fix: prettierignore + lint

* fix: lint

* fix: rev
2026-08-13 16:33:06 +00:00
197 changed files with 6083 additions and 3725 deletions
+1
View File
@@ -10,6 +10,7 @@ on:
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-deploy.yml'
+1
View File
@@ -7,6 +7,7 @@ on:
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-preview.yml'
+8
View File
@@ -59,6 +59,14 @@ apps/frontend/src/generated
.turbo
target
generated
!apps/app-frontend/src/generated/
!apps/app-frontend/src/generated/app-events/
!apps/app-frontend/src/generated/app-events/*.ts
!apps/app-frontend/src/generated/app-events/README.md
!apps/app-frontend/src/generated/app-events/postcard/
!apps/app-frontend/src/generated/app-events/postcard/index.d.ts
!apps/app-frontend/src/generated/app-events/postcard/index.js
!apps/app-frontend/src/generated/app-events/postcard/package.json
.env
# app testing dir
Generated
+172 -2
View File
@@ -2116,7 +2116,7 @@ checksum = "ff6669899e23cb87b43daf7996f0ea3b9c07d0fb933d745bb7b815b052515ae3"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"serde_derive_internals 0.29.1",
"syn 2.0.106",
]
@@ -2340,6 +2340,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.16.2"
@@ -3991,6 +4000,28 @@ dependencies = [
"x11",
]
[[package]]
name = "genco"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ab846431e5d637791b3279e7242fe2b21e11c3d8b4cf6a99f645c5f16ba7c0"
dependencies = [
"genco-macros",
"relative-path",
"smallvec",
]
[[package]]
name = "genco-macros"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42a1fe5a699c7f1d36ea6e04ed680a5c787cabff4b610ae3b8954ea3bcefec1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "generic-array"
version = "0.14.9"
@@ -7509,6 +7540,45 @@ dependencies = [
"serde",
]
[[package]]
name = "postcard-bindgen"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e709af61573ae2563a00e760ff7db47f5c70b9d96c6dcb28993526fb8479f68"
dependencies = [
"postcard-bindgen-core",
"postcard-bindgen-derive",
]
[[package]]
name = "postcard-bindgen-core"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f19cebcb145fdccb24b63ad410785aa7c2ff9364551646ca7a927422d2d0386"
dependencies = [
"convert_case 0.11.0",
"genco",
"tree-ds",
]
[[package]]
name = "postcard-bindgen-derive"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0372f55dd7c15d73f7b0f7ca507ac3db5653f346b2b44a36537e2ad39f55441b"
dependencies = [
"convert_case 0.11.0",
"genco",
"postcard-bindgen-core",
"proc-macro2",
"quote",
"regex",
"regex-macro",
"serde",
"serde_derive_internals 0.30.0",
"syn 3.0.3",
]
[[package]]
name = "potential_utf"
version = "0.1.3"
@@ -8391,12 +8461,27 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da"
[[package]]
name = "regex-macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d306632607af6ec61c0b117971d57a96381b6317cf18ae419b5558048fe016e"
dependencies = [
"regex",
]
[[package]]
name = "regex-syntax"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "relative-path"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "rend"
version = "0.4.2"
@@ -9062,7 +9147,7 @@ checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"serde_derive_internals 0.29.1",
"syn 2.0.106",
]
@@ -9305,6 +9390,16 @@ dependencies = [
"uuid 1.23.3",
]
[[package]]
name = "sequential_gen"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d630b1418311e028ceb45e1e567845501a9d9c770a258b80d2edc025ffad17c"
dependencies = [
"lazy_static",
"uuid 1.23.3",
]
[[package]]
name = "serde"
version = "1.0.228"
@@ -9410,6 +9505,17 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "serde_ini"
version = "0.2.0"
@@ -9850,6 +9956,9 @@ name = "spin"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
dependencies = [
"lock_api",
]
[[package]]
name = "spinning_top"
@@ -10277,6 +10386,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -10890,6 +11010,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "testcontainers"
version = "0.25.2"
@@ -10979,6 +11108,8 @@ dependencies = [
"path-util",
"phf 0.13.1",
"png 0.18.0",
"postcard",
"postcard-bindgen",
"quartz_nbt",
"quick-xml 0.38.3",
"rand 0.8.5",
@@ -10986,6 +11117,7 @@ dependencies = [
"reqwest 0.12.24",
"rgb",
"serde",
"serde-binhum",
"serde_ini",
"serde_json",
"serde_with",
@@ -11003,6 +11135,7 @@ dependencies = [
"tracing",
"tracing-error",
"tracing-subscriber",
"ts-rs",
"url",
"urlencoding",
"uuid 1.23.3",
@@ -11679,12 +11812,49 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "tree-ds"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710c388a2bb52d1a7186ae4aa2e92c6fae0606426bf4bdfb4106fa1e8e5c602e"
dependencies = [
"lazy_static",
"sequential_gen",
"serde",
"spin 0.10.0",
"thiserror 2.0.17",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"chrono",
"thiserror 2.0.17",
"ts-rs-macros",
"uuid 1.23.3",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"termcolor",
]
[[package]]
name = "tungstenite"
version = "0.27.0"
+2
View File
@@ -149,6 +149,7 @@ path-util = { path = "packages/path-util" }
phf = { version = "0.13.1", features = ["macros"] }
png = "0.18.0"
postcard = { version = "1.1.3", default-features = false, features = ["alloc"] }
postcard-bindgen = "0.8.0"
proc-macro2 = { version = "1.0" }
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
@@ -232,6 +233,7 @@ tracing-actix-web = { version = "0.7.19", default-features = false }
tracing-ecs = "0.5.0"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.20"
ts-rs = "12.0.1"
typed-path = "0.12.0"
url = "2.5.7"
urlencoding = "2.1.3"
+4
View File
@@ -24,3 +24,7 @@ gam = "gam"
consts = "consts"
# short for "Copy"
Cpy = "Cpy"
[default.extend-identifiers]
# Constant from the `zip` crate
ZIP64_BYTES_THR = "ZIP64_BYTES_THR"
+4
View File
@@ -2,3 +2,7 @@
*.gltf
src/locales/
src/assets/**/*.svg
# Generated app-event bindings
src/generated/app-events/*.ts
src/generated/app-events/postcard/**
+6 -1
View File
@@ -1,2 +1,7 @@
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
export default config
export default config.append([
{
ignores: ['src/generated/app-events/*.ts', 'src/generated/app-events/postcard/**'],
},
])
+26 -18
View File
@@ -90,9 +90,9 @@ import SplashScreen from '@/components/ui/SplashScreen.vue'
import SurveyPopup from '@/components/ui/SurveyPopup.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useAppEvent } from '@/composables/use-app-event'
import { config } from '@/config'
import {
ads_consent_listener,
hide_ads_window,
init_ads_window,
perform_ads_consent_action,
@@ -103,7 +103,6 @@ import {
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
@@ -142,6 +141,7 @@ import {
} from '@/providers/download-progress.ts'
import { createServerInstall, provideServerInstall } from '@/providers/server-install'
import { setupProviders } from '@/providers/setup'
import { setupAppEventsProvider } from '@/providers/setup/app-events'
import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { useError } from '@/store/error.js'
@@ -157,6 +157,7 @@ import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider()
const breadcrumbManager = createBreadcrumbManager()
provideBreadcrumbManager(breadcrumbManager)
const canNavigateBack = ref(false)
@@ -243,11 +244,22 @@ const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
const { handleError, addNotification } = notificationManager
useAppEvent(
'warning',
(event) =>
addNotification({
title: 'Warning',
text: event.message,
type: 'warning',
}),
appEvents,
)
const popupNotificationManager = new AppPopupNotificationManager()
providePopupNotificationManager(popupNotificationManager)
const { addPopupNotification } = popupNotificationManager
let adsConsentPopupId = null
let unlistenAdsConsent
useAppEvent('ads_consent_required', handleAdsConsentRequired, appEvents)
const appVersion = getVersion()
const tauriApiClient = new TauriModrinthClient({
@@ -384,7 +396,6 @@ const authUnreachable = computed(() => {
onMounted(async () => {
await useCheckDisableMouseover()
try {
unlistenAdsConsent = await ads_consent_listener(handleAdsConsentRequired)
handleAdsConsentRequired(await should_show_ads_consent_popup())
} catch (error) {
handleError(error)
@@ -407,7 +418,6 @@ onUnmounted(async () => {
fullscreenAdsWindowHold = false
await release_ads_window_hold().catch(handleError)
}
await unlistenAdsConsent?.()
await unlistenUpdateDownload?.()
})
@@ -616,14 +626,6 @@ async function setupApp() {
document.getElementsByTagName('html')[0].classList.add('windows')
}
await warning_listener((e) =>
addNotification({
title: 'Warning',
text: e.message,
type: 'warning',
}),
)
fetch(`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`)
.then((response) => response.json())
.then((res) => {
@@ -672,7 +674,7 @@ async function setupApp() {
}
const stateFailed = ref(false)
initialize_state()
initialize_state(appEventChannel)
.then(() => {
setupApp().catch((err) => {
stateFailed.value = true
@@ -802,7 +804,7 @@ const errorModal = ref()
const minecraftAuthErrorModal = ref()
const minecraftRequiredModal = ref()
const contentInstall = createContentInstall({ router, handleError })
const contentInstall = createContentInstall({ router, handleError, appEvents })
provideContentInstall(contentInstall)
const {
instances: contentInstallInstances,
@@ -835,7 +837,12 @@ const {
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
} = contentInstall
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
const serverInstall = createServerInstall({
router,
handleError,
popupNotificationManager,
appEvents,
})
provideServerInstall(serverInstall)
const {
setInstallToPlayModal: setServerInstallToPlayModal,
@@ -1011,8 +1018,8 @@ onMounted(() => {
const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand)
notification_listener(handleLiveNotification)
useAppEvent('command', handleCommand, appEvents)
useAppEvent('notification', handleLiveNotification, appEvents)
async function markLiveNotificationRead(notification) {
try {
@@ -1438,6 +1445,7 @@ async function downloadUpdate(versionToDownload) {
handleError(e)
})
unlistenUpdateDownload = await subscribeToDownloadProgress(
appEvents,
appUpdateDownload,
versionToDownload.version,
)
@@ -104,8 +104,9 @@ import {
useVIntl,
} from '@modrinth/ui'
import type { Ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import {
get_default_user,
@@ -114,7 +115,6 @@ import {
set_default_user,
users,
} from '@/helpers/auth'
import { process_listener } from '@/helpers/events'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins'
@@ -251,16 +251,12 @@ async function logout(id: string) {
trackEvent('AccountLogOut')
}
const unlisten = await process_listener(async (e) => {
useAppEvent('process', async (e) => {
if (e.event === 'launched') {
await refreshValues()
}
})
onUnmounted(() => {
unlisten()
})
const messages = defineMessages({
notSignedIn: {
id: 'minecraft-account.not-signed-in',
@@ -148,8 +148,8 @@ import { useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import type { LoadingBar } from '@/helpers/state'
@@ -294,7 +294,7 @@ onMounted(() => {
window.addEventListener('online', handleOnline)
})
const unlistenProcess = await process_listener(async () => {
useAppEvent('process', async () => {
await refresh()
})
@@ -554,7 +554,7 @@ const installJobNotifications = await useInstallJobNotifications({
await refreshLoadingBars()
const unlistenLoading = await loading_listener(async () => {
useAppEvent('loading', async () => {
await refreshLoadingBars()
})
@@ -573,8 +573,6 @@ onBeforeUnmount(() => {
dismissed.value = false
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
unlistenProcess()
unlistenLoading()
installJobNotifications.dispose()
})
</script>
@@ -1,11 +1,12 @@
<script setup>
import { XIcon } from '@modrinth/assets'
import { FolderOpenIcon, XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
FileTreeSelect,
injectNotificationManager,
injectPopupNotificationManager,
NewModal,
StyledInput,
useVIntl,
@@ -15,8 +16,10 @@ import { ref, shallowRef } from 'vue'
import { PackageIcon } from '@/assets/icons'
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
import { highlightInFolder } from '@/helpers/utils'
const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
@@ -39,6 +42,14 @@ const messages = defineMessages({
defaultMessage: 'Enter modpack description...',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
exportComplete: {
id: 'app.export-modal.export-complete',
defaultMessage: 'Export complete',
},
exportCompleteDescription: {
id: 'app.export-modal.export-complete-description',
defaultMessage: '{name} was exported successfully.',
},
})
const props = defineProps({
@@ -93,16 +104,35 @@ const exportPack = async () => {
})
if (outputPath) {
export_instance_mrpack(
props.instance.id,
outputPath,
includedFilePaths.value,
excludedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
).catch((err) => handleError(err))
exportModal.value.hide()
try {
await export_instance_mrpack(
props.instance.id,
outputPath,
includedFilePaths.value,
excludedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
)
const fileName = outputPath.split(/[\\/]/).pop() ?? outputPath
popupNotificationManager.addPopupNotification({
title: formatMessage(messages.exportComplete),
text: formatMessage(messages.exportCompleteDescription, { name: fileName }),
type: 'success',
buttons: [
{
label: formatMessage(commonMessages.openInFolderButton),
icon: FolderOpenIcon,
action: () => highlightInFolder(outputPath).catch(handleError),
},
],
})
} catch (error) {
handleError(error)
}
}
}
@@ -10,11 +10,11 @@ import {
import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
@@ -136,7 +136,7 @@ defineExpose({
const currentEvent = ref(null)
const unlisten = await process_listener((e) => {
useAppEvent('process', (e) => {
if (e.instance_id === props.instance.id) {
currentEvent.value = e.event
if (e.event === 'finished') {
@@ -148,7 +148,6 @@ const unlisten = await process_listener((e) => {
onMounted(() => {
checkProcess()
})
onUnmounted(() => unlisten())
</script>
<template>
@@ -7,7 +7,7 @@ import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { instance_listener } from '@/helpers/events.js'
import { useAppEvent } from '@/composables/use-app-event'
import { list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
@@ -148,7 +148,7 @@ const getInstances = async () => {
await getInstances()
updateMaxAuto()
const unlistenInstance = await instance_listener(async (event) => {
useAppEvent('instance', async (event) => {
if (event.event !== 'synced') {
await getInstances()
}
@@ -162,7 +162,6 @@ onUnmounted(() => {
window.removeEventListener('resize', updateMaxAuto)
document.body.classList.remove('quick-instance-dragging')
clearOverdragFlash()
unlistenInstance()
})
const messages = defineMessages({
@@ -82,7 +82,7 @@ import { injectLoadingState } from '@modrinth/ui'
import { ref, watch } from 'vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { loading_listener } from '@/helpers/events.js'
import { useAppEvent } from '@/composables/use-app-event'
const doneLoading = ref(false)
const loadingProgress = ref(0)
@@ -132,13 +132,10 @@ function fakeLoadingIncrease() {
}
}
loading_listener(async (e) => {
useAppEvent('loading', (e) => {
if (e.event.type === 'directory_move') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = 'Updating app directory...'
} else if (e.event.type === 'checking_for_updates') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = 'Checking for updates...'
}
})
</script>
@@ -34,6 +34,7 @@ import { get_project_many, get_version, get_version_many } from '@/helpers/cache
import { wait_for_install_job } from '@/helpers/install'
import { update_managed_modrinth_version } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { injectAppEvents } from '@/providers/app-events'
import { injectServerInstall } from '@/providers/server-install'
type Dependency = Labrinth.Versions.v3.Dependency
@@ -74,6 +75,7 @@ type ProjectInfo = {
}
const { formatMessage } = useVIntl()
const appEvents = injectAppEvents()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
@@ -253,7 +255,7 @@ async function handleUpdate() {
try {
if (modpackVersionId.value && instance.value) {
const job = await update_managed_modrinth_version(instance.value.id, modpackVersionId.value)
await wait_for_install_job(job.job_id)
await wait_for_install_job(appEvents, job.job_id)
await onUpdateComplete.value()
}
} catch (error) {
@@ -39,6 +39,7 @@ import {
} from '@/helpers/install'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { injectAppEvents } from '@/providers/app-events'
type UpdateCompleteCallback = () => void | Promise<void>
@@ -54,6 +55,7 @@ const instance = ref<GameInstance | null>(null)
const preview = ref<SharedInstanceUpdatePreview | null>(null)
const onComplete = ref<UpdateCompleteCallback>(() => {})
const { formatMessage } = useVIntl()
const appEvents = injectAppEvents()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const diffs = computed<ContentDiffItem[]>(
() =>
@@ -78,7 +80,7 @@ async function update() {
try {
if (instance.value) {
const job = await install_update_shared_instance(instance.value.id)
await wait_for_install_job(job.job_id)
await wait_for_install_job(appEvents, job.job_id)
await onComplete.value()
successful = true
}
@@ -21,12 +21,12 @@ import {
import { capitalizeString } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Dayjs } from 'dayjs'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
@@ -108,7 +108,7 @@ const stop = async (event: MouseEvent) => {
loading.value = false
}
const unlistenProcesses = await process_listener(async () => {
useAppEvent('process', async () => {
await checkProcess()
})
@@ -121,10 +121,6 @@ const checkProcess = async () => {
onMounted(() => {
checkProcess()
})
onUnmounted(() => {
unlistenProcesses()
})
</script>
<template>
<SmartClickable>
@@ -5,12 +5,12 @@ import { GAME_MODES, injectNotificationManager } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { instance_listener, process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance'
import { get_all } from '@/helpers/process'
import { get_game_versions } from '@/helpers/tags'
@@ -215,11 +215,11 @@ async function stopInstance(path: string) {
const currentInstance = ref<string>()
const currentWorld = ref<string>()
const unlistenProcesses = await process_listener(async () => {
useAppEvent('process', async () => {
await checkProcesses()
})
const unlistenInstances = await instance_listener(async () => {
useAppEvent('instance', async () => {
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
@@ -251,11 +251,6 @@ onMounted(() => {
checkProcesses()
linuxPopulateCount.value = 0
})
onUnmounted(() => {
unlistenProcesses()
unlistenInstances()
})
</script>
<template>
@@ -10,7 +10,6 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import type { Router } from 'vue-router'
import { install_job_listener } from '@/helpers/events'
import {
install_job_dismiss,
install_job_list,
@@ -23,6 +22,7 @@ import {
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
import { injectAppEvents } from '@/providers/app-events'
import { useTheming } from '@/store/state'
const messages = defineMessages({
@@ -234,6 +234,7 @@ export async function useInstallJobNotifications(opts: {
handleError: (err: unknown) => void
onChange: () => void
}) {
const appEvents = injectAppEvents()
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const jobs = ref<InstallJobSnapshot[]>([])
@@ -635,7 +636,7 @@ export async function useInstallJobNotifications(opts: {
void refreshMetadata()
}
const unlisten = await install_job_listener((job: InstallJobSnapshot) => applyJobUpdate(job))
const unlisten = appEvents.on('install_job', applyJobUpdate)
await refresh(false)
return {
@@ -12,7 +12,7 @@ import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { process_listener } from '@/helpers/events'
import { useAppEvent } from '@/composables/use-app-event'
import { kill, list as listInstances } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
@@ -78,7 +78,6 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const contextMenuRef = ref<ContextMenuHandle | null>(null)
let serverPingsActive = true
let unlistenProcesses: (() => void) | null = null
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('checkServerRunningStates', { hitCount: hits.length })
@@ -280,7 +279,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
}
}
process_listener((event: { event: string; instance_id: string }) => {
useAppEvent('process', (event) => {
debugLog('process event', event)
if (event.event === 'finished') {
const projectId = Object.entries(runningServerProjects.value).find(
@@ -292,14 +291,9 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
}
}
})
.then((unlisten) => {
unlistenProcesses = unlisten
})
.catch(options.handleError)
onUnmounted(() => {
serverPingsActive = false
unlistenProcesses?.()
})
return {
@@ -0,0 +1,18 @@
import { onScopeDispose } from 'vue'
import {
type AppEventHandler,
type AppEvents,
type AppEventType,
injectAppEvents,
} from '@/providers/app-events'
export function useAppEvent<Type extends AppEventType>(
type: Type,
handler: AppEventHandler<Type>,
events: AppEvents = injectAppEvents(),
) {
const unsubscribe = events.on(type, handler)
onScopeDispose(unsubscribe)
return unsubscribe
}
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, onUnmounted, toValue } from 'vue'
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
import { useAppEvent } from '@/composables/use-app-event'
import { toError } from '@/helpers/errors'
import { friend_listener } from '@/helpers/events.js'
import {
acceptCachedFriend,
add_friend,
@@ -127,13 +127,9 @@ export function useFriends(options: {
)
}
let unlisten: (() => void) | undefined
void friend_listener(() => {
useAppEvent('friend', () => {
void queryClient.invalidateQueries({ queryKey: queryKey.value })
}).then((listener) => {
unlisten = listener
})
onUnmounted(() => unlisten?.())
return {
query,
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CommandPayload } from "./CommandPayload";
import type { FriendPayload } from "./FriendPayload";
import type { InstallJobSnapshot } from "./InstallJobSnapshot";
import type { InstanceBulkUpdateProgressPayload } from "./InstanceBulkUpdateProgressPayload";
import type { InstancePayload } from "./InstancePayload";
import type { LoadingPayload } from "./LoadingPayload";
import type { LogPayload } from "./LogPayload";
import type { ProcessPayload } from "./ProcessPayload";
import type { WarningPayload } from "./WarningPayload";
export type AppEvent = { "type": "loading", "payload": LoadingPayload } | { "type": "process", "payload": ProcessPayload } | { "type": "instance", "payload": InstancePayload } | { "type": "instance_bulk_update_progress", "payload": InstanceBulkUpdateProgressPayload } | { "type": "install_job", "payload": InstallJobSnapshot } | { "type": "command", "payload": CommandPayload } | { "type": "warning", "payload": WarningPayload } | { "type": "friend", "payload": FriendPayload } | { "type": "notification", "payload": unknown } | { "type": "log", "payload": LogPayload } | { "type": "ads_consent_required", "payload": boolean };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CommandPayload = { "event": "InstallMod", id: string, } | { "event": "InstallVersion", id: string, } | { "event": "InstallModpack", id: string, } | { "event": "InstallServer", id: string, } | { "event": "LaunchInstance", id: string, server: string | null, singleplayer_world: string | null, } | { "event": "InstallSharedInstanceInvite", invite_id: string, } | { "event": "RunMRPack", path: string, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FriendStatusPayload } from "./FriendStatusPayload";
export type FriendPayload = { "event": "friend_request", from: string, } | { "event": "user_offline", id: string, } | { "event": "status_update", user_status: FriendStatusPayload, } | { "event": "status_sync" };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FriendStatusPayload = { user_id: string, profile_name: string | null, last_update: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ImportLauncherType = "MultiMC" | "PrismLauncher" | "ATLauncher" | "GDLauncher" | "Curseforge" | "Unknown";
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallApiErrorDetails = { error: string, status?: number, method?: string, url?: string, route?: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallErrorContext = { operation: string, source_path?: string, target_path?: string, file_path?: string, entry_path?: string, urls: Array<string>, expected_hash?: string, expected_size?: number, project_id?: string, version_id?: string, minecraft_version?: string, loader?: string, java_version?: number, os?: string, arch?: string, };
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { InstallApiErrorDetails } from "./InstallApiErrorDetails";
import type { InstallErrorContext } from "./InstallErrorContext";
import type { InstallPhaseId } from "./InstallPhaseId";
import type { SharedInstanceUnavailableReason } from "./SharedInstanceUnavailableReason";
export type InstallErrorView = { code: string, phase?: InstallPhaseId, message: string, reason?: SharedInstanceUnavailableReason, api?: InstallApiErrorDetails, context?: InstallErrorContext, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallJavaStep = "resolving" | "fetching_metadata" | "downloading" | "extracting" | "validating";
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallJobDisplay = { title: string, icon: string | null, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallJobKind = "create_instance" | "create_modpack_instance" | "create_shared_instance" | "import_instance" | "duplicate_instance" | "install_existing_instance" | "install_pack_to_existing_instance" | "update_shared_instance";
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { InstallErrorView } from "./InstallErrorView";
import type { InstallJobDisplay } from "./InstallJobDisplay";
import type { InstallJobKind } from "./InstallJobKind";
import type { InstallJobStatus } from "./InstallJobStatus";
import type { InstallPhaseDetails } from "./InstallPhaseDetails";
import type { InstallPhaseId } from "./InstallPhaseId";
import type { InstallProgress } from "./InstallProgress";
import type { InstallTarget } from "./InstallTarget";
export type InstallJobSnapshot = { job_id: string, instance_id: string | null, kind: InstallJobKind, status: InstallJobStatus, target: InstallTarget, phase: InstallPhaseId, progress: InstallProgress | null, details: InstallPhaseDetails, display: InstallJobDisplay | null, error: InstallErrorView | null, rollback_error: InstallErrorView | null, created: string, modified: string, finished: string | null, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallJobStatus = "queued" | "running" | "succeeded" | "failed" | "interrupted" | "canceled";
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ImportLauncherType } from "./ImportLauncherType";
import type { InstallJavaStep } from "./InstallJavaStep";
import type { ModLoader } from "./ModLoader";
export type InstallPhaseDetails = { "type": "empty" } | { "type": "instance", name: string, } | { "type": "minecraft", game_version: string, loader: ModLoader, } | { "type": "java", major_version: number, step: InstallJavaStep, } | { "type": "modpack", project_id: string | null, version_id: string | null, title: string | null, } | { "type": "import", launcher_type: ImportLauncherType, instance_folder: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallPhaseId = "preparing_instance" | "resolving_pack" | "downloading_pack_file" | "reading_pack_manifest" | "downloading_content" | "extracting_overrides" | "resolving_minecraft" | "resolving_loader" | "preparing_java" | "downloading_minecraft" | "running_loader_processors" | "finalizing" | "rolling_back";
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { InstallProgressSecondary } from "./InstallProgressSecondary";
export type InstallProgress = { current: number, total: number, secondary?: InstallProgressSecondary, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallProgressSecondary = { current: number, total: number, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallTarget = { "type": "new_instance", instance_id: string | null, } | { "type": "existing_instance", instance_id: string, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { InstanceBulkUpdateProgressStage } from "./InstanceBulkUpdateProgressStage";
export type InstanceBulkUpdateProgressPayload = { instanceId: string, stage: InstanceBulkUpdateProgressStage, current: number, total: number, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstanceBulkUpdateProgressStage = "resolving_versions" | "downloading" | "finishing";
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstancePayload = { instance_id: string, } & ({ "event": "created" } | { "event": "synced" } | { "event": "servers_updated" } | { "event": "world_updated", world: string, } | { "event": "server_joined", host: string, port: number, timestamp: string, } | { "event": "edited" } | { "event": "content_install_finished", project_ids: Array<string>, } | { "event": "content_install_failed", project_ids: Array<string>, message: string, } | { "event": "removed" });
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LoadingBarType = { "type": "legacy_data_migration" } | { "type": "directory_move", old: string, new: string, } | { "type": "java_download", version: number, } | { "type": "pack_file_download", instance_id: string, pack_name: string, icon: string | null, pack_version: string, } | { "type": "pack_download", instance_id: string, pack_name: string, icon: string | null, pack_id: string | null, pack_version: string | null, } | { "type": "pack_import", pack_name: string, } | { "type": "minecraft_download", instance_id: string, instance_name: string, } | { "type": "instance_update", instance_id: string, instance_name: string, } | { "type": "zip_extract", instance_id: string, instance_name: string, } | { "type": "pack_export", instance_id: string, instance_name: string, } | { "type": "config_change", new_path: string, } | { "type": "copy_instance", import_location: string, instance_name: string, } | { "type": "launcher_update", version: string, current_version: string, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LoadingBarType } from "./LoadingBarType";
export type LoadingPayload = { event: LoadingBarType, loader_uuid: string, fraction: number | null, message: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Log4jEvent = { timestamp_millis: number | null, logger_name: string | null, level: string | null, thread_name: string | null, message: string | null, throwable: string | null, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Log4jEvent } from "./Log4jEvent";
export type LogPayload = { instance_id: string, } & ({ "type": "log4j" } & Log4jEvent | { "type": "legacy", message: string, });
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ModLoader = "vanilla" | "forge" | "fabric" | "quilt" | "neoforge";
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ProcessPayloadType } from "./ProcessPayloadType";
export type ProcessPayload = { instance_id: string, uuid: string, event: ProcessPayloadType, message: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ProcessPayloadType = "launched" | "finished";
@@ -0,0 +1,8 @@
# App event bindings
> [!WARNING]
> Do not edit the generated TypeScript or Postcard binding files in this directory manually.
The event bus types are determined by the Rust [`AppEvent`](../../../../../packages/app-lib/src/event/mod.rs) contract.
They are regenerated automatically when you run `pnpm app:dev` from the workspace root. Commit any generated changes with the Rust contract change.
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SharedInstanceUnavailableReason = "deleted" | "access_revoked" | "quarantined";
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WarningPayload = { message: string, };
@@ -0,0 +1,78 @@
declare type u8 = number
declare type u16 = number
declare type u32 = number
declare type u64 = bigint
declare type u128 = bigint
declare type usize = bigint
declare type i8 = number
declare type i16 = number
declare type i32 = number
declare type i64 = bigint
declare type i128 = bigint
declare type isize = bigint
declare type NonZeroU8 = number
declare type NonZeroU16 = number
declare type NonZeroU32 = number
declare type NonZeroU64 = bigint
declare type NonZeroU128 = bigint
declare type NonZeroUsize = bigint
declare type NonZeroI8 = number
declare type NonZeroI16 = number
declare type NonZeroI32 = number
declare type NonZeroI64 = bigint
declare type NonZeroI128 = bigint
declare type NonZeroIsize = bigint
declare type f32 = number
declare type f64 = number
declare type ArrayLengthMutationKeys = "splice" | "push" | "pop" | "shift" | "unshift"
declare type FixedLengthArray<T, L extends number, TObj = [T, ...Array<T>]> =
Pick<TObj, Exclude<keyof TObj, ArrayLengthMutationKeys>>
& {
readonly length: L
[ I : number ] : T
[Symbol.iterator]: () => IterableIterator<T>
}
export type AppEvent = { tag: "loading", value: LoadingPayload } | { tag: "process", value: ProcessPayload } | { tag: "instance", value: InstancePayload } | { tag: "instance_bulk_update_progress", value: InstanceBulkUpdateProgressPayload } | { tag: "install_job", value: InstallJobSnapshot } | { tag: "command", value: CommandPayload } | { tag: "warning", value: WarningPayload } | { tag: "friend", value: FriendPayload } | { tag: "notification", value: string } | { tag: "log", value: LogPayload } | { tag: "ads_consent_required", value: boolean }
export type LoadingBarType = { tag: "legacy_data_migration" } | { tag: "directory_move", value: { old: string, new: string } } | { tag: "java_download", value: { version: u32 } } | { tag: "pack_file_download", value: { instance_id: string, pack_name: string, icon: string | undefined, pack_version: string } } | { tag: "pack_download", value: { instance_id: string, pack_name: string, icon: string | undefined, pack_id: string | undefined, pack_version: string | undefined } } | { tag: "pack_import", value: { pack_name: string } } | { tag: "minecraft_download", value: { instance_id: string, instance_name: string } } | { tag: "instance_update", value: { instance_id: string, instance_name: string } } | { tag: "zip_extract", value: { instance_id: string, instance_name: string } } | { tag: "pack_export", value: { instance_id: string, instance_name: string } } | { tag: "config_change", value: { new_path: string } } | { tag: "copy_instance", value: { import_location: string, instance_name: string } } | { tag: "launcher_update", value: { version: string, current_version: string } }
export type LoadingPayload = { event: LoadingBarType, loader_uuid: string, fraction: f64 | undefined, message: string }
export type WarningPayload = { message: string }
export type InstanceBulkUpdateProgressPayload = { instanceId: string, stage: InstanceBulkUpdateProgressStage, current: u64, total: u64 }
export type InstanceBulkUpdateProgressStage = { tag: "resolving_versions" } | { tag: "downloading" } | { tag: "finishing" }
export type CommandPayload = { tag: "InstallMod", value: { id: string } } | { tag: "InstallVersion", value: { id: string } } | { tag: "InstallModpack", value: { id: string } } | { tag: "InstallServer", value: { id: string } } | { tag: "LaunchInstance", value: { id: string, server: string | undefined, singleplayer_world: string | undefined } } | { tag: "InstallSharedInstanceInvite", value: { invite_id: string } } | { tag: "RunMRPack", value: { path: string } }
export type ProcessPayload = { instance_id: string, uuid: string, event: ProcessPayloadType, message: string }
export type ProcessPayloadType = { tag: "launched" } | { tag: "finished" }
export type InstancePayload = { instance_id: string, event: InstancePayloadType }
export type InstancePayloadType = { tag: "created" } | { tag: "synced" } | { tag: "servers_updated" } | { tag: "world_updated", value: { world: string } } | { tag: "server_joined", value: { host: string, port: u16, timestamp: string } } | { tag: "edited" } | { tag: "content_install_finished", value: { project_ids: string[] } } | { tag: "content_install_failed", value: { project_ids: string[], message: string } } | { tag: "removed" }
export type FriendPayload = { tag: "friend_request", value: { from: string } } | { tag: "user_offline", value: { id: string } } | { tag: "status_update", value: { user_status: FriendStatusPayload } } | { tag: "status_sync" }
export type FriendStatusPayload = { user_id: string, profile_name: string | undefined, last_update: string }
export type SharedInstanceUnavailableReason = { tag: "deleted" } | { tag: "access_revoked" } | { tag: "quarantined" }
export type LogEvent = { tag: "log4j", value: Log4jEvent } | { tag: "legacy", value: { message: string } }
export type LogPayload = { instance_id: string, event: LogEvent }
export type Log4jEvent = { timestamp_millis: i64 | undefined, logger_name: string | undefined, level: string | undefined, thread_name: string | undefined, message: string | undefined, throwable: string | undefined }
export type ModLoader = { tag: "vanilla" } | { tag: "forge" } | { tag: "fabric" } | { tag: "quilt" } | { tag: "neoforge" }
export type InstallJobSnapshot = { job_id: string, instance_id: string | undefined, kind: InstallJobKind, status: InstallJobStatus, target: InstallTarget, phase: InstallPhaseId, progress: InstallProgress | undefined, details: InstallPhaseDetails, display: InstallJobDisplay | undefined, error: InstallErrorView | undefined, rollback_error: InstallErrorView | undefined, created: string, modified: string, finished: string | undefined }
export type InstallJobKind = { tag: "create_instance" } | { tag: "create_modpack_instance" } | { tag: "create_shared_instance" } | { tag: "import_instance" } | { tag: "duplicate_instance" } | { tag: "install_existing_instance" } | { tag: "install_pack_to_existing_instance" } | { tag: "update_shared_instance" }
export type InstallJobStatus = { tag: "queued" } | { tag: "running" } | { tag: "succeeded" } | { tag: "failed" } | { tag: "interrupted" } | { tag: "canceled" }
export type InstallTarget = { tag: "new_instance", value: { instance_id: string | undefined } } | { tag: "existing_instance", value: { instance_id: string } }
export type InstallPhaseId = { tag: "preparing_instance" } | { tag: "resolving_pack" } | { tag: "downloading_pack_file" } | { tag: "reading_pack_manifest" } | { tag: "downloading_content" } | { tag: "extracting_overrides" } | { tag: "resolving_minecraft" } | { tag: "resolving_loader" } | { tag: "preparing_java" } | { tag: "downloading_minecraft" } | { tag: "running_loader_processors" } | { tag: "finalizing" } | { tag: "rolling_back" }
export type InstallProgress = { current: u64, total: u64, secondary: InstallProgressSecondary | undefined }
export type InstallProgressSecondary = { current: u64, total: u64 }
export type InstallPhaseDetails = { tag: "empty" } | { tag: "instance", value: { name: string } } | { tag: "minecraft", value: { game_version: string, loader: ModLoader } } | { tag: "java", value: { major_version: u32, step: InstallJavaStep } } | { tag: "modpack", value: { project_id: string | undefined, version_id: string | undefined, title: string | undefined } } | { tag: "import", value: { launcher_type: ImportLauncherType, instance_folder: string } }
export type InstallJavaStep = { tag: "resolving" } | { tag: "fetching_metadata" } | { tag: "downloading" } | { tag: "extracting" } | { tag: "validating" }
export type InstallJobDisplay = { title: string, icon: string | undefined }
export type InstallErrorView = { code: string, phase: InstallPhaseId | undefined, message: string, reason: SharedInstanceUnavailableReason | undefined, api: InstallApiErrorDetails | undefined, context: InstallErrorContext | undefined }
export type InstallApiErrorDetails = { error: string, status: u16 | undefined, method: string | undefined, url: string | undefined, route: string | undefined }
export type InstallErrorContext = { operation: string, source_path: string | undefined, target_path: string | undefined, file_path: string | undefined, entry_path: string | undefined, urls: string[], expected_hash: string | undefined, expected_size: u64 | undefined, project_id: string | undefined, version_id: string | undefined, minecraft_version: string | undefined, loader: string | undefined, java_version: u32 | undefined, os: string | undefined, arch: string | undefined }
export type ImportLauncherType = { tag: "MultiMC" } | { tag: "PrismLauncher" } | { tag: "ATLauncher" } | { tag: "GDLauncher" } | { tag: "Curseforge" } | { tag: "Unknown" }
export type Type = "AppEvent" | "LoadingBarType" | "LoadingPayload" | "WarningPayload" | "InstanceBulkUpdateProgressPayload" | "InstanceBulkUpdateProgressStage" | "CommandPayload" | "ProcessPayload" | "ProcessPayloadType" | "InstancePayload" | "InstancePayloadType" | "FriendPayload" | "FriendStatusPayload" | "LogEvent" | "LogPayload" | "Log4jEvent" | "InstallJobSnapshot" | "InstallJobKind" | "InstallJobStatus" | "InstallTarget" | "InstallPhaseId" | "InstallProgress" | "InstallProgressSecondary" | "InstallPhaseDetails" | "InstallJavaStep" | "InstallJobDisplay" | "InstallErrorView" | "InstallApiErrorDetails" | "InstallErrorContext" | "ImportLauncherType" | "ModLoader" | "SharedInstanceUnavailableReason"
declare type ValueType<T extends Type> = T extends "AppEvent" ? AppEvent : T extends "LoadingBarType" ? LoadingBarType : T extends "LoadingPayload" ? LoadingPayload : T extends "WarningPayload" ? WarningPayload : T extends "InstanceBulkUpdateProgressPayload" ? InstanceBulkUpdateProgressPayload : T extends "InstanceBulkUpdateProgressStage" ? InstanceBulkUpdateProgressStage : T extends "CommandPayload" ? CommandPayload : T extends "ProcessPayload" ? ProcessPayload : T extends "ProcessPayloadType" ? ProcessPayloadType : T extends "InstancePayload" ? InstancePayload : T extends "InstancePayloadType" ? InstancePayloadType : T extends "FriendPayload" ? FriendPayload : T extends "FriendStatusPayload" ? FriendStatusPayload : T extends "LogEvent" ? LogEvent : T extends "LogPayload" ? LogPayload : T extends "Log4jEvent" ? Log4jEvent : T extends "InstallJobSnapshot" ? InstallJobSnapshot : T extends "InstallJobKind" ? InstallJobKind : T extends "InstallJobStatus" ? InstallJobStatus : T extends "InstallTarget" ? InstallTarget : T extends "InstallPhaseId" ? InstallPhaseId : T extends "InstallProgress" ? InstallProgress : T extends "InstallProgressSecondary" ? InstallProgressSecondary : T extends "InstallPhaseDetails" ? InstallPhaseDetails : T extends "InstallJavaStep" ? InstallJavaStep : T extends "InstallJobDisplay" ? InstallJobDisplay : T extends "InstallErrorView" ? InstallErrorView : T extends "InstallApiErrorDetails" ? InstallApiErrorDetails : T extends "InstallErrorContext" ? InstallErrorContext : T extends "ImportLauncherType" ? ImportLauncherType : T extends "ModLoader" ? ModLoader : T extends "SharedInstanceUnavailableReason" ? SharedInstanceUnavailableReason : void
export interface Result<T extends Type> {
value: ValueType<T>;
bytes: Uint8Array;
}
export function deserialize<T extends Type>(type: T, bytes: Uint8Array): Result<T>
@@ -0,0 +1,968 @@
const BITS_PER_BYTE = 8, BITS_PER_VARINT_BYTE = 7, U8_BYTES = 1, U16_BYTES = 2, U32_BYTES = 4, U64_BYTES = 8, U128_BYTES = 16
const de_zig_zag_signed = (n) => (n >> 1n) ^ (-(n & 0b1n))
const zig_zag = (n_bytes, n) => (n << 1n) ^ (n >> BigInt(n_bytes * BITS_PER_BYTE - 1))
const varint_max = (n_bytes) => Math.floor((n_bytes * BITS_PER_BYTE + (BITS_PER_BYTE - 1)) / BITS_PER_VARINT_BYTE)
const max_of_last_byte = (n_bytes) => (1 << (n_bytes * BITS_PER_BYTE) % 7) - 1
const to_number_if_safe = (n) => Number.MAX_SAFE_INTEGER < ((n < 0n) ? -n : n) ? n : Number(n)
const varint = (n_bytes, n) => { let value = BigInt(n), out = []; for (let i = 0; i < varint_max(n_bytes); i++) { out.push(Number(value & 0xFFn)); if (value < 128n) { return out } out[i] |= 0x80; value >>= 7n } }
class Deserializer {
constructor(bytes_in) { this.bytes = Array.from(bytes_in); }
pop_next = () => { const next = this.bytes.shift(); if (next === undefined) { throw "input buffer too small" } return next }
pop_n = (n) => { const bytes = Array(); for (let i = 0; i < n; i++) { bytes.push(this.bytes.shift()) } return bytes }
get_int8 = (signed) => signed ? new Int8Array([this.pop_next()])[0] : this.pop_next();
try_take = (n_bytes) => { let out = 0n, v_max = varint_max(n_bytes); for (let i = 0; i < v_max; i++) { const val = this.pop_next(), carry = BigInt(val & 0x7F); out |= carry << BigInt(7 * i); if ((val & 0x80) === 0) { if (i === v_max - 1 && val > max_of_last_byte(n_bytes)) { throw "Bad Variant" } else return out } } throw "Bad Variant"; }
deserialize_bool = () => { const byte = this.pop_next(); return byte === undefined ? undefined : byte > 0 ? true : false }
deserialize_number = (n_bytes, signed) => { if (n_bytes === U8_BYTES) { return this.get_int8(signed) } else if (n_bytes === U16_BYTES || n_bytes === U32_BYTES || n_bytes === U64_BYTES || n_bytes === U128_BYTES) { const val = this.try_take(n_bytes); return to_number_if_safe(signed ? de_zig_zag_signed(val) : val) } else { throw "byte count not supported" } }
deserialize_number_float = (n_bytes) => { const b_buffer = new ArrayBuffer(n_bytes), b_view = new DataView(b_buffer); this.pop_n(n_bytes).forEach((b, i) => b_view.setUint8(i, b)); if (n_bytes === U32_BYTES) { return b_view.getFloat32(0, true) } else if (n_bytes === U64_BYTES) { return b_view.getFloat64(0, true) } else { throw "byte count not supported" } }
deserialize_string = () => new TextDecoder().decode(new Uint8Array(this.pop_n(Number(this.try_take(U32_BYTES)))))
deserialize_array = (des, len) => Array.from({length: len === undefined ? Number(this.try_take(U32_BYTES)) : len}, (v, i) => des(this))
deserialize_string_key_map = (des) => { return [...Array(Number(this.try_take(U32_BYTES)))].reduce((prev) => { prev[this.deserialize_string()] = des(this); return prev }, {}) }
deserialize_map = (des) => { return [...Array(Number(this.try_take(U32_BYTES)))].reduce((prev) => { const d = des(this); prev.set(d[0], d[1]); return prev }, new Map()) }
release_bytes = () => { return new Uint8Array(this.bytes); }
}
function deserialize_APP_EVENT(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "loading",
value: deserialize_LOADING_PAYLOAD(d)
};
case 1:
return {
tag: "process",
value: deserialize_PROCESS_PAYLOAD(d)
};
case 2:
return {
tag: "instance",
value: deserialize_INSTANCE_PAYLOAD(d)
};
case 3:
return {
tag: "instance_bulk_update_progress",
value: deserialize_INSTANCE_BULK_UPDATE_PROGRESS_PAYLOAD(d)
};
case 4:
return {
tag: "install_job",
value: deserialize_INSTALL_JOB_SNAPSHOT(d)
};
case 5:
return {
tag: "command",
value: deserialize_COMMAND_PAYLOAD(d)
};
case 6:
return {
tag: "warning",
value: deserialize_WARNING_PAYLOAD(d)
};
case 7:
return {
tag: "friend",
value: deserialize_FRIEND_PAYLOAD(d)
};
case 8:
return {
tag: "notification",
value: d.deserialize_string()
};
case 9:
return {
tag: "log",
value: deserialize_LOG_PAYLOAD(d)
};
case 10:
return {
tag: "ads_consent_required",
value: d.deserialize_bool()
};
default:
throw "variant not implemented"
}
}
function deserialize_LOADING_BAR_TYPE(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "legacy_data_migration"
};
case 1:
return {
tag: "directory_move",
value: {
old: d.deserialize_string(),
new: d.deserialize_string()
}
};
case 2:
return {
tag: "java_download",
value: {
version: d.deserialize_number(U32_BYTES, false)
}
};
case 3:
return {
tag: "pack_file_download",
value: {
instance_id: d.deserialize_string(),
pack_name: d.deserialize_string(),
icon: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
pack_version: d.deserialize_string()
}
};
case 4:
return {
tag: "pack_download",
value: {
instance_id: d.deserialize_string(),
pack_name: d.deserialize_string(),
icon: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
pack_id: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
pack_version: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string()
}
};
case 5:
return {
tag: "pack_import",
value: {
pack_name: d.deserialize_string()
}
};
case 6:
return {
tag: "minecraft_download",
value: {
instance_id: d.deserialize_string(),
instance_name: d.deserialize_string()
}
};
case 7:
return {
tag: "instance_update",
value: {
instance_id: d.deserialize_string(),
instance_name: d.deserialize_string()
}
};
case 8:
return {
tag: "zip_extract",
value: {
instance_id: d.deserialize_string(),
instance_name: d.deserialize_string()
}
};
case 9:
return {
tag: "pack_export",
value: {
instance_id: d.deserialize_string(),
instance_name: d.deserialize_string()
}
};
case 10:
return {
tag: "config_change",
value: {
new_path: d.deserialize_string()
}
};
case 11:
return {
tag: "copy_instance",
value: {
import_location: d.deserialize_string(),
instance_name: d.deserialize_string()
}
};
case 12:
return {
tag: "launcher_update",
value: {
version: d.deserialize_string(),
current_version: d.deserialize_string()
}
};
default:
throw "variant not implemented"
}
}
function deserialize_LOADING_PAYLOAD(d) {
return {
event: deserialize_LOADING_BAR_TYPE(d),
loader_uuid: d.deserialize_string(),
fraction: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_number_float(U64_BYTES),
message: d.deserialize_string()
};
}
function deserialize_WARNING_PAYLOAD(d) {
return {
message: d.deserialize_string()
};
}
function deserialize_INSTANCE_BULK_UPDATE_PROGRESS_PAYLOAD(d) {
return {
instanceId: d.deserialize_string(),
stage: deserialize_INSTANCE_BULK_UPDATE_PROGRESS_STAGE(d),
current: d.deserialize_number(U64_BYTES, false),
total: d.deserialize_number(U64_BYTES, false)
};
}
function deserialize_INSTANCE_BULK_UPDATE_PROGRESS_STAGE(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "resolving_versions"
};
case 1:
return {
tag: "downloading"
};
case 2:
return {
tag: "finishing"
};
default:
throw "variant not implemented"
}
}
function deserialize_COMMAND_PAYLOAD(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "InstallMod",
value: {
id: d.deserialize_string()
}
};
case 1:
return {
tag: "InstallVersion",
value: {
id: d.deserialize_string()
}
};
case 2:
return {
tag: "InstallModpack",
value: {
id: d.deserialize_string()
}
};
case 3:
return {
tag: "InstallServer",
value: {
id: d.deserialize_string()
}
};
case 4:
return {
tag: "LaunchInstance",
value: {
id: d.deserialize_string(),
server: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
singleplayer_world: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string()
}
};
case 5:
return {
tag: "InstallSharedInstanceInvite",
value: {
invite_id: d.deserialize_string()
}
};
case 6:
return {
tag: "RunMRPack",
value: {
path: d.deserialize_string()
}
};
default:
throw "variant not implemented"
}
}
function deserialize_PROCESS_PAYLOAD(d) {
return {
instance_id: d.deserialize_string(),
uuid: d.deserialize_string(),
event: deserialize_PROCESS_PAYLOAD_TYPE(d),
message: d.deserialize_string()
};
}
function deserialize_PROCESS_PAYLOAD_TYPE(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "launched"
};
case 1:
return {
tag: "finished"
};
default:
throw "variant not implemented"
}
}
function deserialize_INSTANCE_PAYLOAD(d) {
return {
instance_id: d.deserialize_string(),
event: deserialize_INSTANCE_PAYLOAD_TYPE(d)
};
}
function deserialize_INSTANCE_PAYLOAD_TYPE(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "created"
};
case 1:
return {
tag: "synced"
};
case 2:
return {
tag: "servers_updated"
};
case 3:
return {
tag: "world_updated",
value: {
world: d.deserialize_string()
}
};
case 4:
return {
tag: "server_joined",
value: {
host: d.deserialize_string(),
port: d.deserialize_number(U16_BYTES, false),
timestamp: d.deserialize_string()
}
};
case 5:
return {
tag: "edited"
};
case 6:
return {
tag: "content_install_finished",
value: {
project_ids: d.deserialize_array(() => d.deserialize_string())
}
};
case 7:
return {
tag: "content_install_failed",
value: {
project_ids: d.deserialize_array(() => d.deserialize_string()),
message: d.deserialize_string()
}
};
case 8:
return {
tag: "removed"
};
default:
throw "variant not implemented"
}
}
function deserialize_FRIEND_PAYLOAD(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "friend_request",
value: {
from: d.deserialize_string()
}
};
case 1:
return {
tag: "user_offline",
value: {
id: d.deserialize_string()
}
};
case 2:
return {
tag: "status_update",
value: {
user_status: deserialize_FRIEND_STATUS_PAYLOAD(d)
}
};
case 3:
return {
tag: "status_sync"
};
default:
throw "variant not implemented"
}
}
function deserialize_FRIEND_STATUS_PAYLOAD(d) {
return {
user_id: d.deserialize_string(),
profile_name: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
last_update: d.deserialize_string()
};
}
function deserialize_LOG_EVENT(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "log4j",
value: deserialize_LOG4J_EVENT(d)
};
case 1:
return {
tag: "legacy",
value: {
message: d.deserialize_string()
}
};
default:
throw "variant not implemented"
}
}
function deserialize_LOG_PAYLOAD(d) {
return {
instance_id: d.deserialize_string(),
event: deserialize_LOG_EVENT(d)
};
}
function deserialize_LOG4J_EVENT(d) {
return {
timestamp_millis: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_number(U64_BYTES, true),
logger_name: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
level: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
thread_name: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
message: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
throwable: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string()
};
}
function deserialize_INSTALL_JOB_SNAPSHOT(d) {
return {
job_id: d.deserialize_string(),
instance_id: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
kind: deserialize_INSTALL_JOB_KIND(d),
status: deserialize_INSTALL_JOB_STATUS(d),
target: deserialize_INSTALL_TARGET(d),
phase: deserialize_INSTALL_PHASE_ID(d),
progress: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : deserialize_INSTALL_PROGRESS(d),
details: deserialize_INSTALL_PHASE_DETAILS(d),
display: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : deserialize_INSTALL_JOB_DISPLAY(d),
error: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : deserialize_INSTALL_ERROR_VIEW(d),
rollback_error: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : deserialize_INSTALL_ERROR_VIEW(d),
created: d.deserialize_string(),
modified: d.deserialize_string(),
finished: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string()
};
}
function deserialize_INSTALL_JOB_KIND(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "create_instance"
};
case 1:
return {
tag: "create_modpack_instance"
};
case 2:
return {
tag: "create_shared_instance"
};
case 3:
return {
tag: "import_instance"
};
case 4:
return {
tag: "duplicate_instance"
};
case 5:
return {
tag: "install_existing_instance"
};
case 6:
return {
tag: "install_pack_to_existing_instance"
};
case 7:
return {
tag: "update_shared_instance"
};
default:
throw "variant not implemented"
}
}
function deserialize_INSTALL_JOB_STATUS(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "queued"
};
case 1:
return {
tag: "running"
};
case 2:
return {
tag: "succeeded"
};
case 3:
return {
tag: "failed"
};
case 4:
return {
tag: "interrupted"
};
case 5:
return {
tag: "canceled"
};
default:
throw "variant not implemented"
}
}
function deserialize_INSTALL_TARGET(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "new_instance",
value: {
instance_id: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string()
}
};
case 1:
return {
tag: "existing_instance",
value: {
instance_id: d.deserialize_string()
}
};
default:
throw "variant not implemented"
}
}
function deserialize_INSTALL_PHASE_ID(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "preparing_instance"
};
case 1:
return {
tag: "resolving_pack"
};
case 2:
return {
tag: "downloading_pack_file"
};
case 3:
return {
tag: "reading_pack_manifest"
};
case 4:
return {
tag: "downloading_content"
};
case 5:
return {
tag: "extracting_overrides"
};
case 6:
return {
tag: "resolving_minecraft"
};
case 7:
return {
tag: "resolving_loader"
};
case 8:
return {
tag: "preparing_java"
};
case 9:
return {
tag: "downloading_minecraft"
};
case 10:
return {
tag: "running_loader_processors"
};
case 11:
return {
tag: "finalizing"
};
case 12:
return {
tag: "rolling_back"
};
default:
throw "variant not implemented"
}
}
function deserialize_INSTALL_PROGRESS(d) {
return {
current: d.deserialize_number(U64_BYTES, false),
total: d.deserialize_number(U64_BYTES, false),
secondary: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : deserialize_INSTALL_PROGRESS_SECONDARY(d)
};
}
function deserialize_INSTALL_PROGRESS_SECONDARY(d) {
return {
current: d.deserialize_number(U64_BYTES, false),
total: d.deserialize_number(U64_BYTES, false)
};
}
function deserialize_INSTALL_PHASE_DETAILS(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "empty"
};
case 1:
return {
tag: "instance",
value: {
name: d.deserialize_string()
}
};
case 2:
return {
tag: "minecraft",
value: {
game_version: d.deserialize_string(),
loader: deserialize_MOD_LOADER(d)
}
};
case 3:
return {
tag: "java",
value: {
major_version: d.deserialize_number(U32_BYTES, false),
step: deserialize_INSTALL_JAVA_STEP(d)
}
};
case 4:
return {
tag: "modpack",
value: {
project_id: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
version_id: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
title: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string()
}
};
case 5:
return {
tag: "import",
value: {
launcher_type: deserialize_IMPORT_LAUNCHER_TYPE(d),
instance_folder: d.deserialize_string()
}
};
default:
throw "variant not implemented"
}
}
function deserialize_INSTALL_JAVA_STEP(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "resolving"
};
case 1:
return {
tag: "fetching_metadata"
};
case 2:
return {
tag: "downloading"
};
case 3:
return {
tag: "extracting"
};
case 4:
return {
tag: "validating"
};
default:
throw "variant not implemented"
}
}
function deserialize_INSTALL_JOB_DISPLAY(d) {
return {
title: d.deserialize_string(),
icon: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string()
};
}
function deserialize_INSTALL_ERROR_VIEW(d) {
return {
code: d.deserialize_string(),
phase: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : deserialize_INSTALL_PHASE_ID(d),
message: d.deserialize_string(),
reason: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : deserialize_SHARED_INSTANCE_UNAVAILABLE_REASON(d),
api: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : deserialize_INSTALL_API_ERROR_DETAILS(d),
context: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : deserialize_INSTALL_ERROR_CONTEXT(d)
};
}
function deserialize_INSTALL_API_ERROR_DETAILS(d) {
return {
error: d.deserialize_string(),
status: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_number(U16_BYTES, false),
method: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
url: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
route: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string()
};
}
function deserialize_INSTALL_ERROR_CONTEXT(d) {
return {
operation: d.deserialize_string(),
source_path: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
target_path: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
file_path: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
entry_path: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
urls: d.deserialize_array(() => d.deserialize_string()),
expected_hash: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
expected_size: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_number(U64_BYTES, false),
project_id: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
version_id: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
minecraft_version: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
loader: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
java_version: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_number(U32_BYTES, false),
os: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string(),
arch: (d.deserialize_number(U32_BYTES, false) === 0) ? undefined : d.deserialize_string()
};
}
function deserialize_IMPORT_LAUNCHER_TYPE(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "MultiMC"
};
case 1:
return {
tag: "PrismLauncher"
};
case 2:
return {
tag: "ATLauncher"
};
case 3:
return {
tag: "GDLauncher"
};
case 4:
return {
tag: "Curseforge"
};
case 5:
return {
tag: "Unknown"
};
default:
throw "variant not implemented"
}
}
function deserialize_MOD_LOADER(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "vanilla"
};
case 1:
return {
tag: "forge"
};
case 2:
return {
tag: "fabric"
};
case 3:
return {
tag: "quilt"
};
case 4:
return {
tag: "neoforge"
};
default:
throw "variant not implemented"
}
}
function deserialize_SHARED_INSTANCE_UNAVAILABLE_REASON(d) {
switch (d.deserialize_number(U32_BYTES, false)) {
case 0:
return {
tag: "deleted"
};
case 1:
return {
tag: "access_revoked"
};
case 2:
return {
tag: "quarantined"
};
default:
throw "variant not implemented"
}
}
/**
* Deserialize a value from an array of bytes.
* @param {string} type - The type of the value to deserialize.
* @param {Uint8Array} bytes - The byte array to deserialize from.
* @return {Object} The deserialized value and remaining bytes.
*/
function deserialize(type, bytes) {
if (!(typeof type === "string")) {
throw "type must be a string";
}
const d = new Deserializer(bytes);
var return_value = undefined;
switch (type) {
case "AppEvent":
return_value = deserialize_APP_EVENT(d);
break;
case "LoadingBarType":
return_value = deserialize_LOADING_BAR_TYPE(d);
break;
case "LoadingPayload":
return_value = deserialize_LOADING_PAYLOAD(d);
break;
case "WarningPayload":
return_value = deserialize_WARNING_PAYLOAD(d);
break;
case "InstanceBulkUpdateProgressPayload":
return_value = deserialize_INSTANCE_BULK_UPDATE_PROGRESS_PAYLOAD(d);
break;
case "InstanceBulkUpdateProgressStage":
return_value = deserialize_INSTANCE_BULK_UPDATE_PROGRESS_STAGE(d);
break;
case "CommandPayload":
return_value = deserialize_COMMAND_PAYLOAD(d);
break;
case "ProcessPayload":
return_value = deserialize_PROCESS_PAYLOAD(d);
break;
case "ProcessPayloadType":
return_value = deserialize_PROCESS_PAYLOAD_TYPE(d);
break;
case "InstancePayload":
return_value = deserialize_INSTANCE_PAYLOAD(d);
break;
case "InstancePayloadType":
return_value = deserialize_INSTANCE_PAYLOAD_TYPE(d);
break;
case "FriendPayload":
return_value = deserialize_FRIEND_PAYLOAD(d);
break;
case "FriendStatusPayload":
return_value = deserialize_FRIEND_STATUS_PAYLOAD(d);
break;
case "LogEvent":
return_value = deserialize_LOG_EVENT(d);
break;
case "LogPayload":
return_value = deserialize_LOG_PAYLOAD(d);
break;
case "Log4jEvent":
return_value = deserialize_LOG4J_EVENT(d);
break;
case "InstallJobSnapshot":
return_value = deserialize_INSTALL_JOB_SNAPSHOT(d);
break;
case "InstallJobKind":
return_value = deserialize_INSTALL_JOB_KIND(d);
break;
case "InstallJobStatus":
return_value = deserialize_INSTALL_JOB_STATUS(d);
break;
case "InstallTarget":
return_value = deserialize_INSTALL_TARGET(d);
break;
case "InstallPhaseId":
return_value = deserialize_INSTALL_PHASE_ID(d);
break;
case "InstallProgress":
return_value = deserialize_INSTALL_PROGRESS(d);
break;
case "InstallProgressSecondary":
return_value = deserialize_INSTALL_PROGRESS_SECONDARY(d);
break;
case "InstallPhaseDetails":
return_value = deserialize_INSTALL_PHASE_DETAILS(d);
break;
case "InstallJavaStep":
return_value = deserialize_INSTALL_JAVA_STEP(d);
break;
case "InstallJobDisplay":
return_value = deserialize_INSTALL_JOB_DISPLAY(d);
break;
case "InstallErrorView":
return_value = deserialize_INSTALL_ERROR_VIEW(d);
break;
case "InstallApiErrorDetails":
return_value = deserialize_INSTALL_API_ERROR_DETAILS(d);
break;
case "InstallErrorContext":
return_value = deserialize_INSTALL_ERROR_CONTEXT(d);
break;
case "ImportLauncherType":
return_value = deserialize_IMPORT_LAUNCHER_TYPE(d);
break;
case "ModLoader":
return_value = deserialize_MOD_LOADER(d);
break;
case "SharedInstanceUnavailableReason":
return_value = deserialize_SHARED_INSTANCE_UNAVAILABLE_REASON(d);
break;
default:
throw "type not implemented";
}
return { value: return_value, bytes: d.release_bytes() };
}
export {
deserialize
};
@@ -0,0 +1,8 @@
{
"name": "postcard",
"description": "Auto generated bindings for postcard format serializing and deserializing javascript to and from bytes.",
"version": "0.0.0",
"main": "index.js",
"types": "index.d.ts",
"type": "module"
}
-5
View File
@@ -1,5 +1,4 @@
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
export async function init_ads_window(overrideShown = false) {
return await invoke('plugin:ads|init_ads_window', {
@@ -47,10 +46,6 @@ export async function open_ads_consent_preferences() {
return await invoke('plugin:ads|open_ads_consent_preferences')
}
export async function ads_consent_listener(callback) {
return await listen('ads-consent-required', (event) => callback(event.payload))
}
export async function record_ads_click() {
return await invoke('plugin:ads|record_ads_click')
}
-137
View File
@@ -1,137 +0,0 @@
/*
Event listeners for interacting with the Rust api
These are all async functions that return a promise that resolves to the payload object (whatever Rust is trying to deliver)
*/
/*
callback is a function that takes a single argument, which is the payload object (whatever Rust is trying to deliver)
You can call these to await any kind of emitted signal from Rust, and then do something with the payload object
An example place to put this is at the start of main.js before the state is initialized- that way
you can listen for any emitted signal from Rust and do something with it as the state is being initialized
Example:
import { loading_listener } from '@/helpers/events'
await loading_listener((event) => {
// event.event is the event name (useful if you want to use a single callback fn for multiple event types)
// event.payload is the payload object
console.log(event)
})
Putting that in a script will print any emitted signal from rust
*/
import { listen } from '@tauri-apps/api/event'
/// Payload for the 'loading' event
/*
LoadingPayload {
event: {
type: string, one of "StateInit", "PackDownload", etc
(Optional fields depending on event type)
pack_name: name of the pack
pack_id, optional, the id of the modpack
pack_version, optional, the version of the modpack
instance_name: name of the instance
instance_id: unique identification of the instance
}
loader_uuid: unique identification of the loading bar
fraction: number, (as a fraction of 1, how much we've loaded so far). If null, by convention, loading is finished
message: message to display to the user
}
*/
export async function loading_listener(callback) {
return await listen('loading', (event) => callback(event.payload))
}
/// Payload for the 'process' event
/*
ProcessPayload {
uuid: unique identification of the process in the state (currently identified by PID, but that will change)
pid: process ID
event: event type ("Launched", "Finished")
message: message to display to the user
}
*/
export async function process_listener(callback) {
return await listen('process', (event) => callback(event.payload))
}
/// Payload for the 'instance' event
/*
InstancePayload {
instance_id: unique identification of the instance
event: event type ("Created", "Added", "Edited", "Removed")
}
*/
export async function instance_listener(callback) {
return await listen('instance', (event) => callback(event.payload))
}
/// Payload for the 'instance_bulk_update_progress' event
/*
InstanceBulkUpdateProgress {
instanceId: string
stage: "resolving_versions" | "downloading" | "finishing"
current: number
total: number
}
*/
export async function instance_bulk_update_progress_listener(callback) {
return await listen('instance_bulk_update_progress', (event) => callback(event.payload))
}
export async function install_job_listener(callback) {
return await listen('install_job', (event) => callback(event.payload))
}
/// Payload for the 'command' event
/*
CommandPayload {
event: event type ("InstallMod", "InstallModpack", "InstallVersion"),
id: string id of the mod/modpack/version to install
}
*/
export async function command_listener(callback) {
return await listen('command', (event) => {
callback(event.payload)
})
}
/// Payload for the 'warning' event
/*
WarningPayload {
message: message to display to the user
}
*/
export async function warning_listener(callback) {
return await listen('warning', (event) => callback(event.payload))
}
export async function friend_listener(callback) {
return await listen('friend', (event) => callback(event.payload))
}
export async function notification_listener(callback) {
return await listen('notification', (event) => callback(event.payload))
}
/// Payload for the 'log' event
/*
LogPayload {
instance_id: string,
type: "log4j" | "legacy",
// log4j fields (when type === "log4j"):
timestamp_millis?: number,
logger_name?: string,
level?: string,
thread_name?: string,
message?: string,
throwable?: string,
// legacy fields (when type === "legacy"):
message?: string,
}
*/
export async function log_listener(callback) {
return await listen('log', (event) => callback(event.payload))
}
+24 -125
View File
@@ -1,8 +1,28 @@
import { invoke } from '@tauri-apps/api/core'
import { install_job_listener } from './events'
import type { InstallErrorView } from '@/generated/app-events/InstallErrorView'
import type { InstallJavaStep } from '@/generated/app-events/InstallJavaStep'
import type { InstallJobSnapshot } from '@/generated/app-events/InstallJobSnapshot'
import type { InstallJobStatus } from '@/generated/app-events/InstallJobStatus'
import type { InstallPhaseId } from '@/generated/app-events/InstallPhaseId'
import type { InstallProgress } from '@/generated/app-events/InstallProgress'
import type { InstallProgressSecondary } from '@/generated/app-events/InstallProgressSecondary'
import type { SharedInstanceUnavailableReason } from '@/generated/app-events/SharedInstanceUnavailableReason'
import type { AppEvents } from '@/providers/app-events'
import type { InstanceLink, InstanceLoader } from './types'
export type {
InstallErrorView,
InstallJavaStep,
InstallJobSnapshot,
InstallJobStatus,
InstallPhaseId,
InstallProgress,
InstallProgressSecondary,
SharedInstanceUnavailableReason,
}
export interface PackLocationVersionId {
type: 'fromVersionId'
project_id: string
@@ -104,8 +124,6 @@ export interface SharedInstanceUpdateDiff {
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
export const SHARED_INSTANCES_API_ERROR_CODE = 'shared_instances_api_error'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'quarantined'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
@@ -136,116 +154,6 @@ export function getErrorMessage(error: unknown): string {
return 'Unknown error'
}
export type InstallJobStatus =
| 'queued'
| 'running'
| 'succeeded'
| 'failed'
| 'interrupted'
| 'canceled'
export type InstallPhaseId =
| 'preparing_instance'
| 'resolving_pack'
| 'downloading_pack_file'
| 'reading_pack_manifest'
| 'downloading_content'
| 'extracting_overrides'
| 'resolving_minecraft'
| 'resolving_loader'
| 'preparing_java'
| 'downloading_minecraft'
| 'running_loader_processors'
| 'finalizing'
| 'rolling_back'
export interface InstallProgress {
current: number
total: number
secondary?: InstallProgressSecondary | null
}
export interface InstallProgressSecondary {
current: number
total: number
}
export type InstallJavaStep =
| 'resolving'
| 'fetching_metadata'
| 'downloading'
| 'extracting'
| 'validating'
export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
reason?: SharedInstanceUnavailableReason | null
api?: {
error: string
status?: number | null
method?: string | null
url?: string | null
route?: string | null
} | null
context?: {
operation: string
source_path?: string | null
target_path?: string | null
file_path?: string | null
entry_path?: string | null
urls?: string[]
expected_hash?: string | null
expected_size?: number | null
project_id?: string | null
version_id?: string | null
minecraft_version?: string | null
loader?: string | null
java_version?: number | null
os?: string | null
arch?: string | null
} | null
}
export interface InstallJobSnapshot {
job_id: string
instance_id?: string | null
kind:
| 'create_instance'
| 'create_modpack_instance'
| 'create_shared_instance'
| 'update_shared_instance'
| 'import_instance'
| 'duplicate_instance'
| 'install_existing_instance'
| 'install_pack_to_existing_instance'
status: InstallJobStatus
target:
| { type: 'new_instance'; instance_id?: string | null }
| { type: 'existing_instance'; instance_id: string }
phase: InstallPhaseId
progress?: InstallProgress | null
details:
| { type: 'empty' }
| { type: 'instance'; name: string }
| { type: 'minecraft'; game_version: string; loader: InstanceLoader }
| { type: 'java'; major_version: number; step: InstallJavaStep }
| {
type: 'modpack'
project_id?: string | null
version_id?: string | null
title?: string | null
}
| { type: 'import'; launcher_type: string; instance_folder: string }
display?: { title: string; icon?: string | null } | null
error?: InstallErrorView | null
rollback_error?: InstallErrorView | null
created: string
modified: string
finished?: string | null
}
export async function install_get_modpack_preview(location: CreatePackLocation) {
return await invoke<InstallModpackPreview>('plugin:install|install_get_modpack_preview', {
location,
@@ -399,7 +307,7 @@ function settleInstallJob(job: InstallJobSnapshot) {
throw new Error(`Install job ${job.job_id} ${job.status}`)
}
export async function wait_for_install_job(jobId: string) {
export async function wait_for_install_job(events: AppEvents, jobId: string) {
const current = await install_job_get(jobId)
if (isInstallJobFinished(current.status)) return settleInstallJob(current)
@@ -434,16 +342,7 @@ export async function wait_for_install_job(jobId: string) {
reject(err)
}
install_job_listener(resolveJob)
.then((listener) => {
if (finished) {
listener()
return
}
unlisten = listener
install_job_get(jobId).then(resolveJob).catch(rejectWait)
})
.catch(rejectWait)
unlisten = events.on('install_job', resolveJob)
install_job_get(jobId).then(resolveJob).catch(rejectWait)
})
}
@@ -15,7 +15,7 @@ export type InstanceContentData = {
export type InstanceContentModpackData = {
project: ManagedContentProject
version: ManagedContentVersion
version: ManagedContentVersion | null
updateVersionId: string | null
}
@@ -53,10 +53,12 @@ function normalizeLinkedModpackInfo(
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
icon_url: modpackInfo.project.icon_url ?? undefined,
},
version: {
...modpackInfo.version,
date_published: modpackInfo.version.date_published.toString(),
},
version: modpackInfo.version
? {
...modpackInfo.version,
date_published: modpackInfo.version.date_published.toString(),
}
: null,
updateVersionId: modpackInfo.update_version_id,
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ export async function refresh_content_updates(instanceId: string): Promise<void>
// Linked modpack info returned from backend
export interface LinkedModpackInfo {
project: Labrinth.Projects.v2.Project
version: Labrinth.Versions.v2.Version
version: Labrinth.Versions.v2.Version | null
owner: ContentOwner | null
has_update: boolean
update_version_id: string | null
+6 -16
View File
@@ -3,7 +3,9 @@
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import { type Channel, invoke } from '@tauri-apps/api/core'
import type { AppEvent } from '@/generated/app-events/AppEvent'
export interface LoadingBarType {
type?: string
@@ -24,24 +26,12 @@ export interface LoadingBar {
bar_type?: LoadingBarType
}
export type OpeningCommandEvent =
| 'RunMRPack'
| 'InstallServer'
| 'InstallVersion'
| 'InstallMod'
| 'InstallModpack'
| string
export interface OpeningCommand {
event: OpeningCommandEvent
id?: string
path?: string
}
export type OpeningCommand = Extract<AppEvent, { type: 'command' }>['payload']
// Initialize the theseus API state
// This should be called during the initializion/opening of the launcher
export async function initialize_state() {
return await invoke<void>('initialize_state')
export async function initialize_state(events: Channel<ArrayBuffer>) {
return await invoke<void>('initialize_state', { events })
}
// Gets active progress bars
+2 -15
View File
@@ -3,6 +3,7 @@ import { autoToHTML } from '@sfirew/minecraft-motd-parser'
import { invoke } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import type { InstancePayload } from '@/generated/app-events/InstancePayload'
import { get_full_path } from '@/helpers/instance'
import { openPath } from '@/helpers/utils'
@@ -532,18 +533,4 @@ export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVer
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
}
export type InstanceEvent = { instance_id: string } & (
| {
event: 'servers_updated'
}
| {
event: 'world_updated'
world: string
}
| {
event: 'server_joined'
host: string
port: number
timestamp: string
}
)
export type InstanceEvent = InstancePayload
@@ -287,6 +287,12 @@
"app.export-modal.export-button": {
"message": "Export"
},
"app.export-modal.export-complete": {
"message": "Export complete"
},
"app.export-modal.export-complete-description": {
"message": "{name} was exported successfully."
},
"app.export-modal.header": {
"message": "Export modpack"
},
+26 -40
View File
@@ -35,14 +35,14 @@ import {
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
import { useAppEvent } from '@/composables/use-app-event'
import { get_project, get_search_results_v3, get_version_many } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events.js'
import {
get_installed_project_ids as getInstalledProjectIds,
list as listInstances,
@@ -123,6 +123,7 @@ const {
effectiveServerWorldId,
serverContextServerData,
serverContentProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
selectedServerInstallProjects,
@@ -143,6 +144,7 @@ const {
enforceSetupModpackRoute,
getQueuedServerInstallPlans,
setQueuedServerInstallPlans,
resolveQueuedServerInstallPlan,
openServerModpackInstallFlow,
onServerFlowBack,
handleServerModpackFlowCreate,
@@ -870,6 +872,7 @@ function getCardActions(
['modpack', 'mod', 'plugin', 'datapack'].includes(currentProjectType)
) {
const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id)
const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(projectResult.project_id)
const isInstallingSelection = isInstallingQueuedServerInstalls.value
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
@@ -897,14 +900,16 @@ function getCardActions(
? CheckIcon
: PlusIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
disabled: showAsInstalled || isInstalling || isInstallingSelection,
disabled:
showAsInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined',
onClick: async () => {
if (isQueued) {
if (isQueuedRoot) {
removeQueuedServerInstall(projectResult.project_id)
return
}
if (isQueued) return
const contentType = currentProjectType as BrowseInstallContentType
const isModpack = contentType === 'modpack'
@@ -913,7 +918,7 @@ function getCardActions(
setProjectInstalling(projectResult.project_id, true)
}
try {
await requestInstall({
const plan = await requestInstall({
project: projectResult,
contentType,
mode: isModpack ? 'immediate' : 'queue',
@@ -937,7 +942,9 @@ function getCardActions(
iconUrl: plan.project.icon_url ?? undefined,
}),
})
if (!isModpack) await resolveQueuedServerInstallPlan(plan)
} catch (err) {
if (!isModpack) removeQueuedServerInstall(projectResult.project_id)
handleError(err as Error)
} finally {
if (shouldShowInstalling) {
@@ -1158,44 +1165,23 @@ if (instance.value?.game_version) {
void searchState.refreshSearch()
type UnlistenFn = () => void
let isUnmounted = false
let unlistenInstances: UnlistenFn | null = null
onMounted(() => {
instance_listener(async (event: { event: string; instance_id: string }) => {
if (event.event === 'added' || event.event === 'created' || event.event === 'removed') {
if (!route.query.i) {
await refreshInstalledProjectIds()
if (projectType.value === 'modpack') {
if (event.event === 'removed') {
syncHiddenInstanceProjectIds()
}
await searchState.refreshSearch()
}
}
}
if (instance.value && event.instance_id === instance.value.id && event.event === 'synced') {
useAppEvent('instance', async (event) => {
if (event.event === 'created' || event.event === 'removed') {
if (!route.query.i) {
await refreshInstalledProjectIds()
await searchState.refreshSearch()
}
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
if (projectType.value === 'modpack') {
if (event.event === 'removed') {
syncHiddenInstanceProjectIds()
}
await searchState.refreshSearch()
}
}
}
unlistenInstances = unlisten
})
.catch(handleError)
})
onUnmounted(() => {
isUnmounted = true
unlistenInstances?.()
if (instance.value && event.instance_id === instance.value.id && event.event === 'synced') {
await refreshInstalledProjectIds()
await searchState.refreshSearch()
}
})
function getProjectBrowseQuery() {
+7 -13
View File
@@ -3,12 +3,12 @@ import { HomeIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import type { SearchResult } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import RowDisplay from '@/components/RowDisplay.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { get_search_results } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events'
import { list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
@@ -89,18 +89,12 @@ async function refreshFeaturedProjects() {
await fetchInstances()
await refreshFeaturedProjects()
const unlistenInstance = await instance_listener(
async (e: { event: string; instance_id: string }) => {
await fetchInstances()
useAppEvent('instance', async (event) => {
await fetchInstances()
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
await refreshFeaturedProjects()
}
},
)
onUnmounted(() => {
unlistenInstance()
if (event.event === 'created' || event.event === 'removed') {
await refreshFeaturedProjects()
}
})
</script>
@@ -32,6 +32,7 @@ import {
} from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { injectAppEvents } from '@/providers/app-events'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { useTheming } from '@/store/state'
@@ -41,6 +42,7 @@ import { injectInstanceSettings } from './instance-settings-context.ts'
import SharedInstanceInstallationSettingsControls from './shared-instance-installation-settings-controls.vue'
const { handleError } = injectNotificationManager()
const appEvents = injectAppEvents()
const filePicker = injectFilePicker()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
@@ -198,7 +200,7 @@ async function installLocalModpackFromPicker() {
}).catch(handleError)
if (!job) return false
const completed = await wait_for_install_job(job.job_id).catch(handleError)
const completed = await wait_for_install_job(appEvents, job.job_id).catch(handleError)
return !!completed
}
@@ -127,13 +127,9 @@ import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import {
instance_bulk_update_progress_listener,
instance_listener,
type InstanceBulkUpdateProgress,
} from '@/helpers/events.js'
import {
add_project_from_path,
edit,
@@ -151,6 +147,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import type { CacheBehaviour } from '@/helpers/types'
import { highlightModInInstance } from '@/helpers/utils.js'
import { type AppEventPayload, injectAppEvents } from '@/providers/app-events'
import { injectContentInstall } from '@/providers/content-install'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme'
@@ -159,6 +156,8 @@ import { injectInstancePage } from '../instance-context'
import { instanceContentQueryOptions, instanceKeys } from '../query-options'
import { injectSharedInstance } from '../shared-instance-context'
type InstanceBulkUpdateProgress = AppEventPayload<'instance_bulk_update_progress'>
const messages = defineMessages({
modpackContentHeader: {
id: 'app.instance.content.managed-content.modpack-header',
@@ -229,6 +228,7 @@ function contentOwnerLink(owner: ContentOwner): NonNullable<ContentOwner['link']
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const appEvents = injectAppEvents()
const { installingItems, installRevisionByInstance, installFailureRevisionByInstance } =
injectContentInstall()
const router = useRouter()
@@ -955,7 +955,7 @@ async function bulkUpdateAllProjects(onProgress?: (status: BulkOperationStatus)
message: formatMessage(messages.bulkUpdateResolvingVersions),
waiting: true,
})
unlisten = await instance_bulk_update_progress_listener((progress) => {
unlisten = appEvents.on('instance_bulk_update_progress', (progress) => {
if (progress.instanceId !== instance.value.id) return
onProgress(formatBulkUpdateProgress(progress))
})
@@ -1725,7 +1725,18 @@ const removeBeforeEach = router.beforeEach(() => {
let isUnmounted = false
let unlistenDragDrop: UnlistenFn | null = null
let unlistenInstances: UnlistenFn | null = null
useAppEvent('instance', async (event) => {
if (
instance.value &&
event.instance_id === instance.value.id &&
event.event === 'synced' &&
instance.value.install_stage === 'installed' &&
!isBulkOperating.value
) {
await initProjects()
}
})
onMounted(() => {
void getCurrentWebview()
@@ -1747,27 +1758,6 @@ onMounted(() => {
unlistenDragDrop = unlisten
})
.catch(handleError)
void instance_listener(async (event: { event: string; instance_id: string }) => {
if (
instance.value &&
event.instance_id === instance.value.id &&
event.event === 'synced' &&
instance.value.install_stage === 'installed' &&
!isBulkOperating.value
) {
await initProjects()
}
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenInstances = unlisten
})
.catch(handleError)
})
watch(
@@ -1803,6 +1793,5 @@ onUnmounted(() => {
isUnmounted = true
removeBeforeEach()
unlistenDragDrop?.()
unlistenInstances?.()
})
</script>
@@ -23,9 +23,9 @@ import {
writeFile as writeFileBytes,
writeTextFile,
} from '@tauri-apps/plugin-fs'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { instance_listener } from '@/helpers/events'
import { useAppEvent } from '@/composables/use-app-event'
import { get_full_path } from '@/helpers/instance'
import { highlightInFolder } from '@/helpers/utils'
@@ -307,20 +307,12 @@ async function handleExtractFile(path: string, override: boolean, dry: boolean)
}
}
debug('setup: registering instance_listener')
const unlistenInstances = await instance_listener(
async (event: { event: string; instance_id: string }) => {
debug('instance_listener: event =', event.event, 'path =', event.instance_id)
if (event.instance_id === instanceId.value && event.event === 'synced') {
debug('instance_listener: synced event matched, calling refresh')
await refresh()
}
},
)
debug('setup: instance_listener registered')
onUnmounted(() => {
unlistenInstances()
useAppEvent('instance', async (event) => {
debug('app event: instance =', event.event, 'path =', event.instance_id)
if (event.instance_id === instanceId.value && event.event === 'synced') {
debug('app event: synced instance matched, calling refresh')
await refresh()
}
})
watch(instanceId, async () => {
+21 -40
View File
@@ -113,7 +113,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { useOnline } from '@vueuse/core'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, type ComputedRef, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, type ComputedRef, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
@@ -126,9 +126,9 @@ import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { useAppEvent } from '@/composables/use-app-event'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { instance_listener, process_listener } from '@/helpers/events'
import {
getSharedInstanceUnavailableReason,
install_existing_instance,
@@ -776,10 +776,6 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
}
}
let unlistenInstances: (() => void) | null = null
let unlistenProcesses: (() => void) | null = null
let instancePageAlive = true
provideInstancePage({
instanceId,
instance: instance as ComputedRef<GameInstance>,
@@ -812,39 +808,27 @@ watch(instanceId, (currentInstanceId, previousInstanceId) => {
destroyInstanceConsole(previousInstanceId)
})
onMounted(() => {
void instance_listener(async (event: { instance_id: string; event: string }) => {
if (event.instance_id !== instanceId.value) return
if (event.event === 'removed' || route.path === '/') {
if (route.path !== '/') await router.push({ path: '/' })
return
}
await queryClient.invalidateQueries({
queryKey: instanceKeys.detail(event.instance_id),
exact: true,
})
useAppEvent('instance', async (event) => {
if (event.instance_id !== instanceId.value) return
if (event.event === 'removed' || route.path === '/') {
if (route.path !== '/') await router.push({ path: '/' })
return
}
await queryClient.invalidateQueries({
queryKey: instanceKeys.detail(event.instance_id),
exact: true,
})
.then((unlisten) => {
if (instancePageAlive) unlistenInstances = unlisten
else unlisten()
})
.catch(handleError)
})
void process_listener((event: { event: string; instance_id: string }) => {
if (event.instance_id !== instanceId.value) return
if (event.event === 'finished') {
queryClient.setQueryData(instanceKeys.processes(event.instance_id), [])
useInstanceConsole(event.instance_id).invalidate()
void queryClient.invalidateQueries({ queryKey: instanceKeys.logs(event.instance_id) })
} else if (event.event === 'launched') {
queryClient.setQueryData(instanceKeys.processes(event.instance_id), [true])
}
})
.then((unlisten) => {
if (instancePageAlive) unlistenProcesses = unlisten
else unlisten()
})
.catch(handleError)
useAppEvent('process', (event) => {
if (event.instance_id !== instanceId.value) return
if (event.event === 'finished') {
queryClient.setQueryData(instanceKeys.processes(event.instance_id), [])
useInstanceConsole(event.instance_id).invalidate()
void queryClient.invalidateQueries({ queryKey: instanceKeys.logs(event.instance_id) })
} else if (event.event === 'launched') {
queryClient.setQueryData(instanceKeys.processes(event.instance_id), [true])
}
})
const icon = computed(() =>
@@ -858,9 +842,6 @@ const timePlayed = computed(() => {
})
onUnmounted(() => {
instancePageAlive = false
unlistenProcesses?.()
unlistenInstances?.()
if (instanceId.value) {
destroyInstanceConsole(instanceId.value)
}
@@ -12,10 +12,10 @@ import {
provideConsoleManager,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, onUnmounted, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
import { computed, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
import { useAppEvent } from '@/composables/use-app-event'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { log_listener, process_listener } from '@/helpers/events.js'
import { delete_logs_by_filename, get_output_by_filename } from '@/helpers/logs.js'
import { injectInstancePage } from '../instance-context'
@@ -191,7 +191,7 @@ if (!instancePage.playing.value) {
void analyseForCrash()
}
const unlistenLog = await log_listener((payload) => {
useAppEvent('log', (payload) => {
if (payload.instance_id !== instanceId.value) return
if (payload.type === 'log4j') {
@@ -201,7 +201,7 @@ const unlistenLog = await log_listener((payload) => {
}
})
const unlistenProcesses = await process_listener(async (e) => {
useAppEvent('process', async (e) => {
if (e.instance_id !== instanceId.value) return
if (e.event === 'launched') {
liveConsole.clear()
@@ -216,9 +216,4 @@ const unlistenProcesses = await process_listener(async (e) => {
void analyseForCrash()
}
})
onUnmounted(() => {
unlistenLog()
unlistenProcesses()
})
</script>
@@ -156,9 +156,9 @@ import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWo
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3 } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags'
import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds'
import {
@@ -169,7 +169,6 @@ import {
handleDefaultInstanceUpdateEvent,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
type InstanceEvent,
normalizeServerAddress,
type ProtocolVersion,
refreshServerData,
@@ -399,35 +398,31 @@ watch(
{ immediate: true },
)
let unlistenInstance: (() => void) | null = null
let worldsTabAlive = true
useAppEvent('instance', async (event) => {
if (event.instance_id !== instance.value.id) return
console.info(`Handling instance event '${event.event}' for instance: ${event.instance_id}`)
if (event.event === 'servers_updated') {
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
if (isLinux) linuxRefreshCount.value++
await refreshAllWorlds()
}
await handleDefaultInstanceUpdateEvent(worlds.value, instance.value.id, event)
})
async function initWorldsTab() {
const [_unlistenInstance, resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
instance_listener(async (e: InstanceEvent) => {
if (e.instance_id !== instance.value.id) return
console.info(`Handling instance event '${e.event}' for instance: ${e.instance_id}`)
if (e.event === 'servers_updated') {
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
if (isLinux) linuxRefreshCount.value++
await refreshAllWorlds()
}
await handleDefaultInstanceUpdateEvent(worlds.value, instance.value.id, e)
}),
const [resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
get_instance_protocol_version(instance.value.id).catch(() => null),
get_game_versions().catch(() => [] as GameVersion[]),
])
if (!worldsTabAlive) {
_unlistenInstance()
return
}
if (!worldsTabAlive) return
unlistenInstance = _unlistenInstance
protocolVersion.value = resolvedProtocolVersion
gameVersions.value = resolvedGameVersions
protocolVersionReady.value = true
@@ -724,6 +719,5 @@ async function proceedRemoveWorld(world: World) {
onBeforeUnmount(() => {
worldsTabAlive = false
unlistenInstance?.()
})
</script>
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { LibraryIcon, PlusIcon } from '@modrinth/assets'
import { Button, injectNotificationManager, NavTabs } from '@modrinth/ui'
import { inject, onUnmounted, ref, shallowRef } from 'vue'
import { inject, ref, shallowRef } from 'vue'
import { useRoute } from 'vue-router'
import { NewInstanceImage } from '@/assets/icons'
import { instance_listener } from '@/helpers/events.js'
import { useAppEvent } from '@/composables/use-app-event'
import { list } from '@/helpers/instance'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
@@ -31,12 +31,9 @@ window.addEventListener('online', () => {
offline.value = false
})
const unlistenInstance = await instance_listener(async () => {
useAppEvent('instance', async () => {
instances.value = await list().catch(handleError)
})
onUnmounted(() => {
unlistenInstance()
})
</script>
<template>
+3 -10
View File
@@ -284,7 +284,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
@@ -294,6 +294,7 @@ import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { useAppEvent } from '@/composables/use-app-event'
import {
get_organization,
get_project,
@@ -302,7 +303,6 @@ import {
get_version,
get_version_many,
} from '@/helpers/cache.js'
import { process_listener } from '@/helpers/events'
import {
get as getInstance,
get_projects as getInstanceProjects,
@@ -814,8 +814,7 @@ function fetchDeferredServerData(project) {
await fetchProjectData()
let unlistenProcesses
process_listener((e) => {
useAppEvent('process', (e) => {
if (
e.event === 'finished' &&
serverInstancePath.value &&
@@ -823,12 +822,6 @@ process_listener((e) => {
) {
serverPlaying.value = false
}
}).then((unlisten) => {
unlistenProcesses = unlisten
})
onUnmounted(() => {
unlistenProcesses?.()
})
watch(
@@ -0,0 +1,19 @@
import { createContext } from '@modrinth/ui'
import type { AppEvent } from '@/generated/app-events/AppEvent'
export type AppEventType = AppEvent['type']
export type AppEventPayload<Type extends AppEventType> = Extract<
AppEvent,
{ type: Type }
>['payload']
export type AppEventHandler<Type extends AppEventType> = (
payload: AppEventPayload<Type>,
) => void | Promise<void>
export interface AppEvents {
on<Type extends AppEventType>(type: Type, handler: AppEventHandler<Type>): () => void
once<Type extends AppEventType>(type: Type, handler: AppEventHandler<Type>): () => void
}
export const [injectAppEvents, provideAppEvents] = createContext<AppEvents>('root', 'appEvents')
@@ -20,7 +20,6 @@ import {
get_team,
get_version_many,
} from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events.js'
import {
install_create_instance,
install_create_modpack_instance,
@@ -38,6 +37,7 @@ import {
} from '@/helpers/instance'
import { get_game_versions } from '@/helpers/tags'
import type { GameInstance, InstanceLoader } from '@/helpers/types'
import type { AppEvents } from '@/providers/app-events'
import { useTheming } from '@/store/state'
interface ModalRef {
show: (initialVersionId?: string) => void
@@ -60,13 +60,6 @@ type InstallingProjectDisplay = {
organization?: string | null
team?: string
}
type ContentInstallInstanceEvent = {
event: string
instance_id: string
project_ids?: string[]
message?: string
}
const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge']
const SUPPORTED_LOADERS: Set<string> = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge'])
const VANILLA_COMPATIBLE_LOADERS: Set<string> = new Set(['minecraft', 'datapack'])
@@ -191,6 +184,7 @@ export const [injectContentInstall, provideContentInstall] = createContext<Conte
export function createContentInstall(opts: {
router: Router
handleError: (err: unknown) => void
appEvents: AppEvents
}): ContentInstallContext {
const { formatMessage } = useVIntl()
const themeStore = useTheming()
@@ -394,17 +388,17 @@ export function createContentInstall(opts: {
installFailureRevisionByInstance.value = next
}
void instance_listener((event: ContentInstallInstanceEvent) => {
opts.appEvents.on('instance', (event) => {
if (event.event === 'content_install_finished') {
markInstanceContentChanged(event.instance_id)
removeInstallingItems(event.instance_id, event.project_ids ?? [])
removeInstallingItems(event.instance_id, event.project_ids)
} else if (event.event === 'content_install_failed') {
removeInstallingItems(event.instance_id, event.project_ids ?? [])
removeInstallingItems(event.instance_id, event.project_ids)
markInstanceContentInstallFailed(event.instance_id)
markInstanceContentChanged(event.instance_id)
opts.handleError(event.message ?? 'Failed to install content')
opts.handleError(event.message)
}
}).catch(opts.handleError)
})
let modalRef: ModalRef | null = null
let modpackAlreadyInstalledModalRef: ModpackAlreadyInstalledModalRef | null = null
@@ -1,7 +1,7 @@
import { createContext } from '@modrinth/ui'
import type { Ref } from 'vue'
import { loading_listener } from '@/helpers/events'
import type { AppEvents } from '@/providers/app-events'
export interface AppDownloadProgressContext {
progress: Ref<number>
@@ -10,26 +10,19 @@ export interface AppDownloadProgressContext {
/* returns unlisten function */
export async function subscribeToDownloadProgress(
events: AppEvents,
context: AppDownloadProgressContext,
version: string,
) {
return await loading_listener(
(event: {
event: {
type: 'launcher_update'
version: string
return events.on('loading', (event) => {
if (event.event.type === 'launcher_update') {
if (!version || event.event.version === version) {
context.progress.value = event.fraction ?? 1.0
context.version.value = event.event.version
console.log(`Progress: ${context.progress.value} ${context.version.value}`)
}
fraction?: number
}) => {
if (event.event.type === 'launcher_update') {
if (!version || event.event.version === version) {
context.progress.value = event.fraction ?? 1.0
context.version.value = event.event.version
console.log(`Progress: ${context.progress.value} ${context.version.value}`)
}
}
},
)
}
})
}
export const [injectAppUpdateDownloadProgress, provideAppUpdateDownloadProgress] =
@@ -17,6 +17,7 @@ import { edit, get, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds'
import { start_join_server } from '@/helpers/worlds.ts'
import type { AppEvents } from '@/providers/app-events'
import { handleSevereError } from '@/store/error.js'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -74,6 +75,7 @@ export function createServerInstall(opts: {
router: Router
handleError: (err: unknown) => void
popupNotificationManager: AbstractPopupNotificationManager
appEvents: AppEvents
}): ServerInstallContext {
const installingServerProjects = ref<string[]>([])
@@ -134,7 +136,7 @@ export function createServerInstall(opts: {
const instanceId = installJobInstanceId(job)
if (!instanceId) return null
await wait_for_install_job(job.job_id)
await wait_for_install_job(opts.appEvents, job.job_id)
await ensureManagedServerWorldExists(instanceId, project.title, serverAddress)
return instanceId
@@ -145,7 +147,7 @@ export function createServerInstall(opts: {
await edit(instance.id, { game_version: targetGameVersion })
const job = await install_existing_instance(instance.id, false)
await wait_for_install_job(job.job_id)
await wait_for_install_job(opts.appEvents, job.job_id)
}
function showModpackInstallSuccess(project: GameInstance, serverAddress: string | null) {
@@ -261,7 +263,7 @@ export function createServerInstall(opts: {
const instanceId = installJobInstanceId(createJob)
if (!instanceId) return
await wait_for_install_job(createJob.job_id)
await wait_for_install_job(opts.appEvents, createJob.job_id)
await ensureManagedServerWorldExists(instanceId, project.title, serverAddress)
}
@@ -0,0 +1,245 @@
import type { AppEvent } from '@/generated/app-events/AppEvent'
import { deserialize } from '@/generated/app-events/postcard'
type WireObject = Record<string, unknown>
interface WireEnum {
tag: string
value?: unknown
}
function wireObject(value: unknown): WireObject {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('Invalid Postcard app event object')
}
return value as WireObject
}
function wireEnum(value: unknown): WireEnum {
const event = wireObject(value)
if (typeof event.tag !== 'string') {
throw new TypeError('Invalid Postcard app event enum')
}
return event as unknown as WireEnum
}
function taggedObject(value: unknown, tag: string): WireObject {
const event = wireEnum(value)
return {
[tag]: event.tag,
...(event.value === undefined ? {} : wireObject(event.value)),
}
}
function unitVariant(value: unknown): string {
return wireEnum(value).tag
}
function nullable(value: unknown): unknown {
return value === undefined ? null : value
}
function number(value: unknown): unknown {
return typeof value === 'bigint' ? Number(value) : value
}
function normalizeLoadingPayload(value: unknown): WireObject {
const payload = wireObject(value)
const event = taggedObject(payload.event, 'type')
for (const field of ['icon', 'pack_id', 'pack_version']) {
if (field in event) event[field] = nullable(event[field])
}
return {
...payload,
event,
fraction: nullable(payload.fraction),
}
}
function normalizeProcessPayload(value: unknown): WireObject {
const payload = wireObject(value)
return { ...payload, event: unitVariant(payload.event) }
}
function normalizeInstancePayload(value: unknown): WireObject {
const payload = wireObject(value)
const event = wireEnum(payload.event)
return {
instance_id: payload.instance_id,
event: event.tag,
...(event.value === undefined ? {} : wireObject(event.value)),
}
}
function normalizeBulkUpdatePayload(value: unknown): WireObject {
const payload = wireObject(value)
return {
...payload,
stage: unitVariant(payload.stage),
current: number(payload.current),
total: number(payload.total),
}
}
function normalizeCommandPayload(value: unknown): WireObject {
const command = taggedObject(value, 'event')
if (command.event === 'LaunchInstance') {
command.server = nullable(command.server)
command.singleplayer_world = nullable(command.singleplayer_world)
}
return command
}
function normalizeFriendPayload(value: unknown): WireObject {
const event = taggedObject(value, 'event')
if (event.event === 'status_update') {
const status = wireObject(event.user_status)
event.user_status = {
...status,
profile_name: nullable(status.profile_name),
}
}
return event
}
function normalizeLogPayload(value: unknown): WireObject {
const payload = wireObject(value)
const event = taggedObject(payload.event, 'type')
if (event.type === 'log4j') {
for (const field of [
'timestamp_millis',
'logger_name',
'level',
'thread_name',
'message',
'throwable',
]) {
event[field] = nullable(event[field])
}
event.timestamp_millis = number(event.timestamp_millis)
}
return { instance_id: payload.instance_id, ...event }
}
function normalizeInstallProgress(value: unknown): WireObject {
const progress = wireObject(value)
const secondary = progress.secondary
return {
...progress,
current: number(progress.current),
total: number(progress.total),
secondary:
secondary === undefined
? undefined
: {
...wireObject(secondary),
current: number(wireObject(secondary).current),
total: number(wireObject(secondary).total),
},
}
}
function normalizeInstallDetails(value: unknown): WireObject {
const details = taggedObject(value, 'type')
if (details.type === 'minecraft') details.loader = unitVariant(details.loader)
if (details.type === 'java') details.step = unitVariant(details.step)
if (details.type === 'import') details.launcher_type = unitVariant(details.launcher_type)
if (details.type === 'modpack') {
details.project_id = nullable(details.project_id)
details.version_id = nullable(details.version_id)
details.title = nullable(details.title)
}
return details
}
function normalizeInstallContext(value: unknown): WireObject {
const context = wireObject(value)
return {
...context,
expected_size: context.expected_size === undefined ? undefined : number(context.expected_size),
}
}
function normalizeInstallError(value: unknown): WireObject {
const error = wireObject(value)
return {
...error,
phase: error.phase === undefined ? undefined : unitVariant(error.phase),
reason: error.reason === undefined ? undefined : unitVariant(error.reason),
api: error.api === undefined ? undefined : wireObject(error.api),
context: error.context === undefined ? undefined : normalizeInstallContext(error.context),
}
}
function normalizeInstallJob(value: unknown): WireObject {
const job = wireObject(value)
const display = job.display === undefined ? null : wireObject(job.display)
if (display !== null) display.icon = nullable(display.icon)
return {
...job,
instance_id: nullable(job.instance_id),
kind: unitVariant(job.kind),
status: unitVariant(job.status),
target: (() => {
const target = taggedObject(job.target, 'type')
if ('instance_id' in target) target.instance_id = nullable(target.instance_id)
return target
})(),
phase: unitVariant(job.phase),
progress: job.progress === undefined ? null : normalizeInstallProgress(job.progress),
details: normalizeInstallDetails(job.details),
display,
error: job.error === undefined ? null : normalizeInstallError(job.error),
rollback_error:
job.rollback_error === undefined ? null : normalizeInstallError(job.rollback_error),
finished: nullable(job.finished),
}
}
export function decodeAppEvent(payload: ArrayBuffer): AppEvent {
const result = deserialize('AppEvent', new Uint8Array(payload))
if (result.bytes.length !== 0) {
throw new TypeError('Postcard app event contained trailing bytes')
}
const event = result.value as WireEnum
const decoded = (() => {
switch (event.tag) {
case 'loading':
return { type: event.tag, payload: normalizeLoadingPayload(event.value) }
case 'process':
return { type: event.tag, payload: normalizeProcessPayload(event.value) }
case 'instance':
return { type: event.tag, payload: normalizeInstancePayload(event.value) }
case 'instance_bulk_update_progress':
return { type: event.tag, payload: normalizeBulkUpdatePayload(event.value) }
case 'install_job':
return { type: event.tag, payload: normalizeInstallJob(event.value) }
case 'command':
return { type: event.tag, payload: normalizeCommandPayload(event.value) }
case 'warning':
return { type: event.tag, payload: wireObject(event.value) }
case 'friend':
return { type: event.tag, payload: normalizeFriendPayload(event.value) }
case 'notification':
return { type: event.tag, payload: JSON.parse(String(event.value)) as unknown }
case 'log':
return { type: event.tag, payload: normalizeLogPayload(event.value) }
case 'ads_consent_required':
return { type: event.tag, payload: event.value }
default:
throw new TypeError(`Unknown Postcard app event: ${event.tag}`)
}
})()
return decoded as AppEvent
}
@@ -0,0 +1,65 @@
import { Channel } from '@tauri-apps/api/core'
import { onScopeDispose } from 'vue'
import {
type AppEventHandler,
type AppEvents,
type AppEventType,
provideAppEvents,
} from '@/providers/app-events'
import { decodeAppEvent } from '@/providers/setup/app-event-codec'
type UntypedAppEventHandler = (payload: unknown) => void | Promise<void>
export function setupAppEventsProvider() {
const handlers = new Map<AppEventType, Set<UntypedAppEventHandler>>()
function on<Type extends AppEventType>(type: Type, handler: AppEventHandler<Type>) {
let eventHandlers = handlers.get(type)
if (!eventHandlers) {
eventHandlers = new Set()
handlers.set(type, eventHandlers)
}
const untypedHandler = handler as unknown as UntypedAppEventHandler
eventHandlers.add(untypedHandler)
return () => {
eventHandlers.delete(untypedHandler)
if (eventHandlers.size === 0) handlers.delete(type)
}
}
function once<Type extends AppEventType>(type: Type, handler: AppEventHandler<Type>) {
let unsubscribe = () => {}
unsubscribe = on(type, async (payload) => {
unsubscribe()
await handler(payload)
})
return unsubscribe
}
const events: AppEvents = { on, once }
const channel = new Channel<ArrayBuffer>((payload) => {
const event = decodeAppEvent(payload)
const eventHandlers = handlers.get(event.type)
if (!eventHandlers) return
for (const handler of [...eventHandlers]) {
void Promise.resolve()
.then(() => handler(event.payload))
.catch((error) => {
console.error(`Unhandled ${event.type} app event`, error)
})
}
})
provideAppEvents(events)
function dispose() {
handlers.clear()
}
onScopeDispose(dispose)
return { channel, events, dispose }
}
@@ -1,21 +1,21 @@
import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client'
import type { Archon, Labrinth } from '@modrinth/api-client'
import {
addPendingServerContentInstalls,
type BrowseInstallPlan,
type BrowseSelectedProject,
createContext,
type CreationFlowContextValue,
flushStoredServerAddonInstallQueue,
getServerAddonInstallPlanProjectIds,
getStoredServerAddonInstallQueue,
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
type ModpackSearchResult,
type PendingServerContentInstall,
type PendingServerContentInstallType,
readPendingServerContentInstalls,
readStoredServerInstallQueue,
removePendingServerContentInstall,
writePendingServerContentInstallBaseline,
resolveServerAddonInstallPlans,
useServerContextRuntime,
useServerPanelSync,
waitForServerContextRuntimeReady,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
@@ -28,7 +28,6 @@ type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
installing?: boolean
installed?: boolean
}
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
export interface ServerModpackSelectionRequest {
projectId: string
@@ -54,6 +53,7 @@ export interface ServerInstallContentContext {
effectiveServerWorldId: ComputedRef<string | null>
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
serverContentProjectIds: Ref<Set<string>>
queuedServerInstallRootProjectIds: ComputedRef<Set<string>>
queuedServerInstallProjectIds: ComputedRef<Set<string>>
queuedServerInstallCount: ComputedRef<number>
selectedServerInstallProjects: ComputedRef<BrowseSelectedProject[]>
@@ -76,6 +76,9 @@ export interface ServerInstallContentContext {
setQueuedServerInstallPlans: (
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) => void
resolveQueuedServerInstallPlan: (
plan: BrowseInstallPlan<InstallableSearchResult>,
) => Promise<void>
openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise<void>
onServerFlowBack: () => void
handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise<void>
@@ -90,114 +93,6 @@ function readQueryString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
return {
id: ownerId,
name: project.organization,
type: 'organization' as const,
link: `https://modrinth.com/organization/${ownerId}`,
}
}
if (!project.author) return null
const ownerId = project.author_id ?? project.author
return {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `/user/${encodeURIComponent(ownerId)}`,
}
}
async function getQueuedInstallOwner(
client: AbstractModrinthClient,
project: InstallableSearchResult,
) {
const fallback = getQueuedInstallOwnerFallback(project)
try {
if (project.organization) {
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
if (organization) {
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `https://modrinth.com/organization/${organization.slug}`,
}
}
}
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
const owner =
members.find((member) => member.user.id === project.author_id)?.user ??
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
members[0]?.user
if (owner) {
return {
id: owner.id,
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `/user/${encodeURIComponent(owner.username)}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<InstallableSearchResult>,
owner: PendingServerContentInstallInput['owner'],
): PendingServerContentInstallInput {
const project = plan.project as InstallableSearchResult & { slug?: string | null }
return {
projectId: plan.projectId,
versionId: plan.versionId,
contentType: plan.contentType as PendingServerContentInstallType,
title: project.name ?? 'Project',
versionName: plan.versionName ?? null,
versionNumber: plan.versionNumber ?? null,
fileName: plan.fileName ?? null,
owner,
slug: project.slug ?? plan.projectId,
iconUrl: project.icon_url ?? null,
}
}
function getQueuedInstallPlaceholderFallbacks(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
client: AbstractModrinthClient,
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)),
),
)
}
export function createServerInstallContent(opts: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
}) {
@@ -220,6 +115,7 @@ export function createServerInstallContent(opts: {
const isFromWorlds = computed(() => browseFrom.value === 'worlds')
const isServerContext = computed(() => !!serverIdQuery.value)
const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value)
useServerContextRuntime(serverIdQuery)
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
@@ -246,11 +142,15 @@ export function createServerInstallContent(opts: {
initialServerId ? getCachedServer(initialServerId) : null,
)
const serverContentProjectIds = ref<Set<string>>(new Set())
const serverContentInstallKeys = ref<Set<string>>(new Set())
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
new Map(),
)
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
const queuedServerInstallRootProjectIds = computed(
() => new Set(queuedServerInstalls.value.keys()),
)
const queuedServerInstallProjectIds = computed(() =>
getServerAddonInstallPlanProjectIds(queuedServerInstalls.value.values()),
)
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
@@ -262,6 +162,10 @@ export function createServerInstallContent(opts: {
const isInstallingQueuedServerInstalls = ref(false)
const queuedInstallProgress = ref({ completed: 0, total: 0 })
const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value)
useServerPanelSync({
serverId: serverIdQuery,
worldId: effectiveServerWorldId,
})
const serverBackUrl = computed(() => {
const sid = serverIdQuery.value
if (!sid) return '/hosting/manage'
@@ -307,11 +211,7 @@ export function createServerInstallContent(opts: {
.map((addon) => addon.project_id)
.filter((projectId): projectId is string => !!projectId),
)
const keys = new Set(
(content.addons ?? []).map((addon) => addon.project_id ?? addon.filename),
)
serverContentProjectIds.value = ids
serverContentInstallKeys.value = keys
} catch (err) {
handleError(err as Error)
}
@@ -353,14 +253,12 @@ export function createServerInstallContent(opts: {
serverContextWorldId.value = null
serverContextServerData.value = null
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
setQueuedServerInstallPlans(new Map())
return
}
if (sid !== prevSid) {
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
serverContextServerData.value = getCachedServer(sid)
try {
@@ -462,6 +360,59 @@ export function createServerInstallContent(opts: {
writeStoredServerInstallQueue(serverId, worldId, plans)
}
function toResolvePreferences(
preferences?: BrowseInstallPlan<InstallableSearchResult>['preferences'],
): Labrinth.Content.v3.ResolutionPreferences {
return {
game_versions: preferences?.gameVersions,
loaders: preferences?.loaders,
}
}
async function resolveAddonPlan(
plan: BrowseInstallPlan<InstallableSearchResult>,
existingProjectIds: string[],
) {
const target = getTargetInstallPreferences(
{
gameVersion: serverContextServerData.value?.mc_version,
loader: serverContextServerData.value?.loader,
},
plan.contentType,
)
const resolved = await client.labrinth.content_v3.resolve({
project_id: plan.projectId,
version_id: plan.versionId,
content_type: plan.contentType as Labrinth.Content.v3.ContentType,
selected: toResolvePreferences(plan.preferences),
target: toResolvePreferences(target),
existing_project_ids: existingProjectIds,
})
return [resolved.primary, ...resolved.dependencies].map((item) => ({
projectId: item.project_id,
versionId: item.version_id,
}))
}
async function resolveQueuedServerInstallPlan(plan: BrowseInstallPlan<InstallableSearchResult>) {
const resolvedContent = await resolveAddonPlan(plan, Array.from(serverContentProjectIds.value))
const storedPlan = queuedServerInstalls.value.get(plan.projectId)
if (!storedPlan || storedPlan.versionId !== plan.versionId) return
const nextPlans = new Map(queuedServerInstalls.value)
nextPlans.set(plan.projectId, { ...storedPlan, resolvedContent })
setQueuedServerInstallPlans(nextPlans)
}
async function resolveQueuedAddonPlans(plans: BrowseInstallPlan<InstallableSearchResult>[]) {
return await resolveServerAddonInstallPlans({
plans,
existingProjectIds: serverContentProjectIds.value,
resolvePlan: resolveAddonPlan,
})
}
async function flushQueuedServerInstalls(
serverId: string | null = serverIdQuery.value,
worldId: string | null = effectiveServerWorldId.value,
@@ -476,6 +427,13 @@ export function createServerInstallContent(opts: {
const queuedPlans = getStoredServerAddonInstallQueue<InstallableSearchResult>(serverId, worldId)
if (queuedPlans.size === 0) return true
try {
await waitForServerContextRuntimeReady(client, serverId)
} catch (error) {
handleError(error as Error)
return false
}
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
@@ -486,22 +444,16 @@ export function createServerInstallContent(opts: {
const result = await flushStoredServerAddonInstallQueue({
serverId,
worldId,
install: (plans) =>
client.archon.content_v1.addAddons(
serverId,
worldId,
plans.map((plan) => ({
project_id: plan.projectId,
version_id: plan.versionId,
})),
),
install: async (plans) => {
const addons = await resolveQueuedAddonPlans(plans)
if (addons.length > 0) {
await client.archon.content_v1.addAddons(serverId, worldId, addons)
}
},
onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans),
})
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
}
handleError(result.error as Error)
return false
}
@@ -514,10 +466,6 @@ export function createServerInstallContent(opts: {
...serverContentProjectIds.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
serverContentInstallKeys.value = new Set([
...serverContentInstallKeys.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
if (result.flushedPlans.length > 0) {
await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] })
}
@@ -542,23 +490,10 @@ export function createServerInstallContent(opts: {
if (sid && wid) {
writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(client, plans)
.then((items) => {
const pendingProjectIds = new Set(
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
)
addPendingServerContentInstalls(
sid,
wid,
items.filter((item) => pendingProjectIds.has(item.projectId)),
)
})
.catch((err) => handleError(err as Error))
}
const installed = await flushQueuedServerInstalls(sid, wid)
if (!installed) return false
await router.push(backUrl)
void flushQueuedServerInstalls(sid, wid)
return true
}
@@ -627,6 +562,7 @@ export function createServerInstallContent(opts: {
effectiveServerWorldId,
serverContextServerData,
serverContentProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
selectedServerInstallProjects,
@@ -647,6 +583,7 @@ export function createServerInstallContent(opts: {
enforceSetupModpackRoute,
getQueuedServerInstallPlans,
setQueuedServerInstallPlans,
resolveQueuedServerInstallPlan,
openServerModpackInstallFlow,
onServerFlowBack,
handleServerModpackFlowCreate,
+1
View File
@@ -77,6 +77,7 @@ default = ["custom-protocol"]
# this feature is used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
export-app-events = ["theseus/export-ts"]
updater = []
[lints]
+1 -1
View File
@@ -3,7 +3,7 @@
"scripts": {
"tauri": "tauri",
"build": "tauri build",
"dev": "tauri dev",
"dev": "tauri dev --features export-app-events",
"test": "cargo nextest run --all-targets --no-fail-fast",
"lint": "cargo fmt --check && cargo clippy --all-targets && cargo clippy --all-targets --features updater",
"lint:ancillary": "prettier --check .",
+13 -9
View File
@@ -3,9 +3,9 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tauri::plugin::TauriPlugin;
use tauri::{Emitter, Manager, PhysicalPosition, PhysicalSize, Runtime};
use tauri::{Manager, PhysicalPosition, PhysicalSize, Runtime};
use tauri_plugin_opener::OpenerExt;
use theseus::settings;
use theseus::{AppEvent, EventState, settings};
use tokio::sync::RwLock;
pub struct AdsState {
@@ -20,7 +20,6 @@ pub struct AdsState {
}
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
const ADS_CONSENT_REQUIRED_EVENT: &str = "ads-consent-required";
const APP_TITLE_BAR_HEIGHT: f32 = 48.0;
#[cfg(any(windows, target_os = "macos"))]
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
@@ -38,6 +37,12 @@ const ADS_USER_AGENT: &str = concat!(
" (Modrinth App)",
);
fn emit_ads_consent_required(required: bool) {
EventState::get()
.send(AppEvent::AdsConsentRequired(required))
.ok();
}
#[cfg(windows)]
fn ads_user_agent_override_params() -> String {
serde_json::json!({
@@ -666,7 +671,7 @@ pub async fn init_ads_window<R: Runtime>(
&& state.consent_required
&& state.consent_notification_enabled
{
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
emit_ads_consent_required(true);
}
Ok(())
@@ -704,7 +709,7 @@ pub async fn update_ads_window_hold<R: Runtime>(
&& state.consent_required
&& state.consent_notification_enabled
{
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
emit_ads_consent_required(true);
}
Ok(())
@@ -730,7 +735,7 @@ pub async fn hide_ads_window<R: Runtime>(
}
if reset {
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, false).ok();
emit_ads_consent_required(false);
}
Ok(())
@@ -761,8 +766,7 @@ pub async fn show_ads_consent_ui<R: Runtime>(
)?;
}
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, show_notification)
.ok();
emit_ads_consent_required(show_notification);
Ok(())
}
@@ -841,7 +845,7 @@ pub async fn finish_ads_consent_flow<R: Runtime>(
}
}
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, false).ok();
emit_ads_consent_required(false);
Ok(())
}
+12 -2
View File
@@ -25,9 +25,12 @@ mod updater_impl_noop;
// Should be called in launcher initialization
#[tracing::instrument(skip_all)]
#[tauri::command]
async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
async fn initialize_state(
app: tauri::AppHandle,
events: tauri::ipc::Channel<tauri::ipc::InvokeResponseBody>,
) -> api::Result<()> {
tracing::info!("Initializing app event state...");
theseus::EventState::init(app.clone()).await?;
theseus::EventState::init(app.clone(), events).await?;
tracing::info!("Initializing app state...");
State::init(app.config().identifier.clone()).await?;
@@ -111,6 +114,13 @@ async fn set_restart_after_pending_update(
// if Tauri app is called with arguments, then those arguments will be treated as commands
// ie: deep links or filepaths for .mrpacks
fn main() {
#[cfg(feature = "export-app-events")]
theseus::export_app_event_bindings(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../app-frontend/src/generated/app-events"),
)
.expect("failed to export app event TypeScript bindings");
/*
tracing is set basd on the environment variable RUST_LOG=xxx, depending on the amount of logs to show
ERROR > WARN > INFO > DEBUG > TRACE
-3
View File
@@ -15,12 +15,9 @@ import { I18nDebugPanel, LoadingBar, NotificationPanel } from '@modrinth/ui'
import AdsConsentNotification from '~/components/ui/AdsConsentNotification.vue'
import { setupProviders } from '~/providers/setup.ts'
import { setupUserCountryProvider } from '~/providers/setup/user-country.ts'
import { useAuth } from './composables/auth'
setupUserCountryProvider()
const auth = await useAuth()
setupProviders(auth)
</script>
@@ -400,10 +400,10 @@ defineExpose({ show, hide })
</Combobox>
<IconButton
v-tooltip="formatMessage(messages.deleteAllGroups)"
type="quiet"
color="red"
type="base"
:label="formatMessage(messages.deleteAllGroups)"
:disabled="titleButtonsDisabled"
class="[&:not(:disabled):focus-visible>svg]:!text-red [&:not(:disabled):hover>svg]:!text-red"
@click="showConfirmClearGroups"
>
<TrashIcon v-if="!isClearing" aria-hidden="true" />
@@ -6,26 +6,25 @@ import type {
CreationFlowContextValue,
EnvironmentSearchOverride,
FilterValue,
PendingServerContentInstall,
PendingServerContentInstallType,
} from '@modrinth/ui'
import {
addPendingServerContentInstalls,
commonMessages,
defineMessages,
flushStoredServerAddonInstallQueue,
getServerAddonInstallPlanProjectIds,
getStoredServerAddonInstallQueue,
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
readPendingServerContentInstalls,
readStoredServerInstallQueue,
removePendingServerContentInstall,
requestInstall,
resolveServerAddonInstallPlans,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useServerContextRuntime,
useServerPanelSync,
useVIntl,
writePendingServerContentInstallBaseline,
waitForServerContextRuntimeReady,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
@@ -35,7 +34,6 @@ import { computed, nextTick, ref, watch } from 'vue'
import { navigateTo, useRoute } from '#app'
import { queryAsString } from '~/utils/router'
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
type ServerInstallBrowseSearchState = Pick<
BrowseSearchState,
'currentFilters' | 'overriddenProvidedFilterTypes'
@@ -88,34 +86,6 @@ const messages = defineMessages({
},
})
function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
return {
id: ownerId,
name: project.organization,
type: 'organization' as const,
link: `/organization/${ownerId}`,
}
}
if (!project.author) return null
const ownerId = project.author_id ?? project.author
return {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `/user/${ownerId}`,
}
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
export function useServerInstallContent({
projectType,
onboardingModalRef,
@@ -136,6 +106,11 @@ export function useServerInstallContent({
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
const fromContext = computed(() => queryAsString(route.query.from) || null)
const currentWorldId = computed(() => queryAsString(route.query.wid) || null)
useServerContextRuntime(currentServerId)
useServerPanelSync({
serverId: currentServerId,
worldId: currentWorldId,
})
const {
data: serverData,
@@ -177,7 +152,12 @@ export function useServerInstallContent({
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<ServerInstallSearchResult>>>(
readStoredServerInstallQueue(currentServerId.value, currentWorldId.value),
)
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
const queuedServerInstallRootProjectIds = computed(
() => new Set(queuedServerInstalls.value.keys()),
)
const queuedServerInstallProjectIds = computed(() =>
getServerAddonInstallPlanProjectIds(queuedServerInstalls.value.values()),
)
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
@@ -219,81 +199,6 @@ export function useServerInstallContent({
writeStoredServerInstallQueue(serverId, worldId, plans)
}
async function getQueuedInstallOwner(project: ServerInstallSearchResult) {
const fallback = getQueuedInstallOwnerFallback(project)
try {
if (project.organization) {
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
if (organization) {
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `/organization/${organization.slug}`,
}
}
}
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
const owner =
members.find((member) => member.user.id === project.author_id)?.user ??
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
members[0]?.user
if (owner) {
return {
id: owner.id,
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `/user/${owner.username}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<ServerInstallSearchResult>,
owner: PendingServerContentInstallInput['owner'],
): PendingServerContentInstallInput {
return {
projectId: plan.projectId,
versionId: plan.versionId,
contentType: plan.contentType as PendingServerContentInstallType,
title: getInstallProjectName(plan.project),
versionName: plan.versionName ?? null,
versionNumber: plan.versionNumber ?? null,
fileName: plan.fileName ?? null,
owner,
slug: plan.project.slug ?? plan.projectId,
iconUrl: plan.project.icon_url ?? null,
}
}
function getQueuedInstallPlaceholderFallbacks(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
),
)
}
function setProjectInstalling(projectId: string, installing: boolean) {
const next = new Set(installingProjectIds.value)
if (installing) {
@@ -319,10 +224,6 @@ export function useServerInstallContent({
)
}
function getServerInstalledContentKeys(data = serverContentData.value) {
return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename))
}
function syncHiddenInstalledProjectIds() {
hiddenInstalledProjectIds.value = new Set([
...getServerInstalledProjectIds(),
@@ -437,32 +338,43 @@ export function useServerInstallContent({
}
}
async function resolveAddonPlan(
plan: BrowseInstallPlan<ServerInstallSearchResult>,
existingProjectIds: string[],
) {
const resolved = await client.labrinth.content_v3.resolve({
project_id: plan.projectId,
version_id: plan.versionId,
content_type: plan.contentType as Labrinth.Content.v3.ContentType,
selected: toResolvePreferences(plan.preferences),
target: toResolvePreferences(getServerInstallTargetPreferences(plan.contentType)),
existing_project_ids: existingProjectIds,
})
return [resolved.primary, ...resolved.dependencies].map((item) => ({
projectId: item.project_id,
versionId: item.version_id,
}))
}
async function resolveAndStoreQueuedAddonPlan(
plan: BrowseInstallPlan<ServerInstallSearchResult>,
) {
const resolvedContent = await resolveAddonPlan(plan, Array.from(getServerInstalledProjectIds()))
const storedPlan = queuedServerInstalls.value.get(plan.projectId)
if (!storedPlan || storedPlan.versionId !== plan.versionId) return
const nextPlans = new Map(queuedServerInstalls.value)
nextPlans.set(plan.projectId, { ...storedPlan, resolvedContent })
serverInstallQueue.set(nextPlans)
}
async function resolveQueuedAddonPlans(plans: BrowseInstallPlan<ServerInstallSearchResult>[]) {
const existingProjectIds = getServerInstalledProjectIds()
const resolvedAddons: Array<{ project_id: string; version_id: string }> = []
for (const plan of plans) {
const resolved = await client.labrinth.content_v3.resolve({
project_id: plan.projectId,
version_id: plan.versionId,
content_type: plan.contentType as Labrinth.Content.v3.ContentType,
selected: toResolvePreferences(plan.preferences),
target: toResolvePreferences(getServerInstallTargetPreferences(plan.contentType)),
existing_project_ids: Array.from(existingProjectIds),
})
const content = [resolved.primary, ...resolved.dependencies]
for (const item of content) {
if (existingProjectIds.has(item.project_id)) continue
existingProjectIds.add(item.project_id)
resolvedAddons.push({
project_id: item.project_id,
version_id: item.version_id,
})
}
}
return resolvedAddons
return await resolveServerAddonInstallPlans({
plans,
existingProjectIds: getServerInstalledProjectIds(),
resolvePlan: resolveAddonPlan,
})
}
function getInstallProjectVersions(projectId: string) {
@@ -498,6 +410,13 @@ export function useServerInstallContent({
)
if (queuedPlans.size === 0) return true
try {
await waitForServerContextRuntimeReady(client, serverId)
} catch (error) {
handleError(error as Error)
return false
}
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
@@ -518,9 +437,6 @@ export function useServerInstallContent({
})
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
}
handleError(result.error as Error)
return false
}
@@ -533,10 +449,7 @@ export function useServerInstallContent({
total: result.flushedPlans.length,
}
if (result.flushedPlans.length > 0) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
])
await queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
}
return true
@@ -559,26 +472,10 @@ export function useServerInstallContent({
if (sid && wid) {
writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, [
...getServerInstalledContentKeys(),
...optimisticallyInstalledProjectIds.value,
])
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(plans)
.then((items) => {
const pendingProjectIds = new Set(
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
)
addPendingServerContentInstalls(
sid,
wid,
items.filter((item) => pendingProjectIds.has(item.projectId)),
)
})
.catch((err) => handleError(err as Error))
}
const installed = await flushQueuedServerInstalls(sid, wid)
if (!installed) return false
await navigateTo(backUrl)
void flushQueuedServerInstalls(sid, wid)
return true
}
@@ -598,16 +495,17 @@ export function useServerInstallContent({
const isModpack = contentType === 'modpack'
try {
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) {
if (!isModpack && queuedServerInstallRootProjectIds.value.has(project.project_id)) {
removeQueuedServerInstall(project.project_id)
return
}
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) return
if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) {
setProjectInstalling(project.project_id, true)
}
await requestInstall({
const plan = await requestInstall({
project,
contentType,
mode: isModpack ? 'immediate' : 'queue',
@@ -646,10 +544,13 @@ export function useServerInstallContent({
ctx.modal.value?.setStage('final-config')
},
})
if (!isModpack) await resolveAndStoreQueuedAddonPlan(plan)
} catch (e) {
console.error(e)
if (isModpack) {
setProjectInstalling(project.project_id, false)
} else {
removeQueuedServerInstall(project.project_id)
}
handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`))
} finally {
@@ -804,6 +705,7 @@ export function useServerInstallContent({
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
isInstallingQueuedServerInstalls,
+2 -25
View File
@@ -97,39 +97,16 @@ import {
LoadingBar,
normalizeChildren,
NotificationPanel,
provideModrinthClient,
provideNotificationManager,
providePageContext,
useVIntl,
} from '@modrinth/ui'
import Logo404 from '~/assets/images/404.svg'
import { getSignInRouteObj } from '~/composables/auth.js'
import { logout } from '~/composables/user.js'
import { createModrinthClient } from './helpers/api.ts'
import { FrontendNotificationManager } from './providers/frontend-notifications.ts'
import { setupLoadingStateProvider } from './providers/setup/loading-state.ts'
import { setupProviders } from '~/providers/setup.ts'
const auth = await useAuth()
const config = useRuntimeConfig()
provideNotificationManager(new FrontendNotificationManager())
setupLoadingStateProvider()
const client = createModrinthClient(auth.value, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
sharedInstancesBaseUrl: config.public.sharedInstancesBaseUrl,
rateLimitKey: config.rateLimitKey,
})
provideModrinthClient(client)
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
adConsentAvailable: ref(false),
openExternalUrl: (url) => window.open(url, '_blank'),
})
setupProviders(auth)
const { formatMessage } = useVIntl()
@@ -3458,6 +3458,12 @@
"project.actions.back-to-project-page": {
"message": "Back to project page"
},
"project.actions.check-modpack-archives": {
"message": "Check modpack unzip"
},
"project.actions.checking-modpack-archives": {
"message": "Checking..."
},
"project.actions.create-server": {
"message": "Create a server"
},
@@ -3743,9 +3749,24 @@
"project.moderation.title": {
"message": "Moderation"
},
"project.modpack-archive-warning.description": {
"message": "Importing this .mrpack might be broken."
},
"project.modpack-archive-warning.title": {
"message": "This modpack was published during export bug"
},
"project.navigation.changelog": {
"message": "Changelog"
},
"project.notification.check-modpack-archives.failed": {
"message": "Some modpack files could not be unzipped"
},
"project.notification.check-modpack-archives.no-files": {
"message": "No .mrpack files were found for this project."
},
"project.notification.check-modpack-archives.success": {
"message": "{count, plural, one {The modpack file can be unzipped.} other {All # modpack files can be unzipped.}}"
},
"project.notification.icon-updated.message": {
"message": "Your project's icon has been updated."
},
+160 -2
View File
@@ -405,6 +405,36 @@
:auth="auth"
:tags="tags"
/>
<Admonition
v-if="
auth.user &&
tags.staffRoles.includes(auth.user.role) &&
project.actualProjectType === 'modpack' &&
hasModpackArchiveInWarningWindow
"
type="warning"
:header="formatMessage(messages.modpackArchiveWarningTitle)"
class="mt-3"
>
{{ formatMessage(messages.modpackArchiveWarningDescription) }}
<template #actions>
<Button
type="colored"
color="orange"
:loading="isCheckingModpackArchives"
@click="checkModpackArchives"
>
<FileArchiveIcon />
{{
formatMessage(
isCheckingModpackArchives
? messages.checkingModpackArchives
: messages.checkModpackArchives,
)
}}
</Button>
</template>
</Admonition>
<Admonition
v-if="
currentMember &&
@@ -529,6 +559,7 @@ import {
ClipboardCopyIcon,
CompassIcon,
DownloadIcon,
FileArchiveIcon,
FolderSearchIcon,
HeartIcon,
LeftArrowIcon,
@@ -663,6 +694,7 @@ const downloadModal = ref()
const openInAppModal = ref()
const overTheTopDownloadAnimation = ref()
const scanModal = ref()
const isCheckingModpackArchives = ref(false)
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
@@ -818,6 +850,35 @@ const messages = defineMessages({
id: 'project.actions.rescan-modpack',
defaultMessage: 'Rescan modpack',
},
checkModpackArchives: {
id: 'project.actions.check-modpack-archives',
defaultMessage: 'Check modpack unzip',
},
checkingModpackArchives: {
id: 'project.actions.checking-modpack-archives',
defaultMessage: 'Checking...',
},
checkModpackArchivesSuccess: {
id: 'project.notification.check-modpack-archives.success',
defaultMessage:
'{count, plural, one {The modpack file can be unzipped.} other {All # modpack files can be unzipped.}}',
},
checkModpackArchivesFailed: {
id: 'project.notification.check-modpack-archives.failed',
defaultMessage: 'Some modpack files could not be unzipped',
},
checkModpackArchivesNoFiles: {
id: 'project.notification.check-modpack-archives.no-files',
defaultMessage: 'No .mrpack files were found for this project.',
},
modpackArchiveWarningTitle: {
id: 'project.modpack-archive-warning.title',
defaultMessage: 'This modpack was published during export bug',
},
modpackArchiveWarningDescription: {
id: 'project.modpack-archive-warning.description',
defaultMessage: 'Importing this .mrpack might be broken.',
},
serversPromoDescription: {
id: 'project.actions.servers-promo.description',
defaultMessage: 'Modrinth Hosting is the easiest way to play with your friends without hassle!',
@@ -1135,7 +1196,7 @@ const {
const dependencies = computed(() => dependenciesRaw.value ?? null)
// V3 Versions - lazy loaded client-side only
// V3 Versions - lazy loaded client-side only (except for staff, who need v3 versions for moderation)
const versionsEnabled = ref(false)
const {
data: versionsV3,
@@ -1149,7 +1210,7 @@ const {
apiVersion: 3,
}),
staleTime: STALE_TIME_LONG,
enabled: computed(() => !!projectId.value && versionsEnabled.value),
enabled: computed(() => !!projectId.value && (versionsEnabled.value || isStaff(auth.value.user))),
})
// Organization
@@ -1749,6 +1810,90 @@ const showProjectHeaderCreateServerAction = computed(
const projectHeaderCreateServerTo = computed(() =>
project.value ? `/hosting?project=${project.value.id}#plan` : '/hosting',
)
const MRPACK_ARCHIVE_WARNING_START = new Date('2026-08-10T17:00:00.000Z').getTime()
const MRPACK_ARCHIVE_WARNING_END = new Date('2026-08-13T20:00:00.000Z').getTime()
const hasModpackArchiveInWarningWindow = computed(() =>
(versionsV3.value ?? []).some((version) => {
const publishedAt = new Date(version.date_published).getTime()
return (
version.files.some((file) => file.filename.toLowerCase().endsWith('.mrpack')) &&
publishedAt >= MRPACK_ARCHIVE_WARNING_START &&
publishedAt <= MRPACK_ARCHIVE_WARNING_END
)
}),
)
async function checkModpackArchives() {
if (!project.value || isCheckingModpackArchives.value) return
isCheckingModpackArchives.value = true
startLoading()
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.value.id)
const filesByUrl = new Map(
versions
.flatMap((version) => version.files)
.filter((file) => file.filename.toLowerCase().endsWith('.mrpack'))
.map((file) => [file.url, file]),
)
const files = [...filesByUrl.values()]
if (files.length === 0) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: formatMessage(messages.checkModpackArchivesNoFiles),
type: 'error',
})
return
}
const { default: JSZip } = await import('jszip')
const failures = []
for (const file of files) {
try {
const response = await fetch(file.url)
if (!response.ok) {
throw new Error(`Download failed (${response.status} ${response.statusText})`)
}
await JSZip.loadAsync(await response.blob(), { checkCRC32: true })
} catch (error) {
failures.push({
filename: file.filename,
error: error?.message ?? String(error),
})
}
}
if (failures.length > 0) {
addNotification({
title: formatMessage(messages.checkModpackArchivesFailed),
text: failures.map((failure) => `${failure.filename}: ${failure.error}`).join('\n'),
type: 'error',
})
return
}
addNotification({
title: formatMessage(commonMessages.successLabel),
text: formatMessage(messages.checkModpackArchivesSuccess, { count: files.length }),
type: 'success',
})
} catch (error) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: error?.data?.description ?? error?.message ?? String(error),
type: 'error',
})
} finally {
isCheckingModpackArchives.value = false
stopLoading()
}
}
const projectHeaderMoreActions = computed(() => {
const isStaff = !!(auth.value.user && tags.value.staffRoles.includes(auth.value.user.role))
@@ -1787,6 +1932,19 @@ const projectHeaderMoreActions = computed(() => {
tone: 'orange',
shown: !!auth.value.user && isStaff && project.value?.actualProjectType === 'modpack',
},
{
id: 'moderation-modpack-check-archives',
label: formatMessage(
isCheckingModpackArchives.value
? messages.checkingModpackArchives
: messages.checkModpackArchives,
),
icon: FileArchiveIcon,
action: checkModpackArchives,
tone: 'orange',
disabled: isCheckingModpackArchives.value,
shown: !!auth.value.user && isStaff && project.value?.actualProjectType === 'modpack',
},
{ type: 'divider', shown: !!auth.value.user && isStaff },
{
id: 'report',
@@ -163,6 +163,7 @@ const {
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
isInstallingQueuedServerInstalls,
@@ -327,6 +328,7 @@ function getCardActions(
if (serverData.value) {
const isQueued = queuedServerInstallProjectIds.value.has(result.project_id)
const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(result.project_id)
const isInstalled =
projectResult.installed ||
optimisticallyInstalledProjectIds.value.has(result.project_id) ||
@@ -362,7 +364,8 @@ function getCardActions(
? CheckIcon
: DownloadIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
disabled: !!isInstalled || isInstalling || isInstallingSelection,
disabled:
!!isInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined',
onClick: () => serverInstall(projectResult),
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="markdown-body">
<h1>Content Rules</h1>
<p><em>Last modified: August 12, 2026</em></p>
<p><em>Last modified: August 13, 2026</em></p>
<p>
These Content Rules are to be considered part of our
+2
View File
@@ -7,6 +7,7 @@ import { setupLoadingStateProvider } from './setup/loading-state'
import { setupModrinthClientProvider } from './setup/modrinth-client'
import { setupPageContextProvider } from './setup/page-context'
import { setupTagsProvider } from './setup/tags'
import { setupUserCountryProvider } from './setup/user-country'
export function setupProviders(auth: Awaited<ReturnType<typeof useAuth>>) {
provideNotificationManager(new FrontendNotificationManager())
@@ -17,4 +18,5 @@ export function setupProviders(auth: Awaited<ReturnType<typeof useAuth>>) {
setupFilePickerProvider()
setupPageContextProvider()
setupLoadingStateProvider()
setupUserCountryProvider()
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -318,6 +318,7 @@ vars! {
NEVERBOUNCE_BASE_URL: String = neverbounce::DEFAULT_API_URL;
EMAIL_DOMAIN_BLACKLIST: StringCsv = StringCsv(vec![]);
EMAIL_DOMAIN_WHITELIST: StringCsv = StringCsv(vec![]);
CLICKHOUSE_REPLICATED: bool = false;
CLICKHOUSE_URL: String = "http://localhost:8123";
+46 -16
View File
@@ -1847,9 +1847,35 @@ impl From<NewAccount> for AccountRegisterFlow {
}
}
/// The bundled disposable domain list is checked first. Environment blacklist
/// entries are matched literally, unless they begin with `*.`, in which case
/// they match any subdomain of the remaining suffix.
#[derive(PartialEq, Eq)]
enum EmailDomainStatus {
Whitelisted,
Neutral,
}
/// Environment list entries are matched literally, unless they begin with `*.`,
/// in which case they match any subdomain of the remaining suffix.
fn matches_domain_entry(domain: &str, entry: &str) -> bool {
let entry = entry.trim().to_ascii_lowercase();
match entry.strip_prefix("*.") {
Some(suffix) => domain
.strip_suffix(suffix)
.is_some_and(|subdomain| subdomain.ends_with('.')),
None => entry == domain,
}
}
fn is_whitelisted_domain(domain: &str) -> bool {
let domain = domain.to_ascii_lowercase();
ENV.EMAIL_DOMAIN_WHITELIST
.iter()
.any(|entry| matches_domain_entry(&domain, entry))
}
/// The bundled disposable domain list is checked first, then the environment
/// blacklist.
fn is_blacklisted_domain(domain: &str) -> bool {
let domain = domain.to_ascii_lowercase();
@@ -1863,35 +1889,39 @@ fn is_blacklisted_domain(domain: &str) -> bool {
return true;
}
ENV.EMAIL_DOMAIN_BLACKLIST.iter().any(|entry| {
let entry = entry.trim().to_ascii_lowercase();
match entry.strip_prefix("*.") {
Some(suffix) => domain
.strip_suffix(suffix)
.is_some_and(|subdomain| subdomain.ends_with('.')),
None => entry == domain,
}
})
ENV.EMAIL_DOMAIN_BLACKLIST
.iter()
.any(|entry| matches_domain_entry(&domain, entry))
}
fn ensure_email_domain_is_allowed(email: &str) -> Result<(), ApiError> {
fn ensure_email_domain_is_allowed(
email: &str,
) -> Result<EmailDomainStatus, ApiError> {
let Some((_, domain)) = email.rsplit_once('@') else {
return Err(ApiError::Request(email_check_error_generic()));
};
if is_whitelisted_domain(domain) {
info!(email.domain = domain, "whitelisted email domain, allowing");
return Ok(EmailDomainStatus::Whitelisted);
}
if is_blacklisted_domain(domain) {
info!(email.domain = domain, "blacklisted email domain, denying");
return Err(ApiError::Request(email_check_error_generic()));
}
Ok(())
Ok(EmailDomainStatus::Neutral)
}
async fn ensure_email_is_usable(email: &str) -> Result<(), ApiError> {
ensure_email_domain_is_allowed(email)
let status = ensure_email_domain_is_allowed(email)
.wrap_api_err("validating email domain is allowed")?;
if status == EmailDomainStatus::Whitelisted {
return Ok(());
}
let result = check_email(email)
.await
.wrap_request_err("checking email address")?;
+26 -5
View File
@@ -232,6 +232,13 @@ pub async fn paypal_webhook(
None,
)
.await
// Make deserialization errors yield a 5xx instead of a 400. For
// webhooks, the server must respect the client's request schema rather
// than requiring the client to conform to the server's schema.
.map_err(|error| match error {
ApiError::Request(report) => ApiError::Internal(report),
error => error,
})
.wrap_api_err("verifying PayPal webhook signature")?;
if &webhook_res.verification_status != "SUCCESS" {
@@ -242,7 +249,7 @@ pub async fn paypal_webhook(
#[derive(Deserialize)]
struct PayPalResource {
pub payout_item_id: String,
pub payout_item_id: Option<String>,
}
#[derive(Deserialize)]
@@ -252,7 +259,7 @@ pub async fn paypal_webhook(
}
let webhook = serde_json::from_str::<PayPalWebhook>(&body)
.wrap_request_err("deserializing JSON data")?;
.wrap_internal_err("deserializing PayPal webhook JSON data")?;
match &*webhook.event_type {
"PAYMENT.PAYOUTS-ITEM.BLOCKED"
@@ -260,6 +267,13 @@ pub async fn paypal_webhook(
| "PAYMENT.PAYOUTS-ITEM.REFUNDED"
| "PAYMENT.PAYOUTS-ITEM.RETURNED"
| "PAYMENT.PAYOUTS-ITEM.CANCELED" => {
let payout_item_id = webhook
.resource
.payout_item_id
.as_deref()
.wrap_internal_err(
"PayPal payout item webhook is missing `payout_item_id`",
)?;
let mut transaction = pool
.begin()
.await
@@ -267,7 +281,7 @@ pub async fn paypal_webhook(
let result = sqlx::query!(
"SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2",
webhook.resource.payout_item_id,
payout_item_id,
PayoutStatus::InTransit.as_str()
)
.fetch_optional(&mut transaction)
@@ -286,7 +300,7 @@ pub async fn paypal_webhook(
PayoutStatus::Failed
}
.as_str(),
webhook.resource.payout_item_id
payout_item_id
)
.execute(&mut transaction)
.await
@@ -309,6 +323,13 @@ pub async fn paypal_webhook(
}
}
"PAYMENT.PAYOUTS-ITEM.SUCCEEDED" => {
let payout_item_id = webhook
.resource
.payout_item_id
.as_deref()
.wrap_internal_err(
"PayPal payout item webhook is missing `payout_item_id`",
)?;
let mut transaction = pool
.begin()
.await
@@ -320,7 +341,7 @@ pub async fn paypal_webhook(
WHERE platform_id = $2
",
PayoutStatus::Success.as_str(),
webhook.resource.payout_item_id
payout_item_id
)
.execute(&mut transaction)
.await
@@ -9,6 +9,7 @@ export type WebSocketEventHandler<
export interface WebSocketConnection {
serverId: string
socket: WebSocket
authenticated: boolean
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
isReconnecting: boolean
@@ -31,6 +32,7 @@ export abstract class AbstractWebSocketClient {
protected readonly MAX_RECONNECT_ATTEMPTS = 10
protected readonly RECONNECT_BASE_DELAY = 1000
protected readonly RECONNECT_MAX_DELAY = 30000
protected readonly AUTHENTICATION_TIMEOUT = 30000
constructor(
protected client: {
@@ -58,6 +60,7 @@ export abstract class AbstractWebSocketClient {
}
if (status && !status.connected && !options?.force) {
await this.waitForAuthentication(serverId)
return
}
@@ -69,6 +72,28 @@ export abstract class AbstractWebSocketClient {
await this.connect(serverId, auth)
}
protected async waitForAuthentication(serverId: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
let unsubscribe = () => {}
const timeout = setTimeout(() => {
unsubscribe()
reject(new Error(`WebSocket authentication timed out for server ${serverId}`))
}, this.AUTHENTICATION_TIMEOUT)
unsubscribe = this.on(serverId, 'auth-ok', () => {
clearTimeout(timeout)
unsubscribe()
resolve()
})
if (this.getStatus(serverId)?.connected) {
clearTimeout(timeout)
unsubscribe()
resolve()
}
})
}
on<E extends Archon.Websocket.v0.WSEventType>(
serverId: string,
eventType: E,
@@ -88,7 +113,7 @@ export abstract class AbstractWebSocketClient {
if (!connection) return null
return {
connected: connection.socket.readyState === WebSocket.OPEN,
connected: connection.socket.readyState === WebSocket.OPEN && connection.authenticated,
reconnecting: connection.isReconnecting,
reconnectAttempts: connection.reconnectAttempts,
}

Some files were not shown because too many files have changed in this diff Show More