Compare commits

...
13 Commits
Author SHA1 Message Date
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
34 changed files with 984 additions and 373 deletions
@@ -1,3 +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": "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": "config_change", new_path: string, } | { "type": "copy_instance", import_location: string, instance_name: string, } | { "type": "launcher_update", version: string, current_version: string, };
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, };
@@ -35,7 +35,7 @@ declare type FixedLengthArray<T, L extends number, TObj = [T, ...Array<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: "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: "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 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 }
@@ -129,15 +129,14 @@ function deserialize_LOADING_BAR_TYPE(d) {
};
case 5:
return {
tag: "minecraft_download",
tag: "pack_import",
value: {
instance_id: d.deserialize_string(),
instance_name: d.deserialize_string()
pack_name: d.deserialize_string()
}
};
case 6:
return {
tag: "instance_update",
tag: "minecraft_download",
value: {
instance_id: d.deserialize_string(),
instance_name: d.deserialize_string()
@@ -145,20 +144,36 @@ function deserialize_LOADING_BAR_TYPE(d) {
};
case 7:
return {
tag: "zip_extract",
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 9:
case 11:
return {
tag: "copy_instance",
value: {
@@ -166,7 +181,7 @@ function deserialize_LOADING_BAR_TYPE(d) {
instance_name: d.deserialize_string()
}
};
case 10:
case 12:
return {
tag: "launcher_update",
value: {
@@ -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
+10 -3
View File
@@ -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) {
@@ -1,4 +1,4 @@
import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client'
import type { Archon, Labrinth } from '@modrinth/api-client'
import {
addPendingServerContentInstalls,
type BrowseInstallPlan,
@@ -6,15 +6,17 @@ import {
createContext,
type CreationFlowContextValue,
flushStoredServerAddonInstallQueue,
getServerAddonInstallPlanProjectIds,
getStoredServerAddonInstallQueue,
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
type ModpackSearchResult,
type PendingServerContentInstall,
type PendingServerContentInstallType,
readPendingServerContentInstalls,
readStoredServerInstallQueue,
removePendingServerContentInstall,
resolveServerAddonInstallPlans,
writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
@@ -54,6 +56,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 +79,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>
@@ -112,48 +118,6 @@ function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
}
}
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>>,
) {
@@ -187,17 +151,6 @@ function getQueuedInstallPlaceholderFallbacks(
)
}
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>
}) {
@@ -250,7 +203,12 @@ export function createServerInstallContent(opts: {
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) => ({
@@ -462,6 +420,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,
@@ -486,15 +497,12 @@ 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),
})
@@ -544,21 +552,10 @@ export function createServerInstallContent(opts: {
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 +624,7 @@ export function createServerInstallContent(opts: {
effectiveServerWorldId,
serverContextServerData,
serverContentProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
selectedServerInstallProjects,
@@ -647,6 +645,7 @@ export function createServerInstallContent(opts: {
enforceSetupModpackRoute,
getQueuedServerInstallPlans,
setQueuedServerInstallPlans,
resolveQueuedServerInstallPlan,
openServerModpackInstallFlow,
onServerFlowBack,
handleServerModpackFlowCreate,
@@ -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" />
@@ -14,14 +14,15 @@ import {
commonMessages,
defineMessages,
flushStoredServerAddonInstallQueue,
getServerAddonInstallPlanProjectIds,
getStoredServerAddonInstallQueue,
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
readPendingServerContentInstalls,
readStoredServerInstallQueue,
removePendingServerContentInstall,
requestInstall,
resolveServerAddonInstallPlans,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useVIntl,
@@ -177,7 +178,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,45 +225,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'],
@@ -284,16 +251,6 @@ export function useServerInstallContent({
)
}
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) {
@@ -437,32 +394,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) {
@@ -533,10 +501,9 @@ 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', 'v1', serverId],
})
}
return true
@@ -564,21 +531,10 @@ export function useServerInstallContent({
...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 +554,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 +603,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 +764,7 @@ export function useServerInstallContent({
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
isInstallingQueuedServerInstalls,
@@ -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
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")?;
@@ -7,6 +7,10 @@ export type UploadSessionFile = {
filename: string
}
function getUploadSessionPath(scope: Kyros.UploadSessions.v1.Scope, worldId: string) {
return `/worlds/${worldId}/${scope}/upload-session`
}
export class KyrosUploadSessionsV1Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_upload_sessions_v1'
@@ -17,7 +21,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
worldId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
getUploadSessionPath(scope, worldId),
{
api: '',
version: 'v1',
@@ -32,7 +36,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
worldId: string,
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
getUploadSessionPath(scope, worldId),
{
api: '',
version: 'v1',
@@ -58,7 +62,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
}
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/files`,
`${getUploadSessionPath(scope, worldId)}/${uploadId}/files`,
{
api: '',
version: 'v1',
@@ -76,7 +80,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/finalize`,
`${getUploadSessionPath(scope, worldId)}/${uploadId}/finalize`,
{
api: '',
version: 'v1',
@@ -92,7 +96,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}`,
`${getUploadSessionPath(scope, worldId)}/${uploadId}`,
{
api: '',
version: 'v1',
@@ -463,32 +463,25 @@ fn pack_get_relative_path(
fn get_mrpack_environment(
environment: Option<VersionEnvironment>,
) -> HashMap<EnvType, SideType> {
let (client, server) = match environment
.unwrap_or(VersionEnvironment::Unknown)
{
VersionEnvironment::ClientAndServer
| VersionEnvironment::SingleplayerOnly => {
(SideType::Required, SideType::Required)
}
VersionEnvironment::ClientOnly => {
(SideType::Required, SideType::Unsupported)
}
VersionEnvironment::ClientOnlyServerOptional => {
(SideType::Required, SideType::Optional)
}
VersionEnvironment::ServerOnly
| VersionEnvironment::DedicatedServerOnly => {
(SideType::Unsupported, SideType::Required)
}
VersionEnvironment::ServerOnlyClientOptional => {
(SideType::Optional, SideType::Required)
}
VersionEnvironment::ClientOrServer
| VersionEnvironment::ClientOrServerPrefersBoth => {
(SideType::Optional, SideType::Optional)
}
VersionEnvironment::Unknown => (SideType::Optional, SideType::Optional),
};
let (client, server) =
match environment.unwrap_or(VersionEnvironment::Unknown) {
VersionEnvironment::ClientAndServer
| VersionEnvironment::ClientOnlyServerOptional
| VersionEnvironment::ServerOnly
| VersionEnvironment::ServerOnlyClientOptional
| VersionEnvironment::ClientOrServer
| VersionEnvironment::ClientOrServerPrefersBoth
| VersionEnvironment::Unknown => {
(SideType::Required, SideType::Required)
}
VersionEnvironment::ClientOnly
| VersionEnvironment::SingleplayerOnly => {
(SideType::Required, SideType::Unsupported)
}
VersionEnvironment::DedicatedServerOnly => {
(SideType::Unsupported, SideType::Required)
}
};
HashMap::from([(EnvType::Client, client), (EnvType::Server, server)])
}
@@ -300,7 +300,7 @@ pub(crate) async fn list_linked_modpack_content(
let Some((_, version_id)) = linked_modpack_ids(&link) else {
return Ok(Vec::new());
};
let ids = match get_modpack_identifiers(
let modpack_ids = match get_modpack_identifiers(
&version_id,
&resolved.content_set,
&state.pool,
@@ -308,19 +308,25 @@ pub(crate) async fn list_linked_modpack_content(
)
.await
{
Ok(ids) => ids,
Ok(ids) => Some(ids),
Err(err) => {
tracing::warn!("Failed to fetch modpack identifiers: {}", err);
return Ok(Vec::new());
None
}
};
let files = content_projects_for_scope(
&resolved,
cache_behaviour,
state,
ContentFilter::OnlyModpack(&ids),
)
.await?;
let filter = if let Some(ids) = modpack_ids.as_ref() {
ContentFilter::OnlyModpack(ids)
} else if let Some(source_kind) = linked_modpack_source_kind(&link) {
ContentFilter::OnlySourceKind {
source_kind,
include_untracked: true,
}
} else {
return Ok(Vec::new());
};
let files =
content_projects_for_scope(&resolved, cache_behaviour, state, filter)
.await?;
let files = files.into_iter().collect::<Vec<_>>();
content_files_to_content_items(
@@ -371,30 +377,30 @@ pub(crate) async fn get_linked_modpack_info(
&state.api_semaphore,
),
)?;
let version = version.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Linked modpack version {version_id} not found"
))
})?;
let (project, all_versions) = if version.project_id != project_id {
let (modpack_project, modpack_versions) = tokio::try_join!(
CachedEntry::get_project(
&version.project_id,
cache_behaviour,
&state.pool,
&state.api_semaphore,
),
CachedEntry::get_project_versions(
&version.project_id,
cache_behaviour,
&state.pool,
&state.api_semaphore,
),
)?;
(modpack_project.or(project), modpack_versions)
} else {
(project, all_versions)
};
let version_project_id = version
.as_ref()
.filter(|version| version.project_id != project_id)
.map(|version| version.project_id.clone());
let (project, all_versions) =
if let Some(version_project_id) = version_project_id {
let (modpack_project, modpack_versions) = tokio::try_join!(
CachedEntry::get_project(
&version_project_id,
cache_behaviour,
&state.pool,
&state.api_semaphore,
),
CachedEntry::get_project_versions(
&version_project_id,
cache_behaviour,
&state.pool,
&state.api_semaphore,
),
)?;
(modpack_project.or(project), modpack_versions)
} else {
(project, all_versions)
};
let project = project.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Linked modpack project {project_id} not found"
@@ -433,12 +439,17 @@ pub(crate) async fn get_linked_modpack_info(
})
})
};
let (has_update, update_version_id, update_version) = check_modpack_update(
&version_id,
&version,
all_versions,
resolved.instance.update_channel,
);
let (has_update, update_version_id, update_version) = version
.as_ref()
.map(|version| {
check_modpack_update(
&version_id,
version,
all_versions,
resolved.instance.update_channel,
)
})
.unwrap_or((false, None, None));
Ok(Some(LinkedModpackInfo {
project,
@@ -77,7 +77,7 @@ pub enum OwnerType {
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LinkedModpackInfo {
pub project: Project,
pub version: Version,
pub version: Option<Version>,
pub owner: Option<ContentItemOwner>,
pub has_update: bool,
pub update_version_id: Option<String>,
+38
View File
@@ -10,6 +10,44 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-08-14T17:47:21+00:00`,
product: 'app',
version: '0.17.9',
body: `## Fixed
- Reverted server panel change that wasn't meant to go out yet.`,
},
{
date: `2026-08-14T17:47:21+00:00`,
product: 'hosting',
body: `## Fixed
- Reverted server panel change that wasn't meant to go out yet.`,
},
{
date: `2026-08-14T17:47:21+00:00`,
product: 'web',
body: `## Fixed
- Fixed project pages failing to load when user is not logged in.`,
},
{
date: `2026-08-14T17:04:48+00:00`,
product: 'hosting',
body: `## Changed
- The server panel will no longer be locked while individual content is being installed when using the Browse content button.
- Better stability when content is being installed
## Fixed
- Fixed issue when trying to upgrade a medal server from the manage servers page.`,
},
{
date: `2026-08-14T17:04:48+00:00`,
product: 'app',
version: '0.17.8',
body: `## Fixed
- Fixed some issues rendering Ears skins
- Fixed mrpack exports setting the wrong environment inclusion values.
- Fixed issue where if your instance was linked to a modpack version that has been deleted from Modrinth the Managed content card on the content tab would not show up`,
},
{
date: `2026-08-13T17:59:06+00:00`,
product: 'app',
@@ -61,13 +61,17 @@ const customer = ref<any>(null)
const paymentMethods = ref<any[]>([])
const selectedCurrency = ref<string>('USD')
const pyroProducts = (props.products as Labrinth.Billing.Internal.Product[])
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
.sort((a, b) => {
const aRam = a?.metadata?.type === 'pyro' || a?.metadata?.type === 'medal' ? a.metadata.ram : 0
const bRam = b?.metadata?.type === 'pyro' || b?.metadata?.type === 'medal' ? b.metadata.ram : 0
return aRam - bRam
})
const pyroProducts = computed(() =>
(props.products as Labrinth.Billing.Internal.Product[])
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
.sort((a, b) => {
const aRam =
a?.metadata?.type === 'pyro' || a?.metadata?.type === 'medal' ? a.metadata.ram : 0
const bRam =
b?.metadata?.type === 'pyro' || b?.metadata?.type === 'medal' ? b.metadata.ram : 0
return aRam - bRam
}),
)
function handleError(err: unknown) {
debug('Purchase modal error:', err)
@@ -120,7 +124,7 @@ const subscription = ref<Labrinth.Billing.Internal.UserSubscription | null>(null
const pendingDowngradeBody = ref<Labrinth.Billing.Internal.EditSubscriptionRequest | null>(null)
const currentPlanFromSubscription = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
return subscription.value
? (pyroProducts.find((p) =>
? (pyroProducts.value.find((p) =>
p.prices.some((price) => price.id === subscription.value?.price_id),
) ?? undefined)
: undefined
@@ -68,6 +68,7 @@ useQuery({
from_modpack: false,
}),
enabled: computed(() => !!worldId.value),
staleTime: 30_000,
})
const serverSettingsTabComponentMap = {
@@ -6,6 +6,7 @@
@click="onCanvasClick"
>
<div
data-skin-preview-debug="controls"
class="absolute left-0 right-0 z-10 flex items-center justify-center pointer-events-none"
:style="previewControlsPositionStyle"
>
@@ -18,6 +19,7 @@
</div>
<div
v-if="$slots.subtitle"
data-skin-preview-debug="subtitle"
class="absolute left-0 right-0 z-10 flex items-center justify-center pointer-events-none"
:style="subtitlePositionStyle"
>
@@ -27,6 +29,7 @@
</div>
<div
v-if="nametag || $slots['nametag-badge']"
data-skin-preview-debug="nametag"
class="absolute left-1/2 pointer-events-none z-10"
:style="nametagStyle"
>
@@ -267,16 +270,23 @@ const {
},
})
const { hasEarsFeatures, isModelLoaded, isTextureLoaded, modelCenter, modelSize, scene } =
useSkinPreviewScene({
selectedModelSrc,
textureSrc: toRef(props, 'textureSrc'),
earsTextureSrc: toRef(props, 'earsTextureSrc'),
capeSrc: toRef(props, 'capeSrc'),
earsEnabled: toRef(props, 'earsEnabled'),
initializeAnimations,
cleanupAnimationState,
})
const {
hasEarsFeatures,
isModelLoaded,
isTextureLoaded,
modelCenter,
modelSize,
scene,
visibleBounds,
} = useSkinPreviewScene({
selectedModelSrc,
textureSrc: toRef(props, 'textureSrc'),
earsTextureSrc: toRef(props, 'earsTextureSrc'),
capeSrc: toRef(props, 'capeSrc'),
earsEnabled: toRef(props, 'earsEnabled'),
initializeAnimations,
cleanupAnimationState,
})
function syncDamageFlashShaderMaterials() {
syncDamageFlashShader(scene.value, damageFlashIntensity.value)
@@ -310,6 +320,8 @@ const {
subtitleWrapped: isSubtitleWrapped,
modelCenter,
modelSize,
scene,
visibleBounds,
isModelLoaded,
})
@@ -52,7 +52,7 @@ export function createRadialSpotlightShader() {
`,
transparent: true,
depthWrite: false,
depthTest: false,
depthTest: true,
}
}
@@ -199,7 +199,6 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
}
: current,
)
void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) })
}
function handleWorldContentBaseUpdate(
@@ -14,6 +14,11 @@ export interface SkinPreviewFitPadding {
left: number
}
export interface SkinPreviewBounds {
min: SkinPreviewTuple
max: SkinPreviewTuple
}
export interface SkinPreviewFitLock {
containerSize: {
width: number
@@ -23,6 +28,7 @@ export interface SkinPreviewFitLock {
modelSize: SkinPreviewTuple
padding: SkinPreviewFitPadding
rotation: number
visibleBounds: SkinPreviewBounds
}
export type SkinPreviewTuple = [number, number, number]
@@ -1939,6 +1939,10 @@ export function removeEarsMod(model: THREE.Object3D | null) {
registry.removeFromParent()
}
export function isEarsModFeature(object: THREE.Object3D) {
return object.name.startsWith(EARS_FEATURE_PREFIX)
}
export function applyEarsMod(model: THREE.Object3D, sourceTexture: THREE.Texture, enabled = true) {
removeEarsMod(model)
@@ -11,6 +11,7 @@ import {
} from 'vue'
import type {
SkinPreviewBounds,
SkinPreviewFitLock,
SkinPreviewFitPadding,
SkinPreviewFraming,
@@ -41,8 +42,88 @@ function cloneModelTuple(tuple: SkinPreviewTuple): SkinPreviewTuple {
return [tuple[0], tuple[1], tuple[2]]
}
function cloneBounds(bounds: SkinPreviewBounds): SkinPreviewBounds {
return {
min: cloneModelTuple(bounds.min),
max: cloneModelTuple(bounds.max),
}
}
const MODEL_ROTATION_AXIS = new THREE.Vector3(0, 1, 0)
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
type SkinPreviewDebugEntry = Record<string, unknown>
type SkinPreviewDebugWindow = Window & {
__SKIN_PREVIEW_DEBUG__?: SkinPreviewDebugEntry[]
}
function serializeRect(rect: DOMRect) {
return {
bottom: rect.bottom,
height: rect.height,
left: rect.left,
right: rect.right,
top: rect.top,
width: rect.width,
x: rect.x,
y: rect.y,
}
}
function serializeElement(element: Element | null) {
if (!(element instanceof HTMLElement)) return null
const style = window.getComputedStyle(element)
return {
rect: serializeRect(element.getBoundingClientRect()),
style: {
bottom: style.bottom,
height: style.height,
left: style.left,
position: style.position,
top: style.top,
transform: style.transform,
width: style.width,
},
}
}
function getVisibleMeshDebugBounds(root: THREE.Object3D | null) {
if (!root) return []
root.updateWorldMatrix(true, true)
const entries: SkinPreviewDebugEntry[] = []
root.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.geometry || mesh.visible === false) return
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
if (materials.length && materials.every((material) => material.visible === false)) return
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
if (!mesh.geometry.boundingBox) return
const box = mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld)
entries.push({
materials: materials.map((material) => ({
name: material.name,
side: material.side,
visible: material.visible,
})),
max: box.max.toArray(),
min: box.min.toArray(),
name: mesh.name,
parent: mesh.parent?.name,
uuid: mesh.uuid,
})
})
return entries
}
export function useSkinPreviewFit({
containerElement,
fit,
@@ -59,6 +140,8 @@ export function useSkinPreviewFit({
subtitleWrapped,
modelCenter,
modelSize,
scene,
visibleBounds,
isModelLoaded,
}: {
containerElement: MaybeReadonlyRef<HTMLElement | null>
@@ -76,11 +159,15 @@ export function useSkinPreviewFit({
subtitleWrapped: MaybeReadonlyRef<boolean>
modelCenter: MaybeReadonlyRef<SkinPreviewTuple>
modelSize: MaybeReadonlyRef<SkinPreviewTuple>
scene: MaybeReadonlyRef<THREE.Object3D | null>
visibleBounds: MaybeReadonlyRef<SkinPreviewBounds>
isModelLoaded: MaybeReadonlyRef<boolean>
}) {
const containerSize = ref({ width: 1, height: 1 })
const fitLock = ref<SkinPreviewFitLock | null>(null)
let resizeObserver: ResizeObserver | undefined
let debugAnimationFrame: number | null = null
const pendingDebugReasons = new Set<string>()
const fitEnabled = computed(() => {
if (fit.value !== undefined) return fit.value
@@ -113,6 +200,11 @@ export function useSkinPreviewFit({
const fitModelRotation = computed(() =>
lockFitEnabled.value ? (fitLock.value?.rotation ?? modelRotation.value) : modelRotation.value,
)
const fitVisibleBounds = computed(() =>
lockFitEnabled.value
? (fitLock.value?.visibleBounds ?? visibleBounds.value)
: visibleBounds.value,
)
const resolvedFitPadding = computed<SkinPreviewFitPadding>(() => {
const preset = FRAMING_PRESETS[currentFraming.value].padding
@@ -198,22 +290,42 @@ export function useSkinPreviewFit({
}
})
const modelFeetTop = computed(() => {
const projectedVisibleBounds = computed(() => {
if (!fitEnabled.value) return null
const width = Math.max(containerSize.value.width, 1)
const height = Math.max(containerSize.value.height, 1)
const [, sizeY] = fitModelSize.value
const { fov: resolvedFov, position, target } = cameraConfig.value
const distance = Math.max(Math.abs(position[2] - target[2]), 0.001)
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
const modelFeetY = -sizeY / 2
const projectedY =
(modelFeetY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
const topPercent = THREE.MathUtils.clamp(((1 - projectedY) / 2) * 100, 0, 100)
const camera = new THREE.PerspectiveCamera(resolvedFov, width / height, 0.1, 1000)
camera.position.set(...position)
camera.lookAt(...target)
camera.updateProjectionMatrix()
camera.updateMatrixWorld(true)
return (topPercent / 100) * height
const bounds = fitVisibleBounds.value
const [offsetX, offsetY, offsetZ] = modelOffset.value
let top = Number.POSITIVE_INFINITY
let bottom = Number.NEGATIVE_INFINITY
for (const x of [bounds.min[0], bounds.max[0]]) {
for (const y of [bounds.min[1], bounds.max[1]]) {
for (const z of [bounds.min[2], bounds.max[2]]) {
const point = new THREE.Vector3(x + offsetX, y + offsetY, z + offsetZ)
point.applyAxisAngle(MODEL_ROTATION_AXIS, fitModelRotation.value)
point.project(camera)
const screenY = ((1 - point.y) / 2) * height
top = Math.min(top, screenY)
bottom = Math.max(bottom, screenY)
}
}
}
return { bottom, top }
})
const modelFeetTop = computed(() => projectedVisibleBounds.value?.bottom ?? null)
const previewControlsTop = computed(() =>
modelFeetTop.value === null ? null : modelFeetTop.value + PREVIEW_CONTROLS_FOOT_OFFSET,
)
@@ -250,22 +362,8 @@ export function useSkinPreviewFit({
const nametagTop = computed(() => {
if (!fitEnabled.value) return '18%'
const height = Math.max(containerSize.value.height, 1)
const [sizeX, sizeY, sizeZ] = fitModelSize.value
const { fov: resolvedFov, position, target } = cameraConfig.value
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
const modelTopY = sizeY / 2
const halfX = sizeX / 2
const halfZ = sizeZ / 2
const sinRotation = Math.sin(fitModelRotation.value)
const cosRotation = Math.cos(fitModelRotation.value)
const modelTopZ = -Math.abs(halfX * sinRotation) - Math.abs(halfZ * cosRotation)
const distance = Math.max(Math.abs(position[2] - target[2]) + modelTopZ, 0.001)
const projectedY =
(modelTopY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
const topPercent = ((1 - projectedY) / 2) * 100
return `${(topPercent / 100) * height - NAMETAG_HEAD_OFFSET}px`
const top = projectedVisibleBounds.value?.top
return top === undefined ? '18%' : `${top - NAMETAG_HEAD_OFFSET}px`
})
const spotlightY = computed(() => {
@@ -292,6 +390,89 @@ export function useSkinPreviewFit({
return [radius, radius, radius]
})
function captureDebugSnapshot(reasons: string[]) {
if (typeof window === 'undefined') return
const container = containerElement.value
const canvas = container?.querySelector('canvas') ?? null
const debugWindow = window as SkinPreviewDebugWindow
const entries = (debugWindow.__SKIN_PREVIEW_DEBUG__ ??= [])
const snapshot = JSON.parse(
JSON.stringify({
camera: cameraConfig.value,
currentInputs: {
isModelLoaded: isModelLoaded.value,
modelCenter: modelCenter.value,
modelSize: modelSize.value,
padding: resolvedFitPadding.value,
rotation: modelRotation.value,
visibleBounds: visibleBounds.value,
},
dom: {
canvas: {
...serializeElement(canvas),
bufferHeight: canvas instanceof HTMLCanvasElement ? canvas.height : null,
bufferWidth: canvas instanceof HTMLCanvasElement ? canvas.width : null,
},
container: serializeElement(container),
controls: serializeElement(
container?.querySelector('[data-skin-preview-debug="controls"]') ?? null,
),
nametag: serializeElement(
container?.querySelector('[data-skin-preview-debug="nametag"]') ?? null,
),
subtitle: serializeElement(
container?.querySelector('[data-skin-preview-debug="subtitle"]') ?? null,
),
},
effectiveFit: {
containerSize: fitContainerSize.value,
modelCenter: fitModelCenter.value,
modelOffset: modelOffset.value,
modelSize: fitModelSize.value,
padding: fitResolvedPadding.value,
rotation: fitModelRotation.value,
visibleBounds: fitVisibleBounds.value,
},
fitEnabled: fitEnabled.value,
fitLock: fitLock.value,
lockFitEnabled: lockFitEnabled.value,
meshBounds: getVisibleMeshDebugBounds(scene.value),
overlayCalculations: {
nametagTop: nametagTop.value,
previewControlsPositionStyle: previewControlsPositionStyle.value,
projectedVisibleBounds: projectedVisibleBounds.value,
subtitlePositionStyle: subtitlePositionStyle.value,
},
reasons,
timestamp: new Date().toISOString(),
window: {
devicePixelRatio: window.devicePixelRatio,
innerHeight: window.innerHeight,
innerWidth: window.innerWidth,
},
}),
) as SkinPreviewDebugEntry
entries.push(snapshot)
if (entries.length > 100) entries.splice(0, entries.length - 100)
console.log('[SkinPreviewDebug]', snapshot)
}
function queueDebugSnapshot(reason: string) {
if (typeof window === 'undefined') return
pendingDebugReasons.add(reason)
if (debugAnimationFrame !== null) return
debugAnimationFrame = window.requestAnimationFrame(() => {
debugAnimationFrame = null
const reasons = Array.from(pendingDebugReasons)
pendingDebugReasons.clear()
captureDebugSnapshot(reasons)
})
}
function lockFitState() {
if (!fitEnabled.value || !lockFitEnabled.value || fitLock.value || !isModelLoaded.value) return
@@ -304,7 +485,9 @@ export function useSkinPreviewFit({
modelSize: cloneModelTuple(modelSize.value),
padding: { ...resolvedFitPadding.value },
rotation: modelRotation.value,
visibleBounds: cloneBounds(visibleBounds.value),
}
queueDebugSnapshot('fit-lock-created')
}
function resetFitLockForLayoutChange() {
@@ -312,6 +495,7 @@ export function useSkinPreviewFit({
fitLock.value = null
lockFitState()
queueDebugSnapshot('fit-lock-reset')
}
onMounted(() => {
@@ -332,10 +516,12 @@ export function useSkinPreviewFit({
if (didContainerSizeChange) {
resetFitLockForLayoutChange()
queueDebugSnapshot('container-resized')
}
})
resizeObserver.observe(el)
queueDebugSnapshot('mounted')
})
watch(
@@ -358,8 +544,22 @@ export function useSkinPreviewFit({
lockFitState()
})
watch([modelCenter, modelSize, visibleBounds, resolvedFitPadding], () => {
resetFitLockForLayoutChange()
queueDebugSnapshot('model-inputs-changed')
})
watch(
projectedVisibleBounds,
() => {
queueDebugSnapshot('projection-changed')
},
{ deep: true },
)
onUnmounted(() => {
resizeObserver?.disconnect()
if (debugAnimationFrame !== null) window.cancelAnimationFrame(debugAnimationFrame)
})
return {
@@ -20,8 +20,8 @@ import {
loadTexture as loadSkinTexture,
} from '#ui/utils/webgl/skin-rendering.ts'
import type { SkinPreviewTuple } from './types'
import { applyEarsMod, removeEarsMod } from './use-ears-mod-features'
import type { SkinPreviewBounds, SkinPreviewTuple } from './types'
import { applyEarsMod, isEarsModFeature, removeEarsMod } from './use-ears-mod-features'
const SKIN_LAYER_DEPTH_BIAS = -1
@@ -77,16 +77,25 @@ function disposeSceneMaterials(root: THREE.Object3D | null) {
materials.forEach((material) => material.dispose())
}
function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
function getVisibleMeshBox(
root: THREE.Object3D,
includeMesh: (mesh: THREE.Mesh) => boolean = () => true,
): THREE.Box3 | null {
root.updateWorldMatrix(true, true)
const result = new THREE.Box3()
const meshBox = new THREE.Box3()
const rootParentInverse = new THREE.Matrix4()
const meshToRootParent = new THREE.Matrix4()
let found = false
if (root.parent) {
rootParentInverse.copy(root.parent.matrixWorld).invert()
}
root.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.geometry || mesh.visible === false) return
if (!mesh.isMesh || !mesh.geometry || mesh.visible === false || !includeMesh(mesh)) return
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
if (materials.length && materials.every((material) => material.visible === false)) return
@@ -97,7 +106,8 @@ function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
if (!mesh.geometry.boundingBox) return
meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld)
meshToRootParent.multiplyMatrices(rootParentInverse, mesh.matrixWorld)
meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(meshToRootParent)
result.union(meshBox)
found = true
})
@@ -105,6 +115,13 @@ function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
return found && !result.isEmpty() ? result.clone() : null
}
function isPlayerMesh(mesh: THREE.Mesh) {
if (isEarsModFeature(mesh)) return false
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
return materials.some((material) => material.name !== 'cape')
}
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewScene({
@@ -136,6 +153,10 @@ export function useSkinPreviewScene({
const transparentTexture = createTransparentTexture()
const modelCenter = ref<SkinPreviewTuple>([0, 1, 0])
const modelSize = ref<SkinPreviewTuple>([1, 2, 1])
const visibleBounds = ref<SkinPreviewBounds>({
min: [-0.5, 0, -0.5],
max: [0.5, 2, 0.5],
})
const isModelLoaded = ref(false)
const isTextureLoaded = ref(false)
const hasEarsFeatures = ref(false)
@@ -252,6 +273,7 @@ export function useSkinPreviewScene({
capeTexture.value = loadedCapeTexture
loadedCapeSrc.value = src
applyCapeTextureToLoadedModel()
updateModelInfo()
}
async function loadAndApplyEarsTexture(src: string | undefined) {
@@ -269,21 +291,44 @@ export function useSkinPreviewScene({
function updateModelInfo() {
const box = scene.value ? getVisibleMeshBox(scene.value) : null
const playerBox = scene.value ? getVisibleMeshBox(scene.value, isPlayerMesh) : null
if (!box) {
modelCenter.value = [0, 1, 0]
modelSize.value = [1, 2, 1]
visibleBounds.value = {
min: [-0.5, 0, -0.5],
max: [0.5, 2, 0.5],
}
return
}
const center = new THREE.Vector3()
const size = new THREE.Vector3()
const playerCenter = new THREE.Vector3()
const rotationCenterBox = playerBox ?? box
box.getCenter(center)
box.getSize(size)
rotationCenterBox.getCenter(playerCenter)
modelCenter.value = [center.x, center.y, center.z]
modelSize.value = [Math.max(size.x, 0.001), Math.max(size.y, 0.001), Math.max(size.z, 0.001)]
const halfWidth = Math.max(
Math.abs(box.min.x - playerCenter.x),
Math.abs(box.max.x - playerCenter.x),
)
const halfDepth = Math.max(
Math.abs(box.min.z - playerCenter.z),
Math.abs(box.max.z - playerCenter.z),
)
modelCenter.value = [playerCenter.x, center.y, playerCenter.z]
modelSize.value = [
Math.max(halfWidth * 2, 0.001),
Math.max(box.max.y - box.min.y, 0.001),
Math.max(halfDepth * 2, 0.001),
]
visibleBounds.value = {
min: [box.min.x, box.min.y, box.min.z],
max: [box.max.x, box.max.y, box.max.z],
}
}
watch(
@@ -303,6 +348,7 @@ export function useSkinPreviewScene({
texture.value = loadedTexture
loadedTextureSrc.value = newSrc
applyTextureToLoadedModel()
updateModelInfo()
isTextureLoaded.value = true
},
)
@@ -365,5 +411,6 @@ export function useSkinPreviewScene({
modelCenter,
modelSize,
scene,
visibleBounds,
}
}
@@ -31,6 +31,11 @@ export interface BrowseInstallTarget {
loader?: string | null
}
export interface BrowseResolvedInstallContent {
projectId: string
versionId: string
}
/**
* Minimal project shape needed by shared install resolution.
*/
@@ -59,6 +64,7 @@ export interface BrowseInstallPlan<TProject extends BrowseInstallProject = Brows
contentType: BrowseInstallContentType
preferences: BrowseInstallPreferences
source: BrowseInstallPlanSource
resolvedContent?: BrowseResolvedInstallContent[]
}
/**
@@ -335,6 +341,15 @@ export interface FlushStoredServerAddonInstallQueueOptions<TProject extends Brow
onQueueChange?: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
}
export interface ResolveServerAddonInstallPlansOptions<TProject extends BrowseInstallProject> {
plans: readonly BrowseInstallPlan<TProject>[]
existingProjectIds: Iterable<string>
resolvePlan: (
plan: BrowseInstallPlan<TProject>,
existingProjectIds: string[],
) => Promise<BrowseResolvedInstallContent[]>
}
/**
* Result of a queue flush. Failed plans are also written back to the queue.
*/
@@ -646,6 +661,62 @@ export function getStoredServerAddonInstallQueue<
return addonPlans
}
export function getServerAddonInstallPlanProjectIds<TProject extends BrowseInstallProject>(
plans: Iterable<BrowseInstallPlan<TProject>>,
) {
const projectIds = new Set<string>()
for (const plan of plans) {
projectIds.add(plan.projectId)
for (const item of plan.resolvedContent ?? []) {
projectIds.add(item.projectId)
}
}
return projectIds
}
export async function resolveServerAddonInstallPlans<TProject extends BrowseInstallProject>({
plans,
existingProjectIds,
resolvePlan,
}: ResolveServerAddonInstallPlansOptions<TProject>) {
const installedProjectIds = new Set(existingProjectIds)
const resolutionExistingProjectIds = Array.from(installedProjectIds)
const resolvedPlans = await Promise.all(
plans.map(
async (plan) =>
plan.resolvedContent ?? (await resolvePlan(plan, resolutionExistingProjectIds)),
),
)
const explicitProjectIds = new Set(plans.map((plan) => plan.projectId))
const addons = new Map<string, { project_id: string; version_id: string }>()
for (const plan of plans) {
if (installedProjectIds.has(plan.projectId)) continue
addons.set(plan.projectId, {
project_id: plan.projectId,
version_id: plan.versionId,
})
}
for (const content of resolvedPlans) {
for (const item of content) {
if (
installedProjectIds.has(item.projectId) ||
explicitProjectIds.has(item.projectId) ||
addons.has(item.projectId)
) {
continue
}
addons.set(item.projectId, {
project_id: item.projectId,
version_id: item.versionId,
})
}
}
return Array.from(addons.values())
}
export async function flushStoredServerAddonInstallQueue<TProject extends BrowseInstallProject>({
serverId,
worldId,
@@ -869,7 +940,22 @@ function isStoredBrowseInstallPlan(
typeof record.versionId === 'string' &&
isStoredBrowseInstallContentType(record.contentType) &&
isStoredBrowseInstallPreferences(record.preferences) &&
(record.source === 'filtered' || record.source === 'target')
(record.source === 'filtered' || record.source === 'target') &&
isOptionalStoredResolvedContent(record.resolvedContent)
)
}
function isOptionalStoredResolvedContent(value: unknown) {
return (
value === undefined ||
(Array.isArray(value) &&
value.every(
(item) =>
!!item &&
typeof item === 'object' &&
typeof (item as Record<string, unknown>).projectId === 'string' &&
typeof (item as Record<string, unknown>).versionId === 'string',
))
)
}
@@ -245,6 +245,7 @@ const addonsQuery = useQuery({
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
staleTime: 30_000,
})
const modpack = computed(() => addonsQuery.data.value?.modpack ?? null)
@@ -411,6 +412,9 @@ async function uploadLocalModpackWithSoftOverride() {
provideInstallationSettings({
closeSettings: serverSettings.closeModal,
afterSave: async () => {
serverSettings.closeModal?.()
},
onGameVersionHover: handleGameVersionHover,
loading: computed(() => !server.value || addonsQuery.isLoading.value),
installationInfo: computed(() => {
@@ -32,6 +32,7 @@ import {
flushStoredServerAddonInstallQueue,
getStoredServerAddonInstallQueue,
getTargetInstallPreferences,
resolveServerAddonInstallPlans,
} from '../../../shared/browse-tab/composables/install-logic'
import ManagedContentModal from '../../../shared/content-tab/components/managed-content-modal/index.vue'
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
@@ -157,7 +158,7 @@ const contentQuery = useQuery({
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
staleTime: 0,
staleTime: 30_000,
})
const isModpackContentModalOpen = ref(false)
@@ -168,7 +169,7 @@ const modpackContentQuery = useQuery({
from_modpack: true,
}),
enabled: computed(() => worldId.value !== null && !!contentQuery.data.value?.modpack),
staleTime: 0,
staleTime: 30_000,
})
const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
@@ -395,37 +396,32 @@ function toResolvePreferences(
}
async function resolveStoredServerAddonPlans(plans: BrowseInstallPlan[]) {
const existingProjectIds = getInstalledProjectIds()
const resolvedAddons: Array<{ project_id: string; version_id: string }> = []
for (const plan of plans) {
const target = getTargetInstallPreferences(
{
gameVersion: server.value?.mc_version,
loader: server.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: Array.from(existingProjectIds),
})
for (const item of [resolved.primary, ...resolved.dependencies]) {
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 await resolveServerAddonInstallPlans({
plans,
existingProjectIds: getInstalledProjectIds(),
resolvePlan: async (plan, existingProjectIds) => {
const target = getTargetInstallPreferences(
{
gameVersion: server.value?.mc_version,
loader: server.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 resolvedAddons
return [resolved.primary, ...resolved.dependencies].map((item) => ({
projectId: item.project_id,
versionId: item.version_id,
}))
},
})
}
function addonMatchesPendingInstall(
@@ -1124,8 +1124,13 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
)
}
let newModInvalidateTimer: ReturnType<typeof setTimeout> | null = null
const handleNewMod = () => {
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
if (newModInvalidateTimer) clearTimeout(newModInvalidateTimer)
newModInvalidateTimer = setTimeout(() => {
newModInvalidateTimer = null
void queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
}, 500)
}
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
@@ -1520,6 +1525,10 @@ function initializeServer() {
const cleanup = () => {
isMounted.value = false
if (newModInvalidateTimer) {
clearTimeout(newModInvalidateTimer)
newModInvalidateTimer = null
}
saveWsStateToCache()
@@ -167,7 +167,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
const propertiesNeedUpdate = setShaderMaterialProperties(mat, {
alphaTest: 0.1,
flatShading: true,
side: THREE.FrontSide,
side: isSkinLayer ? THREE.DoubleSide : THREE.FrontSide,
toneMapped: false,
transparent: isSkinLayer,
})
+1 -1
View File
@@ -5,7 +5,7 @@ export const getUserLink = (user) => {
}
export const isStaff = (user) => {
return user && STAFF_ROLES.includes(user.role)
return !!user && STAFF_ROLES.includes(user.role)
}
export const isAdmin = (user) => {