feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,36 @@
export type MinecraftAccountSortable = {
account_type?: string
profile?: {
id?: string
name?: string
}
}
const ACCOUNT_TYPE_ORDER: Record<string, number> = {
microsoft: 0,
yggdrasil: 1,
offline: 2,
}
export function compareMinecraftAccounts(
left: MinecraftAccountSortable,
right: MinecraftAccountSortable,
): number {
const nameCmp = (left.profile?.name ?? '').localeCompare(right.profile?.name ?? '')
if (nameCmp !== 0) return nameCmp
const typeCmp =
(ACCOUNT_TYPE_ORDER[left.account_type ?? ''] ?? 3) -
(ACCOUNT_TYPE_ORDER[right.account_type ?? ''] ?? 3)
if (typeCmp !== 0) return typeCmp
const leftId = left.profile?.id ?? ''
const rightId = right.profile?.id ?? ''
return leftId < rightId ? -1 : leftId > rightId ? 1 : 0
}
export function sortMinecraftAccounts<T extends MinecraftAccountSortable>(
accounts: readonly T[],
): T[] {
return [...accounts].sort(compareMinecraftAccounts)
}

View File

@ -0,0 +1,141 @@
import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
export type AIProtocol =
| 'openai'
| 'anthropic'
| 'google'
| 'ollama'
| 'azure'
| 'azure-ai'
| 'bedrock'
| 'cloudflare'
| 'huggingface'
| 'router'
export type AIAuthType = 'apiKey' | 'oauthDeviceFlow' | 'none'
export interface AIProviderDefinition {
id: string
name: string
protocol: AIProtocol
auth_type: AIAuthType
default_endpoint: string
check_model: string
show_model_fetcher: boolean
required_settings: string[]
sponsored: boolean
}
export interface AIProviderModel {
id: string
name: string
enabled: boolean
source: 'builtin' | 'custom' | 'remote'
}
export interface AIProviderConfig {
provider_id: string
custom_name: string | null
protocol: AIProtocol
enabled: boolean
endpoint: string
settings: Record<string, string>
has_api_key: boolean
configured_credentials: string[]
oauth_connected: boolean
models: AIProviderModel[]
}
export interface AIState {
settings: { enabled: boolean }
catalog_source: string
providers: AIProviderConfig[]
}
export interface AIProviderConfigUpdate {
provider_id: string
custom_name: string | null
enabled: boolean
endpoint: string
settings: Record<string, string>
}
export interface AIModelUpdate {
provider_id: string
model_id: string
display_name: string
enabled: boolean
}
export interface OAuthDeviceCode {
flow_id: string
user_code: string
verification_uri: string
verification_uri_complete: string | null
expires_in: number
interval: number
}
export type OAuthPollStatus = 'pending' | 'success' | 'expired' | 'denied' | 'slow_down'
export const sharedAIState = ref<AIState | null>(null)
export async function getAICatalog(): Promise<AIProviderDefinition[]> {
return await invoke('plugin:ai|ai_get_catalog')
}
export async function getAIState(): Promise<AIState> {
const state = await invoke<AIState>('plugin:ai|ai_get_state')
sharedAIState.value = state
return state
}
export async function updateAISettings(enabled: boolean): Promise<void> {
await invoke('plugin:ai|ai_update_settings', { settings: { enabled } })
if (sharedAIState.value) sharedAIState.value.settings.enabled = enabled
}
export async function updateAIProvider(update: AIProviderConfigUpdate): Promise<void> {
await invoke('plugin:ai|ai_update_provider', { update })
}
export async function setAIProviderKey(providerId: string, secret: string | null): Promise<void> {
await invoke('plugin:ai|ai_set_api_key', { providerId, secret })
}
export async function setAIProviderCredential(
providerId: string,
credential: string,
secret: string | null,
): Promise<void> {
await invoke('plugin:ai|ai_set_credential', { providerId, credential, secret })
}
export async function updateAIModel(update: AIModelUpdate): Promise<void> {
await invoke('plugin:ai|ai_update_model', { update })
}
export async function removeAIModel(providerId: string, modelId: string): Promise<void> {
await invoke('plugin:ai|ai_remove_model', { providerId, modelId })
}
export async function fetchAIModels(providerId: string): Promise<AIProviderModel[]> {
return await invoke('plugin:ai|ai_fetch_models', { providerId })
}
export async function testAIProvider(providerId: string, modelId: string): Promise<string> {
return await invoke('plugin:ai|ai_test_provider', { providerId, modelId })
}
export async function beginAIOAuth(providerId: string): Promise<OAuthDeviceCode> {
return await invoke('plugin:ai|ai_begin_oauth', { providerId })
}
export async function pollAIOAuth(flowId: string): Promise<OAuthPollStatus> {
return await invoke('plugin:ai|ai_poll_oauth', { flowId })
}
export async function disconnectAIOAuth(providerId: string): Promise<void> {
await invoke('plugin:ai|ai_disconnect_oauth', { providerId })
}

View File

@ -0,0 +1,75 @@
interface InstanceProperties {
loader: string
game_version: string
}
interface ProjectProperties extends InstanceProperties {
id: string
project_type: string
}
type AnalyticsEventMap = {
Launched: { version: string; dev: boolean; onboarded: boolean }
PageView: { path: string; fromPath: string; failed: unknown }
InstanceCreate: { source: string }
InstanceCreateStart: { source: string }
InstanceStart: InstanceProperties & { source: string }
InstanceStop: Partial<InstanceProperties> & { source?: string }
InstanceDuplicate: InstanceProperties
InstanceRepair: InstanceProperties
InstanceSetIcon: Record<string, never>
InstanceRemoveIcon: Record<string, never>
InstanceUpdateAll: InstanceProperties & { count: number; selected: boolean }
InstanceProjectUpdate: InstanceProperties & { id: string; name: string; project_type: string }
InstanceProjectDisable: InstanceProperties & {
id: string
name: string
project_type: string
disabled: boolean
}
InstanceProjectRemove: InstanceProperties & { id: string; name: string; project_type: string }
ProjectInstall: ProjectProperties & { version_id: string; title: string; source: string }
ProjectInstallStart: { source: string }
PackInstall: { id: string; version_id: string; title: string; source: string }
PackInstallStart: Record<string, never>
AccountLogIn: { source?: string }
AccountLogOut: Record<string, never>
JavaTest: { path: string; success: boolean }
JavaManualSelect: { version: string }
JavaAutoDetect: { path: string; version: string }
GalleryImageNext: { project_id: string; url: string }
GalleryImagePrevious: { project_id: string; url: unknown }
GalleryImageExpand: { project_id: string; url: string }
}
export type AnalyticsEvent = keyof AnalyticsEventMap
let optedIn = false
let debugEnabled = false
export const initAnalytics = () => {
optedIn = true
}
export const debugAnalytics = () => {
debugEnabled = true
}
export const optOutAnalytics = () => {
optedIn = false
}
export const optInAnalytics = () => {
optedIn = true
}
type OptionalArgs<T> = Record<string, never> extends T ? [properties?: T] : [properties: T]
export const trackEvent = <E extends AnalyticsEvent>(
eventName: E,
...args: OptionalArgs<AnalyticsEventMap[E]>
) => {
if (optedIn && debugEnabled) {
console.debug('[Axolotl telemetry disabled]', eventName, args[0])
}
}

View File

@ -0,0 +1,123 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* 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'
// Example function:
// User goes to auth_url to complete flow, and when completed, authenticate_await_completion() returns the credentials
// export async function authenticate() {
// const auth_url = await authenticate_begin_flow()
// console.log(auth_url)
// await authenticate_await_completion()
// }
/**
* Check if the authentication servers are reachable, throwing an exception if
* not reachable.
*/
export async function check_reachable() {
await invoke('plugin:auth|check_reachable')
}
/**
* Check the Mojang services mirrored by the Fallen proxy, returning their
* individual reachability states.
*/
export async function check_mojang_services() {
return await invoke('plugin:auth|check_mojang_services')
}
/**
* Authenticate a user with Hydra - part 1.
* This begins the authentication flow quasi-synchronously.
*
* @returns {Promise<DeviceLoginSuccess>} A DeviceLoginSuccess object with two relevant fields:
* @property {string} verification_uri - The URL to go to complete the flow.
* @property {string} user_code - The code to enter on the verification_uri page.
*/
export async function login(troubleLinks) {
return await invoke('plugin:auth|login', { troubleLinks })
}
export async function browser_login() {
return await invoke('plugin:auth|browser_login')
}
export async function begin_device_login() {
return await invoke('plugin:auth|begin_device_login')
}
export async function poll_device_login(deviceCode) {
return await invoke('plugin:auth|poll_device_login', { deviceCode })
}
export async function begin_yggdrasil_login(apiRoot, login, password) {
return await invoke('plugin:auth|begin_yggdrasil_login', { apiRoot, login, password })
}
export async function finish_yggdrasil_login(flowId, profileId) {
return await invoke('plugin:auth|finish_yggdrasil_login', { flowId, profileId })
}
export async function list_yggdrasil_saved_logins() {
return await invoke('plugin:auth|list_yggdrasil_saved_logins')
}
export async function get_yggdrasil_password(apiRoot, login) {
return await invoke('plugin:auth|get_yggdrasil_password', { apiRoot, login })
}
export async function set_yggdrasil_password(apiRoot, login, password) {
return await invoke('plugin:auth|set_yggdrasil_password', { apiRoot, login, password })
}
export async function delete_yggdrasil_password(apiRoot, login) {
return await invoke('plugin:auth|delete_yggdrasil_password', { apiRoot, login })
}
/**
* Creates and selects a local Minecraft account.
* @param {string} username
* @param {string} [uuid] Custom UUID as 32 hexadecimal characters, with or without hyphens
* @returns {Promise<Credential>}
*/
export async function add_offline_user(username, uuid) {
return await invoke('plugin:auth|add_offline_user', {
username,
...(uuid ? { uuid } : {}),
})
}
/**
* Retrieves the default user
* @return {Promise<UUID | undefined>}
*/
export async function get_default_user(offlineMode = false) {
return await invoke('plugin:auth|get_default_user', { offlineMode })
}
/**
* Updates the default user
* @param {UUID} user
*/
export async function set_default_user(user) {
return await invoke('plugin:auth|set_default_user', { user })
}
/**
* Remove a user account from the database
* @param {UUID} user
*/
export async function remove_user(user) {
return await invoke('plugin:auth|remove_user', { user })
}
/**
* Returns a list of users
* @returns {Promise<Credential[]>}
*/
export async function users(offlineMode = false) {
return await invoke('plugin:auth|get_users', { offlineMode })
}

View File

@ -0,0 +1,10 @@
export function resolveBreadcrumbLabel<Message>(
name: string,
getDynamicName: (key: string) => string,
staticLabels: Readonly<Record<string, Message>>,
formatMessage: (message: Message) => string,
): string {
if (name.startsWith('?')) return getDynamicName(name.slice(1))
const label = staticLabels[name]
return label === undefined ? name : formatMessage(label)
}

View File

@ -0,0 +1,33 @@
export type BrowseContentDisplayMode = 'list' | 'compact' | 'grid'
export type BrowseContentProjectType =
| 'modpack'
| 'mod'
| 'resourcepack'
| 'datapack'
| 'shader'
| 'world'
const BROWSE_CONTENT_DISPLAY_MODE_STORAGE_KEY = 'axolotl-browse-content-display-mode'
const BROWSE_CONTENT_PROJECT_TYPE_STORAGE_KEY = 'axolotl-browse-content-project-type'
export function getLastBrowseContentDisplayMode(): BrowseContentDisplayMode {
const value = globalThis.localStorage?.getItem(BROWSE_CONTENT_DISPLAY_MODE_STORAGE_KEY)
return value === 'compact' || value === 'grid' ? value : 'list'
}
export function setLastBrowseContentDisplayMode(mode: BrowseContentDisplayMode) {
globalThis.localStorage?.setItem(BROWSE_CONTENT_DISPLAY_MODE_STORAGE_KEY, mode)
}
export function isBrowseContentProjectType(value: string): value is BrowseContentProjectType {
return ['modpack', 'mod', 'resourcepack', 'datapack', 'shader', 'world'].includes(value)
}
export function getLastBrowseContentProjectType(): BrowseContentProjectType {
const value = globalThis.localStorage?.getItem(BROWSE_CONTENT_PROJECT_TYPE_STORAGE_KEY)
return value && isBrowseContentProjectType(value) ? value : 'modpack'
}
export function setLastBrowseContentProjectType(type: BrowseContentProjectType) {
globalThis.localStorage?.setItem(BROWSE_CONTENT_PROJECT_TYPE_STORAGE_KEY, type)
}

View File

@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { getBrowseFilterMemory, setBrowseFilterMemory } from './browse-filter-memory.ts'
const storageKey = 'axolotl-browse-filter-memory-v1'
const originalStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
function installMemoryStorage() {
const values = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
})
return values
}
function restoreStorage() {
if (originalStorageDescriptor) {
Object.defineProperty(globalThis, 'localStorage', originalStorageDescriptor)
} else {
delete (globalThis as { localStorage?: Storage }).localStorage
}
}
test('browse filters are remembered independently for each project type', () => {
installMemoryStorage()
try {
setBrowseFilterMemory('mod', {
filters: [{ type: 'game_version', option: '1.21.1' }],
toggledGroups: ['all_versions'],
overriddenProvidedFilterTypes: ['game_version'],
})
setBrowseFilterMemory('modpack', {
filters: [{ type: 'modpack_loader', option: 'neoforge' }],
toggledGroups: [],
overriddenProvidedFilterTypes: [],
})
assert.deepEqual(getBrowseFilterMemory('mod'), {
filters: [{ type: 'game_version', option: '1.21.1' }],
toggledGroups: ['all_versions'],
overriddenProvidedFilterTypes: ['game_version'],
})
assert.deepEqual(getBrowseFilterMemory('modpack'), {
filters: [{ type: 'modpack_loader', option: 'neoforge' }],
toggledGroups: [],
overriddenProvidedFilterTypes: [],
})
assert.equal(getBrowseFilterMemory('server'), null)
} finally {
restoreStorage()
}
})
test('invalid browse filter memory is ignored', () => {
const values = installMemoryStorage()
try {
values.set(storageKey, '{invalid json')
assert.equal(getBrowseFilterMemory('mod'), null)
values.set(
storageKey,
JSON.stringify({
mod: {
filters: [{ type: 'game_version', option: 121 }],
toggledGroups: [],
overriddenProvidedFilterTypes: [],
},
}),
)
assert.equal(getBrowseFilterMemory('mod'), null)
} finally {
restoreStorage()
}
})

View File

@ -0,0 +1,59 @@
import type { FilterValue } from '@modrinth/ui'
export interface BrowseFilterMemory {
filters: FilterValue[]
toggledGroups: string[]
overriddenProvidedFilterTypes: string[]
}
const BROWSE_FILTER_MEMORY_STORAGE_KEY = 'axolotl-browse-filter-memory-v1'
function isFilterValue(value: unknown): value is FilterValue {
if (!value || typeof value !== 'object') return false
const filter = value as Partial<FilterValue>
return (
typeof filter.type === 'string' &&
typeof filter.option === 'string' &&
(filter.negative === undefined || typeof filter.negative === 'boolean')
)
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string')
}
function parseFilterMemory(value: unknown): BrowseFilterMemory | null {
if (!value || typeof value !== 'object') return null
const memory = value as Partial<BrowseFilterMemory>
if (!Array.isArray(memory.filters) || !memory.filters.every(isFilterValue)) return null
if (!isStringArray(memory.toggledGroups)) return null
if (!isStringArray(memory.overriddenProvidedFilterTypes)) return null
return {
filters: memory.filters.map((filter) => ({ ...filter })),
toggledGroups: [...memory.toggledGroups],
overriddenProvidedFilterTypes: [...memory.overriddenProvidedFilterTypes],
}
}
function readFilterMemories(): Record<string, unknown> {
try {
const parsed = JSON.parse(localStorage.getItem(BROWSE_FILTER_MEMORY_STORAGE_KEY) ?? '{}')
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
export function getBrowseFilterMemory(projectType: string): BrowseFilterMemory | null {
return parseFilterMemory(readFilterMemories()[projectType])
}
export function setBrowseFilterMemory(projectType: string, memory: BrowseFilterMemory) {
const memories = readFilterMemories()
memories[projectType] = {
filters: memory.filters.map((filter) => ({ ...filter })),
toggledGroups: [...memory.toggledGroups],
overriddenProvidedFilterTypes: [...memory.overriddenProvidedFilterTypes],
}
localStorage.setItem(BROWSE_FILTER_MEMORY_STORAGE_KEY, JSON.stringify(memories))
}

View File

@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { Labrinth } from '@modrinth/api-client'
import {
getLatestMatchingInstallVersion,
getTargetInstallPreferences,
} from '@modrinth/ui/src/layouts/shared/browse-tab/composables/install-logic.ts'
function version(
id: string,
loaders: string[],
options: {
datePublished?: string
versionType?: Labrinth.Versions.v2.Version['version_type']
} = {},
): Labrinth.Versions.v2.Version {
return {
id,
date_published: options.datePublished ?? '2026-08-18T00:00:00Z',
version_type: options.versionType ?? 'release',
game_versions: ['1.21.1'],
loaders,
} as Labrinth.Versions.v2.Version
}
test('resource packs ignore the target game version and match the Minecraft loader', () => {
const preferences = getTargetInstallPreferences(
{ gameVersion: '26.2', loader: 'neoforge' },
'resourcepack',
)
assert.deepEqual(preferences, {
gameVersions: [],
loaders: ['minecraft'],
})
assert.equal(
getLatestMatchingInstallVersion(
[version('minecraft-resource-pack', ['minecraft']), version('neoforge-mod', ['neoforge'])],
preferences,
)?.id,
'minecraft-resource-pack',
)
})
test('automatic installs prefer a compatible release over a newer beta', () => {
const preferences = getTargetInstallPreferences(
{ gameVersion: '1.21.1', loader: 'neoforge' },
'mod',
)
assert.equal(
getLatestMatchingInstallVersion(
[
version('newer-beta', ['neoforge'], {
datePublished: '2026-06-01T00:00:00Z',
versionType: 'beta',
}),
version('stable-release', ['neoforge'], {
datePublished: '2026-05-01T00:00:00Z',
}),
],
preferences,
)?.id,
'stable-release',
)
})
test('shader packs use their Iris compatibility tag instead of the target mod loader', () => {
const preferences = getTargetInstallPreferences(
{ gameVersion: '1.21.1', loader: 'neoforge' },
'shader',
)
assert.deepEqual(preferences, {
gameVersions: ['1.21.1'],
loaders: ['iris'],
})
assert.equal(
getLatestMatchingInstallVersion(
[version('iris-shader-pack', ['iris']), version('neoforge-mod', ['neoforge'])],
preferences,
)?.id,
'iris-shader-pack',
)
})

View File

@ -0,0 +1,141 @@
export type BrowseMergeSort = 'relevance' | 'downloads' | 'follows' | 'newest' | 'updated' | string
export interface BrowseMergeHit {
provider: 'modrinth' | 'curseforge'
project_id: string
downloads?: number | null
follows?: number | null
date_created?: string | null
date_modified?: string | null
chinese_search_score?: number | null
}
export interface MergeProviderResultsOptions<T extends BrowseMergeHit> {
modrinthHits: T[]
curseForgeHits: T[]
sort: BrowseMergeSort | null | undefined
query?: string | null
limit: number
}
function providerKey(hit: BrowseMergeHit): string {
return `${hit.provider}:${hit.project_id}`
}
function compareStableHit(left: BrowseMergeHit, right: BrowseMergeHit): number {
const projectDelta = left.project_id.localeCompare(right.project_id)
return projectDelta || left.provider.localeCompare(right.provider)
}
function toTimestamp(value?: string | null): number {
if (!value) return 0
const time = Date.parse(value)
return Number.isFinite(time) ? time : 0
}
function maxMetric(hits: BrowseMergeHit[], read: (hit: BrowseMergeHit) => number): number {
let max = 0
for (const hit of hits) {
const value = read(hit)
if (value > max) max = value
}
return max
}
function normalize(value: number, max: number): number {
if (max <= 0) return 0
return value / max
}
function sortByMetric<T extends BrowseMergeHit>(
hits: T[],
read: (hit: BrowseMergeHit) => number,
limit: number,
): T[] {
return [...hits]
.sort((left, right) => {
const delta = read(right) - read(left)
if (delta !== 0) return delta
return providerKey(left).localeCompare(providerKey(right))
})
.slice(0, limit)
}
/**
* Merge Modrinth + CurseForge page results for "all sources".
*
* Dual-source pagination still uses the same offset on each provider (known
* limitation: pages may have mild overlap/holes). Prefer metric order over
* strict alternating so sort controls stay meaningful.
*/
export function mergeProviderResults<T extends BrowseMergeHit>(
options: MergeProviderResultsOptions<T>,
): T[] {
const { modrinthHits, curseForgeHits, sort, query, limit } = options
const combined = [...modrinthHits, ...curseForgeHits]
if (combined.length === 0 || limit <= 0) return []
const effectiveSort = sort || 'relevance'
const hasQuery = Boolean(query?.trim())
if (effectiveSort === 'downloads') {
return sortByMetric(combined, (hit) => hit.downloads ?? 0, limit)
}
if (effectiveSort === 'updated') {
return sortByMetric(combined, (hit) => toTimestamp(hit.date_modified), limit)
}
if (effectiveSort === 'newest') {
return sortByMetric(combined, (hit) => toTimestamp(hit.date_created), limit)
}
if (effectiveSort === 'follows') {
const maxFollows = maxMetric(modrinthHits, (hit) => hit.follows ?? 0)
const maxDownloads = maxMetric(curseForgeHits, (hit) => hit.downloads ?? 0)
return [...combined]
.map((hit) => {
const score =
hit.provider === 'curseforge'
? normalize(hit.downloads ?? 0, maxDownloads)
: normalize(hit.follows ?? 0, maxFollows)
return { hit, score }
})
.sort((left, right) => {
const delta = right.score - left.score
if (delta !== 0) return delta
return compareStableHit(left.hit, right.hit)
})
.slice(0, limit)
.map(({ hit }) => hit)
}
// relevance (default) and unknown sorts: rank fusion + download prior
const ranked = new Map<string, { hit: T; score: number }>()
const maxDownloads = maxMetric(combined, (hit) => hit.downloads ?? 0)
const maxChineseScore = maxMetric(combined, (hit) => hit.chinese_search_score ?? 0)
const chineseWeight = hasQuery && maxChineseScore > 0 ? 0.65 : 0
const rankWeight = chineseWeight > 0 ? 0.27 : hasQuery ? 0.82 : 0.55
const downloadWeight = 1 - chineseWeight - rankWeight
const rankBias = hasQuery ? 12 : 20
for (const hits of [modrinthHits, curseForgeHits]) {
hits.forEach((hit, index) => {
const rankScore = 1 / (rankBias + index + 1)
const downloadScore = normalize(Math.log1p(hit.downloads ?? 0), Math.log1p(maxDownloads))
const chineseScore = normalize(hit.chinese_search_score ?? 0, maxChineseScore)
const score =
chineseWeight * chineseScore + rankWeight * rankScore + downloadWeight * downloadScore
ranked.set(providerKey(hit), { hit, score })
})
}
return [...ranked.values()]
.sort((left, right) => {
const delta = right.score - left.score
if (delta !== 0) return delta
return compareStableHit(left.hit, right.hit)
})
.slice(0, limit)
.map(({ hit }) => hit)
}

View File

@ -0,0 +1,66 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createBrowseProjectTabs,
getBrowseProjectTabOptions,
supportsDataPacks,
} from './browse-project-tabs.ts'
const labels = {
modpacks: 'Modpacks',
mods: 'Mods',
resourcepacks: 'Resource Packs',
datapacks: 'Data Packs',
maps: 'Maps',
shaders: 'Shaders',
servers: 'Servers',
favorites: 'Favorites',
}
test('browse project tabs keep favorites after servers and preserve the route context', () => {
const tabs = createBrowseProjectTabs(labels, '?i=instance-id')
assert.deepEqual(
tabs.map((tab) => tab.label),
['Modpacks', 'Mods', 'Resource Packs', 'Data Packs', 'Maps', 'Shaders', 'Servers', 'Favorites'],
)
assert.equal(tabs.at(-1)?.href, '/browse/favorites?i=instance-id')
assert.equal(tabs.at(-1)?.onboardingId, 'browse-favorites-tab')
})
test('browse project tabs preserve content-context visibility while keeping favorites available', () => {
const tabs = createBrowseProjectTabs(labels, '', {
modpacks: false,
mods: false,
datapacks: false,
servers: false,
})
assert.equal(tabs.find((tab) => tab.label === 'Modpacks')?.shown, false)
assert.equal(tabs.find((tab) => tab.label === 'Mods')?.shown, false)
assert.equal(tabs.find((tab) => tab.label === 'Data Packs')?.shown, false)
assert.equal(tabs.find((tab) => tab.label === 'Servers')?.shown, false)
assert.equal(tabs.find((tab) => tab.label === 'Favorites')?.shown, true)
})
test('browse project tab visibility follows the selected instance capabilities', () => {
assert.equal(supportsDataPacks('1.12.2'), false)
assert.equal(supportsDataPacks('1.13'), true)
assert.deepEqual(
getBrowseProjectTabOptions({
instance: { game_version: '1.12.2', loader: 'vanilla' },
hasInstanceContext: true,
}),
{ modpacks: false, mods: false, datapacks: false, servers: false },
)
assert.deepEqual(
getBrowseProjectTabOptions({
instance: { game_version: '1.20.1', loader: 'fabric' },
hasInstanceContext: true,
isServerInstance: true,
}),
{ modpacks: false, mods: true, datapacks: false, servers: false },
)
})

View File

@ -0,0 +1,91 @@
export interface BrowseProjectTabLabels {
modpacks: string
mods: string
resourcepacks: string
datapacks: string
maps: string
shaders: string
servers: string
favorites: string
}
export interface BrowseProjectTab {
label: string
href: string
shown?: boolean
onboardingId?: string
}
export interface BrowseProjectTabOptions {
modpacks?: boolean
mods?: boolean
datapacks?: boolean
servers?: boolean
favorites?: boolean
}
export interface BrowseProjectTabVisibilityInput {
instance?: {
game_version?: string
loader?: string
} | null
hasInstanceContext?: boolean
isServerInstance?: boolean
}
export function supportsDataPacks(gameVersion: string | undefined): boolean {
const match = gameVersion?.match(/^1\.(\d+)/)
return match ? Number(match[1]) >= 13 : false
}
export function getBrowseProjectTabOptions({
instance,
hasInstanceContext = false,
isServerInstance = false,
}: BrowseProjectTabVisibilityInput): BrowseProjectTabOptions {
const hasInstance = !!instance
return {
modpacks: !hasInstanceContext,
mods: !hasInstance || instance?.loader !== 'vanilla',
datapacks: !hasInstance || (!isServerInstance && supportsDataPacks(instance?.game_version)),
servers: !hasInstanceContext,
}
}
export function createBrowseProjectTabs(
labels: BrowseProjectTabLabels,
suffix = '',
options: BrowseProjectTabOptions = {},
): BrowseProjectTab[] {
return [
{
label: labels.modpacks,
href: `/browse/modpack${suffix}`,
shown: options.modpacks ?? true,
},
{
label: labels.mods,
href: `/browse/mod${suffix}`,
shown: options.mods ?? true,
},
{ label: labels.resourcepacks, href: `/browse/resourcepack${suffix}` },
{
label: labels.datapacks,
href: `/browse/datapack${suffix}`,
shown: options.datapacks ?? true,
},
{ label: labels.maps, href: `/browse/world${suffix}` },
{ label: labels.shaders, href: `/browse/shader${suffix}` },
{
label: labels.servers,
href: `/browse/server${suffix}`,
shown: options.servers ?? true,
},
{
label: labels.favorites,
href: `/browse/favorites${suffix}`,
shown: options.favorites ?? true,
onboardingId: 'browse-favorites-tab',
},
]
}

View File

@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
clearBrowseReturnSnapshot,
completeBrowseReturnNavigation,
consumeBrowseReturnSnapshot,
hasBrowseReturnSnapshot,
isBrowseReturnNavigation,
isBrowseReturnSourcePath,
prepareBrowseReturnNavigation,
saveBrowseReturnSnapshot,
} from './browse-return-state.ts'
test('consumes a matching browse snapshot only once', () => {
const url = '/browse/mod?m=100&o=100'
saveBrowseReturnSnapshot({ url, scrollTop: 480, state: { currentPage: 2, hits: ['a'] } })
assert.equal(prepareBrowseReturnNavigation(url, '/project/sodium'), true)
assert.deepEqual(consumeBrowseReturnSnapshot(url), {
url,
scrollTop: 480,
state: { currentPage: 2, hits: ['a'] },
})
assert.equal(consumeBrowseReturnSnapshot(url), null)
assert.equal(isBrowseReturnNavigation(url), true)
completeBrowseReturnNavigation(url)
assert.equal(isBrowseReturnNavigation(url), false)
})
test('does not consume a snapshot for a different browse URL', () => {
saveBrowseReturnSnapshot({ url: '/browse/mod?page=2', scrollTop: 480, state: {} })
assert.equal(consumeBrowseReturnSnapshot('/browse/mod?page=3'), null)
assert.equal(hasBrowseReturnSnapshot('/browse/mod?page=2'), true)
clearBrowseReturnSnapshot()
})
test('consumes a matching snapshot without a route guard marker', () => {
const url = '/browse/mod?source=modrinth'
saveBrowseReturnSnapshot({ url, scrollTop: 480, state: {} })
assert.deepEqual(consumeBrowseReturnSnapshot(url), { url, scrollTop: 480, state: {} })
})
test('clears snapshots for ordinary Browse navigation', () => {
const url = '/browse/mod?page=2'
saveBrowseReturnSnapshot({ url, scrollTop: 480, state: {} })
assert.equal(prepareBrowseReturnNavigation(url, '/library'), false)
assert.equal(hasBrowseReturnSnapshot(url), false)
})
test('recognizes only project, download, and instance return routes', () => {
assert.equal(isBrowseReturnSourcePath('/project/sodium'), true)
assert.equal(isBrowseReturnSourcePath('/project/sodium/versions'), true)
assert.equal(isBrowseReturnSourcePath('/downloads'), true)
assert.equal(isBrowseReturnSourcePath('/instance/example'), true)
assert.equal(isBrowseReturnSourcePath('/library'), false)
})

View File

@ -0,0 +1,51 @@
export interface BrowseReturnSnapshot<T> {
url: string
scrollTop: number
state: T
}
let pendingSnapshot: BrowseReturnSnapshot<unknown> | null = null
let pendingReturnUrl: string | null = null
export function saveBrowseReturnSnapshot<T>(snapshot: BrowseReturnSnapshot<T>): void {
pendingSnapshot = snapshot
}
export function consumeBrowseReturnSnapshot<T>(url: string): BrowseReturnSnapshot<T> | null {
if (pendingSnapshot?.url !== url) return null
const snapshot = pendingSnapshot as BrowseReturnSnapshot<T>
pendingSnapshot = null
return snapshot
}
export function hasBrowseReturnSnapshot(url: string): boolean {
return pendingSnapshot?.url === url
}
export function clearBrowseReturnSnapshot(): void {
pendingSnapshot = null
pendingReturnUrl = null
}
export function isBrowseReturnSourcePath(path: string): boolean {
return path === '/downloads' || path.startsWith('/project/') || path.startsWith('/instance/')
}
export function prepareBrowseReturnNavigation(url: string, sourcePath: string): boolean {
if (isBrowseReturnSourcePath(sourcePath) && hasBrowseReturnSnapshot(url)) {
pendingReturnUrl = url
return true
}
clearBrowseReturnSnapshot()
return false
}
export function isBrowseReturnNavigation(url: string): boolean {
return pendingReturnUrl === url
}
export function completeBrowseReturnNavigation(url: string): void {
if (pendingReturnUrl === url) pendingReturnUrl = null
}

View File

@ -0,0 +1,87 @@
import { invoke } from '@tauri-apps/api/core'
export async function get_project(id, cacheBehaviour) {
return await invoke('plugin:cache|get_project', { id, cacheBehaviour })
}
export async function get_project_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_project_many', { ids, cacheBehaviour })
}
export async function get_project_v3(id, cacheBehaviour) {
return await invoke('plugin:cache|get_project_v3', { id, cacheBehaviour })
}
export async function get_project_v3_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_project_v3_many', { ids, cacheBehaviour })
}
export async function get_version(id, cacheBehaviour) {
return await invoke('plugin:cache|get_version', { id, cacheBehaviour })
}
export async function get_version_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_version_many', { ids, cacheBehaviour })
}
export async function get_user(id, cacheBehaviour) {
return await invoke('plugin:cache|get_user', { id, cacheBehaviour })
}
export async function get_user_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_user_many', { ids, cacheBehaviour })
}
export async function get_team(id, cacheBehaviour) {
return await invoke('plugin:cache|get_team', { id, cacheBehaviour })
}
export async function get_team_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_team_many', { ids, cacheBehaviour })
}
export async function get_organization(id, cacheBehaviour) {
return await invoke('plugin:cache|get_organization', { id, cacheBehaviour })
}
export async function get_organization_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_organization_many', { ids, cacheBehaviour })
}
export async function get_search_results(id, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results', { id, cacheBehaviour })
}
export async function get_search_results_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results_many', { ids, cacheBehaviour })
}
export async function get_search_results_v3(id, cacheBehaviour, requestId) {
return await invoke('plugin:cache|get_search_results_v3', { id, cacheBehaviour, requestId })
}
export async function get_search_results_v3_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results_v3_many', { ids, cacheBehaviour })
}
export async function cancel_search_request(requestId) {
return await invoke('plugin:cache|cancel_search_request', { requestId })
}
export async function purge_cache_types(cacheTypes) {
return await invoke('plugin:cache|purge_cache_types', { cacheTypes })
}
/**
* Get versions for a project (without changelogs for fast loading).
* Uses the cache system - versions are cached for 30 minutes.
* @param {string} projectId - The project ID
* @param {string} [cacheBehaviour] - Cache behaviour ('must_revalidate', etc.)
* @returns {Promise<Array|null>} Array of version objects (without changelogs) or null
*/
export async function get_project_versions(projectId, cacheBehaviour) {
return await invoke('plugin:cache|get_project_versions', {
projectId,
cacheBehaviour,
})
}

View File

@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { contentFavoriteKey, isFavoriteContentType } from './content-favorites.ts'
test('content favorites keep provider-qualified identities separate', () => {
assert.notEqual(
contentFavoriteKey('modrinth', 'same-id'),
contentFavoriteKey('curseforge', 'same-id'),
)
})
test('content favorites only accept installable content types', () => {
assert.equal(isFavoriteContentType('mod'), true)
assert.equal(isFavoriteContentType('resourcepack'), true)
assert.equal(isFavoriteContentType('datapack'), true)
assert.equal(isFavoriteContentType('shader'), true)
assert.equal(isFavoriteContentType('modpack'), false)
assert.equal(isFavoriteContentType('world'), false)
})

View File

@ -0,0 +1,50 @@
import { invoke } from '@tauri-apps/api/core'
export type FavoriteProvider = 'modrinth' | 'curseforge' | 'mcarchive'
export type FavoriteContentType = 'mod' | 'resourcepack' | 'datapack' | 'shader'
export interface ContentFavorite {
provider: FavoriteProvider
project_id: string
content_type: FavoriteContentType
saved_at: number
}
export interface ContentFavoriteInput {
provider: FavoriteProvider
project_id: string
content_type: FavoriteContentType
}
export const FAVORITE_CONTENT_TYPES: FavoriteContentType[] = [
'mod',
'resourcepack',
'datapack',
'shader',
]
export function isFavoriteContentType(value: string): value is FavoriteContentType {
return FAVORITE_CONTENT_TYPES.includes(value as FavoriteContentType)
}
export function contentFavoriteKey(provider: FavoriteProvider, projectId: string) {
return `${provider}:${projectId}`
}
export async function listContentFavorites(): Promise<ContentFavorite[]> {
return await invoke('plugin:content-favorites|content_favorites_list')
}
export async function addContentFavorite(favorite: ContentFavoriteInput): Promise<ContentFavorite> {
return await invoke('plugin:content-favorites|content_favorites_add', { favorite })
}
export async function removeContentFavorite(
provider: FavoriteProvider,
projectId: string,
): Promise<void> {
await invoke('plugin:content-favorites|content_favorites_remove', {
provider,
projectId,
})
}

View File

@ -0,0 +1,94 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
compareContentIdentities,
contentIdentityFromInput,
contentIdentityInputsFromSnapshot,
normalizeContentIdentityText,
} from './content-identity.ts'
function identity(
provider: 'modrinth' | 'curseforge',
projectId: string,
contentType = 'mod',
values: Record<string, string> = {},
) {
return contentIdentityFromInput({ provider, projectId, contentType, ...values })
}
test('normalizes platform suffixes and versions', () => {
assert.equal(normalizeContentIdentityText('Sodium-Fabric-0.5.8.jar'), 'sodium')
assert.equal(normalizeContentIdentityText('sodium_forge_1.20.1'), 'sodium')
})
test('same provider never creates a cross-platform conflict', () => {
assert.equal(
compareContentIdentities(
identity('modrinth', 'a', 'mod', { title: 'Sodium' }),
identity('modrinth', 'b', 'mod', { title: 'Sodium' }),
),
null,
)
})
test('different content types never conflict', () => {
assert.equal(
compareContentIdentities(
identity('modrinth', 'a', 'mod', { title: 'Sodium' }),
identity('curseforge', 'b', 'resourcepack', { title: 'Sodium' }),
),
null,
)
})
test('matching sha1 is an exact cross-platform conflict', () => {
const result = compareContentIdentities(
identity('modrinth', 'a', 'mod', { sha1: 'ABC123', title: 'First' }),
identity('curseforge', 'b', 'mod', { sha1: 'abc123', title: 'Second' }),
)
assert.equal(result?.source, 'sha1')
assert.equal(result?.confidence, 'exact')
})
test('matching names are heuristic conflicts', () => {
const result = compareContentIdentities(
identity('modrinth', 'a', 'mod', { title: 'Example Mod' }),
identity('curseforge', 'b', 'mod', { title: 'example-mod' }),
)
assert.equal(result?.source, 'heuristic')
})
test('curated mapping conflicts are exact', () => {
const left = { ...identity('modrinth', 'a'), key: 'mapping:1', ambiguous: false }
const right = { ...identity('curseforge', 'b'), key: 'mapping:1', ambiguous: false }
const result = compareContentIdentities(left, right)
assert.equal(result?.source, 'curated_mapping')
assert.equal(result?.confidence, 'exact')
})
test('pack-managed snapshot members participate in cross-platform conflicts', () => {
const [installedInput] = contentIdentityInputsFromSnapshot(
[
{
projectType: 'mod',
provider: 'modrinth',
providerProjectId: 'AANobbMI',
expectedRelativePath: 'mods/sodium-fabric-0.6.13.jar',
content: null,
},
],
{
modrinth: new Map([['AANobbMI', { slug: 'sodium', title: 'Sodium' }]]),
},
)
const installed = { ...contentIdentityFromInput(installedInput), key: 'mapping:sodium' }
const candidate = {
...identity('curseforge', '394468', 'mod', { slug: 'sodium' }),
key: 'mapping:sodium',
}
const result = compareContentIdentities(candidate, installed)
assert.equal(result?.source, 'curated_mapping')
assert.equal(result?.confidence, 'exact')
})

View File

@ -0,0 +1,250 @@
import { invoke } from '@tauri-apps/api/core'
export type ContentIdentityProvider = 'modrinth' | 'curseforge'
export type ContentIdentitySource = 'curated_mapping' | 'sha1' | 'heuristic'
export type ContentIdentityConfidence = 'exact' | 'high' | 'possible'
export interface ContentIdentityInput {
provider: ContentIdentityProvider
projectId: string
contentType: string
slug?: string | null
title?: string | null
fileName?: string | null
sha1?: string | null
}
export interface ContentIdentitySnapshotItem {
projectType: string
provider: ContentIdentityProvider | null
providerProjectId: string | null
expectedRelativePath: string
content: {
id: string
file_name: string
project_type: string
project?: { slug?: string | null; title?: string | null } | null
provider_refs: Array<{
provider: ContentIdentityProvider
project_id: string | number
}>
} | null
}
export interface ContentIdentityProjectMetadata {
slug?: string | null
title?: string | null
}
export type ContentIdentityProjectMetadataByProvider = Partial<
Record<ContentIdentityProvider, ReadonlyMap<string, ContentIdentityProjectMetadata>>
>
export interface ContentIdentityCounterpart {
provider: ContentIdentityProvider
projectId: string
slug?: string
}
export interface ContentIdentity {
key?: string
source?: ContentIdentitySource
confidence?: ContentIdentityConfidence
counterparts?: ContentIdentityCounterpart[]
provider: ContentIdentityProvider
projectId: string
contentType: string
slug?: string
title?: string
fileName?: string
sha1?: string
normalizedSlug?: string
normalizedTitle?: string
normalizedFileName?: string
ambiguous?: boolean
}
interface ContentIdentityRecord {
key: string
counterparts: ContentIdentityCounterpart[]
}
interface ContentIdentityLookup {
modrinth: Record<string, ContentIdentityRecord[]>
curseforge: Record<string, ContentIdentityRecord[]>
}
export interface ContentIdentityMatch {
source: ContentIdentitySource
confidence: ContentIdentityConfidence
identity: ContentIdentity
}
export function contentIdentityInputsFromSnapshot(
items: ContentIdentitySnapshotItem[],
projectMetadata: ContentIdentityProjectMetadataByProvider = {},
): ContentIdentityInput[] {
const inputs: ContentIdentityInput[] = []
for (const item of items) {
const references = new Map<string, { provider: ContentIdentityProvider; projectId: string }>()
if (item.provider && item.providerProjectId) {
const reference = { provider: item.provider, projectId: item.providerProjectId }
references.set(`${reference.provider}:${reference.projectId}`, reference)
}
for (const providerReference of item.content?.provider_refs ?? []) {
const reference = {
provider: providerReference.provider,
projectId: String(providerReference.project_id),
}
references.set(`${reference.provider}:${reference.projectId}`, reference)
}
for (const reference of references.values()) {
const metadata = projectMetadata[reference.provider]?.get(reference.projectId)
inputs.push({
...reference,
contentType: item.projectType || item.content?.project_type || 'mod',
slug: metadata?.slug ?? item.content?.project?.slug,
title: metadata?.title ?? item.content?.project?.title,
fileName:
item.content?.file_name ??
item.expectedRelativePath.split(/[\\/]/u).pop() ??
item.expectedRelativePath,
sha1: item.content?.id,
})
}
}
return inputs
}
function normalizeText(value: string | null | undefined) {
return (value ?? '')
.toLocaleLowerCase()
.replace(/\.(?:jar|zip|mrpack|litemod)(?:\.disabled)?$/u, '')
.replace(/(?:[-_. ]+)(?:v?\d[\w.-]*)$/u, '')
.replace(/(?:[-_. ]+)(?:fabric|forge|quilt|neoforge|neo|liteloader)$/u, '')
.replace(/[^a-z0-9]+/gu, '')
}
export function normalizeContentIdentityText(value: string | null | undefined) {
return normalizeText(value)
}
function identitySlug(input: ContentIdentityInput) {
return input.slug ? normalizeText(input.slug) : ''
}
function identityTitle(input: ContentIdentityInput) {
return input.title ? normalizeText(input.title) : ''
}
function identityFileName(input: ContentIdentityInput) {
return input.fileName ? normalizeText(input.fileName) : ''
}
export async function resolveContentIdentities(
inputs: ContentIdentityInput[],
): Promise<ContentIdentity[]> {
const modrinthSlugs = [
...new Set(
inputs
.filter((input) => input.provider === 'modrinth' && input.slug)
.map((input) => input.slug as string),
),
]
const curseforgeSlugs = [
...new Set(
inputs
.filter((input) => input.provider === 'curseforge' && input.slug)
.map((input) => input.slug as string),
),
]
let lookup: ContentIdentityLookup = { modrinth: {}, curseforge: {} }
if (modrinthSlugs.length || curseforgeSlugs.length) {
lookup = await invoke<ContentIdentityLookup>(
'plugin:content-search|lookup_content_identities',
{
modrinthSlugs,
curseforgeSlugs,
},
).catch(() => lookup)
}
return inputs.map((input) => {
const records = input.slug
? ((input.provider === 'modrinth'
? (lookup.modrinth[input.slug] ?? lookup.modrinth[input.slug.toLowerCase()])
: (lookup.curseforge[input.slug] ?? lookup.curseforge[input.slug.toLowerCase()])) ?? [])
: []
const uniqueKeys = [...new Set(records.map((record) => record.key))]
const mapping =
uniqueKeys.length === 1 ? records.find((record) => record.key === uniqueKeys[0]) : undefined
return {
...input,
slug: input.slug ?? undefined,
title: input.title ?? undefined,
fileName: input.fileName ?? undefined,
sha1: input.sha1?.toLowerCase() || undefined,
key: mapping?.key,
source: mapping ? 'curated_mapping' : undefined,
confidence: mapping ? 'exact' : undefined,
counterparts: mapping?.counterparts,
normalizedSlug: identitySlug(input),
normalizedTitle: identityTitle(input),
normalizedFileName: identityFileName(input),
ambiguous: uniqueKeys.length > 1,
}
})
}
function sameProvider(left: ContentIdentity, right: ContentIdentity) {
return left.provider === right.provider
}
function sameContentType(left: ContentIdentity, right: ContentIdentity) {
return left.contentType === right.contentType
}
export function compareContentIdentities(
left: ContentIdentity,
right: ContentIdentity,
): ContentIdentityMatch | null {
if (sameProvider(left, right) || !sameContentType(left, right)) return null
if (left.key && right.key && left.key === right.key && !left.ambiguous && !right.ambiguous) {
return { source: 'curated_mapping', confidence: 'exact', identity: right }
}
if (left.sha1 && right.sha1 && left.sha1 === right.sha1) {
return { source: 'sha1', confidence: 'exact', identity: right }
}
if (left.normalizedSlug && right.normalizedSlug && left.normalizedSlug === right.normalizedSlug) {
return { source: 'heuristic', confidence: 'high', identity: right }
}
if (
left.normalizedTitle &&
right.normalizedTitle &&
left.normalizedTitle === right.normalizedTitle
) {
return { source: 'heuristic', confidence: 'possible', identity: right }
}
if (
left.normalizedFileName &&
right.normalizedFileName &&
left.normalizedFileName === right.normalizedFileName
) {
return { source: 'heuristic', confidence: 'possible', identity: right }
}
return null
}
export function contentIdentityFromInput(input: ContentIdentityInput): ContentIdentity {
return {
...input,
slug: input.slug ?? undefined,
title: input.title ?? undefined,
fileName: input.fileName ?? undefined,
sha1: input.sha1?.toLowerCase() || undefined,
normalizedSlug: identitySlug(input),
normalizedTitle: identityTitle(input),
normalizedFileName: identityFileName(input),
}
}

View File

@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { ContentItem } from '@modrinth/ui'
import { applyContentItemUpdates } from './content-item-state.ts'
function contentItem(path: string): ContentItem {
const fileName = path.split('/').pop() ?? path
return {
id: 'content',
file_name: fileName,
file_path: path,
size: 1,
enabled: true,
project_type: 'mod',
project: {
id: 'local:content',
slug: 'content',
title: 'Content',
icon_url: 'C:/icons/content.png',
},
version: {
id: 'local:content',
version_number: '1.0.0',
file_name: fileName,
},
update: null,
provider_refs: [],
} as ContentItem
}
test('toggle updates survive recomputing an icon-bearing display clone', () => {
const source = contentItem('mods/content.jar')
const rendered = {
...source,
project: { ...source.project!, icon_url: 'asset://localhost/icons/content.png' },
}
applyContentItemUpdates([source], rendered, source.file_name, source.file_path, {
file_name: 'content.jar.disabled',
file_path: 'mods/content.jar.disabled',
enabled: false,
})
assert.equal(source.enabled, false)
assert.equal(source.file_name, 'content.jar.disabled')
assert.equal(source.file_path, 'mods/content.jar.disabled')
assert.equal(rendered.enabled, false)
assert.equal(rendered.file_path, 'mods/content.jar.disabled')
})

View File

@ -0,0 +1,36 @@
import type { ContentItem } from '@modrinth/ui'
export function matchesContentItem(
item: ContentItem,
target: ContentItem,
originalFileName: string,
originalFilePath?: string,
) {
if (
item.file_name === originalFileName ||
item.file_path === originalFilePath ||
item.file_path === target.file_path
)
return true
const projectId = target.project?.id
if (!projectId || item.project?.id !== projectId) return false
const versionId = target.version?.id
return !versionId || item.version?.id === versionId
}
export function applyContentItemUpdates(
items: ContentItem[],
target: ContentItem,
originalFileName: string,
originalFilePath: string | undefined,
updates: Partial<ContentItem>,
) {
for (const item of items) {
if (matchesContentItem(item, target, originalFileName, originalFilePath)) {
Object.assign(item, updates)
}
}
Object.assign(target, updates)
}

View File

@ -0,0 +1,171 @@
import type { ContentItem } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
export interface ChineseSearchTranslation {
chineseName: string
curseforgeSlug?: string
modrinthSlug?: string
matchScore: number
exact: boolean
}
export interface ChineseSearchResolution {
isChinese: boolean
normalizedQuery: string
curseforgeQuery?: string
modrinthQuery?: string
modrinthSlugs: string[]
translations: ChineseSearchTranslation[]
}
export interface ContentSearchExpansion {
suggestedSplit?: string | null
}
export interface ChineseNameLookup {
modrinth: Record<string, string>
curseforge: Record<string, string>
}
export interface WikiIdLookup {
modrinth: Record<string, number>
curseforge: Record<string, number>
}
export function containsChineseSearchText(query: string): boolean {
return /[\u3400-\u4dbf\u4e00-\u9fff]/u.test(query)
}
export function resolveChineseContentSearch(query: string) {
return invoke<ChineseSearchResolution>('plugin:content-search|resolve_chinese_content_search', {
query,
})
}
/**
* Resolves the dictionary-based segmentation of a compact (separator-free)
* search query, e.g. `sodiumextra` → `sodium extra`. Returns `null` when the
* bundled dictionary cannot safely split the query.
*/
export function expandContentSearchQuery(query: string) {
return invoke<ContentSearchExpansion>('plugin:content-search|expand_content_search_query', {
query,
})
}
export function lookupChineseContentNames(modrinthSlugs: string[], curseforgeSlugs: string[]) {
return invoke<ChineseNameLookup>('plugin:content-search|lookup_chinese_content_names', {
modrinthSlugs,
curseforgeSlugs,
})
}
export function lookupContentWikiIds(modrinthSlugs: string[], curseforgeSlugs: string[]) {
return invoke<WikiIdLookup>('plugin:content-search|lookup_content_wiki_ids', {
modrinthSlugs,
curseforgeSlugs,
})
}
/**
* Resolves the MC 百科 (mcmod.cn) page URL for a project slug, or `null`
* when the bundled wiki dictionary has no entry for it.
*/
export async function resolveMcmodUrl(
slug: string | null | undefined,
provider: 'modrinth' | 'curseforge',
): Promise<string | null> {
if (!slug) return null
const lookup = await lookupContentWikiIds(
provider === 'modrinth' ? [slug] : [],
provider === 'curseforge' ? [slug] : [],
).catch(() => null)
const wikiId = provider === 'curseforge' ? lookup?.curseforge[slug] : lookup?.modrinth[slug]
return wikiId ? `https://www.mcmod.cn/class/${wikiId}.html` : null
}
export function bilingualTitle(chineseName: string, originalTitle: string) {
const chineseTitle = chineseName.replace(/\s+\([^()]*[A-Za-z][^()]*\)$/u, '').trim()
if (
!chineseTitle ||
chineseTitle.toLocaleLowerCase() === originalTitle.toLocaleLowerCase() ||
originalTitle.startsWith(`${chineseTitle} (`)
) {
return originalTitle
}
return `${chineseTitle} (${originalTitle})`
}
/**
* Rewrites search hit titles to the bilingual `中文名 (English)` format,
* resolving names from the bundled wiki dictionary by project slug. Hits are
* returned unchanged unless the locale is zh-CN; hits already carrying a
* bilingual title (e.g. from the Chinese search flow) are left untouched.
*/
export async function translateSearchHitTitles<
T extends { slug?: string | null; title: string; provider: 'modrinth' | 'curseforge' },
>(hits: T[], locale: string): Promise<T[]> {
if (locale !== 'zh-CN' || hits.length === 0) return hits
const modrinthSlugs: string[] = []
const curseforgeSlugs: string[] = []
for (const hit of hits) {
if (!hit.slug) continue
if (hit.provider === 'curseforge') curseforgeSlugs.push(hit.slug)
else if (hit.provider === 'modrinth') modrinthSlugs.push(hit.slug)
}
if (modrinthSlugs.length === 0 && curseforgeSlugs.length === 0) return hits
const lookup = await lookupChineseContentNames(modrinthSlugs, curseforgeSlugs).catch(() => null)
if (!lookup) return hits
return hits.map((hit) => {
if (!hit.slug) return hit
const chineseName =
hit.provider === 'curseforge'
? lookup.curseforge[hit.slug]
: hit.provider === 'modrinth'
? lookup.modrinth[hit.slug]
: undefined
if (!chineseName) return hit
return { ...hit, title: bilingualTitle(chineseName, hit.title) }
})
}
/**
* Rewrites content item titles to the bilingual `中文名 (English)` format used
* by the Browse page, resolving names from the bundled wiki dictionary by
* project slug. Items are returned unchanged unless the locale is zh-CN.
*/
export async function translateContentItemTitles<T extends ContentItem>(
items: T[],
locale: string,
): Promise<T[]> {
if (locale !== 'zh-CN' || items.length === 0) return items
const modrinthSlugs: string[] = []
const curseforgeSlugs: string[] = []
for (const item of items) {
const slug = item.project?.slug
if (!slug) continue
if (item.origin_provider === 'curseforge') curseforgeSlugs.push(slug)
else if (item.origin_provider === 'modrinth') modrinthSlugs.push(slug)
}
if (modrinthSlugs.length === 0 && curseforgeSlugs.length === 0) return items
const lookup = await lookupChineseContentNames(modrinthSlugs, curseforgeSlugs).catch(() => null)
if (!lookup) return items
return items.map((item) => {
const project = item.project
if (!project?.slug) return item
const chineseName =
item.origin_provider === 'curseforge'
? lookup.curseforge[project.slug]
: item.origin_provider === 'modrinth'
? lookup.modrinth[project.slug]
: undefined
if (!chineseName) return item
return { ...item, project: { ...project, title: bilingualTitle(chineseName, project.title) } }
})
}

View File

@ -0,0 +1,555 @@
import type { CurseForgeCategory } from '@/helpers/curseforge'
export const CF_EXTRA_CATEGORY_HEADER = 'cf-extra'
export const CF_CATEGORY_VALUE_PREFIX = 'cf:'
/**
* Hand-maintained Modrinth category slug → CurseForge category slug aliases.
* Runtime resolution still prefers exact slug matches against live CF categories.
*/
const MODRINTH_TO_CURSEFORGE_SLUGS: Record<string, string[]> = {
// Keys are Modrinth category slugs. Values are live CurseForge slugs.
// Prefer one primary CF category so multi-select does not AND-filter to empty.
adventure: ['adventure-rpg', 'adventure-and-rpg', 'adventure'],
atmosphere: ['fantasy', 'realistic'],
audio: ['sound'],
blocks: ['world-gen'],
bloom: ['realistic', 'fantasy'],
cartoon: ['traditional', 'fantasy'],
challenging: ['hardcore', 'expert'],
combat: ['armor-weapons-tools', 'combat-pvp'],
'core-shaders': ['realistic', 'fantasy'],
cursed: ['mc-miscellaneous', 'miscellaneous'],
decoration: ['cosmetic'],
economy: ['economy'],
entities: ['world-mobs', 'mobs'],
environment: ['world-gen', 'world-biomes'],
equipment: ['armor-weapons-tools'],
fantasy: ['fantasy', 'magic', 'adventure-and-rpg'],
foliage: ['world-biomes', 'world-gen'],
fonts: ['font-packs'],
food: ['mc-food', 'food'],
'game-mechanics': ['mc-miscellaneous', 'mechanics'],
gui: ['map-information'],
items: ['armor-weapons-tools'],
'kitchen-sink': ['extra-large', 'multiplayer'],
library: ['library-api', 'library'],
lightweight: ['small-light', 'performance', 'vanilla'],
locale: ['font-packs'],
magic: ['magic'],
management: ['server-utility', 'admin-tools'],
minigame: ['mini-game', 'fun'],
mobs: ['world-mobs', 'mobs'],
modded: ['mod-support'],
models: ['cosmetic'],
multiplayer: ['multiplayer'],
optimization: ['performance'],
'path-tracing': ['realistic', 'photo-realistic'],
pve: ['adventure-and-rpg', 'adventure-rpg', 'hardcore'],
pvp: ['combat-pvp', 'armor-weapons-tools'],
quests: ['quests', 'adventure-and-rpg'],
realistic: ['realistic', 'photo-realistic'],
simplistic: ['traditional', 'vanilla', 'small-light'],
social: ['multiplayer', 'chat-related'],
storage: ['storage'],
technology: ['technology', 'tech'],
themed: ['steampunk', 'medieval', 'modern'],
transportation: ['technology-player-transport', 'tech', 'technology'],
tweaks: ['utility-qol', 'vanilla'],
utility: ['utility-qol', 'utility'],
'vanilla-like': ['vanilla', 'traditional', 'small-light'],
worldgen: ['world-gen', 'exploration'],
}
/**
* CurseForge category slug / English name → Simplified Chinese.
* Keys are normalized (lowercase, hyphenated). Covers common Minecraft CF classes.
*/
const CF_NAME_TRANSLATIONS: Record<string, string> = {
addon: '附加内容',
addons: '附加内容',
advanced: '进阶',
adventure: '冒险',
ae2: '应用能源 2',
aether: '天境',
age: '时代',
ages: '时代',
agriculture: '农业',
anarchy: '无政府',
animated: '动态',
api: 'API',
atm: 'ATM 系列',
atmosphere: '氛围',
atmospheric: '氛围',
audio: '音频',
automation: '自动化',
beginner: '新手友好',
beginners: '新手友好',
biomes: '生物群系',
blocks: '方块',
bloom: '泛光',
botania: '植物魔法',
builders: '建筑',
building: '建筑',
bukkit: 'Bukkit',
bungeecord: 'BungeeCord',
campaign: '战役',
cartoon: '卡通',
categories: '分类',
category: '分类',
cave: '洞穴',
caves: '洞穴',
challenge: '挑战',
challenging: '高挑战',
chat: '聊天',
cobblemon: '宝可梦',
colored: '彩色',
combat: '战斗',
community: '社区',
coop: '合作',
cosmetic: '装饰',
cosmetics: '装饰',
crafttweaker: 'CraftTweaker',
create: '机械动力',
creative: '创造',
ctm: '连接纹理',
cursed: '诅咒',
customization: '自定义',
datapack: '数据包',
datapacks: '数据包',
decoration: '装饰',
difficult: '困难',
difficulty: '难度',
dimension: '维度',
dimensions: '维度',
easy: '简单',
economy: '经济',
education: '教育',
emi: 'EMI',
end: '末地',
energy: '能源',
enigmatica: 'Enigmatica',
entities: '实体',
equipment: '装备',
expert: '专家难度',
exploration: '探索',
fabric: 'Fabric',
fantasy: '奇幻',
farming: '农业',
fixed: '固定',
fonts: '字体',
food: '食物',
forestry: '林业',
forge: 'Forge',
ftb: 'FTB',
fun: '娱乐',
galacticraft: '星系',
gamestages: '游戏阶段',
general: '通用',
genetics: '基因',
gregtech: '格雷科技',
gtnh: 'GTNH',
gui: '界面',
hard: '困难',
hardcore: '极限',
heavy: '重度',
high: '高配',
horror: '恐怖',
hqm: '极限任务',
hybrid: '混合',
ic2: '工业 2',
immersive: '沉浸工程',
industrial: '工业',
information: '信息',
informational: '信息',
integration: '集成',
intermediate: '中等',
items: '物品',
jei: 'JEI',
kubejs: 'KubeJS',
lan: '局域网',
languages: '语言',
large: '大型',
library: '支持库',
light: '轻量',
lightweight: '轻量',
liteloader: 'LiteLoader',
locales: '语言',
localization: '本地化',
magic: '魔法',
mechanics: '机制',
medieval: '中世纪',
medium: '中型',
mega: '大型',
mekanism: '通用机械',
minigame: '小游戏',
minigames: '小游戏',
misc: '杂项',
miscellaneous: '杂项',
mixed: '综合',
mobs: '生物',
mod: '模组',
modded: '模组化',
models: '模型',
modern: '现代',
modpack: '整合包',
modpacks: '整合包',
mods: '模组',
multiplayer: '多人',
neoforge: 'NeoForge',
nether: '下界',
nomifactory: '诺米工厂',
oceanblock: '海岛',
official: '官方',
op: '超模',
optimization: '优化',
options: '选项',
ores: '矿石',
overpowered: '超模',
paper: 'Paper',
pbr: 'PBR',
performance: '性能优化',
photorealistic: '写实',
pixelmon: '神奇宝贝',
plugin: '插件',
plugins: '插件',
plugman: '插件管理',
potato: '低配',
processing: '加工',
progression: '进度导向',
prominence: 'Prominence',
protection: '保护',
purpur: 'Purpur',
pve: 'PvE',
pvp: 'PvP',
qol: '生活质量',
quest: '任务',
quests: '任务',
quilt: 'Quilt',
realistic: '写实',
redstone: '红石',
rei: 'REI',
resourcepacks: '资源包',
rift: 'Rift',
rlcraft: 'RLCraft',
roleplay: '角色扮演',
rpg: 'RPG',
science: '科学',
scifi: '科幻',
server: '服务器',
shader: '光影',
shaders: '光影',
simplistic: '简约',
singleplayer: '单人',
skyblock: '空岛',
skyfactory: '天空工厂',
small: '小型',
smp: '多人生存',
sound: '音效',
space: '太空',
spigot: 'Spigot',
steampunk: '蒸汽朋克',
stoneblock: '石块空岛',
storage: '存储',
story: '剧情',
structures: '结构',
survival: '生存',
tech: '科技',
technology: '科技',
teleportation: '传送',
thaumcraft: '神秘时代',
themed: '主题',
thermal: '热力系列',
tinkers: '匠魂',
traditional: '传统',
transport: '运输',
transportation: '交通',
tweaks: '微调',
twilight: '暮色',
twitch: 'Twitch',
ultra: '极致',
utility: '实用',
valhelsia: 'Valhelsia',
vanilla: '原版+',
velocity: 'Velocity',
waterfall: 'Waterfall',
world: '世界',
worldgen: '世界生成',
worlds: '世界',
'128x': '128x',
'16x': '16x',
'256x': '256x',
'32x': '32x',
'512x': '512x',
'512x-and-higher': '512x+',
'512x-plus': '512x+',
'64x': '64x',
'admin-tools': '管理工具',
'adventure-and-rpg': '冒险与 RPG',
'adventure-maps': '冒险地图',
'adventure-rpg': '冒险与 RPG',
'adventure-worlds': '冒险世界',
'all-the-mods': 'ATM 系列',
'anti-griefing': '反破坏',
'anti-griefing-tools': '反破坏',
'api-and-library': '支持库与 API',
'applied-energistics-2': '应用能源 2',
'armor-tools-and-weapons': '盔甲、工具与武器',
'armor-tools-weapons': '盔甲、工具与武器',
'armor-weapons-tools': '盔甲、工具与武器',
'better-minecraft': 'Better Minecraft',
'blood-magic': '血魔法',
'bug-fix': '漏洞修复',
'bug-fixes': '漏洞修复',
'co-op': '合作',
'colored-lighting': '彩色光照',
'combat-pvp': '战斗 / PvP',
'connected-textures': '连接纹理',
'core-shaders': '核心光影',
'create-based': '机械动力向',
'creation-worlds': '创造世界',
'data-packs': '数据包',
'data-packs-and-scripts': '数据包与脚本',
'developer-tools': '开发工具',
'divine-journey': '神圣之旅',
'economy-plugins': '经济插件',
'exploration-adventure': '探索冒险',
'extra-large': '超大型',
'fixed-inventory': '固定物品栏',
'font-packs': '字体包',
'food-and-farming': '食物与农业',
'food-farming': '食物与农业',
'ftb-official-pack': 'FTB 官方整合包',
'ftb-quests': 'FTB 任务',
'game-map': '游戏地图',
'game-mechanics': '游戏机制',
'genetic-engineering': '基因工程',
'hardcore-questing': '极限任务',
'immersive-engineering': '沉浸工程',
'industrial-craft': '工业',
'kitchen-sink': '大杂烩',
'kitchen-sinks': '大杂烩',
'library-api': '支持库与 API',
'magic-based': '魔法向',
'map-and-information': '地图与信息',
'map-based': '基于地图',
'map-information': '地图与信息',
'mc-frp': 'MC FRP',
'mini-game': '小游戏',
'mod-support': '模组支持',
'modded-worlds': '模组世界',
'ores-resources': '矿石与资源',
'parkour-maps': '跑酷地图',
'path-tracing': '路径追踪',
'photo-realistic': '写实',
'player-transport': '玩家运输',
'puzzle-maps': '解谜地图',
'quality-of-life': '生活质量',
'ray-tracing': '光线追踪',
'resource-packs': '资源包',
'role-playing': '角色扮演',
'sci-fi': '科幻',
'server-pack': '服务器包',
'server-ready': '服务器就绪',
'server-utility': '服务器实用',
'sevs-tech': 'SevTech',
'single-player': '单人',
'sky-block': '空岛',
'sky-factory': '天空工厂',
'small-light': '小型 / 轻量',
'story-driven': '剧情向',
'survival-maps': '生存地图',
'tech-and-magic': '科技与魔法',
'tech-based': '科技向',
'tech-magic': '科技魔法',
'thermal-expansion': '热力膨胀',
'tinkers-construct': '匠魂',
'twilight-forest': '暮色森林',
'twitch-integration': 'Twitch 集成',
'utility-and-qol': '实用与生活质量',
'utility-qol': '实用与生活质量',
'vanilla-like': '原版风格',
'vanilla-plus': '原版+',
'vault-hunters': 'Vault Hunters',
'website-administration': '网站管理',
'world-editing': '世界编辑',
'world-editing-and-management': '世界编辑与管理',
'world-gen': '世界生成',
'world-generation': '世界生成',
'world-generators': '世界生成器',
'world-management': '世界管理',
}
function normalizeSlug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/&/g, 'and')
.replace(/['"]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function uniqueNumbers(values: number[]): number[] {
return [...new Set(values.filter((value) => Number.isFinite(value)))]
}
function lookupTranslation(...rawValues: Array<string | null | undefined>): string | undefined {
for (const raw of rawValues) {
if (!raw) continue
const key = normalizeSlug(raw)
if (!key) continue
const hit = CF_NAME_TRANSLATIONS[key]
if (hit) return hit
}
return undefined
}
export function isCurseForgeOnlyCategoryName(name: string): boolean {
return name.startsWith(CF_CATEGORY_VALUE_PREFIX)
}
export function parseCurseForgeCategoryValue(name: string): number | undefined {
if (!isCurseForgeOnlyCategoryName(name)) return undefined
const id = Number(name.slice(CF_CATEGORY_VALUE_PREFIX.length))
return Number.isFinite(id) ? id : undefined
}
export function curseForgeCategoryValue(id: number): string {
return `${CF_CATEGORY_VALUE_PREFIX}${id}`
}
export function localizeCurseForgeLabel(...rawValues: Array<string | null | undefined>): string {
const translated = lookupTranslation(...rawValues)
if (translated) return translated
const fallback = rawValues.find((value) => Boolean(value && String(value).trim()))
if (!fallback) return ''
// Title-case leftover English labels so untranslated CF tags still look readable.
return String(fallback)
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, (char) => char.toUpperCase())
}
export function localizeCurseForgeCategoryName(
category: Pick<CurseForgeCategory, 'name' | 'slug'>,
): string {
return localizeCurseForgeLabel(category.slug, category.name)
}
export function localizeCurseForgeCategoryLabels(labels: string[] | undefined | null): string[] {
if (!labels?.length) return []
return labels.map((label) => localizeCurseForgeLabel(label))
}
export function buildCurseForgeCategoryIndex(categories: CurseForgeCategory[]) {
const bySlug = new Map<string, CurseForgeCategory[]>()
const byId = new Map<number, CurseForgeCategory>()
for (const category of categories) {
if (category.isClass) continue
byId.set(category.id, category)
const keys = [category.slug, category.name]
.filter(Boolean)
.map((value) => normalizeSlug(String(value)))
for (const key of keys) {
const list = bySlug.get(key) ?? []
list.push(category)
bySlug.set(key, list)
}
}
return { bySlug, byId }
}
export function mapModrinthCategoryToCurseForgeIds(
modrinthSlug: string,
categories: CurseForgeCategory[],
): number[] {
const { bySlug } = buildCurseForgeCategoryIndex(categories)
const normalized = normalizeSlug(modrinthSlug)
// Exact CF slug/name first.
const exact = bySlug.get(normalized) ?? []
if (exact.length > 0) {
return uniqueNumbers(exact.map((category) => category.id)).slice(0, 1)
}
// Otherwise use the first alias that exists in the live CF category list.
// Returning multiple IDs can AND-filter CF search and yield empty pages.
for (const alias of (MODRINTH_TO_CURSEFORGE_SLUGS[normalized] ?? []).map(normalizeSlug)) {
const matches = bySlug.get(alias) ?? []
if (matches.length > 0) {
return uniqueNumbers(matches.map((category) => category.id)).slice(0, 1)
}
}
return []
}
export function findUnmappedCurseForgeCategories(
modrinthSlugs: string[],
categories: CurseForgeCategory[],
): CurseForgeCategory[] {
const mappedIds = new Set<number>()
for (const slug of modrinthSlugs) {
for (const id of mapModrinthCategoryToCurseForgeIds(slug, categories)) {
mappedIds.add(id)
}
}
// Also treat exact slug overlaps as mapped even without explicit table entries.
const mrSlugSet = new Set(modrinthSlugs.map(normalizeSlug))
return categories.filter((category) => {
if (category.isClass) return false
if (mappedIds.has(category.id)) return false
const slug = normalizeSlug(category.slug || category.name)
return !mrSlugSet.has(slug)
})
}
export function resolveCurseForgeCategoryIdsFromFilterValues(
values: string[],
categories: CurseForgeCategory[],
loaderSlugs: Set<string>,
): number[] {
const { bySlug, byId } = buildCurseForgeCategoryIndex(categories)
const ids: number[] = []
for (const value of values) {
const normalized = normalizeSlug(value)
if (!normalized || loaderSlugs.has(normalized) || loaderSlugs.has(value)) continue
const prefixedId = parseCurseForgeCategoryValue(value)
if (prefixedId !== undefined) {
if (byId.has(prefixedId) || categories.some((category) => category.id === prefixedId)) {
ids.push(prefixedId)
}
continue
}
// Numeric category ids (rare, but keep as a fallback).
if (/^\d+$/.test(value)) {
const numericId = Number(value)
if (byId.has(numericId) || categories.some((category) => category.id === numericId)) {
ids.push(numericId)
continue
}
}
// Prefer exact CF slug/name matches from the live category list.
const directMatches = bySlug.get(normalized) ?? []
if (directMatches.length > 0) {
ids.push(...directMatches.map((category) => category.id))
continue
}
// Fall back to Modrinth slug → CF alias mapping for unified "all sources" mode.
ids.push(...mapModrinthCategoryToCurseForgeIds(value, categories))
}
return uniqueNumbers(ids).slice(0, 10)
}

View File

@ -0,0 +1,174 @@
export interface CurseForgeManualDownloadItem {
projectId: number
fileId: number
fileName: string
websiteUrl?: string
projectType?: string
projectSlug?: string
targetFolder?: string
hashes?: Array<{ value: string; algo: number }>
fileLength?: number
fileFingerprint?: number
ownershipKind?: 'pack_managed' | 'user_added'
operationKind?: 'pack_install' | 'pack_update' | 'content_install' | 'content_update'
}
export interface InstalledCurseForgeContentItem {
file_name: string
provider_refs?: Array<
| { provider: 'modrinth'; project_id: string; version_id?: string | null }
| { provider: 'curseforge'; project_id: number; file_id?: number | null }
>
}
const manualDownloadsByInstance = new Map<string, CurseForgeManualDownloadItem[]>()
function modFileFamily(fileName: string) {
const baseName = fileName.replace(/\.disabled$/i, '')
const extension = baseName.match(/\.([^.]+)$/)?.[1]?.toLowerCase()
if (!extension) return undefined
const stem = baseName
.toLowerCase()
.replace(/\.(?:jar|zip|litemod|mrpack)$/i, '')
.replace(/\s*\(\d+\)$/, '')
const versionStart = stem.search(/[-_. ]+v?\d/)
if (versionStart <= 0) return undefined
const family = stem.slice(0, versionStart).replace(/[^a-z0-9]+/g, '')
return family.length >= 3 ? `${extension}:${family}` : undefined
}
export function getCurseForgeManualDownloads(instanceId: string): CurseForgeManualDownloadItem[] {
return manualDownloadsByInstance.get(instanceId) ?? []
}
export function setCurseForgeManualDownloads(
instanceId: string,
items: CurseForgeManualDownloadItem[],
) {
if (!items.length) {
manualDownloadsByInstance.delete(instanceId)
return
}
const existing = new Map(
(manualDownloadsByInstance.get(instanceId) ?? []).map((item) => [
`${item.projectId}:${item.fileId}`,
item,
]),
)
const deduped = new Map<string, CurseForgeManualDownloadItem>()
for (const item of items) {
const key = `${item.projectId}:${item.fileId}`
const previous = existing.get(key)
deduped.set(key, {
...previous,
...item,
projectType: item.projectType ?? previous?.projectType,
projectSlug: item.projectSlug ?? previous?.projectSlug,
targetFolder: item.targetFolder ?? previous?.targetFolder,
hashes: item.hashes?.length ? item.hashes : previous?.hashes,
fileLength: item.fileLength ?? previous?.fileLength,
fileFingerprint: item.fileFingerprint ?? previous?.fileFingerprint,
ownershipKind: item.ownershipKind ?? previous?.ownershipKind,
operationKind: item.operationKind ?? previous?.operationKind,
})
}
manualDownloadsByInstance.set(instanceId, [...deduped.values()])
}
export function getCurseForgeManualDownloadUrl(item: CurseForgeManualDownloadItem) {
const projectTypePath = {
mod: 'mc-mods',
modpack: 'modpacks',
datapack: 'data-packs',
resourcepack: 'texture-packs',
shader: 'shaders',
shaderpack: 'shaders',
world: 'worlds',
}[item.projectType ?? '']
const fallback =
item.projectSlug && projectTypePath
? `https://www.curseforge.com/minecraft/${projectTypePath}/${item.projectSlug}/download/${item.fileId}`
: `https://www.curseforge.com/minecraft/search?search=${encodeURIComponent(item.fileName)}`
if (!item.websiteUrl) return fallback
try {
const url = new URL(item.websiteUrl)
if (!['curseforge.com', 'www.curseforge.com', 'legacy.curseforge.com'].includes(url.hostname)) {
return item.websiteUrl
}
const projectPath = url.pathname.match(
/^\/minecraft\/(?:mc-mods|modpacks|data-packs|texture-packs|shaders|worlds)\/([^/]+)/i,
)
if (projectPath?.[1] && /^\d+$/.test(projectPath[1])) return fallback
url.pathname = url.pathname.replace(
/\/(?:files|download)\/\d+\/?$/i,
`/download/${item.fileId}`,
)
if (!url.pathname.endsWith(`/download/${item.fileId}`)) {
url.pathname = `${url.pathname.replace(/\/$/, '')}/download/${item.fileId}`
}
url.search = ''
url.hash = ''
return url.toString()
} catch {
return fallback
}
}
export function clearCurseForgeManualDownloads(instanceId: string) {
setCurseForgeManualDownloads(instanceId, [])
}
export function filterInstalledCurseForgeManualDownloads(
manualDownloads: CurseForgeManualDownloadItem[],
installedItems: InstalledCurseForgeContentItem[],
) {
const installedFileNames = new Set(
installedItems.flatMap((item) => {
const lower = item.file_name.toLowerCase()
const base = lower.replace(/\.disabled$/i, '')
return [lower, base].filter(Boolean)
}),
)
const installedFileFamilies = new Set(
installedItems
.map((item) => modFileFamily(item.file_name))
.filter((family): family is string => !!family),
)
const installedCurseForgeProjects = new Set(
installedItems.flatMap((item) =>
(item.provider_refs ?? [])
.filter((reference) => reference.provider === 'curseforge')
.map((reference) => reference.project_id),
),
)
const installedCurseForgeFiles = new Set(
installedItems.flatMap((item) =>
(item.provider_refs ?? [])
.filter((reference) => reference.provider === 'curseforge' && reference.file_id != null)
.map((reference) => `${reference.project_id}:${reference.file_id}`),
),
)
return manualDownloads.filter((item) => {
const fileFamily = modFileFamily(item.fileName)
return (
!installedCurseForgeProjects.has(item.projectId) &&
!installedCurseForgeFiles.has(`${item.projectId}:${item.fileId}`) &&
!installedFileNames.has(item.fileName.toLowerCase()) &&
(!fileFamily || !installedFileFamilies.has(fileFamily))
)
})
}
export function removeInstalledCurseForgeManualDownloads(
instanceId: string,
manualDownloads: CurseForgeManualDownloadItem[],
installedItems: InstalledCurseForgeContentItem[],
) {
const remaining = filterInstalledCurseForgeManualDownloads(manualDownloads, installedItems)
setCurseForgeManualDownloads(instanceId, remaining)
return remaining
}

View File

@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
type CurseForgeFile,
getCurseForgeDownloadFailureDetails,
hasCompatibleCurseForgeFile,
} from './curseforge.ts'
function curseForgeFile(id: number, isAvailable: boolean, gameVersions: string[]): CurseForgeFile {
return {
id,
modId: 322385,
isAvailable,
displayName: '',
fileName: '',
releaseType: 1,
fileDate: '',
fileLength: 0,
hashes: [],
fileFingerprint: 0,
downloadCount: 0,
gameVersions,
dependencies: [],
}
}
test('recognizes CurseForge download diagnostics without exposing them in the notification', () => {
const details = getCurseForgeDownloadFailureDetails(
new Error(
'Network download error: connection failed\nDownload failed after 4/4 attempts. Recent attempt history:\n- attempt=4; url=https://mediafilez.forgecdn.net/files/example.jar; proxy=System; category=connect',
),
)
assert.match(details ?? '', /forgecdn\.net/)
})
test('does not classify non-CurseForge download failures', () => {
assert.equal(
getCurseForgeDownloadFailureDetails(
new Error(
'Download failed after 4/4 attempts. Recent attempt history:\n- url=https://cdn.modrinth.com/data/example.jar',
),
),
null,
)
})
test('requires an available exact CurseForge game version match', () => {
const files = [
curseForgeFile(1, true, ['1.19.2']),
curseForgeFile(2, false, ['1.20.1']),
curseForgeFile(3, true, ['1.20.1']),
]
assert.equal(hasCompatibleCurseForgeFile(files, '1.20.1'), true)
assert.equal(hasCompatibleCurseForgeFile(files, '1.20.2'), false)
})

View File

@ -0,0 +1,510 @@
import { invoke } from '@tauri-apps/api/core'
import type { InstallJobSnapshot } from './install'
export type ContentProvider = 'modrinth' | 'curseforge'
export interface CurseForgeCapability {
status: 'missing_key' | 'ready' | 'unauthorized'
configured: boolean
}
export interface CurseForgeSearchRequest {
classId: number
categoryId?: number
categoryIds?: number[]
searchFilter?: string
gameVersion?: string
modLoaderType?: number
sortField?: number
sortOrder?: 'asc' | 'desc'
index?: number
pageSize?: number
}
export interface UnifiedSearchHit {
provider: 'curseforge'
project_id: string
slug?: string
author: string
author_url?: string
title: string
description: string
project_type: string
categories: string[]
versions: string[]
downloads: number
icon_url?: string
date_created: string
date_modified: string
latest_version?: string
gallery: string[]
website_url?: string
source_url?: string
allow_mod_distribution?: boolean
}
export interface UnifiedSearchResponse {
provider: 'curseforge'
hits: UnifiedSearchHit[]
offset: number
limit: number
total_hits: number
}
export interface CurseForgeFilesRequest {
gameVersion?: string
modLoaderType?: number
gameVersionTypeId?: number
index?: number
pageSize?: number
}
export interface CurseForgeProject {
id: number
name: string
slug: string
summary: string
downloadCount: number
mainFileId: number
classId?: number
dateCreated: string
dateModified: string
dateReleased: string
allowModDistribution?: boolean
gamePopularityRank?: number
logo?: { thumbnailUrl: string; url: string }
authors: Array<{ id: number; name: string; url: string }>
categories: Array<{ id: number; name: string; slug: string; iconUrl?: string }>
screenshots: Array<{ id: number; title: string; url: string; thumbnailUrl: string }>
latestFilesIndexes: Array<{
gameVersion: string
fileId: number
filename: string
releaseType: number
gameVersionTypeId?: number
modLoader?: number
}>
links: {
websiteUrl?: string
wikiUrl?: string
issuesUrl?: string
sourceUrl?: string
}
}
export interface CurseForgeCategory {
id: number
gameId: number
name: string
slug: string
url: string
iconUrl?: string
dateModified: string
isClass?: boolean | null
classId?: number
parentCategoryId?: number
displayIndex?: number
}
export interface CurseForgeFile {
id: number
modId: number
isAvailable: boolean
displayName: string
fileName: string
releaseType: number
fileDate: string
fileLength: number
hashes: Array<{ value: string; algo: number }>
fileFingerprint: number
downloadCount: number
downloadUrl?: string
gameVersions: string[]
dependencies: Array<{ modId: number; relationType: number }>
}
export function hasCompatibleCurseForgeFile(files: CurseForgeFile[], gameVersion: string) {
return files.some((file) => file.isAvailable && file.gameVersions.includes(gameVersion))
}
export function getCurseForgeImageUrl(source?: string | null, width = 256): string | undefined {
if (!source) return undefined
try {
const url = new URL(source)
if (url.protocol !== 'https:' || !url.hostname.endsWith('forgecdn.net')) return source
const proxy = new URL('https://images.weserv.nl/')
proxy.searchParams.set('url', source)
proxy.searchParams.set('w', String(width))
proxy.searchParams.set('fit', 'contain')
proxy.searchParams.set('output', 'webp')
return proxy.toString()
} catch {
return source
}
}
export interface CurseForgeFilesResponse {
files: CurseForgeFile[]
pagination: {
index: number
pageSize: number
resultCount: number
totalCount: number
}
}
export interface CurseForgeInstallRequest {
instanceId: string
projectId: number
fileId: number
projectType: string
ownershipKind?: 'pack_managed' | 'user_added'
manualOperationKind?: 'pack_install' | 'pack_update' | 'content_install' | 'content_update'
gameVersion?: string
modLoaderType?: number
worldName?: string
installDependencies?: boolean
excludedDependencyProjectIds?: number[]
forceDependencyProjectIds?: number[]
dependencyPlanId?: string
}
export interface CurseForgeWorldInstallRequest {
instanceId: string
projectId: number
fileId: number
}
export interface CurseForgeInstallResult {
installed: Array<{
projectId: number
fileId: number
relativePath: string
dependency: boolean
}>
manualDownloads: Array<{
projectId: number
fileId: number
fileName: string
ownershipKind: 'pack_managed' | 'user_added'
operationKind: 'pack_install' | 'pack_update' | 'content_install' | 'content_update'
websiteUrl?: string
projectType: string
projectSlug: string
targetFolder: string
hashes: Array<{ value: string; algo: number }>
fileLength: number
fileFingerprint: number
}>
failedDownloads: Array<{
projectId: number
fileId: number
fileName: string
reason: string
}>
optionalDependencies: number[]
incompatibleDependencies: number[]
skippedDependencies?: Array<{
projectId: number
fileId: number | null
reason: string
}>
}
export interface CurseForgeInstallPreview {
planId: string
primary: {
projectId: number
fileId: number
title: string
versionNumber: string
fileName: string
size: number
requiredByProjectIds: number[]
iconUrl?: string | null
}
dependencies: Array<{
projectId: number
fileId: number
title: string
versionNumber: string
fileName: string
size: number
requiredByProjectIds: number[]
iconUrl?: string | null
versionMismatch?: boolean
selectionReason?: 'native_strict_match' | 'sha1_verified_modrinth_fallback'
required?: boolean
}>
modrinthFallbacks?: Array<{
projectId: string
versionId: string
title: string
versionNumber: string
parentProjectId: number
iconUrl?: string | null
required?: boolean
}>
skipped: Array<{
projectId: number
fileId: number | null
reason: string
}>
optionalDependencies: number[]
incompatibleDependencies: number[]
}
export interface CurseForgeManualDownloadImport {
projectId: number
fileId: number
relativePath: string
}
export type CurseForgeManualDownloadImportErrorKind =
| 'not_pending'
| 'verification_failed'
| 'other'
export interface CurseForgeManualDownloadScanResult {
downloadDirectory?: string | null
imported: CurseForgeManualDownloadImport[]
errors: Array<{
projectId: number
fileId: number
message: string
}>
}
export interface CurseForgeModpackInstallResult {
content: CurseForgeInstallResult
overridesWritten: number
minecraftVersion: string
loader?: string
}
export function summarizeCurseForgeInstall(result: CurseForgeInstallResult) {
const installed = result.installed?.length ?? 0
const manual = result.manualDownloads?.length ?? 0
const failed = result.failedDownloads?.length ?? 0
const optional = result.optionalDependencies?.length ?? 0
const incompatible = result.incompatibleDependencies?.length ?? 0
return { installed, manual, failed, optional, incompatible }
}
function getErrorMessage(error: unknown): string | null {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
if (typeof error !== 'object' || error === null || !('message' in error)) return null
return String(error.message)
}
export function classifyCurseForgeManualDownloadImportError(
error: unknown,
): CurseForgeManualDownloadImportErrorKind {
const message = getErrorMessage(error)
if (message?.includes('The selected CurseForge file is not pending for this instance')) {
return 'not_pending'
}
if (message?.includes('The selected file does not match the required CurseForge file')) {
return 'verification_failed'
}
return 'other'
}
export function getCurseForgeDownloadFailureDetails(error: unknown): string | null {
const message = getErrorMessage(error)
if (!message) return null
const normalized = message.toLowerCase()
const isDownloadFailure = normalized.includes('download failed after')
const isCurseForge =
normalized.includes('curseforge') ||
normalized.includes('forgecdn.net') ||
normalized.includes('forgecdn')
return isDownloadFailure && isCurseForge ? message : null
}
export function getCurseForgeCapability() {
return invoke<CurseForgeCapability>('plugin:curseforge|curseforge_capability')
}
export function validateCurseForgeCredentials() {
return invoke<CurseForgeCapability>('plugin:curseforge|curseforge_validate_credentials')
}
export function searchCurseForgeProjects(request: CurseForgeSearchRequest, requestId?: string) {
return invoke<UnifiedSearchResponse>('plugin:curseforge|curseforge_search_projects', {
request,
requestId,
})
}
export function getCurseForgeProject(projectId: number) {
return invoke<CurseForgeProject>('plugin:curseforge|curseforge_get_project', { projectId })
}
export function getCurseForgeProjects(projectIds: number[], cacheBehaviour?: CacheBehaviour) {
return invoke<CurseForgeProject[]>('plugin:curseforge|curseforge_get_projects', {
projectIds,
cacheBehaviour,
})
}
export function getCurseForgeChangelog(projectId: number, fileId: number) {
return invoke<string>('plugin:curseforge|curseforge_get_changelog', {
projectId,
fileId,
})
}
export function getCurseForgeDescription(projectId: number) {
return invoke<string>('plugin:curseforge|curseforge_get_description', {
projectId,
})
}
export function getCurseForgeFiles(projectId: number, request: CurseForgeFilesRequest) {
return invoke<CurseForgeFilesResponse>('plugin:curseforge|curseforge_get_files', {
projectId,
request,
})
}
export function getCurseForgeFile(projectId: number, fileId: number) {
return invoke<CurseForgeFile>('plugin:curseforge|curseforge_get_file', {
projectId,
fileId,
})
}
export function getCurseForgeDownloadUrl(projectId: number, fileId: number) {
return invoke<string | null>('plugin:curseforge|curseforge_get_download_url', {
projectId,
fileId,
})
}
export function getCurseForgeCategories(classId?: number) {
return invoke<CurseForgeCategory[]>('plugin:curseforge|curseforge_get_categories', { classId })
}
export function installCurseForgeFile(request: CurseForgeInstallRequest) {
return invoke<CurseForgeInstallResult>('plugin:curseforge|curseforge_install_file', { request })
}
export function previewCurseForgeFile(request: CurseForgeInstallRequest) {
return invoke<CurseForgeInstallPreview>('plugin:curseforge|curseforge_preview_install_file', {
request,
})
}
export function queueCurseForgeFile(
request: CurseForgeInstallRequest,
display: { title: string; iconUrl?: string | null },
) {
return invoke<InstallJobSnapshot>('plugin:instance|instance_queue_curseforge_content', {
request,
displayTitle: display.title,
displayIcon: display.iconUrl ?? null,
})
}
export function queueCurseForgeWorld(
request: CurseForgeWorldInstallRequest,
display: { title: string; iconUrl?: string | null },
) {
return invoke<InstallJobSnapshot>('plugin:instance|instance_queue_curseforge_world', {
request,
displayTitle: display.title,
displayIcon: display.iconUrl ?? null,
})
}
export function updateCurseForgeFile(instanceId: string, relativePath: string) {
return invoke<CurseForgeInstallResult>('plugin:curseforge|curseforge_update_installed_file', {
instanceId,
relativePath,
})
}
export function switchCurseForgeFileVersion(
instanceId: string,
relativePath: string,
fileId: number,
) {
return invoke<CurseForgeInstallResult>(
'plugin:curseforge|curseforge_switch_installed_file_version',
{
instanceId,
relativePath,
fileId,
},
)
}
export function recognizeCurseForgeFiles(instanceId: string) {
return invoke<{
scanned: number
matched: number
linked: CurseForgeInstallResult['installed']
unmatchedPaths: string[]
}>('plugin:curseforge|curseforge_recognize_instance_files', { instanceId })
}
export function importCurseForgeManualDownloads(instanceId: string, scanDirectory?: string | null) {
return invoke<CurseForgeManualDownloadScanResult>(
'plugin:curseforge|curseforge_import_manual_downloads',
{ instanceId, scanDirectory: scanDirectory ?? null },
)
}
export function listPendingCurseForgeManualDownloads(instanceId: string) {
return invoke<CurseForgeInstallResult['manualDownloads']>(
'plugin:curseforge|curseforge_list_pending_manual_downloads',
{ instanceId },
)
}
export function importPendingCurseForgeManualDownloadFile(
instanceId: string,
projectId: number,
fileId: number,
sourcePath: string,
) {
return invoke<CurseForgeManualDownloadImport>(
'plugin:curseforge|curseforge_import_pending_manual_download_file',
{ instanceId, projectId, fileId, sourcePath },
)
}
export function configureCurseForgeManualDownloadWatcher(
enabled: boolean,
scanDirectory?: string | null,
) {
return invoke<string | null>('plugin:curseforge|curseforge_configure_manual_download_watcher', {
enabled,
scanDirectory: scanDirectory ?? null,
})
}
export function installCurseForgeModpack(request: {
instanceId: string
projectId: number
fileId: number
installOptional?: boolean
}) {
return invoke<CurseForgeModpackInstallResult>('plugin:curseforge|curseforge_install_modpack', {
request,
})
}
export function updateManagedCurseForgeModpack(instanceId: string, fileId: number) {
return invoke<InstallJobSnapshot>('plugin:curseforge|curseforge_update_managed_modpack', {
instanceId,
fileId,
})
}

View File

@ -0,0 +1,52 @@
import { invoke } from '@tauri-apps/api/core'
import type { SingleplayerWorld } from '@/helpers/worlds'
export type DatapackKind = 'folder' | 'zip'
export type WorldDatapack = {
file_name: string
display_name: string
kind: DatapackKind
pack_format?: number
supported_formats?: number[]
description?: unknown
icon?: string
enabled?: boolean
size: number
modified?: string
}
export type WorldWithDatapacks = SingleplayerWorld & {
datapacks: WorldDatapack[]
}
export async function listDatapacks(instanceId: string): Promise<WorldWithDatapacks[]> {
return await invoke('plugin:datapacks|list_datapacks', { instanceId })
}
export async function deleteDatapack(
instanceId: string,
worldPath: string,
fileName: string,
): Promise<void> {
return await invoke('plugin:datapacks|delete_datapack', {
instanceId,
worldPath,
fileName,
})
}
export async function setDatapackEnabled(
instanceId: string,
worldPath: string,
fileName: string,
enabled: boolean,
): Promise<void> {
return await invoke('plugin:datapacks|set_datapack_enabled', {
instanceId,
worldPath,
fileName,
enabled,
})
}

View File

@ -0,0 +1,37 @@
import {
type DirectLinkSyncReport,
type ExternalMinecraftRoot,
sync_direct_links,
} from './instance'
export const DIRECT_LINKS_SYNCED_EVENT = 'axolotl-direct-links-synced'
let requestedRoots: ExternalMinecraftRoot[] = []
let syncWorker: Promise<void> | undefined
let syncPending = false
/**
* Serializes direct-link reconciliation across Settings, routing, and focus
* events. Each request records a fresh snapshot; changes received during an
* in-flight reconciliation always run immediately afterwards.
*/
export function syncConfiguredDirectLinks(roots: readonly ExternalMinecraftRoot[]): Promise<void> {
requestedRoots = roots.map((root) => ({ ...root }))
syncPending = true
if (!syncWorker) {
syncWorker = drainSyncRequests().finally(() => {
syncWorker = undefined
})
}
return syncWorker
}
async function drainSyncRequests() {
while (syncPending) {
syncPending = false
const report = await sync_direct_links(requestedRoots)
window.dispatchEvent(
new CustomEvent<DirectLinkSyncReport>(DIRECT_LINKS_SYNCED_EVENT, { detail: report }),
)
}
}

View File

@ -0,0 +1,265 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createDownloadsScanLoop,
createDownloadsScannerPresentationState,
getMissingContentScannerSettings,
reduceDownloadsScannerPresentation,
setMissingContentScannerSettings,
} from './downloads-scanner.ts'
function memoryStorage(initial?: string) {
let value = initial ?? null
return {
getItem: () => value,
setItem: (_key: string, next: string) => {
value = next
},
}
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((complete) => {
resolve = complete
})
return { promise, resolve }
}
test('close and reopen ignores stale scan results', async () => {
const first = deferred<string>()
const second = deferred<string>()
const results: string[] = []
let scanCount = 0
const loop = createDownloadsScanLoop({
scan: () => (scanCount++ === 0 ? first.promise : second.promise),
onResult: (result) => results.push(result),
schedule: () => undefined,
cancelSchedule: () => undefined,
})
loop.start()
const staleRun = loop.runNow()
loop.stop()
loop.start()
first.resolve('stale')
await staleRun
assert.deepEqual(results, [])
const currentRun = loop.runNow()
second.resolve('current')
await currentRun
assert.deepEqual(results, ['current'])
loop.stop()
})
test('one scan stays in flight at a time', async () => {
const pending = deferred<string>()
let calls = 0
const loop = createDownloadsScanLoop({
scan: () => {
calls += 1
return pending.promise
},
onResult: () => undefined,
schedule: () => undefined,
cancelSchedule: () => undefined,
})
loop.start()
const firstRun = loop.runNow()
await loop.runNow()
assert.equal(calls, 1)
pending.resolve('done')
await firstRun
loop.stop()
})
test('empty scan activity keeps monitoring presentation stable', async () => {
let state = createDownloadsScannerPresentationState()
state = reduceDownloadsScannerPresentation(state, {
type: 'scan_result',
downloadDirectory: 'C:\\Downloads',
importedItemIds: [],
rejectedItemIds: [],
pendingCandidates: 0,
hasErrors: false,
items: [],
})
const scan = deferred<string>()
const observedPhases: string[] = []
const loop = createDownloadsScanLoop({
scan: () => scan.promise,
onResult: () => {
state = reduceDownloadsScannerPresentation(state, {
type: 'scan_result',
downloadDirectory: 'C:\\Downloads',
importedItemIds: [],
rejectedItemIds: [],
pendingCandidates: 0,
hasErrors: false,
items: [],
})
},
onScanningChange: () => observedPhases.push(state.phase),
schedule: () => undefined,
cancelSchedule: () => undefined,
})
loop.start()
const running = loop.runNow()
assert.equal(state.phase, 'monitoring')
scan.resolve('done')
await running
assert.equal(state.phase, 'monitoring')
assert.ok(observedPhases.every((phase) => phase === 'monitoring'))
loop.stop()
})
test('empty interval scan keeps an unchanged same-name rejection visible', () => {
let state = createDownloadsScannerPresentationState()
state = reduceDownloadsScannerPresentation(state, {
type: 'scan_result',
downloadDirectory: 'C:\\Downloads',
importedItemIds: [],
rejectedItemIds: ['mods/example.jar'],
pendingCandidates: 0,
hasErrors: false,
items: [],
})
assert.equal(state.phase, 'rejected')
state = reduceDownloadsScannerPresentation(state, {
type: 'scan_result',
downloadDirectory: 'C:\\Downloads',
importedItemIds: [],
rejectedItemIds: ['mods/example.jar'],
pendingCandidates: 0,
hasErrors: false,
items: [],
})
assert.equal(state.phase, 'rejected')
assert.deepEqual(state.rejectedItemIds, ['mods/example.jar'])
})
test('candidate progresses from stability wait through verification and import', () => {
let state = createDownloadsScannerPresentationState()
state = reduceDownloadsScannerPresentation(state, {
type: 'scan_result',
downloadDirectory: 'C:\\Downloads',
importedItemIds: [],
rejectedItemIds: [],
pendingCandidates: 1,
hasErrors: false,
items: [],
})
assert.equal(state.phase, 'waiting_for_stability')
assert.deepEqual(state.rejectedItemIds, [])
state = reduceDownloadsScannerPresentation(state, {
type: 'items_updated',
items: [{ id: 'mods/example.jar', status: 'verifying' }],
})
assert.equal(state.phase, 'verifying')
assert.deepEqual(state.verifyingItemIds, ['mods/example.jar'])
state = reduceDownloadsScannerPresentation(state, {
type: 'items_updated',
items: [{ id: 'mods/example.jar', status: 'writing' }],
})
assert.equal(state.phase, 'importing')
state = reduceDownloadsScannerPresentation(state, {
type: 'scan_result',
downloadDirectory: 'C:\\Downloads',
importedItemIds: ['mods/example.jar'],
rejectedItemIds: [],
pendingCandidates: 0,
hasErrors: false,
items: [],
})
assert.equal(state.phase, 'imported')
assert.deepEqual(state.rejectedItemIds, [])
assert.deepEqual(state.verifyingItemIds, [])
assert.equal(state.importedCount, 1)
})
test('presentation keeps multiple concurrent candidate verifications visible', () => {
let state = createDownloadsScannerPresentationState()
state = reduceDownloadsScannerPresentation(state, {
type: 'items_updated',
items: [
{ id: 'mods/one.jar', status: 'verifying' },
{ id: 'mods/two.jar', status: 'verifying' },
],
})
assert.equal(state.phase, 'verifying')
assert.deepEqual(state.verifyingItemIds, ['mods/one.jar', 'mods/two.jar'])
})
test('rejected candidate disappearance returns presentation to monitoring', () => {
let state = createDownloadsScannerPresentationState()
state = reduceDownloadsScannerPresentation(state, {
type: 'scan_result',
downloadDirectory: 'C:\\Downloads',
importedItemIds: [],
rejectedItemIds: ['mods/example.jar'],
pendingCandidates: 0,
hasErrors: false,
items: [],
})
assert.equal(state.phase, 'rejected')
state = reduceDownloadsScannerPresentation(state, {
type: 'scan_result',
downloadDirectory: 'C:\\Downloads',
importedItemIds: [],
rejectedItemIds: [],
pendingCandidates: 0,
hasErrors: false,
items: [],
})
assert.equal(state.phase, 'monitoring')
assert.deepEqual(state.rejectedItemIds, [])
})
test('presentation reset drops previous modal session result', () => {
let state = createDownloadsScannerPresentationState()
state = reduceDownloadsScannerPresentation(state, {
type: 'scan_result',
downloadDirectory: 'C:\\Downloads',
importedItemIds: [],
rejectedItemIds: ['mods/example.jar'],
pendingCandidates: 0,
hasErrors: false,
items: [],
})
state = reduceDownloadsScannerPresentation(state, { type: 'reset' })
assert.equal(state.phase, 'idle')
assert.equal(state.downloadDirectory, null)
assert.deepEqual(state.rejectedItemIds, [])
})
test('missing-content scanner settings default enabled and persist a custom folder', () => {
const storage = memoryStorage()
assert.deepEqual(getMissingContentScannerSettings(storage), {
enabled: true,
directory: null,
})
setMissingContentScannerSettings({ enabled: false, directory: 'D:\\Modpack Imports' }, storage)
assert.deepEqual(getMissingContentScannerSettings(storage), {
enabled: false,
directory: 'D:\\Modpack Imports',
})
})
test('invalid scanner settings fall back safely', () => {
const storage = memoryStorage('{not-json')
assert.deepEqual(getMissingContentScannerSettings(storage), {
enabled: true,
directory: null,
})
})

View File

@ -0,0 +1,252 @@
export interface DownloadsScanLoopOptions<T> {
scan: () => Promise<T>
onResult: (result: T) => void
onError?: (error: unknown) => void
onScanningChange?: (scanning: boolean) => void
intervalMs?: number
schedule?: (callback: () => void, delay: number) => unknown
cancelSchedule?: (timer: unknown) => void
}
export interface MissingContentScannerSettings {
enabled: boolean
directory: string | null
}
type ScannerSettingsStorage = Pick<Storage, 'getItem' | 'setItem'>
const MISSING_CONTENT_SCANNER_SETTINGS_KEY = 'axolotl-missing-content-scanner'
export function getMissingContentScannerSettings(
storage: ScannerSettingsStorage = localStorage,
): MissingContentScannerSettings {
try {
const parsed = JSON.parse(storage.getItem(MISSING_CONTENT_SCANNER_SETTINGS_KEY) ?? '{}')
return {
enabled: parsed.enabled !== false,
directory:
typeof parsed.directory === 'string' && parsed.directory.trim() ? parsed.directory : null,
}
} catch {
return { enabled: true, directory: null }
}
}
export function setMissingContentScannerSettings(
settings: MissingContentScannerSettings,
storage: ScannerSettingsStorage = localStorage,
) {
storage.setItem(
MISSING_CONTENT_SCANNER_SETTINGS_KEY,
JSON.stringify({
enabled: settings.enabled,
directory: settings.directory?.trim() || null,
}),
)
}
export type DownloadsScannerPresentationPhase =
| 'idle'
| 'monitoring'
| 'importing'
| 'verifying'
| 'waiting_for_stability'
| 'rejected'
| 'imported'
| 'error'
| 'unavailable'
export interface DownloadsScannerPresentationState {
phase: DownloadsScannerPresentationPhase
downloadDirectory: string | null
importedCount: number
pendingCandidates: number
importingItemIds: string[]
rejectedItemIds: string[]
verifyingItemIds: string[]
}
export type DownloadsScannerPresentationEvent =
| { type: 'reset' }
| { type: 'scan_failed' }
| {
type: 'items_updated'
items: Array<{ id: string; status: string }>
}
| {
type: 'scan_result'
downloadDirectory: string | null
importedItemIds: string[]
rejectedItemIds: string[]
pendingCandidates: number
hasErrors: boolean
items: Array<{ id: string; status: string }>
}
| { type: 'items_resolved'; itemIds: string[] }
export function createDownloadsScannerPresentationState(): DownloadsScannerPresentationState {
return {
phase: 'idle',
downloadDirectory: null,
importedCount: 0,
pendingCandidates: 0,
importingItemIds: [],
rejectedItemIds: [],
verifyingItemIds: [],
}
}
function itemIdsWithStatus(items: Array<{ id: string; status: string }>, status: string) {
return items.filter((item) => item.status === status).map((item) => item.id)
}
function withoutItemIds(current: string[], removed: string[]) {
const removedSet = new Set(removed)
return current.filter((itemId) => !removedSet.has(itemId))
}
function withPhase(
state: Omit<DownloadsScannerPresentationState, 'phase'>,
options: { failed?: boolean } = {},
): DownloadsScannerPresentationState {
let phase: DownloadsScannerPresentationPhase
if (state.importingItemIds.length > 0) phase = 'importing'
else if (state.verifyingItemIds.length > 0) phase = 'verifying'
else if (state.pendingCandidates > 0) phase = 'waiting_for_stability'
else if (state.rejectedItemIds.length > 0) phase = 'rejected'
else if (state.importedCount > 0) phase = 'imported'
else if (options.failed) phase = 'error'
else if (state.downloadDirectory) phase = 'monitoring'
else phase = 'unavailable'
return { ...state, phase }
}
export function reduceDownloadsScannerPresentation(
state: DownloadsScannerPresentationState,
event: DownloadsScannerPresentationEvent,
): DownloadsScannerPresentationState {
if (event.type === 'reset') return createDownloadsScannerPresentationState()
const current = {
downloadDirectory: state.downloadDirectory,
importedCount: state.importedCount,
pendingCandidates: state.pendingCandidates,
importingItemIds: [...state.importingItemIds],
rejectedItemIds: [...state.rejectedItemIds],
verifyingItemIds: [...state.verifyingItemIds],
}
if (event.type === 'scan_failed') {
if (
state.importingItemIds.length > 0 ||
state.verifyingItemIds.length > 0 ||
state.pendingCandidates > 0 ||
state.rejectedItemIds.length > 0 ||
state.importedCount > 0
) {
return state
}
return withPhase(current, { failed: true })
}
if (event.type === 'items_updated') {
const importingItemIds = itemIdsWithStatus(event.items, 'writing')
const verifyingItemIds = itemIdsWithStatus(event.items, 'verifying')
if (importingItemIds.length === 0 && verifyingItemIds.length === 0) return state
current.importingItemIds = importingItemIds
current.verifyingItemIds = verifyingItemIds
current.rejectedItemIds = withoutItemIds(current.rejectedItemIds, [
...importingItemIds,
...verifyingItemIds,
])
return withPhase(current)
}
if (event.type === 'items_resolved') {
current.pendingCandidates = 0
current.rejectedItemIds = withoutItemIds(current.rejectedItemIds, event.itemIds)
current.importingItemIds = withoutItemIds(current.importingItemIds, event.itemIds)
current.verifyingItemIds = withoutItemIds(current.verifyingItemIds, event.itemIds)
return withPhase(current)
}
current.downloadDirectory = event.downloadDirectory
current.pendingCandidates = event.pendingCandidates
current.importingItemIds = itemIdsWithStatus(event.items, 'writing')
current.verifyingItemIds = itemIdsWithStatus(event.items, 'verifying')
current.rejectedItemIds = withoutItemIds(event.rejectedItemIds, [
...event.importedItemIds,
...current.importingItemIds,
...current.verifyingItemIds,
])
current.importedCount += event.importedItemIds.length
return withPhase(current, { failed: event.hasErrors })
}
export function createDownloadsScanLoop<T>(options: DownloadsScanLoopOptions<T>) {
const schedule = options.schedule ?? ((callback, delay) => setTimeout(callback, delay))
const cancelSchedule = options.cancelSchedule ?? ((timer) => clearTimeout(timer as number))
const intervalMs = options.intervalMs ?? 3000
let active = false
let generation = 0
let inFlight = false
let timer: unknown
function clearTimer() {
if (timer != null) cancelSchedule(timer)
timer = undefined
}
function scheduleNext(delay: number) {
if (!active) return
clearTimer()
timer = schedule(() => {
timer = undefined
void runNow()
}, delay)
}
function start() {
stop()
active = true
generation += 1
scheduleNext(0)
}
function stop() {
active = false
generation += 1
clearTimer()
options.onScanningChange?.(false)
}
async function runNow() {
if (!active) return
if (inFlight) {
scheduleNext(100)
return
}
const runGeneration = generation
inFlight = true
options.onScanningChange?.(true)
try {
const result = await options.scan()
if (active && runGeneration === generation) options.onResult(result)
} catch (error) {
if (active && runGeneration === generation) options.onError?.(error)
} finally {
inFlight = false
if (active && runGeneration === generation) {
options.onScanningChange?.(false)
scheduleNext(intervalMs)
}
}
}
return {
start,
stop,
runNow,
isActive: () => active,
}
}

View File

@ -0,0 +1,185 @@
/**
* Bridge helpers for the drop classification Tauri commands.
*
* These wrap the Tauri `invoke()` calls so the frontend can classify
* dropped files, scan launcher instances, and detect file locks.
*/
import type { ClassificationResult } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
/**
* Information about a process that has a file handle open.
*/
export interface LockingProcess {
pid: number
name: string
path: string
start_time: string | null
}
/**
* Classify a dropped file or folder by its path on disk.
*
* @param path Absolute filesystem path to the dropped item
* @returns Classification result indicating what kind of content it is
*/
export function classifyDroppedItem(
path: string,
allowNestedExtraction = false,
): Promise<ClassificationResult> {
return invoke('plugin:drop|drop_classify', { path, allowNestedExtraction })
}
/**
* Classify a dropped ZIP file by extracting it to a temporary directory first.
*
* This is a potentially **long-running** operation — the UI MUST prompt the
* user before calling this, since extraction can take significant time for
* large archives.
*
* Only call this when [`classifyDroppedItem`] returned `Unknown` with a reason
* containing "extraction".
*
* @param path Absolute filesystem path to the ZIP file
* @returns Classification result after extraction and analysis
*/
export function classifyDroppedItemWithExtraction(path: string): Promise<ClassificationResult> {
return invoke('plugin:drop|drop_classify_extract', { path })
}
/**
* Extract a ZIP archive into a fresh temporary directory and return its path.
*
* Used for compressed launcher folders (e.g. a zipped `.minecraft`): the
* instance scan and import then operate on the extraction, so the archive is
* unpacked exactly once. Call [`removeTempDir`] when the flow finishes.
*
* @param zipPath Absolute path to the ZIP archive
* @returns Absolute path of the extraction directory
*/
export function extractZipToTemp(zipPath: string): Promise<string> {
return invoke('plugin:drop|drop_extract_zip_to_temp', { zipPath })
}
/**
* Remove a temporary directory created by [`extractZipToTemp`].
*
* @param path Absolute path of the extraction directory
*/
export function removeTempDir(path: string): Promise<void> {
return invoke('plugin:drop|drop_remove_temp_dir', { path })
}
/**
* Metadata about a single importable instance within a launcher.
*/
export interface ScanInstance {
name: string
/** Resolved filesystem path (informational — the backend resolves it) */
path: string
/** Minecraft version, if known (empty string otherwise) */
version: string
/** Mod loader, if known (e.g. "fabric", "forge"; empty string otherwise) */
loader: string
/** Whether this instance qualifies for compatible mode import */
compatibleMode?: boolean
/** For compatible mode: path to the version subfolder containing the JSON */
versionPath?: string
}
/**
* Result of scanning a single launcher type for importable instances.
*/
export interface ScanResult {
launcherName: string
launcherType: string
instances: ScanInstance[]
}
/**
* Scan a launcher's data directory for importable Minecraft instances.
*
* @param launcherType Launcher type name (e.g. "MultiMC", "PrismLauncher", "HMCL")
* @param basePath Root directory of the launcher's data
* @returns List of scan results (one entry per launcher type)
*/
export async function scanLauncherInstances(
launcherType: string,
basePath: string,
): Promise<ScanResult[]> {
const instances: {
name: string
path: string
compatibleMode?: boolean
versionPath?: string
}[] = await invoke('plugin:drop|drop_scan_launcher_instances', {
launcherType,
basePath,
})
return [
{
launcherName: launcherType,
launcherType,
instances: instances.map((inst) => ({
name: inst.name,
path: inst.path,
version: '',
loader: '',
compatibleMode: inst.compatibleMode,
versionPath: inst.versionPath,
})),
},
]
}
/**
* Detect processes holding a file lock on the given path.
*
* @param path Absolute path to the file to check
* @returns List of locking processes (empty if unavailable or none found)
*/
export function detectFileLock(path: string): Promise<LockingProcess[]> {
return invoke('plugin:drop|drop_detect_file_lock', { path })
}
/**
* Extract mod metadata from a JAR file without installing it.
*
* @param path Absolute path to the JAR file
* @returns JSON string of LocalModMetadata, or null if no metadata found
*/
export function extractModMetadata(path: string): Promise<string | null> {
return invoke('plugin:drop|drop_extract_mod_metadata', { path })
}
/**
* Metadata extracted from a mod JAR file.
*/
export interface LocalModMetadata {
mod_id: string
name?: string
version?: string
authors?: string[]
description?: string
url?: string
icon_path?: string
minecraft_version?: string
loader_version?: string
loader?: string
}
export interface ModrinthLookupResult {
hash: string
project_id: string
version_id: string
project_name?: string
project_slug?: string
version_number?: string
game_versions: string[]
loaders: string[]
}
export async function lookupModHash(path: string): Promise<ModrinthLookupResult | null> {
return invoke('plugin:drop|drop_lookup_mod_hash', { path })
}

View File

@ -0,0 +1,171 @@
/*
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
crashed: whether a finished process exited unexpectedly
}
*/
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))
}
export async function import_plan_listener(callback) {
return await listen('import_plan', (event) => callback(event.payload))
}
export async function drop_classify_progress_listener(callback) {
return await listen('drop_classify_progress', (event) => callback(event.payload))
}
export async function download_request_listener(callback) {
return await listen('download_request', (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))
}
/// Payload for the 'java_discovery_update' event
/*
JavaDiscoveryPayload {
count: number of Java installations found by the background rescan
}
*/
export async function java_discovery_listener(callback) {
return await listen('java_discovery_update', (event) => callback(event.payload))
}
/// Payload for the 'java_download_confirmation' event
/*
JavaDownloadConfirmationPayload {
requestId: unique identifier for the confirmation request
version: required Java major version
}
*/
export async function java_download_confirmation_listener(callback) {
return await listen('java_download_confirmation', (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))
}

View File

@ -0,0 +1,79 @@
import type { User } from '@modrinth/utils'
import { invoke } from '@tauri-apps/api/core'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { get_user_many } from '@/helpers/cache'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
export type UserStatus = {
user_id: string
instance_name: string | null
last_update: string
}
export type UserFriend = {
id: string
friend_id: string
accepted: boolean
created: string
}
export async function friends(): Promise<UserFriend[]> {
return await invoke('plugin:friends|friends')
}
export async function friend_statuses(): Promise<UserStatus[]> {
return await invoke('plugin:friends|friend_statuses')
}
export async function add_friend(userId: string): Promise<void> {
return await invoke('plugin:friends|add_friend', { userId })
}
export async function remove_friend(userId: string): Promise<void> {
return await invoke('plugin:friends|remove_friend', { userId })
}
export type FriendWithUserData = {
id: string
friend_id: string | null
status: string | null
last_updated: Dayjs | null
created: Dayjs
username: string
accepted: boolean
online: boolean
avatar: string
}
export async function transformFriends(
friends: UserFriend[],
credentials: ModrinthCredentials | null,
): Promise<FriendWithUserData[]> {
if (friends.length === 0 || !credentials) {
return []
}
const friendStatuses = await friend_statuses()
const users = await get_user_many(
friends.map((x) => (x.id === credentials.user_id ? x.friend_id : x.id)),
)
return friends.map((friend) => {
const user = users.find((x: User) => x.id === friend.id || x.id === friend.friend_id)
const status = friendStatuses.find(
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
)
return {
id: friend.id,
friend_id: friend.friend_id,
status: status?.profile_name ?? null,
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
created: dayjs(friend.created),
avatar: user?.avatar_url ?? '',
username: user?.username ?? '',
online: !!status,
accepted: friend.accepted,
}
})
}

View File

@ -0,0 +1,13 @@
import { ref } from 'vue'
import type { GcLaunchReport } from '@/helpers/instance'
/**
* The GC launch report from the most recent launch, so settings pages can show
* what strategy the JVM actually accepted (and any fallback that happened).
*/
export const lastGcLaunchReport = ref<GcLaunchReport | null>(null)
export function setLastGcLaunchReport(report: GcLaunchReport | null) {
lastGcLaunchReport.value = report
}

View File

@ -0,0 +1,144 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveAutoGcStrategy } from './auto-selector.ts'
import type { GcContext } from './types.ts'
function createContext(overrides: Partial<GcContext> = {}): GcContext {
return {
javaMajorVersion: 21,
allocatedMemoryMb: 8192,
systemCpuCores: 8,
systemLogicalProcessors: 8,
modCount: 50,
loader: 'forge',
...overrides,
}
}
test('hard fallback: unknown Java version falls back to G1GC', () => {
const context = createContext({ javaMajorVersion: null })
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'g1gc-mojang')
assert.ok(result.reasonChain.includes('Java 版本未知'))
})
test('hard fallback: Java < 15 falls back to G1GC', () => {
const context = createContext({ javaMajorVersion: 11 })
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'g1gc-mojang')
assert.ok(result.reasonChain.includes('Java 太旧Shenandoah/ZGC 不可靠'))
})
test('hard fallback: memory < 4GB falls back to G1GC', () => {
const context = createContext({ allocatedMemoryMb: 2048 })
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'g1gc-mojang')
assert.ok(result.reasonChain.some((r) => r.includes('内存不足')))
})
test('hard fallback: insufficient CPU resources falls back to G1GC', () => {
const context = createContext({
systemCpuCores: 4,
systemLogicalProcessors: 4,
})
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'g1gc-mojang')
assert.ok(result.reasonChain.some((r) => r.includes('CPU 资源不足')))
})
test('hard fallback: large modpack with insufficient resources falls back to G1GC', () => {
const context = createContext({
modCount: 200,
allocatedMemoryMb: 6144,
})
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'g1gc-mojang')
assert.ok(result.reasonChain.some((r) => r.includes('大型 ModPack')))
})
test('lightweight vanilla instance selects G1GC', () => {
const context = createContext({
loader: 'vanilla',
modCount: 5,
})
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'g1gc-mojang')
assert.ok(result.reasonChain.some((r) => r.includes('轻量实例')))
})
test('lightweight fabric instance selects G1GC', () => {
const context = createContext({
loader: 'fabric',
modCount: 20,
})
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'g1gc-mojang')
assert.ok(result.reasonChain.some((r) => r.includes('轻量实例')))
})
test('low resources selects G1GC', () => {
const context = createContext({
allocatedMemoryMb: 6143,
systemCpuCores: 6,
})
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'g1gc-mojang')
assert.ok(result.reasonChain.some((r) => r.includes('资源低')))
})
test('medium resources selects Shenandoah', () => {
const context = createContext({
allocatedMemoryMb: 8192,
systemCpuCores: 8,
})
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'shenandoah')
assert.ok(result.reasonChain.some((r) => r.includes('资源中')))
})
test('high resources with Java < 21 selects Shenandoah', () => {
const context = createContext({
javaMajorVersion: 17,
allocatedMemoryMb: 16384,
systemCpuCores: 16,
})
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'shenandoah')
assert.ok(result.reasonChain.some((r) => r.includes('Java < 21')))
})
test('high resources with Java >= 21 selects ZGC', () => {
const context = createContext({
javaMajorVersion: 21,
allocatedMemoryMb: 16384,
systemCpuCores: 16,
})
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'zgc')
assert.ok(result.reasonChain.some((r) => r.includes('内存充足且 CPU 核心数高')))
})
test('Java 21 with insufficient resources for ZGC selects Shenandoah', () => {
const context = createContext({
javaMajorVersion: 21,
allocatedMemoryMb: 10240,
systemCpuCores: 12,
})
const result = resolveAutoGcStrategy(context)
assert.equal(result.resolvedStrategy, 'shenandoah')
assert.ok(result.reasonChain.some((r) => r.includes('资源未达到 ZGC 推荐配置')))
})
test('reason chain contains all decision nodes', () => {
const context = createContext({
javaMajorVersion: 21,
allocatedMemoryMb: 16384,
systemCpuCores: 16,
modCount: 100,
loader: 'forge',
})
const result = resolveAutoGcStrategy(context)
assert.ok(result.reasonChain.length > 0)
assert.equal(result.reasonChain[0], 'Java 21')
})

View File

@ -0,0 +1,101 @@
import { GC_STRATEGY_DEFINITIONS } from './strategies.ts'
import type { GcContext, GcResolution, ResolvedGcStrategyId } from './types'
export function resolveAutoGcArgs(context: GcContext): string {
const resolution = resolveAutoGcStrategy(context)
return GC_STRATEGY_DEFINITIONS[resolution.resolvedStrategy].buildArgs(context)
}
export function resolveAutoGcStrategy(context: GcContext): GcResolution {
const reasonChain: string[] = []
const javaVersion = context.javaMajorVersion
if (javaVersion !== null) {
reasonChain.push(`Java ${javaVersion}`)
} else {
reasonChain.push('Java 版本未知')
}
if (javaVersion === null || javaVersion < 15) {
reasonChain.push('Java 太旧Shenandoah/ZGC 不可靠')
return { resolvedStrategy: 'g1gc-mojang', reasonChain }
}
if (context.allocatedMemoryMb < 4096) {
reasonChain.push(`内存不足 (${Math.round(context.allocatedMemoryMb / 1024)}GB < 4GB)`)
return { resolvedStrategy: 'g1gc-mojang', reasonChain }
}
if (context.systemCpuCores <= 4 && context.systemLogicalProcessors <= 8) {
reasonChain.push(
`CPU 资源不足 (${context.systemCpuCores}核/${context.systemLogicalProcessors}线程)`,
)
return { resolvedStrategy: 'g1gc-mojang', reasonChain }
}
if (context.modCount >= 200 && context.allocatedMemoryMb < 8192) {
reasonChain.push(
`大型 ModPack (${context.modCount} mods) 但资源不足 (${Math.round(context.allocatedMemoryMb / 1024)}GB < 8GB)`,
)
return { resolvedStrategy: 'g1gc-mojang', reasonChain }
}
const isLightweight =
(context.loader === 'vanilla' || context.loader === 'fabric' || context.loader === 'quilt') &&
context.modCount < 30
if (isLightweight) {
reasonChain.push(`轻量实例 (${context.loader}, ${context.modCount} mods)`)
return { resolvedStrategy: 'g1gc-mojang', reasonChain }
}
reasonChain.push(`重型实例 (${context.loader}, ${context.modCount} mods)`)
const memoryGb = context.allocatedMemoryMb / 1024
const isResourceLow = context.allocatedMemoryMb < 6144 || context.systemCpuCores <= 6
const isResourceMedium =
!isResourceLow && context.allocatedMemoryMb < 10240 && context.systemCpuCores <= 12
if (isResourceLow) {
reasonChain.push(`资源低 (${Math.round(memoryGb)}GB, ${context.systemCpuCores}核)`)
return { resolvedStrategy: 'g1gc-mojang', reasonChain }
}
if (isResourceMedium) {
reasonChain.push(`资源中 (${Math.round(memoryGb)}GB, ${context.systemCpuCores}核)`)
reasonChain.push('→ Shenandoah')
return { resolvedStrategy: 'shenandoah', reasonChain }
}
reasonChain.push(`资源高 (${Math.round(memoryGb)}GB, ${context.systemCpuCores}核)`)
if (javaVersion < 21) {
reasonChain.push('Java < 21ZGC 非分代模式性能不佳')
reasonChain.push('→ Shenandoah')
return { resolvedStrategy: 'shenandoah', reasonChain }
}
if (context.allocatedMemoryMb >= 10240 && context.systemCpuCores > 12) {
reasonChain.push('内存充足且 CPU 核心数高')
reasonChain.push('→ ZGC')
return { resolvedStrategy: 'zgc', reasonChain }
}
reasonChain.push('资源未达到 ZGC 推荐配置')
reasonChain.push('→ Shenandoah')
return { resolvedStrategy: 'shenandoah', reasonChain }
}
export function getResolvedStrategyName(strategyId: ResolvedGcStrategyId): string {
const names: Record<ResolvedGcStrategyId, string> = {
'g1gc-mojang': 'Mojang G1GC',
pcl: 'PCL',
shenandoah: 'Shenandoah',
zgc: 'ZGC',
}
return names[strategyId]
}
// Re-exported from strategies so callers can keep importing from the
// auto-selector module while the chain stays testable via `node --test`.
export { buildGcCandidateChain } from './strategies.ts'

View File

@ -0,0 +1,43 @@
import type { InstanceLoader } from '@/helpers/types'
import type { GcContext } from './types'
export async function collectGcContext(
allocatedMemoryMb: number,
loader: InstanceLoader | null,
javaMajorVersion?: number | null,
modCount?: number,
): Promise<GcContext> {
const systemCpuCores = navigator.hardwareConcurrency ?? 4
const systemLogicalProcessors = systemCpuCores
return {
javaMajorVersion: javaMajorVersion ?? null,
allocatedMemoryMb,
systemCpuCores,
systemLogicalProcessors,
modCount: modCount ?? 0,
loader: loader ?? 'vanilla',
}
}
export function extractJavaMajorVersion(
parsedVersion: string | number | null | undefined,
): number | null {
if (parsedVersion === null || parsedVersion === undefined) return null
// 如果是数字,直接返回
if (typeof parsedVersion === 'number') {
return Number.isNaN(parsedVersion) ? null : parsedVersion
}
// 如果是字符串,尝试解析
if (typeof parsedVersion === 'string') {
const match = parsedVersion.match(/^(?:1\.)?(\d+)/)
if (!match) return null
const num = parseInt(match[1], 10)
return Number.isNaN(num) ? null : num
}
return null
}

View File

@ -0,0 +1,104 @@
import { defineMessage } from '@modrinth/ui'
import { getResolvedStrategyName, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector'
import { GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategies'
import type { GcContext, JavaArgumentPreset } from '@/helpers/gc/types'
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
const GC_WIKI_URL = 'https://docs.oracle.com/en/java/javase/21/gctuning/introduction.html'
const G1GC_DOCS_URL =
'https://docs.oracle.com/en/java/javase/21/gctuning/garbage-collector-implementation.html'
const SHENANDOAH_DOCS_URL = 'https://wiki.openjdk.org/display/shenandoah/Main'
const ZGC_DOCS_URL = 'https://wiki.openjdk.org/display/zgc/Main'
export function createGcPresets(gcContext?: GcContext): JavaArgumentPreset[] {
const autoResolution = gcContext ? resolveAutoGcStrategy(gcContext) : null
const autoResolvedName = autoResolution
? getResolvedStrategyName(autoResolution.resolvedStrategy)
: 'Mojang G1GC'
return [
{
id: 'gc-auto',
group: 'gc',
title: defineMessage({
id: 'app.java-arguments.presets.gc.auto.title',
defaultMessage: 'Auto',
}),
description: defineMessage({
id: 'app.java-arguments.presets.gc.auto.description',
defaultMessage: 'Automatically select the best GC strategy for your system',
}),
args: AUTO_GC_PRESET_ARG,
resolveArgs: () => AUTO_GC_PRESET_ARG,
detect: (currentArgs) => currentArgs.includes(AUTO_GC_PRESET_ARG),
link: GC_WIKI_URL,
autoResolvedName,
autoReasonChain: autoResolution?.reasonChain,
},
{
id: 'gc-g1gc-mojang',
group: 'gc',
title: defineMessage({
id: 'app.java-arguments.presets.gc.g1gc-mojang.title',
defaultMessage: 'Mojang G1GC',
}),
description: defineMessage({
id: 'app.java-arguments.presets.gc.g1gc-mojang.description',
defaultMessage: 'G1GC tuning from the official Minecraft launcher',
}),
args: GC_STRATEGY_DEFINITIONS['g1gc-mojang'].baseArgs,
resolveArgs: () => GC_STRATEGY_DEFINITIONS['g1gc-mojang'].baseArgs,
detect: GC_STRATEGY_DEFINITIONS['g1gc-mojang'].detect,
link: G1GC_DOCS_URL,
},
{
id: 'gc-pcl',
group: 'gc',
title: defineMessage({
id: 'app.java-arguments.presets.gc.g1gc-pcl.title',
defaultMessage: 'PCL',
}),
description: defineMessage({
id: 'app.java-arguments.presets.gc.g1gc-pcl.description',
defaultMessage: 'Shenandoah (adaptive) tuning used by the PCL launcher',
}),
args: GC_STRATEGY_DEFINITIONS.pcl.baseArgs,
resolveArgs: () => GC_STRATEGY_DEFINITIONS.pcl.baseArgs,
detect: GC_STRATEGY_DEFINITIONS.pcl.detect,
link: SHENANDOAH_DOCS_URL,
},
{
id: 'gc-shenandoah',
group: 'gc',
title: defineMessage({
id: 'app.java-arguments.presets.gc.shenandoah.title',
defaultMessage: 'Shenandoah',
}),
description: defineMessage({
id: 'app.java-arguments.presets.gc.shenandoah.description',
defaultMessage: 'Low-pause adaptive Shenandoah with large pages (if supported)',
}),
args: GC_STRATEGY_DEFINITIONS.shenandoah.baseArgs,
resolveArgs: () => GC_STRATEGY_DEFINITIONS.shenandoah.baseArgs,
detect: GC_STRATEGY_DEFINITIONS.shenandoah.detect,
link: SHENANDOAH_DOCS_URL,
},
{
id: 'gc-zgc',
group: 'gc',
title: defineMessage({
id: 'app.java-arguments.presets.gc.zgc.title',
defaultMessage: 'ZGC',
}),
description: defineMessage({
id: 'app.java-arguments.presets.gc.zgc.description',
defaultMessage: 'Ultra-low latency GC for high-end systems (Java 15+)',
}),
args: GC_STRATEGY_DEFINITIONS.zgc.buildArgs(gcContext),
resolveArgs: (context) => GC_STRATEGY_DEFINITIONS.zgc.buildArgs(context),
detect: GC_STRATEGY_DEFINITIONS.zgc.detect,
link: ZGC_DOCS_URL,
},
]
}

View File

@ -0,0 +1,15 @@
export {
buildGcCandidateChain,
getResolvedStrategyName,
resolveAutoGcStrategy,
} from './auto-selector'
export { collectGcContext } from './context'
export { createGcPresets, getAutoResolution, getResolvedStrategyDisplayName } from './gc-presets'
export { detectGcStrategy, GC_STRATEGY_DEFINITIONS, getStrategyBaseArgs } from './strategies.ts'
export type {
GcContext,
GcResolution,
GcStrategyDefinition,
GcStrategyId,
ResolvedGcStrategyId,
} from './types'

View File

@ -0,0 +1,149 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildGcCandidateChain, detectGcStrategy, GC_STRATEGY_DEFINITIONS } from './strategies.ts'
function createContext(overrides: Partial<Parameters<typeof buildGcCandidateChain>[0]> = {}) {
return {
javaMajorVersion: 21,
allocatedMemoryMb: 16384,
systemCpuCores: 16,
systemLogicalProcessors: 16,
modCount: 100,
loader: 'forge',
...overrides,
}
}
test('Mojang G1GC args include official launcher parameters', () => {
const args = GC_STRATEGY_DEFINITIONS['g1gc-mojang'].buildArgs()
assert.ok(args.includes('-XX:+UseG1GC'))
assert.ok(args.includes('-XX:MaxGCPauseMillis=200'))
assert.ok(args.includes('-XX:G1MixedGCCountTarget=4'))
assert.ok(args.includes('-XX:SurvivorRatio=8'))
assert.ok(!args.includes('-XX:G1UncommitBias=1'))
})
test('PCL args are Shenandoah-adaptive without large pages', () => {
const args = GC_STRATEGY_DEFINITIONS.pcl.buildArgs()
assert.ok(args.includes('-XX:+UseShenandoahGC'))
assert.ok(args.includes('-XX:ShenandoahGCHeuristics=adaptive'))
assert.ok(!args.includes('-XX:+UseLargePages'))
})
test('Shenandoah args include large pages', () => {
const args = GC_STRATEGY_DEFINITIONS.shenandoah.buildArgs()
assert.ok(args.includes('-XX:+UseShenandoahGC'))
assert.ok(args.includes('-XX:ShenandoahGCHeuristics=adaptive'))
assert.ok(args.includes('-XX:+UseLargePages'))
})
test('ZGC args include -XX:+ZGenerational for Java 21+', () => {
const args = GC_STRATEGY_DEFINITIONS.zgc.buildArgs(createContext({ javaMajorVersion: 21 }))
assert.ok(args.includes('-XX:+UseZGC'))
assert.ok(args.includes('-XX:+ZGenerational'))
assert.ok(args.includes('-XX:+AlwaysPreTouch'))
assert.ok(args.includes('-XX:-ZUncommit'))
})
test('ZGC args do not include -XX:+ZGenerational for Java < 21', () => {
const args = GC_STRATEGY_DEFINITIONS.zgc.buildArgs(createContext({ javaMajorVersion: 17 }))
assert.ok(args.includes('-XX:+UseZGC'))
assert.ok(!args.includes('-XX:+ZGenerational'))
})
test('detectGcStrategy correctly identifies Mojang G1GC', () => {
const args = GC_STRATEGY_DEFINITIONS['g1gc-mojang'].buildArgs()
assert.equal(detectGcStrategy(args), 'g1gc-mojang')
})
test('detectGcStrategy correctly identifies PCL', () => {
const args = GC_STRATEGY_DEFINITIONS.pcl.buildArgs()
assert.equal(detectGcStrategy(args), 'pcl')
})
test('detectGcStrategy correctly identifies Shenandoah', () => {
const args = GC_STRATEGY_DEFINITIONS.shenandoah.buildArgs()
assert.equal(detectGcStrategy(args), 'shenandoah')
})
test('detectGcStrategy correctly identifies ZGC', () => {
const args = GC_STRATEGY_DEFINITIONS.zgc.buildArgs()
assert.equal(detectGcStrategy(args), 'zgc')
})
test('detectGcStrategy returns null for unknown strategy', () => {
assert.equal(detectGcStrategy('-Xmx4G'), null)
})
test('Mojang G1GC is not misidentified as PCL or Shenandoah', () => {
const args = GC_STRATEGY_DEFINITIONS['g1gc-mojang'].buildArgs()
const detected = detectGcStrategy(args)
assert.equal(detected, 'g1gc-mojang')
assert.notEqual(detected, 'pcl')
})
test('PCL is not misidentified as Mojang or Shenandoah', () => {
const args = GC_STRATEGY_DEFINITIONS.pcl.buildArgs()
const detected = detectGcStrategy(args)
assert.equal(detected, 'pcl')
assert.notEqual(detected, 'shenandoah')
assert.notEqual(detected, 'g1gc-mojang')
})
test('Shenandoah is not misidentified as PCL', () => {
const args = GC_STRATEGY_DEFINITIONS.shenandoah.buildArgs()
assert.equal(detectGcStrategy(args), 'shenandoah')
})
test('a bare -XX:+UseG1GC is not treated as the full official preset', () => {
assert.equal(detectGcStrategy('-XX:+UseG1GC'), null)
})
test('a partial ZGC arg list is not auto-tagged', () => {
assert.equal(detectGcStrategy('-XX:+UseZGC'), null)
})
test('a partial Shenandoah arg list is not auto-tagged', () => {
assert.equal(detectGcStrategy('-XX:+UseShenandoahGC -XX:ShenandoahGCHeuristics=adaptive'), null)
})
test('ZGC is recognized if the complete base set is present regardless of order', () => {
const args = '-XX:-ZUncommit -XX:+AlwaysPreTouch -XX:+UseZGC'
assert.equal(detectGcStrategy(args), 'zgc')
})
test('ZGC with -XX:+ZGenerational on top of the base set is still ZGC', () => {
const args = GC_STRATEGY_DEFINITIONS.zgc.buildArgs(createContext({ javaMajorVersion: 21 }))
assert.equal(detectGcStrategy(args), 'zgc')
})
test('PCL complete set with large pages added is Shenandoah, not PCL', () => {
const args = GC_STRATEGY_DEFINITIONS.pcl.buildArgs() + ' -XX:+UseLargePages'
assert.equal(detectGcStrategy(args), 'shenandoah')
})
test('buildGcCandidateChain puts preferred first, dedupes, ends at minimal G1', () => {
const { ids, args } = buildGcCandidateChain(createContext(), 'zgc')
assert.deepEqual(ids, ['zgc', 'shenandoah', 'pcl', 'g1gc-mojang', 'minimal-g1'])
assert.equal(args.length, ids.length)
assert.deepEqual(args[args.length - 1], ['-XX:+UseG1GC'])
})
test('buildGcCandidateChain starts at a non-ZGC preferred strategy', () => {
const { ids } = buildGcCandidateChain(createContext(), 'shenandoah')
assert.deepEqual(ids, ['shenandoah', 'pcl', 'g1gc-mojang', 'minimal-g1'])
})
test('buildGcCandidateChain for PCL only falls back to G1', () => {
const { ids } = buildGcCandidateChain(createContext(), 'pcl')
assert.deepEqual(ids, ['pcl', 'g1gc-mojang', 'minimal-g1'])
})
test('buildGcCandidateChain never repeats the preferred strategy', () => {
for (const preferred of ['zgc', 'shenandoah', 'pcl', 'g1gc-mojang']) {
const { ids } = buildGcCandidateChain(createContext(), preferred)
assert.equal(ids[0], preferred)
assert.equal(new Set(ids).size, ids.length)
}
})

View File

@ -0,0 +1,160 @@
import type { GcContext, GcStrategyDefinition, GcStrategyId, ResolvedGcStrategyId } from './types'
// Official Minecraft launcher G1GC tuning. `-XX:SurvivorRatio=8` (the flag in
// the leak of the original list was misspelled "SurvialRation", which JVMs
// would reject).
function buildG1gcMojangArgs(): string {
return [
'-XX:+UseG1GC',
'-XX:+ParallelRefProcEnabled',
'-XX:MaxGCPauseMillis=200',
'-XX:+UnlockExperimentalVMOptions',
'-XX:+DisableExplicitGC',
'-XX:+AlwaysPreTouch',
'-XX:G1NewSizePercent=30',
'-XX:G1MaxNewSizePercent=40',
'-XX:G1HeapRegionSize=8M',
'-XX:G1ReservePercent=15',
'-XX:G1HeapWastePercent=5',
'-XX:G1MixedGCCountTarget=4',
'-XX:InitiatingHeapOccupancyPercent=15',
'-XX:G1MixedGCLiveThresholdPercent=90',
'-XX:G1RSetUpdatingPauseTimePercent=5',
'-XX:SurvivorRatio=8',
].join(' ')
}
// PCL-style Shenandoah (adaptive, no large pages — the safe default variant).
function buildPclShenandoahArgs(): string {
return [
'-XX:+UseShenandoahGC',
'-XX:ShenandoahGCHeuristics=adaptive',
'-XX:+AlwaysPreTouch',
'-XX:+DisableExplicitGC',
].join(' ')
}
// Shenandoah with large pages enabled (may warn on systems without support).
function buildShenandoahArgs(): string {
return [
'-XX:+UseShenandoahGC',
'-XX:ShenandoahGCHeuristics=adaptive',
'-XX:+AlwaysPreTouch',
'-XX:+UseLargePages',
'-XX:+DisableExplicitGC',
].join(' ')
}
function buildZgcArgs(javaMajorVersion: number | null): string {
const args = ['-XX:+UseZGC']
// Generational ZGC only exists on JDK 21+.
if (javaMajorVersion !== null && javaMajorVersion >= 21) {
args.push('-XX:+ZGenerational')
}
args.push('-XX:+AlwaysPreTouch', '-XX:-ZUncommit')
return args.join(' ')
}
// Detection only tags a preset when its *complete* flag set is present in the
// pasted args — a partial or edited arg list is treated as the user's own raw
// args, never auto-mislabeled as a preset.
function tokensOf(argString: string): string[] {
return argString.split(/\s+/).filter(Boolean)
}
function hasFullArgSet(pastedArgs: string, presetArgString: string): boolean {
const inputSet = new Set(tokensOf(pastedArgs))
return tokensOf(presetArgString).every((token) => inputSet.has(token))
}
function detectG1gcMojang(args: string): boolean {
return hasFullArgSet(args, buildG1gcMojangArgs())
}
// PCL Shenandoah: complete adaptive set, and no large pages.
function detectPclShenandoah(args: string): boolean {
return hasFullArgSet(args, buildPclShenandoahArgs()) && !args.includes('-XX:+UseLargePages')
}
// Shenandoah with large pages (its full set already requires `-XX:+UseLargePages`).
function detectShenandoah(args: string): boolean {
return hasFullArgSet(args, buildShenandoahArgs())
}
function detectZgc(args: string): boolean {
return hasFullArgSet(args, buildZgcArgs(null))
}
export const GC_STRATEGY_DEFINITIONS: Record<ResolvedGcStrategyId, GcStrategyDefinition> = {
'g1gc-mojang': {
id: 'g1gc-mojang',
baseArgs: buildG1gcMojangArgs(),
detect: detectG1gcMojang,
buildArgs: () => buildG1gcMojangArgs(),
},
pcl: {
id: 'pcl',
baseArgs: buildPclShenandoahArgs(),
detect: detectPclShenandoah,
buildArgs: () => buildPclShenandoahArgs(),
},
shenandoah: {
id: 'shenandoah',
baseArgs: buildShenandoahArgs(),
detect: detectShenandoah,
buildArgs: () => buildShenandoahArgs(),
},
zgc: {
id: 'zgc',
baseArgs: buildZgcArgs(null),
detect: detectZgc,
buildArgs: (context) => buildZgcArgs(context?.javaMajorVersion ?? null),
},
}
export function detectGcStrategy(args: string): ResolvedGcStrategyId | null {
for (const [strategyId, definition] of Object.entries(GC_STRATEGY_DEFINITIONS)) {
if (definition.detect(args)) {
return strategyId as ResolvedGcStrategyId
}
}
return null
}
export function getStrategyBaseArgs(strategyId: GcStrategyId): string {
if (strategyId === 'auto') {
return GC_STRATEGY_DEFINITIONS['g1gc-mojang'].baseArgs
}
return GC_STRATEGY_DEFINITIONS[strategyId].baseArgs
}
/**
* The preferred strategy plus the fallback chain, ordered by preference. The
* backend verifies each block against the actual JVM and picks the first one
* that is accepted (pruning unsupported tuning flags along the way).
*
* Fallbacks only ever move to less resource-hungry strategies — if the
* heuristic deliberately avoided ZGC (insufficient resources), we must not
* silently jump back up to it when Shenandoah is unavailable.
*/
const SAFE_TO_DEMANDING: ResolvedGcStrategyId[] = ['g1gc-mojang', 'pcl', 'shenandoah', 'zgc']
export function buildGcCandidateChain(
context: GcContext,
preferred: ResolvedGcStrategyId,
): { ids: string[]; args: string[][] } {
const preferredDemand = SAFE_TO_DEMANDING.indexOf(preferred)
const ids: string[] = [preferred]
if (preferredDemand > 0) {
for (let demand = preferredDemand - 1; demand >= 0; demand -= 1) {
ids.push(SAFE_TO_DEMANDING[demand])
}
}
// Absolute last resort: just the G1 selector (known to every HotSpot JVM).
ids.push('minimal-g1')
const args = ids.map((id) => {
if (id === 'minimal-g1') return ['-XX:+UseG1GC']
return GC_STRATEGY_DEFINITIONS[id].buildArgs(context).split(/\s+/).filter(Boolean)
})
return { ids, args }
}

View File

@ -0,0 +1,41 @@
import type { MessageDescriptor } from '@modrinth/ui'
import type { InstanceLoader } from '@/helpers/types'
export type GcStrategyId = 'g1gc-mojang' | 'pcl' | 'shenandoah' | 'zgc' | 'auto'
export type ResolvedGcStrategyId = Exclude<GcStrategyId, 'auto'>
export interface GcContext {
javaMajorVersion: number | null
allocatedMemoryMb: number
systemCpuCores: number
systemLogicalProcessors: number
modCount: number
loader: InstanceLoader
}
export interface GcResolution {
resolvedStrategy: ResolvedGcStrategyId
reasonChain: string[]
}
export interface GcStrategyDefinition {
id: GcStrategyId
baseArgs: string
detect: (currentArgs: string) => boolean
buildArgs: (context?: GcContext) => string
}
export interface JavaArgumentPreset {
id: string
title: MessageDescriptor
description: MessageDescriptor
args: string
link: string
group: string
resolveArgs?: (context?: GcContext) => string
detect?: (currentArgs: string) => boolean
autoResolvedName?: string
autoReasonChain?: string[]
}

View File

@ -0,0 +1,99 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* 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 {
install_cancel_import_plan,
install_import_instance,
install_start_import_plan,
} from './install'
/** Create an Axolotl record that directly manages an external version folder. */
export async function create_direct_link_instance(
name,
launcherType,
basePath,
instanceFolder,
instancePath = undefined,
) {
return await invoke('plugin:instance|instance_create_direct_link', {
request: { name, launcherType, basePath, instanceFolder, instancePath },
})
}
/*
API for importing instances from other launchers
launcherType can be one of the following:
- MultiMC
- GDLauncher
- ATLauncher
- Curseforge
- PrismLauncher
- Unknown (shouldn't be used, but is used internally if the launcher type isn't recognized)
For each launcher type, we can get a guess of the default path for the launcher, and a list of importable instances
For most launchers, this will be the application's data directory, with two exceptions:
- MultiMC: this goes to the app directory (wherever the app is)
- Curseforge: this goes to the 'minecraft' subdirectory of the data directory, as Curseforge has multiple games
*/
/// Gets a list of importable instances from a launcher type and base path
/// eg: get_importable_instances("MultiMC", "C:/MultiMC")
/// returns ["Instance 1", "Instance 2"]
export async function get_importable_instances(launcherType, basePath) {
return await invoke('plugin:import|get_importable_instances', { launcherType, basePath })
}
/// Import an instance from a launcher type and base path
/** @param {string|undefined} instancePath @param {string|undefined} gameVersion @param {string|undefined} loader @param {string|undefined} loaderVersion @param {string|null|undefined} gameDirOverride */
export async function import_instance(
launcherType,
basePath,
instanceFolder,
symlink = false,
instancePath = undefined,
gameVersion = undefined,
loader = undefined,
loaderVersion = undefined,
gameDirOverride = undefined,
) {
return await install_import_instance(
launcherType,
basePath,
instanceFolder,
symlink,
instancePath,
gameVersion,
loader,
loaderVersion,
gameDirOverride,
)
}
export async function start_import_plan(request) {
return await install_start_import_plan(request)
}
export async function cancel_import_plan(requestId) {
return await install_cancel_import_plan(requestId)
}
/// Checks if this instance is valid for importing, given a certain launcher type
/// eg: is_valid_importable_instance("C:/MultiMC/Instance 1", "MultiMC")
export async function is_valid_importable_instance(instanceFolder, launcherType) {
return await invoke('plugin:import|is_valid_importable_instance', {
instanceFolder,
launcherType,
})
}
/// Gets the default path for the given launcher type
/// null if it can't be found or doesn't exist
/// eg: get_default_launcher_path("MultiMC")
export async function get_default_launcher_path(launcherType) {
return await invoke('plugin:import|get_default_launcher_path', { launcherType })
}

View File

@ -0,0 +1,190 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
effectiveInstallProgress,
effectiveParallelProgress,
hasDeterminateInstallProgress,
installProgressFraction,
installProgressTextSource,
} from './install-progress.ts'
function progressJob(overrides: Record<string, unknown> = {}) {
return {
status: 'running',
phase: 'resolving_loader',
progress: null,
details: { type: 'empty' },
summary: {
files_completed: 0,
files_total: null,
bytes_downloaded: 0,
bytes_total: null,
},
...overrides,
}
}
test('clears completed content progress when the next phase has no progress', () => {
const completed = {
phase: 'downloading_content',
progress: { current: 10, total: 10 },
}
assert.equal(installProgressFraction(completed), 1)
const nextPhase = {
phase: 'downloading_minecraft',
progress: null,
}
assert.equal(effectiveInstallProgress(nextPhase), null)
assert.equal(installProgressFraction(nextPhase), null)
})
test('parallel track exposes its own progress', () => {
const job = {
phase: 'downloading_content',
progress: { current: 2, total: 3, secondary: { current: 220, total: 300 } },
parallel: {
phase: 'downloading_minecraft',
current: 120,
total: 300,
},
}
assert.deepEqual(effectiveInstallProgress(job), { current: 220, total: 300 })
assert.deepEqual(effectiveParallelProgress(job), { current: 120, total: 300 })
assert.equal(installProgressFraction(job), 220 / 300)
assert.equal(effectiveParallelProgress({ phase: 'downloading_minecraft', progress: null }), null)
})
test('treats zero and non-finite totals as indeterminate', () => {
for (const total of [0, Number.NaN, Number.POSITIVE_INFINITY]) {
const progress = { current: 1, total }
assert.equal(hasDeterminateInstallProgress(progress), false)
assert.equal(installProgressFraction({ phase: 'downloading_minecraft', progress }), null)
}
})
test('non-download phase ignores historical byte and file summary', () => {
const source = installProgressTextSource(
progressJob({
phase: 'resolving_loader',
summary: {
files_completed: 186,
files_total: 187,
bytes_downloaded: 268,
bytes_total: 18,
},
}),
)
assert.deepEqual(source, { type: 'phase' })
assert.doesNotMatch(JSON.stringify(source), /268|18 MiB/)
})
test('non-download phase ignores historical file counter', () => {
assert.deepEqual(
installProgressTextSource(
progressJob({
phase: 'resolving_loader',
summary: {
files_completed: 186,
files_total: 187,
bytes_downloaded: 0,
bytes_total: null,
},
}),
),
{ type: 'phase' },
)
})
test('pack download uses current progress instead of stale summary', () => {
assert.deepEqual(
installProgressTextSource(
progressJob({
phase: 'downloading_pack_file',
progress: { current: 0, total: 51 },
summary: {
files_completed: 0,
files_total: null,
bytes_downloaded: 268,
bytes_total: 300,
},
}),
),
{ type: 'bytes', current: 0, total: 51 },
)
})
test('minecraft download uses current progress instead of content summary', () => {
assert.deepEqual(
installProgressTextSource(
progressJob({
phase: 'downloading_minecraft',
progress: { current: 0, total: 18 },
summary: {
files_completed: 187,
files_total: 187,
bytes_downloaded: 268,
bytes_total: 300,
},
}),
),
{ type: 'bytes', current: 0, total: 18 },
)
})
test('content download uses current secondary bytes', () => {
assert.deepEqual(
installProgressTextSource(
progressJob({
phase: 'downloading_content',
progress: {
current: 2,
total: 3,
secondary: { current: 220, total: 300 },
},
}),
),
{ type: 'bytes', current: 220, total: 300 },
)
})
test('content download without secondary uses current file counter', () => {
assert.deepEqual(
installProgressTextSource(
progressJob({
phase: 'downloading_content',
progress: { current: 2, total: 3 },
}),
),
{ type: 'items', current: 2, total: 3 },
)
})
test('Java downloading uses current byte progress', () => {
assert.deepEqual(
installProgressTextSource(
progressJob({
phase: 'preparing_java',
progress: { current: 4, total: 12 },
details: { type: 'java', major_version: 21, step: 'downloading' },
}),
),
{ type: 'bytes', current: 4, total: 12 },
)
})
test('waiting job preserves required-file progress policy', () => {
assert.deepEqual(
installProgressTextSource(
progressJob({
status: 'waiting_for_user',
phase: 'downloading_content',
progress: { current: 2, total: 3 },
}),
),
{ type: 'required_files' },
)
})

View File

@ -0,0 +1,115 @@
export interface ProgressValue {
current: number
total: number
secondary?: ProgressValue | null
}
export interface ProgressSnapshot {
phase: string
progress?: ProgressValue | null
parallel?: {
phase: string
current: number
total: number
} | null
}
export interface ProgressTextSnapshot extends ProgressSnapshot {
status: string
details?: { type: string; step?: string } | null
summary: {
files_completed: number
files_total?: number | null
bytes_downloaded: number
bytes_total?: number | null
}
}
export type InstallProgressTextSource =
| { type: 'required_files' | 'phase' }
| { type: 'bytes' | 'items'; current: number; total: number }
export function effectiveInstallProgress(
snapshot: ProgressSnapshot,
): ProgressValue | null | undefined {
if (snapshot.phase === 'downloading_content' && snapshot.progress?.secondary) {
return snapshot.progress.secondary
}
return snapshot.progress
}
export function effectiveParallelProgress(
snapshot: ProgressSnapshot,
): ProgressValue | null | undefined {
if (!snapshot.parallel) return null
return {
current: snapshot.parallel.current,
total: snapshot.parallel.total,
}
}
export function hasDeterminateInstallProgress(
progress: ProgressValue | null | undefined,
): progress is ProgressValue {
return (
progress != null &&
Number.isFinite(progress.current) &&
Number.isFinite(progress.total) &&
progress.current >= 0 &&
progress.total > 0
)
}
export function installProgressFraction(snapshot: ProgressSnapshot): number | null {
const progress = effectiveInstallProgress(snapshot)
if (!hasDeterminateInstallProgress(progress)) return null
return Math.max(0, Math.min(1, progress.current / progress.total))
}
export function installProgressTextSource(
snapshot: ProgressTextSnapshot,
): InstallProgressTextSource {
if (snapshot.status === 'waiting_for_user') return { type: 'required_files' }
const isContentDownload = snapshot.phase === 'downloading_content'
const isByteDownload =
snapshot.phase === 'downloading_pack_file' ||
snapshot.phase === 'downloading_minecraft' ||
(snapshot.phase === 'preparing_java' &&
snapshot.details?.type === 'java' &&
snapshot.details.step === 'downloading')
const progress = effectiveInstallProgress(snapshot)
if (hasDeterminateInstallProgress(progress)) {
if (isContentDownload) {
return {
type: snapshot.progress?.secondary ? 'bytes' : 'items',
current: progress.current,
total: progress.total,
}
}
if (isByteDownload) {
return { type: 'bytes', current: progress.current, total: progress.total }
}
}
if (snapshot.progress != null) return { type: 'phase' }
if ((isContentDownload || isByteDownload) && snapshot.summary.bytes_total) {
return {
type: 'bytes',
current: snapshot.summary.bytes_downloaded,
total: snapshot.summary.bytes_total,
}
}
if (isContentDownload && snapshot.summary.files_total) {
return {
type: 'items',
current: snapshot.summary.files_completed,
total: snapshot.summary.files_total,
}
}
return { type: 'phase' }
}

View File

@ -0,0 +1,584 @@
import { invoke } from '@tauri-apps/api/core'
import { install_job_listener } from './events'
import type { InstanceUpgradeResult } from './instance-upgrade'
import type { InstanceLink, InstanceLoader, LoaderComponent } from './types'
export interface PackLocationVersionId {
type: 'fromVersionId'
project_id: string
version_id: string
title: string
icon_url?: string | null
}
export interface PackLocationFile {
type: 'fromFile'
path: string
}
export type CreatePackLocation = PackLocationVersionId | PackLocationFile
export interface InstallModpackPreview {
name: string
gameVersion: string
modloader: InstanceLoader
loaderVersion: string | null
adjuncts?: LoaderComponent[]
icon?: string | null
iconUrl?: string | null
link?: InstanceLink | null
unknownFile: boolean
}
export interface InstallCreateInstanceRequest {
name: string
gameVersion: string
loader: InstanceLoader
loaderVersion: string | null
adjuncts?: LoaderComponent[]
iconPath: string | null
link?: InstanceLink | null
gameDirOverride?: string | null
}
export interface InstallPostInstallEdit {
name?: string | null
iconPath?: string | null
link?: InstanceLink | null
}
export type InstallJobStatus =
| 'queued'
| 'running'
| 'canceling'
| 'waiting_for_user'
| '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'
| 'creating_backup'
| 'staging_content'
| 'applying_content'
| 'updating_loader'
| 'verifying'
| 'finalizing'
| 'completed'
| 'rolling_back'
export interface InstallProgress {
current: number
total: number
secondary?: InstallProgressSecondary | null
}
export interface InstallProgressSecondary {
current: number
total: number
}
export interface InstallParallelProgress {
phase: InstallPhaseId
current: number
total: number
details: InstallJobSnapshot['details']
}
export type InstallJavaStep =
| 'resolving'
| 'fetching_metadata'
| 'downloading'
| 'extracting'
| 'validating'
export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
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[]
cache_types?: string[]
sqlite_code?: string | null
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 type InstallPauseReason = {
type: 'missing_required_content'
failed_files: number
paths: string[]
}
export interface InstallJobSnapshot {
job_id: string
instance_id?: string | null
source_instance_id?: string | null
instance_deleted: boolean
kind:
| 'create_instance'
| 'create_modpack_instance'
| 'import_instance'
| 'duplicate_instance'
| 'install_existing_instance'
| 'install_pack_to_existing_instance'
| 'install_content'
| 'upgrade_unmanaged_instance'
| 'download_java'
status: InstallJobStatus
execution_mode: 'normal' | 'recovery_validation'
provider: 'modrinth' | 'curse_forge' | 'minecraft' | 'java' | 'application' | 'local'
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 }
parallel?: InstallParallelProgress | null
display?: { title: string; icon?: string | null } | null
error?: InstallErrorView | null
rollback_error?: InstallErrorView | null
pause_reason?: InstallPauseReason | null
upgrade_result?: InstanceUpgradeResult | null
created: string
modified: string
finished?: string | null
summary: {
files_completed: number
files_total?: number | null
bytes_downloaded: number
bytes_total?: number | null
speed_bytes_per_second?: number | null
eta_seconds?: number | null
source?: string | null
fallback_count: number
}
items: Array<{
id: string
name: string
project_id?: string | null
version_id?: string | null
status:
| 'queued'
| 'worker_started'
| 'waiting_for_resource'
| 'connecting'
| 'downloading'
| 'verifying'
| 'writing'
| 'metadata'
| 'waiting_for_database'
| 'finalizing'
| 'waiting_for_user'
| 'completed'
| 'skipped'
| 'failed'
| 'canceled'
bytes_downloaded: number
bytes_total?: number | null
attempt?: number | null
max_attempts?: number | null
error?: string | null
manual_url?: string | null
request_url?: string | null
source?: string | null
}>
}
export interface DownloadJobListRequest {
status?: InstallJobStatus
provider?: InstallJobSnapshot['provider']
query?: string
cursor?: string
limit?: number
}
export interface DownloadJobPage {
jobs: InstallJobSnapshot[]
nextCursor?: string | null
}
export interface MissingModpackContentView {
remaining: number
files: Array<{
itemId: string
path: string
expectedSize: number
status: InstallJobSnapshot['items'][number]['status']
lastError?: string | null
browserUrls: string[]
attempt?: number | null
maxAttempts?: number | null
}>
}
export interface MissingModpackScanResult {
downloadDirectory?: string | null
content: MissingModpackContentView
importedItemIds: string[]
mismatchedItemIds: string[]
rejectedItemIds: string[]
checkedCandidates: number
pendingCandidates: number
errors: Array<{ itemId: string; message: string }>
job: InstallJobSnapshot
}
export type DownloadRequestUpdate =
| {
type: 'started'
job_id: string
id: string
name: string
url: string
source: string
bytes_total?: number | null
attempt: number
max_attempts: number
}
| {
type: 'progress'
job_id: string
id: string
bytes: number
status:
| 'worker_started'
| 'waiting_for_resource'
| 'connecting'
| 'downloading'
| 'writing'
| 'verifying'
| 'metadata'
| 'waiting_for_database'
| 'finalizing'
speed_bytes_per_second?: number | null
eta_seconds?: number | null
}
| { type: 'finished'; job_id: string; id: string; bytes: number }
| { type: 'failed'; job_id: string; id: string }
export type ImportPlanStage = 'resolving' | 'scanning' | 'done' | 'error'
export interface ImportPlanCounts {
files: number
bytes: number
}
export interface ImportPlanSnapshot {
requestId: string
stage: ImportPlanStage
gameVersion: string | null
loader: string | null
loaderVersion: string | null
importPath: string
minecraftRoot: string
modCount: number
cache: ImportPlanCounts
local: ImportPlanCounts
network: ImportPlanCounts
migrate: ImportPlanCounts
error?: string | null
}
export interface ImportPlanRequest {
requestId: string
launcherType: string
basePath: string
instanceFolder: string
instancePath?: string | null
gameVersion?: string | null
loader?: string | null
loaderVersion?: string | null
}
export async function install_get_modpack_preview(location: CreatePackLocation) {
return await invoke<InstallModpackPreview>('plugin:install|install_get_modpack_preview', {
location,
})
}
export async function install_create_instance(request: InstallCreateInstanceRequest) {
return await invoke<InstallJobSnapshot>('plugin:install|install_create_instance', { request })
}
export async function install_create_modpack_instance(
location: CreatePackLocation,
postInstallEdit?: InstallPostInstallEdit | null,
) {
return await invoke<InstallJobSnapshot>('plugin:install|install_create_modpack_instance', {
location,
postInstallEdit,
})
}
export async function install_import_instance(
launcherType: string,
basePath: string,
instanceFolder: string,
symlink?: boolean,
instancePath?: string,
gameVersion?: string | null,
loader?: string | null,
loaderVersion?: string | null,
gameDirOverride?: string | null,
) {
return await invoke<InstallJobSnapshot>('plugin:install|install_import_instance', {
launcherType,
basePath,
instanceFolder,
instancePath,
symlink,
gameVersion,
loader,
loaderVersion,
gameDirOverride,
})
}
export async function install_start_import_plan(request: ImportPlanRequest) {
return await invoke<string>('plugin:install|install_start_import_plan', { request })
}
export async function install_cancel_import_plan(requestId: string) {
return await invoke<void>('plugin:install|install_cancel_import_plan', { requestId })
}
export async function install_duplicate_instance(sourceInstanceId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_duplicate_instance', {
sourceInstanceId,
})
}
export async function install_existing_instance(instanceId: string, force: boolean) {
return await invoke<InstallJobSnapshot>('plugin:install|install_existing_instance', {
instanceId,
force,
})
}
export async function install_pack_to_existing_instance(
instanceId: string,
location: CreatePackLocation,
postInstallEdit?: InstallPostInstallEdit | null,
) {
return await invoke<InstallJobSnapshot>('plugin:install|install_pack_to_existing_instance', {
instanceId,
location,
postInstallEdit,
})
}
export async function install_job_list(includeFinished: boolean) {
return await invoke<InstallJobSnapshot[]>('plugin:install|install_job_list', { includeFinished })
}
export async function install_job_get(jobId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_job_get', { jobId })
}
export async function install_job_retry(jobId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_job_retry', { jobId })
}
export async function install_job_repair_cache_and_retry(jobId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_job_repair_cache_and_retry', {
jobId,
})
}
export async function install_job_resume(jobId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_job_resume', { jobId })
}
export async function install_job_skip_missing_content(jobId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_job_skip_missing_content', {
jobId,
})
}
export async function install_job_missing_files(jobId: string) {
return await invoke<MissingModpackContentView>('plugin:install|install_job_missing_files', {
jobId,
})
}
export async function install_job_scan_missing_files(jobId: string, scanDirectory?: string | null) {
return await invoke<MissingModpackScanResult>('plugin:install|install_job_scan_missing_files', {
jobId,
scanDirectory: scanDirectory ?? null,
})
}
export async function install_job_retry_missing_file(jobId: string, itemId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_job_retry_missing_file', {
jobId,
itemId,
})
}
export async function install_job_import_missing_file(
jobId: string,
itemId: string,
selectedFilePath: string,
) {
return await invoke<InstallJobSnapshot>('plugin:install|install_job_import_missing_file', {
jobId,
itemId,
selectedFilePath,
})
}
export async function install_job_cancel(jobId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_job_cancel', { jobId })
}
export async function install_job_dismiss(jobId: string) {
return await invoke<void>('plugin:install|install_job_dismiss', { jobId })
}
export async function install_job_support_details(jobId: string) {
return await invoke<string>('plugin:install|install_job_support_details', { jobId })
}
export async function download_job_list(request: DownloadJobListRequest = {}) {
return await invoke<DownloadJobPage>('plugin:install|download_job_list', { request })
}
export async function download_job_get(jobId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|download_job_get', { jobId })
}
export async function download_job_retry(jobId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|download_job_retry', { jobId })
}
export async function download_job_resume(jobId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|download_job_resume', { jobId })
}
export async function download_job_cancel(jobId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|download_job_cancel', { jobId })
}
export async function download_job_delete(jobId: string) {
return await invoke<void>('plugin:install|download_job_delete', { jobId })
}
export async function download_history_clear() {
return await invoke<number>('plugin:install|download_history_clear')
}
export async function download_job_support_details(jobId: string) {
return await invoke<string>('plugin:install|download_job_support_details', { jobId })
}
export function installJobInstanceId(job: InstallJobSnapshot): string | null {
return job.instance_id ?? job.target.instance_id ?? null
}
export function isInstallJobFinished(status: InstallJobStatus) {
return (
status === 'succeeded' ||
status === 'failed' ||
status === 'interrupted' ||
status === 'canceled'
)
}
function settleInstallJob(job: InstallJobSnapshot) {
if (job.status === 'succeeded') return job
throw new Error(job.error?.message ?? `Install job ${job.job_id} ${job.status}`)
}
export async function wait_for_install_job(jobId: string) {
const current = await install_job_get(jobId)
if (isInstallJobFinished(current.status)) return settleInstallJob(current)
return await new Promise<InstallJobSnapshot>((resolve, reject) => {
let finished = false
let unlisten: (() => void) | null = null
const cleanup = () => {
if (unlisten) {
unlisten()
unlisten = null
}
}
const resolveJob = (job: InstallJobSnapshot) => {
if (finished || job.job_id !== jobId || !isInstallJobFinished(job.status)) return
finished = true
cleanup()
try {
resolve(settleInstallJob(job))
} catch (err) {
reject(err)
}
}
const rejectWait = (err: unknown) => {
if (finished) return
finished = true
cleanup()
reject(err)
}
install_job_listener(resolveJob)
.then((listener) => {
if (finished) {
listener()
return
}
unlisten = listener
install_job_get(jobId).then(resolveJob).catch(rejectWait)
})
.catch(rejectWait)
})
}

View File

@ -0,0 +1,237 @@
import type {
ContentItem,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from '@modrinth/ui'
export interface InstanceContentModpackCache {
project: ContentModpackCardProject
version: ContentModpackCardVersion | null
owner: ContentOwner | null
categories: ContentModpackCardCategory[]
hasUpdate: boolean
updateVersionId: string | null
}
export interface InstanceContentCache {
instanceId: string
updatedAt: number
// 内容数据(大,变动频率低)
contentItems: ContentItem[] | null
modpack: InstanceContentModpackCache | null
linkedContentItems: ContentItem[]
// UI 偏好(关闭软件后保留)
modpackHintDismissed: boolean
}
// ---- 内部实现:数据拆分存储,避免单 key 过大导致 localStorage 静默写入失败 ----
interface CacheDataSlice {
contentItems: ContentItem[] | null
modpack: InstanceContentModpackCache | null
linkedContentItems: ContentItem[]
}
interface CacheUiSlice {
modpackHintDismissed: boolean
}
function dataKey(instanceId: string): string {
return `instance:${instanceId}:data`
}
function uiKey(instanceId: string): string {
return `instance:${instanceId}:ui`
}
function safeGetItem(key: string): string | null {
try {
return localStorage.getItem(key)
} catch (err) {
console.error(`[InstanceCache] Failed to read localStorage key "${key}"`, err)
return null
}
}
function safeSetItem(key: string, value: string): boolean {
try {
localStorage.setItem(key, value)
return true
} catch (err) {
console.error(
`[InstanceCache] Failed to write localStorage key "${key}" (size: ${value.length} bytes)`,
err,
)
return false
}
}
function safeRemoveItem(key: string): void {
try {
localStorage.removeItem(key)
} catch (err) {
console.error(`[InstanceCache] Failed to remove localStorage key "${key}"`, err)
}
}
function defaultData(): CacheDataSlice {
return {
contentItems: null,
modpack: null,
linkedContentItems: [],
}
}
function defaultUi(): CacheUiSlice {
return {
modpackHintDismissed: false,
}
}
function readDataSlice(instanceId: string): CacheDataSlice | null {
try {
const raw = safeGetItem(dataKey(instanceId))
if (!raw) return null
return JSON.parse(raw) as CacheDataSlice
} catch (err) {
console.error(`[InstanceCache] Failed to parse data slice for "${instanceId}"`, err)
return null
}
}
function writeDataSlice(instanceId: string, patch: Partial<CacheDataSlice>): boolean {
const existing = readDataSlice(instanceId)
const base = existing ?? defaultData()
const merged = { ...base, ...patch }
return safeSetItem(dataKey(instanceId), JSON.stringify(merged))
}
function readUiSlice(instanceId: string): CacheUiSlice | null {
try {
const raw = safeGetItem(uiKey(instanceId))
if (!raw) return null
return JSON.parse(raw) as CacheUiSlice
} catch (err) {
console.error(`[InstanceCache] Failed to parse UI slice for "${instanceId}"`, err)
return null
}
}
function writeUiSlice(instanceId: string, patch: Partial<CacheUiSlice>): boolean {
const existing = readUiSlice(instanceId)
const base = existing ?? defaultUi()
const merged = { ...base, ...patch }
return safeSetItem(uiKey(instanceId), JSON.stringify(merged))
}
// ---- 旧缓存迁移 ----
/**
* 旧的分散缓存 key 列表。在首次写入新缓存后清理,释放 localStorage 空间。
*/
const LEGACY_KEYS = [
'instance-content-cache',
'instance-linked-content-cache',
'content-ui-state',
'content-tab-modpack-hint-dismissed',
]
/** 匹配旧的 content-filters-* key */
const LEGACY_FILTER_KEY_PREFIX = 'content-filters-'
let migrationDone = false
/**
* 清理旧版缓存系统遗留的 localStorage key。
* 只执行一次per session释放被旧数据占用的配额。
*/
function migrateFromLegacyCache(): void {
if (migrationDone) return
migrationDone = true
// 删除固定名称的旧 key
for (const key of LEGACY_KEYS) {
safeRemoveItem(key)
}
// 删除所有 content-filters-type:* 和 content-filters-status:* 旧 key
try {
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith(LEGACY_FILTER_KEY_PREFIX)) {
keysToRemove.push(key)
}
}
for (const key of keysToRemove) {
safeRemoveItem(key)
}
} catch (err) {
console.error('[InstanceCache] Failed to enumerate localStorage keys during migration', err)
}
}
/**
* 读取实例的统一内容缓存。
* 返回 null 表示该实例没有任何数据缓存data slice 为空)。
*/
export function readInstanceCache(instanceId: string): InstanceContentCache | null {
const data = readDataSlice(instanceId)
if (!data) return null
const hasContent =
(data.contentItems?.length ?? 0) > 0 || (data.linkedContentItems?.length ?? 0) > 0
if (!hasContent) {
removeInstanceCache(instanceId)
return null
}
const ui = readUiSlice(instanceId)
return {
instanceId,
updatedAt: 0,
contentItems: data.contentItems ?? [],
modpack: data.modpack,
linkedContentItems: data.linkedContentItems ?? [],
modpackHintDismissed: ui?.modpackHintDismissed ?? false,
}
}
/**
* 写入实例的统一内容缓存merge 模式)。
* 大小数据自动分流:内容数据写入 data keyUI 状态写入 ui key。
*/
export function writeInstanceCache(
instanceId: string,
patch: Partial<Omit<InstanceContentCache, 'instanceId' | 'updatedAt'>>,
): void {
migrateFromLegacyCache()
const dataPatch: Partial<CacheDataSlice> = {}
const uiPatch: Partial<CacheUiSlice> = {}
if ('contentItems' in patch) dataPatch.contentItems = patch.contentItems
if ('modpack' in patch) dataPatch.modpack = patch.modpack
if ('linkedContentItems' in patch) dataPatch.linkedContentItems = patch.linkedContentItems
if ('modpackHintDismissed' in patch) uiPatch.modpackHintDismissed = patch.modpackHintDismissed
if (Object.keys(dataPatch).length > 0) {
writeDataSlice(instanceId, dataPatch)
}
if (Object.keys(uiPatch).length > 0) {
writeUiSlice(instanceId, uiPatch)
}
}
/**
* 删除实例的统一内容缓存。
* 在实例被删除时调用。
*/
export function removeInstanceCache(instanceId: string): void {
safeRemoveItem(dataKey(instanceId))
safeRemoveItem(uiKey(instanceId))
}

View File

@ -0,0 +1,196 @@
import type {
ContentItem,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import {
get_content_snapshot,
type InstanceContentSnapshot,
type LinkedModpackInfo,
refresh_content,
} from '@/helpers/instance'
import type { CacheBehaviour } from '@/helpers/types'
export type InstanceContentData = {
path: string
snapshot: InstanceContentSnapshot
contentItems: ContentItem[]
linkedContentItems: ContentItem[]
modpack: InstanceContentModpackData | null
}
export type InstanceContentModpackData = {
project: ContentModpackCardProject
version: ContentModpackCardVersion | null
owner: ContentOwner | null
hasUpdate: boolean
updateVersionId: string | null
}
export function isWorldSaveContentItem(item: Pick<ContentItem, 'project_type'>): boolean {
return ['world', 'worldsave', 'world_save'].includes(item.project_type)
}
export function localContentIconUrl(iconUrl?: string | null): string {
if (!iconUrl) return ''
return /^(https?:|data:|blob:|asset:|tauri:)/.test(iconUrl) ? iconUrl : convertFileSrc(iconUrl)
}
export async function loadInstanceContentData(
path: string,
cacheBehaviour?: CacheBehaviour,
onError?: (error: Error) => unknown,
): Promise<InstanceContentData | null> {
try {
const snapshot =
cacheBehaviour === 'bypass' || cacheBehaviour === 'must_revalidate'
? await refresh_content(path)
: await get_content_snapshot(path)
const normalizedItems = snapshot.items.map((item) => {
const fileName = item.expectedRelativePath.split('/').pop() ?? item.expectedRelativePath
const requiresManualDownload =
item.ownershipKind === 'pack_managed' &&
item.required &&
item.provider === 'curseforge' &&
item.materializationState === 'pending_manual'
const curseForgeProjectId =
item.provider === 'curseforge' && /^\d+$/.test(item.providerProjectId ?? '')
? Number(item.providerProjectId)
: null
const projectId =
item.provider === 'curseforge' && curseForgeProjectId != null
? `curseforge:${curseForgeProjectId}`
: item.providerProjectId
const fallbackContent: ContentItem = {
id: item.memberId ?? item.entryId ?? item.fileId ?? item.expectedRelativePath,
file_name: fileName,
file_path: item.expectedRelativePath,
size: 0,
enabled: false,
project_type: item.projectType,
project: {
id: projectId ?? `local:${item.expectedRelativePath}`,
slug: projectId ?? item.expectedRelativePath,
title: fileName,
icon_url: undefined,
},
version: item.providerReleaseId
? {
id: item.providerReleaseId,
version_number: item.providerReleaseId,
file_name: fileName,
}
: undefined,
update: null,
origin_provider: item.provider,
provider_refs:
curseForgeProjectId != null
? [
{
provider: 'curseforge' as const,
project_id: curseForgeProjectId,
file_id:
item.providerReleaseId && /^\d+$/.test(item.providerReleaseId)
? Number(item.providerReleaseId)
: null,
},
]
: item.provider === 'modrinth' && item.providerProjectId
? [
{
provider: 'modrinth' as const,
project_id: item.providerProjectId,
version_id: item.providerReleaseId,
},
]
: [],
pendingManualDownload: requiresManualDownload,
}
return {
...(item.content ?? fallbackContent),
instanceFileId: item.fileId ?? undefined,
instanceEntryId: item.entryId ?? undefined,
instanceMemberId: item.memberId ?? undefined,
instanceOwnershipKind: item.ownershipKind,
instanceCapabilities: item.capabilities,
instanceMaterializationState: item.materializationState,
instanceOverrideKind: item.overrideKind,
pendingManualDownload: requiresManualDownload,
dependency: item.dependency ?? null,
}
}) satisfies ContentItem[]
const contentItems = normalizedItems.filter(
(item) => item.instanceOwnershipKind !== 'pack_managed',
)
const linkedContentItems = normalizedItems.filter(
(item) => item.instanceOwnershipKind === 'pack_managed',
)
return {
path,
snapshot,
contentItems,
linkedContentItems,
modpack: normalizePack(snapshot),
}
} catch (error) {
if (onError) {
onError(error as Error)
return null
}
throw error
}
}
function normalizePack(snapshot: InstanceContentSnapshot): InstanceContentModpackData | null {
const pack = snapshot.pack
if (!pack) return null
const metadata = pack.metadata
const project = metadata
? normalizeProject(metadata, pack.iconPath)
: {
id: pack.projectId ?? snapshot.instanceId,
slug: pack.projectId ?? snapshot.instanceId,
title: pack.name,
icon_url: pack.iconPath ?? undefined,
description: '',
}
const version = metadata
? ({
...metadata.version,
date_published: metadata.version.date_published.toString(),
} as ContentModpackCardVersion)
: null
return {
project,
version,
owner: metadata?.owner
? {
...metadata.owner,
avatar_url: metadata.owner.avatar_url ?? undefined,
}
: null,
hasUpdate: pack.canUpdate && metadata?.update != null,
updateVersionId:
metadata?.update?.provider === 'modrinth'
? metadata.update.target_version_id
: metadata?.update?.provider === 'curseforge'
? String(metadata.update.target_file_id)
: null,
}
}
function normalizeProject(
metadata: LinkedModpackInfo,
fallbackIconPath?: string | null,
): ContentModpackCardProject {
return {
...metadata.project,
slug: metadata.project.slug ?? metadata.project.id,
icon_url: metadata.project.icon_url || fallbackIconPath || undefined,
}
}

View File

@ -0,0 +1,73 @@
const builtInInstanceIconHashes = new Set([
'2bea5c08afa67675f49b70194990de0e20c0ef2e',
'1db2c92a33ef1f6f490ec27070d49cb8353bad7c',
'a55366513a44c3d77a4b4c1004728fb3b35943d8',
'7af62e3150462a95959813d6f1cb5790d8964b8c',
'289bb4e05e7b59c381586f5b9cb6f9ed8c8aaa9e',
'304c474d04fd264a08a09579491dc6885148519c',
'3a3537adb1f5f4986c51ce7291323b8d63cdacb6',
'17079681f0b24fb14146130110e41fa31fdfc2fd',
'92322c1a03ac054fa8dae65f58460c897941190c',
'e0eb7788d1ba7efcedddc4bd36d0e6d0cd681332',
'a8031d4bedb57a5bc2ae518e44758f6218e1cb0e',
'1b18b5576b932db211df122c0b47eeeb1e9eaed3',
'591c36b5b9f3317ccfc50182ca8c2a2090cde76a',
'a8605b9c54e09a19d4b4f93e60f0e2b6a837aef4',
'10a23de727a86fa8de557edb7792a68d379b8731',
'77ccc6d5a44e1316de9c18d56367cbfd1b7e64b6',
'e5a333971e4acbc2b1112f58d0f84f1f36ef7411',
'10d2332465a667ab338a69ee59cc4c9c0531de8f',
'a975108ca40a06718b504a411ddd7c4944113ac3',
'4c680e8810b69ded4e94d372d150bd910f0cc592',
'fc4db584550fecf28d8633ab2fea253a48b02413',
'2e2ac8b09e7617f12679855ef705cf6f45aee38d',
'39becdcb5c0922493600d840aca4c81a39dd9b1b',
'8e0b0656f10a79b1f1d68130ef90c9ef554cb306',
'262a48256a863d0714482a526845e8357056cb0b',
'ddd405b338e3796175eb858d81561887d6838df0',
'9e8d37099c3b89de81248eaebc438e6628cffa3a',
'31d34f1e7a957075e7336f0a2980a423f49cc8a2',
'6e740702583150ec0ac9b5433f18c3ac48795cf8',
'09f516d2cfdd5b8603e0189f6a9599625ef547f9',
'c45c9f779a65e6c9538b1c995cca10182fc4e7c0',
'de63014b5899442b1ee08b67ffc6fef935fe4ad5',
'774effda6a48790fe90e2508f198b3dc77338063',
'188651e373b3f3851cb4bc54fa672d6443eedc6c',
'db67f001449f57ed33668a334e2cf7842a28fb69',
'801aceb880d332f47fe0694077f4c1eea0fd9dc5',
'628c79c2a459327e3aefcb763901f4ef57c92673',
'364f5dc80a985f7f38079dae6bfc15af16f001da',
'd6b88995337fa404c939c851e6bb3188a73cc30d',
'26bd550e2aecd6d8d61a105b0fa78b41dd2ff777',
'46d29b70521de27e2ed74ede55e435c950f7b1d4',
'b35c64a657c0860749df6ba06bff2ecf7219b8e7',
'429559d77a1e659eb63c58eeddf7de744b2755b0',
'0428c718547ee3ecd074230fe27e4167e790ad1b',
'a4ddb00cfebe88b6bd45d9274e07661425266db3',
'f6e0e6dc4913eb203f287d9c4c400383e2c96709',
'cfa1d686d417bdfd94f83285cc68a7e5ab359737',
'628ac7eee9f9db10365f075d1a1e6d216e4e3970',
'0a0b29aea731c8a32736585ae76d2309714d978c',
'7a61ee126a43dfd3e89f60146a3f3311c90a8f4d',
'1d34d8a71fc19d1f909dc0834e2fd7aa2bfd189f',
'6ada0ae9369c0a6690586c659451d96b44715871',
'b83cc8c974753574edb264881189a4841aefbc1e',
'3aad9d904c4dae900799eccbf65acbc6540fe2e6',
'817987ac37265b9c2392a4e201eaf477ffc8f649',
'063c1f96b95f0e108ae28f43db88370a1a562e32',
'1abbcb6d977845301b32be753808ccd3a70ab219',
'8be6c8e2f3eff7ce8c8e68166257dfe2f799b8da',
'da93b9d9183d1e21e2bd758d6aa01eac3460f6b5',
'beff1d51f5d3cf3cdc9bc834ab97b6bb43a1cbff',
'6a78a26f9fcf2a2ecc4a88bc92d7a896cfa0eaa8',
'957e0e2b1561ec27a4c4857c7765473075eb634f',
'08a828f6132ee8f794a215d22b8feb5870fa40d5',
'd348f7796a282758715ea1b330b3553287c01a07',
'd179029903df1e1fb71fe436c377059676279608',
])
export function isBuiltInInstanceIcon(iconPath: string | null | undefined): boolean {
if (!iconPath) return false
const match = iconPath.match(/([a-f\d]{40})(?:\.[^/?#\\]+)?(?:[?#].*)?$/i)
return match ? builtInInstanceIconHashes.has(match[1].toLowerCase()) : false
}

View File

@ -0,0 +1,430 @@
import { defineMessage, defineMessages, type MessageDescriptor } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { isBuiltInInstanceIcon } from './instance-icon-frame'
export interface BuiltInInstanceIcon {
id: string
name: MessageDescriptor
url: string
}
export const builtInInstanceIcons: BuiltInInstanceIcon[] = [
{
id: 'bread',
name: defineMessage({ id: 'app.instance.icon-picker.icon.bread', defaultMessage: 'Bread' }),
url: new URL('../assets/instance-icons/bread.png', import.meta.url).href,
},
{
id: 'carrot',
name: defineMessage({ id: 'app.instance.icon-picker.icon.carrot', defaultMessage: 'Carrot' }),
url: new URL('../assets/instance-icons/carrot.png', import.meta.url).href,
},
{
id: 'cooked-chicken',
name: defineMessage({
id: 'app.instance.icon-picker.icon.cooked-chicken',
defaultMessage: 'Cooked Chicken',
}),
url: new URL('../assets/instance-icons/cooked-chicken.png', import.meta.url).href,
},
{
id: 'crafting-table',
name: defineMessage({
id: 'app.instance.icon-picker.icon.crafting-table',
defaultMessage: 'Crafting Table',
}),
url: new URL('../assets/instance-icons/crafting-table.png', import.meta.url).href,
},
{
id: 'diamond-axe',
name: defineMessage({
id: 'app.instance.icon-picker.icon.diamond-axe',
defaultMessage: 'Diamond Axe',
}),
url: new URL('../assets/instance-icons/diamond-axe.png', import.meta.url).href,
},
{
id: 'diamond-block',
name: defineMessage({
id: 'app.instance.icon-picker.icon.diamond-block',
defaultMessage: 'Diamond Block',
}),
url: new URL('../assets/instance-icons/diamond-block.png', import.meta.url).href,
},
{
id: 'diamond-sword',
name: defineMessage({
id: 'app.instance.icon-picker.icon.diamond-sword',
defaultMessage: 'Diamond Sword',
}),
url: new URL('../assets/instance-icons/diamond-sword.png', import.meta.url).href,
},
{
id: 'end-stone',
name: defineMessage({
id: 'app.instance.icon-picker.icon.end-stone',
defaultMessage: 'End Stone',
}),
url: new URL('../assets/instance-icons/end-stone.png', import.meta.url).href,
},
{
id: 'furnace',
name: defineMessage({ id: 'app.instance.icon-picker.icon.furnace', defaultMessage: 'Furnace' }),
url: new URL('../assets/instance-icons/furnace.png', import.meta.url).href,
},
{
id: 'glass-bottle',
name: defineMessage({
id: 'app.instance.icon-picker.icon.glass-bottle',
defaultMessage: 'Glass Bottle',
}),
url: new URL('../assets/instance-icons/glass-bottle.png', import.meta.url).href,
},
{
id: 'golden-apple',
name: defineMessage({
id: 'app.instance.icon-picker.icon.golden-apple',
defaultMessage: 'Golden Apple',
}),
url: new URL('../assets/instance-icons/golden-apple.png', import.meta.url).href,
},
{
id: 'gold-block',
name: defineMessage({
id: 'app.instance.icon-picker.icon.gold-block',
defaultMessage: 'Gold Block',
}),
url: new URL('../assets/instance-icons/gold-block.png', import.meta.url).href,
},
{
id: 'grass-block',
name: defineMessage({
id: 'app.instance.icon-picker.icon.grass-block',
defaultMessage: 'Grass Block',
}),
url: new URL('../assets/instance-icons/grass-block.png', import.meta.url).href,
},
{
id: 'iron-block',
name: defineMessage({
id: 'app.instance.icon-picker.icon.iron-block',
defaultMessage: 'Iron Block',
}),
url: new URL('../assets/instance-icons/iron-block.png', import.meta.url).href,
},
{
id: 'item-frame',
name: defineMessage({
id: 'app.instance.icon-picker.icon.item-frame',
defaultMessage: 'Item Frame',
}),
url: new URL('../assets/instance-icons/item-frame.png', import.meta.url).href,
},
{
id: 'netherrack',
name: defineMessage({
id: 'app.instance.icon-picker.icon.netherrack',
defaultMessage: 'Netherrack',
}),
url: new URL('../assets/instance-icons/netherrack.png', import.meta.url).href,
},
{
id: 'oak-sapling',
name: defineMessage({
id: 'app.instance.icon-picker.icon.oak-sapling',
defaultMessage: 'Oak Sapling',
}),
url: new URL('../assets/instance-icons/oak-sapling.png', import.meta.url).href,
},
{
id: 'stone',
name: defineMessage({ id: 'app.instance.icon-picker.icon.stone', defaultMessage: 'Stone' }),
url: new URL('../assets/instance-icons/stone.png', import.meta.url).href,
},
{
id: 'totem-of-undying',
name: defineMessage({
id: 'app.instance.icon-picker.icon.totem-of-undying',
defaultMessage: 'Totem of Undying',
}),
url: new URL('../assets/instance-icons/totem-of-undying.png', import.meta.url).href,
},
{
id: 'water-bucket',
name: defineMessage({
id: 'app.instance.icon-picker.icon.water-bucket',
defaultMessage: 'Water Bucket',
}),
url: new URL('../assets/instance-icons/water-bucket.png', import.meta.url).href,
},
{
id: 'anvil',
name: defineMessage({
id: 'app.instance.icon-picker.icon.anvil',
defaultMessage: 'Anvil',
}),
url: new URL('../assets/instance-icons/anvil.png', import.meta.url).href,
},
{
id: 'fabric',
name: defineMessage({
id: 'app.instance.icon-picker.icon.fabric',
defaultMessage: 'Fabric',
}),
url: new URL('../assets/instance-icons/Fabric.png', import.meta.url).href,
},
{
id: 'cleanroom',
name: defineMessage({
id: 'app.instance.icon-picker.icon.cleanroom',
defaultMessage: 'Cleanroom',
}),
url: new URL('../assets/instance-icons/Cleanroom.png', import.meta.url).href,
},
{
id: 'liteloader',
name: defineMessage({
id: 'app.instance.icon-picker.icon.liteloader',
defaultMessage: 'LiteLoader',
}),
url: new URL('../assets/instance-icons/LiteLoader.png', import.meta.url).href,
},
{
id: 'neoforge',
name: defineMessage({
id: 'app.instance.icon-picker.icon.neoforge',
defaultMessage: 'NeoForge',
}),
url: new URL('../assets/instance-icons/NeoForge.png', import.meta.url).href,
},
{
id: 'quilt',
name: defineMessage({
id: 'app.instance.icon-picker.icon.quilt',
defaultMessage: 'Quilt',
}),
url: new URL('../assets/instance-icons/Quilt.png', import.meta.url).href,
},
{
id: 'optifine',
name: defineMessage({
id: 'app.instance.icon-picker.icon.optifine',
defaultMessage: 'OptiFine',
}),
url: new URL('../assets/instance-icons/OptiFine.png', import.meta.url).href,
},
]
const modrinth3DIconAssets = import.meta.glob<string>(
'../assets/instance-icons/modrinth-3d/*.png',
{
eager: true,
query: '?url',
import: 'default',
},
)
const modrinth3DNames = defineMessages({
backpack: { id: 'app.instance.icon-picker.modrinth-3d.backpack', defaultMessage: 'Backpack' },
beacon: { id: 'app.instance.icon-picker.modrinth-3d.beacon', defaultMessage: 'Beacon' },
blueShark: {
id: 'app.instance.icon-picker.modrinth-3d.blue-shark',
defaultMessage: 'Blue Shark',
},
bookshelf: { id: 'app.instance.icon-picker.modrinth-3d.bookshelf', defaultMessage: 'Bookshelf' },
brownBear: {
id: 'app.instance.icon-picker.modrinth-3d.brown-bear',
defaultMessage: 'Brown Bear',
},
cake: { id: 'app.instance.icon-picker.modrinth-3d.cake', defaultMessage: 'Cake' },
campfire: { id: 'app.instance.icon-picker.modrinth-3d.campfire', defaultMessage: 'Campfire' },
chest: { id: 'app.instance.icon-picker.modrinth-3d.chest', defaultMessage: 'Chest' },
cogwheel: { id: 'app.instance.icon-picker.modrinth-3d.cogwheel', defaultMessage: 'Cogwheel' },
commandBlock: {
id: 'app.instance.icon-picker.modrinth-3d.command-block',
defaultMessage: 'Command Block',
},
cookingPot: {
id: 'app.instance.icon-picker.modrinth-3d.cooking-pot',
defaultMessage: 'Cooking Pot',
},
couch: { id: 'app.instance.icon-picker.modrinth-3d.couch', defaultMessage: 'Couch' },
craftingTable: {
id: 'app.instance.icon-picker.modrinth-3d.crafting-table',
defaultMessage: 'Crafting Table',
},
creeper: { id: 'app.instance.icon-picker.modrinth-3d.creeper', defaultMessage: 'Creeper' },
enchantingTable: {
id: 'app.instance.icon-picker.modrinth-3d.enchanting-table',
defaultMessage: 'Enchanting Table',
},
enderChest: {
id: 'app.instance.icon-picker.modrinth-3d.ender-chest',
defaultMessage: 'Ender Chest',
},
enderDragon: {
id: 'app.instance.icon-picker.modrinth-3d.ender-dragon',
defaultMessage: 'Ender Dragon',
},
engine: { id: 'app.instance.icon-picker.modrinth-3d.engine', defaultMessage: 'Engine' },
furnace: { id: 'app.instance.icon-picker.modrinth-3d.furnace', defaultMessage: 'Furnace' },
gizmo: { id: 'app.instance.icon-picker.modrinth-3d.gizmo', defaultMessage: 'Gizmo' },
globe: { id: 'app.instance.icon-picker.modrinth-3d.globe', defaultMessage: 'Globe' },
grassBlock: {
id: 'app.instance.icon-picker.modrinth-3d.grass-block',
defaultMessage: 'Grass Block',
},
lantern: { id: 'app.instance.icon-picker.modrinth-3d.lantern', defaultMessage: 'Lantern' },
moobloom: { id: 'app.instance.icon-picker.modrinth-3d.moobloom', defaultMessage: 'Moobloom' },
mrPack: { id: 'app.instance.icon-picker.modrinth-3d.mr-pack', defaultMessage: 'Mr Pack' },
orb: { id: 'app.instance.icon-picker.modrinth-3d.orb', defaultMessage: 'Orb of Origins' },
oxygenDistributor: {
id: 'app.instance.icon-picker.modrinth-3d.oxygen-distributor',
defaultMessage: 'Oxygen Distributor',
},
pancakes: { id: 'app.instance.icon-picker.modrinth-3d.pancakes', defaultMessage: 'Pancakes' },
pickaxe: { id: 'app.instance.icon-picker.modrinth-3d.pickaxe', defaultMessage: 'Pickaxe' },
pokeBall: { id: 'app.instance.icon-picker.modrinth-3d.poke-ball', defaultMessage: 'Poké Ball' },
redstoneBlock: {
id: 'app.instance.icon-picker.modrinth-3d.redstone-block',
defaultMessage: 'Redstone Block',
},
sculkSensor: {
id: 'app.instance.icon-picker.modrinth-3d.sculk-sensor',
defaultMessage: 'Sculk Sensor',
},
skeleton: { id: 'app.instance.icon-picker.modrinth-3d.skeleton', defaultMessage: 'Skeleton' },
skillet: { id: 'app.instance.icon-picker.modrinth-3d.skillet', defaultMessage: 'Skillet' },
slimeBlock: {
id: 'app.instance.icon-picker.modrinth-3d.slime-block',
defaultMessage: 'Slime Block',
},
spaceHelmet: {
id: 'app.instance.icon-picker.modrinth-3d.space-helmet',
defaultMessage: 'Space Helmet',
},
stickyPiston: {
id: 'app.instance.icon-picker.modrinth-3d.sticky-piston',
defaultMessage: 'Sticky Piston',
},
sword: { id: 'app.instance.icon-picker.modrinth-3d.sword', defaultMessage: 'Sword' },
terminal: { id: 'app.instance.icon-picker.modrinth-3d.terminal', defaultMessage: 'Terminal' },
tinyPotato: {
id: 'app.instance.icon-picker.modrinth-3d.tiny-potato',
defaultMessage: 'Tiny Potato',
},
tire: { id: 'app.instance.icon-picker.modrinth-3d.tire', defaultMessage: 'Tire' },
tnt: { id: 'app.instance.icon-picker.modrinth-3d.tnt', defaultMessage: 'TNT' },
wrenchRinth: {
id: 'app.instance.icon-picker.modrinth-3d.wrench-rinth',
defaultMessage: 'Modrinth Wrench',
},
wrench: { id: 'app.instance.icon-picker.modrinth-3d.wrench', defaultMessage: 'Wrench' },
zombie: { id: 'app.instance.icon-picker.modrinth-3d.zombie', defaultMessage: 'Zombie' },
})
function modrinth3DIcon(
id: string,
name: MessageDescriptor,
fileName: string,
): BuiltInInstanceIcon {
const assetPath = `../assets/instance-icons/modrinth-3d/${fileName}`
const url = modrinth3DIconAssets[assetPath]
if (!url) throw new Error(`Missing Modrinth 3D instance icon: ${fileName}`)
return {
id: `modrinth-3d-${id}`,
name,
url,
}
}
export const modrinth3DInstanceIcons: BuiltInInstanceIcon[] = [
modrinth3DIcon('backpack', modrinth3DNames.backpack, 'backpack.png'),
modrinth3DIcon('beacon', modrinth3DNames.beacon, 'beacon.png'),
modrinth3DIcon('blue-shark', modrinth3DNames.blueShark, 'blue-shark.png'),
modrinth3DIcon('bookshelf', modrinth3DNames.bookshelf, 'bookshelf.png'),
modrinth3DIcon('brown-bear', modrinth3DNames.brownBear, 'brown-bear.png'),
modrinth3DIcon('cake', modrinth3DNames.cake, 'cake.png'),
modrinth3DIcon('campfire', modrinth3DNames.campfire, 'campfire.png'),
modrinth3DIcon('chest', modrinth3DNames.chest, 'chest.png'),
modrinth3DIcon('cogwheel', modrinth3DNames.cogwheel, 'cogwheel.png'),
modrinth3DIcon('command-block', modrinth3DNames.commandBlock, 'command-block.png'),
modrinth3DIcon('cooking-pot', modrinth3DNames.cookingPot, 'cooking-pot.png'),
modrinth3DIcon('couch', modrinth3DNames.couch, 'couch.png'),
modrinth3DIcon('crafting-table', modrinth3DNames.craftingTable, 'crafting-table.png'),
modrinth3DIcon('creeper', modrinth3DNames.creeper, 'creeper.png'),
modrinth3DIcon('enchanting-table', modrinth3DNames.enchantingTable, 'enchanting-table.png'),
modrinth3DIcon('ender-chest', modrinth3DNames.enderChest, 'ender-chest.png'),
modrinth3DIcon('ender-dragon', modrinth3DNames.enderDragon, 'ender-dragon.png'),
modrinth3DIcon('engine', modrinth3DNames.engine, 'engine.png'),
modrinth3DIcon('furnace', modrinth3DNames.furnace, 'furnace.png'),
modrinth3DIcon('gizmo', modrinth3DNames.gizmo, 'gizmo.png'),
modrinth3DIcon('globe', modrinth3DNames.globe, 'globe.png'),
modrinth3DIcon('grass-block', modrinth3DNames.grassBlock, 'grass-block.png'),
modrinth3DIcon('lantern', modrinth3DNames.lantern, 'lantern.png'),
modrinth3DIcon('moobloom', modrinth3DNames.moobloom, 'moobloom.png'),
modrinth3DIcon('mr-pack', modrinth3DNames.mrPack, 'mr-pack.png'),
modrinth3DIcon('orb', modrinth3DNames.orb, 'orb.png'),
modrinth3DIcon('oxygen-distributor', modrinth3DNames.oxygenDistributor, 'oxygen-distributor.png'),
modrinth3DIcon('pancakes', modrinth3DNames.pancakes, 'pancakes.png'),
modrinth3DIcon('pickaxe', modrinth3DNames.pickaxe, 'pickaxe.png'),
modrinth3DIcon('poke-ball', modrinth3DNames.pokeBall, 'poke-ball.png'),
modrinth3DIcon('redstone-block', modrinth3DNames.redstoneBlock, 'redstone-block.png'),
modrinth3DIcon('sculk-sensor', modrinth3DNames.sculkSensor, 'sculk-sensor.png'),
modrinth3DIcon('skeleton', modrinth3DNames.skeleton, 'skeleton.png'),
modrinth3DIcon('skillet', modrinth3DNames.skillet, 'skillet.png'),
modrinth3DIcon('slime-block', modrinth3DNames.slimeBlock, 'slime-block.png'),
modrinth3DIcon('space-helmet', modrinth3DNames.spaceHelmet, 'space-helmet.png'),
modrinth3DIcon('sticky-piston', modrinth3DNames.stickyPiston, 'sticky-piston.png'),
modrinth3DIcon('sword', modrinth3DNames.sword, 'sword.png'),
modrinth3DIcon('terminal', modrinth3DNames.terminal, 'terminal.png'),
modrinth3DIcon('tiny-potato', modrinth3DNames.tinyPotato, 'tiny-potato.png'),
modrinth3DIcon('tire', modrinth3DNames.tire, 'tire.png'),
modrinth3DIcon('tnt', modrinth3DNames.tnt, 'tnt.png'),
modrinth3DIcon('wrench-rinth', modrinth3DNames.wrenchRinth, 'wrench-rinth.png'),
modrinth3DIcon('wrench', modrinth3DNames.wrench, 'wrench.png'),
modrinth3DIcon('zombie', modrinth3DNames.zombie, 'zombie.png'),
]
const builtInInstanceIconMap = new Map(builtInInstanceIcons.map((icon) => [icon.id, icon]))
const loaderIconIds: Record<string, string> = {
vanilla: 'grass-block',
fabric: 'fabric',
forge: 'anvil',
neoforge: 'neoforge',
quilt: 'quilt',
optifine: 'optifine',
cleanroom: 'cleanroom',
lite_loader: 'liteloader',
legacy_fabric: 'fabric',
babric: 'fabric',
}
export interface DisplayInstanceIcon {
url: string | null
frameless: boolean
}
export function getLoaderInstanceIcon(
loader: string | null | undefined,
): BuiltInInstanceIcon | undefined {
if (!loader) return undefined
const iconId = loaderIconIds[loader]
return (
(iconId ? builtInInstanceIconMap.get(iconId) : undefined) ?? builtInInstanceIconMap.get('stone')
)
}
export function getDisplayInstanceIcon(
iconPath: string | null | undefined,
loader: string | null | undefined,
): DisplayInstanceIcon {
const fallbackIcon = getLoaderInstanceIcon(loader)
return {
url: iconPath ? convertFileSrc(iconPath) : (fallbackIcon?.url ?? null),
frameless: isBuiltInInstanceIcon(iconPath) || !!fallbackIcon,
}
}

View File

@ -0,0 +1,275 @@
import { invoke } from '@tauri-apps/api/core'
import type { InstallJobSnapshot } from './install'
import type { InstanceLoader } from './types'
export type ContentProvider = 'modrinth' | 'curseforge' | 'local'
export type InstanceUpgradeProjectType =
| 'mod'
| 'datapack'
| 'resourcepack'
| 'shaderpack'
| 'schematic'
| 'worldsave'
export type ShaderRuntime = 'iris' | 'opti_fine' | 'none' | 'unknown'
export type InstanceUpgradeItemStatus =
| 'upgrade_available'
| 'already_compatible'
| 'no_compatible_release'
| 'prerelease_only'
| 'unidentified'
| 'dependency_conflict'
| 'missing_required_dependency'
| 'incompatible_dependency'
| 'unsupported_content_type'
| 'no_compatible_shader_runtime'
| 'shader_runtime_missing'
| 'shader_runtime_unknown'
export type InstanceUpgradeAction = 'upgrade' | 'keep' | 'disable'
export type InstanceUpgradeIssueCode =
| 'prerelease_only'
| 'unidentified'
| 'dependency_conflict'
| 'missing_required_dependency'
| 'incompatible_dependency'
| 'unsupported_content_type'
| 'no_compatible_release'
| 'no_compatible_shader_runtime'
| 'shader_runtime_missing'
| 'shader_runtime_unknown'
| 'search_limit_reached'
| 'keep_incompatible'
export type InstanceUpgradeDependencyChangeKind = 'add' | 'upgrade' | 'keep' | 'remove'
export type InstanceUpgradeSolutionKind = 'newest' | 'minimal_change' | 'custom'
export type InstanceUpgradeSolutionChoice = InstanceUpgradeSolutionKind
export type SharedUpgradeMode = 'direct' | 'copy_and_upgrade'
export type InstanceUpgradeExternalChangeKind = 'added' | 'removed' | 'modified'
export interface InstanceUpgradeTargetEnvironment {
gameVersion: string
modLoader: InstanceLoader
modLoaderVersion: string | null
shaderRuntime: ShaderRuntime
}
export interface InstanceUpgradePrereleaseConfirmation {
provider: ContentProvider
projectId: string
versionId: string
}
export interface InstanceUpgradeResolution {
contentId: string
action: InstanceUpgradeAction
allowPrerelease: boolean
confirmedPrereleaseDependencies: InstanceUpgradePrereleaseConfirmation[]
}
export interface InstanceUpgradeResolutionResult {
contentId: string
code: string | null
message: string | null
}
export interface InstanceUpgradeResolutionBatchResult {
plan: InstanceUpgradePlan
requestedCount: number
applied: InstanceUpgradeResolutionResult[]
skipped: InstanceUpgradeResolutionResult[]
failed: InstanceUpgradeResolutionResult[]
}
export interface InstanceUpgradePlanItem {
contentId: string
relativePath: string
projectType: InstanceUpgradeProjectType
provider: ContentProvider | null
projectId: string | null
currentReleaseId: string | null
currentEnabled: boolean
autoDependency: boolean
status: InstanceUpgradeItemStatus
resolution: InstanceUpgradeResolution
candidateReleaseIds: string[]
}
export interface InstanceUpgradeDependencyRequirement {
rootContentId: string
rootProvider: ContentProvider
rootProjectId: string
parentProvider: ContentProvider
parentProjectId: string
parentReleaseId: string
dependencyProvider: ContentProvider
dependencyProjectId: string
requiredReleaseId: string | null
candidateReleaseId: string | null
}
export interface InstanceUpgradeIssue {
code: InstanceUpgradeIssueCode
message: string
contentId: string | null
provider: ContentProvider | null
projectId: string | null
conflictingProjectId: string | null
dependencyRequirements: InstanceUpgradeDependencyRequirement[]
}
export interface InstanceUpgradeDependencyChange {
existingContentId: string | null
provider: ContentProvider
projectId: string
currentReleaseId: string | null
targetReleaseId: string | null
kind: InstanceUpgradeDependencyChangeKind
enabled: boolean
}
export interface InstanceUpgradeSourceFile {
relativePath: string
sha1: string
size: number
enabled: boolean
}
export interface InstanceUpgradeSelection {
contentId: string
provider: ContentProvider | null
projectId: string | null
currentReleaseId: string | null
targetReleaseId: string | null
action: InstanceUpgradeAction
enabled: boolean
}
export interface InstanceUpgradeSolution {
kind: InstanceUpgradeSolutionKind
selections: InstanceUpgradeSelection[]
dependencyChanges: InstanceUpgradeDependencyChange[]
warnings: InstanceUpgradeIssue[]
}
export interface InstanceUpgradeFixedConstraint {
contentId: string
provider: ContentProvider
projectId: string
versionId: string
}
export interface InstanceUpgradePlan {
id: string
instanceId: string
sourceRevision: number
sourceFiles: InstanceUpgradeSourceFile[]
sourceEnvironment: InstanceUpgradeTargetEnvironment
targetEnvironment: InstanceUpgradeTargetEnvironment
items: InstanceUpgradePlanItem[]
dependencyChanges: InstanceUpgradeDependencyChange[]
warnings: InstanceUpgradeIssue[]
blockingIssues: InstanceUpgradeIssue[]
newestSolution: InstanceUpgradeSolution | null
minimalChangeSolution: InstanceUpgradeSolution | null
selectedSolution: InstanceUpgradeSolution | null
customConstraints: InstanceUpgradeFixedConstraint[]
}
export interface InstanceUpgradeExternalChange {
relativePath: string
kind: InstanceUpgradeExternalChangeKind
}
export interface InstanceUpgradeCompatibilityWarning {
code: InstanceUpgradeIssueCode
relativePath: string | null
contentId: string | null
provider: ContentProvider | null
projectId: string | null
conflictingProjectId: string | null
}
export interface InstanceUpgradeDisplayNames {
backup: string | null
copy: string | null
upgradedTarget: string | null
shouldAutoRename: boolean
}
export interface InstanceUpgradeResult {
planId: string
sourceInstanceId: string
targetInstanceId: string
backupInstanceId: string | null
sourceEnvironment?: InstanceUpgradeTargetEnvironment | null
targetEnvironment?: InstanceUpgradeTargetEnvironment | null
solution: InstanceUpgradeSolution
compatibilityWarnings: InstanceUpgradeIssue[]
compatibilityWarningDetails?: InstanceUpgradeCompatibilityWarning[]
externalChanges: InstanceUpgradeExternalChange[]
skippedDueToExternalConflict: string[]
}
export async function plan_instance_upgrade(
instanceId: string,
targetEnvironment: InstanceUpgradeTargetEnvironment,
): Promise<InstanceUpgradePlan> {
return await invoke('plugin:instance|instance_plan_upgrade', { instanceId, targetEnvironment })
}
export async function select_instance_upgrade_solution(
planId: string,
choice: InstanceUpgradeSolutionChoice,
): Promise<InstanceUpgradePlan> {
return await invoke('plugin:instance|instance_select_upgrade_solution', { planId, choice })
}
export async function update_instance_upgrade_resolution(
planId: string,
resolution: InstanceUpgradeResolution,
): Promise<InstanceUpgradePlan> {
return await invoke('plugin:instance|instance_update_upgrade_resolution', { planId, resolution })
}
export async function update_instance_upgrade_resolutions(
planId: string,
resolutions: InstanceUpgradeResolution[],
): Promise<InstanceUpgradeResolutionBatchResult> {
return await invoke('plugin:instance|instance_update_upgrade_resolutions', {
planId,
resolutions,
})
}
export async function reset_instance_upgrade_resolution(
planId: string,
contentId: string,
): Promise<InstanceUpgradePlan> {
return await invoke('plugin:instance|instance_reset_upgrade_resolution', {
planId,
contentId,
})
}
export async function resolve_custom_instance_upgrade_solution(
planId: string,
fixedConstraints: InstanceUpgradeFixedConstraint[],
): Promise<InstanceUpgradePlan> {
return await invoke('plugin:instance|instance_resolve_custom_upgrade_solution', {
planId,
fixedConstraints,
})
}
export async function execute_instance_upgrade(
planId: string,
createFullBackup: boolean,
sharedUpgradeMode: SharedUpgradeMode,
displayNames: InstanceUpgradeDisplayNames,
): Promise<InstallJobSnapshot> {
return await invoke('plugin:instance|instance_execute_upgrade', {
planId,
createFullBackup,
sharedUpgradeMode,
displayNames,
})
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,65 @@
import { defineMessage, type MessageDescriptor } from '@modrinth/ui'
import { createGcPresets } from '@/helpers/gc/gc-presets'
import type { GcContext, JavaArgumentPreset } from '@/helpers/gc/types'
import {
FALLEN_AUTH_PROXY_BLOG_URL,
FALLEN_AUTH_PROXY_JAVA_ARGS_STRING,
} from '@/helpers/java-arguments'
export type { JavaArgumentPreset }
export const JAVA_ARGUMENT_PRESET_GROUP_TITLES: Record<string, MessageDescriptor> = {
gc: defineMessage({
id: 'app.java-arguments.presets.gc-group-title',
defaultMessage: 'Memory recycling strategy (GC)',
}),
auth: defineMessage({
id: 'app.java-arguments.presets.auth-group-title',
defaultMessage: 'Authentication service',
}),
}
export const JAVA_ARGUMENT_PRESETS: JavaArgumentPreset[] = [
{
id: 'mojang-auth-mirror',
group: 'auth',
title: defineMessage({
id: 'app.java-arguments.presets.auth-mirror.title',
defaultMessage: 'Authentication service mirror',
}),
description: defineMessage({
id: 'app.java-arguments.presets.auth-mirror.description',
defaultMessage:
'HTTP forwarding for the Mojang authentication servers hosted by Fallen-Breath.',
}),
args: FALLEN_AUTH_PROXY_JAVA_ARGS_STRING,
link: FALLEN_AUTH_PROXY_BLOG_URL,
},
]
export function getJavaArgumentPresets(gcContext?: GcContext): JavaArgumentPreset[] {
return [...JAVA_ARGUMENT_PRESETS, ...createGcPresets(gcContext)]
}
export interface JavaArgumentPresetGroup {
group: string
title: MessageDescriptor
presets: JavaArgumentPreset[]
}
export function getPresetsByGroup(presets: JavaArgumentPreset[]): JavaArgumentPresetGroup[] {
const groups = new Map<string, JavaArgumentPreset[]>()
for (const preset of presets) {
const group = preset.group
if (!groups.has(group)) {
groups.set(group, [])
}
groups.get(group)!.push(preset)
}
return Array.from(groups, ([group, groupPresets]) => ({
group,
title: JAVA_ARGUMENT_PRESET_GROUP_TITLES[group] ?? JAVA_ARGUMENT_PRESET_GROUP_TITLES.auth,
presets: groupPresets,
}))
}

View File

@ -0,0 +1,28 @@
export const FALLEN_AUTH_PROXY_BLOG_URL =
'https://blog.fallenbreath.me/zh-CN/2025/minecraft-service-proxy'
export const FALLEN_AUTH_PROXY_JAVA_ARGS = [
'-Dminecraft.api.auth.host=https://auth.msp.fallenbreath.me',
'-Dminecraft.api.account.host=https://account.msp.fallenbreath.me',
'-Dminecraft.api.session.host=https://session.msp.fallenbreath.me',
'-Dminecraft.api.services.host=https://services.msp.fallenbreath.me',
'-Dminecraft.api.profiles.host=https://profiles.msp.fallenbreath.me',
]
export const FALLEN_AUTH_PROXY_JAVA_ARGS_STRING = FALLEN_AUTH_PROXY_JAVA_ARGS.join(' ')
export const AUTO_GC_PRESET_ARG = '@axolotl:gc:auto'
const FALLEN_AUTH_PROXY_ARG_SET = new Set(FALLEN_AUTH_PROXY_JAVA_ARGS)
export function removeFallenAuthProxyArgs(args: string[]): string[] {
return args.filter((arg) => !FALLEN_AUTH_PROXY_ARG_SET.has(arg))
}
export function ensureFallenAuthProxyArgs(args: string[]): string[] {
return [...FALLEN_AUTH_PROXY_JAVA_ARGS, ...removeFallenAuthProxyArgs(args)]
}
export function hasFallenAuthProxyArgs(args: string[]): boolean {
return FALLEN_AUTH_PROXY_JAVA_ARGS.every((arg) => args.includes(arg))
}

View File

@ -0,0 +1,116 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* 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'
/*
JavaVersion {
path: Path
version: String
}
*/
export async function get_java_versions() {
return await invoke('plugin:jre|get_java_versions')
}
export async function get_java_default_versions() {
return await invoke('plugin:jre|get_java_default_versions')
}
export async function set_java_version(javaVersion) {
return await invoke('plugin:jre|set_java_version', { javaVersion })
}
export async function set_java_default_version(majorVersion, path) {
return await invoke('plugin:jre|set_java_default_version', { majorVersion, path })
}
export async function remove_java_default_version(majorVersion) {
return await invoke('plugin:jre|remove_java_default_version', { majorVersion })
}
export async function remove_java_version(path) {
return await invoke('plugin:jre|remove_java_version', { path })
}
// Finds all the installation of the given Java version, if it exists
// Returns [JavaVersion]
export async function find_filtered_jres(
version,
fullScan = false,
forceFresh = false,
exhaustive = false,
) {
return await invoke('plugin:jre|jre_find_filtered_jres', {
version,
fullScan,
forceFresh,
exhaustive,
})
}
// Gets java version from a specific path by trying to run 'java -version' on it.
// This also validates it, as it returns null if no valid java version is found at the path
export async function get_jre(path) {
return await invoke('plugin:jre|jre_get_jre', { path })
}
// Tests JRE version by running 'java -version' on it.
// Returns true if the version is valid, and matches given (after extraction)
export async function test_jre(path, majorVersion) {
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
}
// Automatically installs specified java version
export async function auto_install_java(javaVersion) {
return await invoke('plugin:jre|jre_auto_install_java', { javaVersion })
}
export async function respond_to_java_download_confirmation(requestId, approved) {
return await invoke('plugin:jre|jre_respond_to_download_confirmation', {
requestId,
approved,
})
}
export async function list_java_distribution_versions(distribution) {
return await invoke('plugin:jre|list_java_distribution_versions', { distribution })
}
// Get max memory in KiB
export async function get_max_memory() {
return await invoke('plugin:jre|jre_get_max_memory')
}
export async function get_memory_status(instanceId, requestedMemoryMb, automatic) {
return await invoke('plugin:jre|jre_get_memory_status', {
instanceId,
requestedMemoryMb,
automatic,
})
}
export async function optimize_memory() {
return await invoke('plugin:jre|jre_optimize_memory')
}
export async function list_java_feed_vendors() {
return await invoke('plugin:jre|list_java_feed_vendors')
}
export async function list_java_feed_versions(vendor) {
return await invoke('plugin:jre|list_java_feed_versions', { vendor })
}
export async function download_java_from_feed(vendor, jdkVersionMajor) {
return await invoke('plugin:jre|download_java_from_feed', { vendor, jdkVersionMajor })
}
export async function download_java(vendor, version) {
return await invoke('plugin:jre|download_java', { vendor, version })
}

View File

@ -0,0 +1,53 @@
export type LabCategoryFilter = 'all' | 'creation' | 'maintenance' | 'world'
export type LabFavoriteFilter = 'all' | 'favorite' | 'unfavorite'
const LAB_FAVORITE_TOOL_IDS_STORAGE_KEY = 'axolotl-lab-favorite-tool-ids'
const LAB_CATEGORY_FILTER_STORAGE_KEY = 'axolotl-lab-category-filter'
const LAB_FAVORITE_FILTER_STORAGE_KEY = 'axolotl-lab-favorite-filter'
function readStringArray(key: string): string[] {
try {
const parsed = JSON.parse(globalThis.localStorage?.getItem(key) ?? '[]')
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string')
: []
} catch {
return []
}
}
export function getLabFavoriteToolIds(): string[] {
return readStringArray(LAB_FAVORITE_TOOL_IDS_STORAGE_KEY)
}
export function setLabFavoriteToolIds(ids: string[]) {
globalThis.localStorage?.setItem(LAB_FAVORITE_TOOL_IDS_STORAGE_KEY, JSON.stringify(ids))
}
export function isLabCategoryFilter(value: string): value is LabCategoryFilter {
return value === 'creation' || value === 'maintenance' || value === 'world'
}
export function getLabCategoryFilter(): LabCategoryFilter {
const value = globalThis.localStorage?.getItem(LAB_CATEGORY_FILTER_STORAGE_KEY)
return value && isLabCategoryFilter(value) ? value : 'all'
}
export function setLabCategoryFilter(filter: LabCategoryFilter) {
if (filter === 'all') globalThis.localStorage?.removeItem(LAB_CATEGORY_FILTER_STORAGE_KEY)
else globalThis.localStorage?.setItem(LAB_CATEGORY_FILTER_STORAGE_KEY, filter)
}
export function isLabFavoriteFilter(value: string): value is LabFavoriteFilter {
return value === 'favorite' || value === 'unfavorite'
}
export function getLabFavoriteFilter(): LabFavoriteFilter {
const value = globalThis.localStorage?.getItem(LAB_FAVORITE_FILTER_STORAGE_KEY)
return value && isLabFavoriteFilter(value) ? value : 'all'
}
export function setLabFavoriteFilter(filter: LabFavoriteFilter) {
if (filter === 'all') globalThis.localStorage?.removeItem(LAB_FAVORITE_FILTER_STORAGE_KEY)
else globalThis.localStorage?.setItem(LAB_FAVORITE_FILTER_STORAGE_KEY, filter)
}

View File

@ -0,0 +1,12 @@
export type LibraryDisplayMode = 'standard' | 'cards'
const LIBRARY_DISPLAY_MODE_STORAGE_KEY = 'axolotl-library-display-mode'
export function getLastLibraryDisplayMode(): LibraryDisplayMode {
const value = globalThis.localStorage?.getItem(LIBRARY_DISPLAY_MODE_STORAGE_KEY)
return value === 'cards' ? value : 'standard'
}
export function setLastLibraryDisplayMode(mode: LibraryDisplayMode) {
globalThis.localStorage?.setItem(LIBRARY_DISPLAY_MODE_STORAGE_KEY, mode)
}

View File

@ -0,0 +1,238 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createLatestRequestGuard,
gameVersionSelectorText,
isLoaderSupportStateDisabled,
loaderMetadataCacheKey,
loaderMetadataQueryKey,
loaderSupportState,
loaderVersionSelectorText,
loaderVersionsForGameVersion,
loaderVersionSummaryState,
preserveOrSelectGameVersion,
scopedLoaderMetadataQueryKey,
} from '../../../../packages/ui/src/components/flows/creation-flow-modal/loader-metadata.ts'
import {
clientInstallableLoaders,
instanceInstallablePlatforms,
} from '../../../../packages/ui/src/utils/loaders.ts'
const manifest = (gameVersion: string, versions: string[]) => ({
gameVersions: [
{
id: gameVersion,
loaders: versions.map((id) => ({ id, stable: true })),
},
],
})
test('shares the complete client loader list with instance installation settings', () => {
assert.deepEqual(
[...clientInstallableLoaders],
[
'fabric',
'neoforge',
'forge',
'quilt',
'optifine',
'cleanroom',
'lite_loader',
'legacy_fabric',
'babric',
],
)
assert.deepEqual([...instanceInstallablePlatforms], ['vanilla', ...clientInstallableLoaders])
})
test('instance settings scopes Forge and Fabric queries by Minecraft version', () => {
const forgeA = scopedLoaderMetadataQueryKey('instance-settings', 'forge', '1.20.1')
const forgeB = scopedLoaderMetadataQueryKey('instance-settings', 'forge', '26.2')
const forgeAReturn = scopedLoaderMetadataQueryKey('instance-settings', 'forge', '1.20.1')
const fabricA = scopedLoaderMetadataQueryKey('instance-settings', 'fabric', '1.20.1')
const fabricB = scopedLoaderMetadataQueryKey('instance-settings', 'fabric', '1.21.1')
assert.deepEqual(forgeA, ['instance-settings', 'loader-versions', 'forge', '1.20.1'])
assert.deepEqual(forgeB, ['instance-settings', 'loader-versions', 'forge', '26.2'])
assert.deepEqual(forgeAReturn, forgeA)
assert.notDeepEqual(forgeA, forgeB)
assert.notDeepEqual(fabricA, fabricB)
const cache = new Map([
[JSON.stringify(forgeA), manifest('1.20.1', ['47.4.22'])],
[JSON.stringify(forgeB), manifest('26.2', ['65.1.1', '65.1.0'])],
[JSON.stringify(fabricA), manifest('1.20.1', ['0.18.4'])],
[JSON.stringify(fabricB), manifest('1.21.1', ['0.18.4', '0.17.3'])],
])
const ids = (key: readonly string[], gameVersion: string) =>
loaderVersionsForGameVersion(cache.get(JSON.stringify(key)), gameVersion).map(
(version) => version.id,
)
assert.deepEqual(ids(forgeA, '1.20.1'), ['47.4.22'])
assert.deepEqual(ids(forgeB, '26.2'), ['65.1.1', '65.1.0'])
assert.deepEqual(ids(forgeAReturn, '1.20.1'), ['47.4.22'])
assert.deepEqual(ids(fabricA, '1.20.1'), ['0.18.4'])
assert.deepEqual(ids(fabricB, '1.21.1'), ['0.18.4', '0.17.3'])
})
test('isolates loader metadata by loader and Minecraft version', () => {
const forge262Key = loaderMetadataCacheKey('forge', '26.2')
const forge1201Key = loaderMetadataCacheKey('forge', '1.20.1')
const cache = {
[forge262Key]: manifest('26.2', ['65.1.1', '65.1.0']),
[forge1201Key]: manifest('1.20.1', ['47.4.22', '47.4.21']),
}
assert.notEqual(forge262Key, forge1201Key)
assert.deepEqual(loaderMetadataQueryKey('forge', '26.2'), [
'creation-flow',
'loader-versions',
'forge',
'26.2',
])
assert.deepEqual(loaderMetadataQueryKey('forge', '1.20.1'), [
'creation-flow',
'loader-versions',
'forge',
'1.20.1',
])
assert.deepEqual(
loaderVersionsForGameVersion(cache[forge262Key], '26.2').map((version) => version.id),
['65.1.1', '65.1.0'],
)
assert.deepEqual(
loaderVersionsForGameVersion(cache[forge1201Key], '1.20.1').map((version) => version.id),
['47.4.22', '47.4.21'],
)
assert.deepEqual(
loaderVersionsForGameVersion(cache[forge262Key], '26.2').map((version) => version.id),
['65.1.1', '65.1.0'],
)
})
test('rejects stale loader metadata requests after rapid selection changes', () => {
const guard = createLatestRequestGuard()
const minecraftARequest = guard.begin()
const minecraftBRequest = guard.begin()
const minecraftAReturnRequest = guard.begin()
assert.equal(guard.isCurrent(minecraftARequest), false)
assert.equal(guard.isCurrent(minecraftBRequest), false)
assert.equal(guard.isCurrent(minecraftAReturnRequest), true)
const fabricRequest = guard.begin()
const forgeRequest = guard.begin()
const neoForgeRequest = guard.begin()
assert.equal(guard.isCurrent(fabricRequest), false)
assert.equal(guard.isCurrent(forgeRequest), false)
assert.equal(guard.isCurrent(neoForgeRequest), true)
})
test('does not treat missing, loading, or errored metadata as unsupported', () => {
assert.equal(loaderSupportState('unknown', undefined, '1.20.1'), 'unknown')
assert.equal(loaderSupportState('loading', undefined, '1.20.1'), 'loading')
assert.equal(loaderSupportState('error', undefined, '1.20.1'), 'error')
})
test('marks only a successfully resolved empty loader set as unsupported', () => {
assert.equal(
loaderSupportState('success', manifest('1.20.1', ['47.4.22']), '1.20.1'),
'supported',
)
assert.equal(
loaderSupportState('success', manifest('unsupported', []), 'unsupported'),
'unsupported',
)
})
test('restores loader support state across Minecraft version changes', () => {
const cache = {
[loaderMetadataCacheKey('forge', '1.20.1')]: manifest('1.20.1', ['47.4.22']),
[loaderMetadataCacheKey('forge', 'unsupported')]: manifest('unsupported', []),
}
const support = (gameVersion: string) =>
loaderSupportState(
'success',
cache[loaderMetadataCacheKey('forge', gameVersion) as keyof typeof cache],
gameVersion,
)
assert.equal(support('1.20.1'), 'supported')
assert.equal(support('unsupported'), 'unsupported')
assert.equal(support('1.20.1'), 'supported')
})
test('disables loader chips until compatibility is positively confirmed', () => {
assert.equal(isLoaderSupportStateDisabled('unknown'), true)
assert.equal(isLoaderSupportStateDisabled('loading'), true)
assert.equal(isLoaderSupportStateDisabled('unsupported'), true)
assert.equal(isLoaderSupportStateDisabled('supported'), false)
assert.equal(isLoaderSupportStateDisabled('error'), true)
})
test('does not replace an explicit Minecraft version when loader options change', () => {
assert.equal(preserveOrSelectGameVersion('26.2', ['26.1.2', '1.21.11']), '26.2')
assert.equal(preserveOrSelectGameVersion(null, ['26.1.2', '1.21.11']), '26.1.2')
assert.equal(preserveOrSelectGameVersion(null, []), null)
})
test('tracks loader chips through pending, supported, and unsupported responses', () => {
const pendingB = loaderSupportState('loading', undefined, '26.2')
assert.equal(isLoaderSupportStateDisabled(pendingB), true)
const unsupportedB = loaderSupportState('success', manifest('unsupported', []), 'unsupported')
assert.equal(unsupportedB, 'unsupported')
assert.equal(isLoaderSupportStateDisabled(unsupportedB), true)
const pendingC = loaderSupportState('loading', undefined, '1.20.1')
assert.equal(isLoaderSupportStateDisabled(pendingC), true)
const supportedC = loaderSupportState('success', manifest('1.20.1', ['47.4.22']), '1.20.1')
assert.equal(supportedC, 'supported')
assert.equal(isLoaderSupportStateDisabled(supportedC), false)
})
test('uses the loading label for both loader version selector placeholders while pending', () => {
assert.deepEqual(
loaderVersionSelectorText(true, false, {
loading: 'Loading',
empty: 'No versions available',
placeholder: 'Select loader version',
searchPlaceholder: 'Search loader version...',
}),
{ placeholder: 'Loading', searchPlaceholder: 'Loading' },
)
})
test('hides an old loader version behind the loading summary until the new version resolves', () => {
assert.equal(loaderVersionSummaryState(true, '47.4.22'), 'loading')
assert.equal(loaderVersionSummaryState(false, '65.1.0'), 'selected')
assert.equal(loaderVersionSummaryState(false, null), 'empty')
})
test('shows a loader-specific message after game version metadata resolves empty', () => {
const labels = {
loading: 'Loading',
empty: 'No game versions support this loader',
error: 'Failed to load game versions',
placeholder: 'Select game version',
searchPlaceholder: 'Search game version...',
}
assert.deepEqual(gameVersionSelectorText('loading', labels), {
placeholder: 'Loading',
searchPlaceholder: 'Loading',
})
assert.deepEqual(gameVersionSelectorText('empty', labels), {
placeholder: 'No game versions support this loader',
searchPlaceholder: 'No game versions support this loader',
})
assert.deepEqual(gameVersionSelectorText('error', labels), {
placeholder: 'Failed to load game versions',
searchPlaceholder: 'Failed to load game versions',
})
})

View File

@ -0,0 +1,113 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* 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 { save } from '@tauri-apps/plugin-dialog'
/*
A log is a struct containing the filename string and optional output, as follows:
pub struct Logs {
pub filename: String,
pub output: Option<String>,
}
*/
/// Get all logs that exist for a given instance
/// This is returned as an array of Log objects, sorted by filename (the folder name, when the log was created)
export async function get_logs(instanceId, clearContents) {
return await invoke('plugin:logs|logs_get_logs', { instanceId, clearContents })
}
/// Get an instance's log by filename
export async function get_logs_by_filename(instanceId, logType, filename) {
return await invoke('plugin:logs|logs_get_logs_by_filename', { instanceId, logType, filename })
}
/// Get an instance's log text only by filename
export async function get_output_by_filename(instanceId, logType, filename) {
return await invoke('plugin:logs|logs_get_output_by_filename', {
instanceId,
logType,
filename,
})
}
/// Delete an instance's log by filename
export async function delete_logs_by_filename(instanceId, logType, filename) {
return await invoke('plugin:logs|logs_delete_logs_by_filename', {
instanceId,
logType,
filename,
})
}
/// Delete all logs for a given instance
export async function delete_logs(instanceId) {
return await invoke('plugin:logs|logs_delete_logs', { instanceId })
}
/// Get the latest log for a given instance and cursor (startpoint to read within the file)
/// Returns:
/*
{
cursor: u64
output: String
new_file: bool <- the cursor was too far, meaning that the file was likely rotated/reset. This signals to the frontend to clear the log and start over with this struct.
}
*/
// From the launcher's launcher_log.txt directly
export async function get_latest_log_cursor(instanceId, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { instanceId, cursor })
}
/// Read Minecraft's logs/latest.log from a cursor.
export async function get_minecraft_latest_log_cursor(instanceId, cursor) {
return await invoke('plugin:logs|logs_get_minecraft_latest_log_cursor', { instanceId, cursor })
}
/// Get all buffered live log lines for an instance from the Rust ring buffer
export async function get_live_log_buffer(instanceId) {
return await invoke('plugin:logs|logs_get_live_log_buffer', { instanceId })
}
/// Clear the live log buffer for an instance on the Rust side
export async function clear_log_buffer(instanceId) {
return await invoke('plugin:logs|logs_clear_live_log_buffer', { instanceId })
}
/// Collect and locally analyze the logs from an instance's latest run.
export async function analyze_crash(instanceId) {
return await invoke('plugin:logs|logs_analyze_crash', { instanceId })
}
export async function get_crash_analysis_ai_settings() {
return await invoke('plugin:logs|logs_get_crash_analysis_ai_settings')
}
export async function update_crash_analysis_ai_settings(settings) {
return await invoke('plugin:logs|logs_update_crash_analysis_ai_settings', { settings })
}
export async function explain_crash_with_ai(instanceId) {
return await invoke('plugin:logs|logs_explain_crash_with_ai', { instanceId })
}
export async function undo_added_mod(instanceId, filename, expectedHash) {
return await invoke('plugin:logs|logs_undo_added_mod', { instanceId, filename, expectedHash })
}
/// Export the censored files and local analysis from an instance's latest run.
export async function export_crash_context(instanceId, instanceName) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const outputPath = await save({
defaultPath: `${instanceName || 'Minecraft'} crash context ${timestamp}.zip`,
filters: [{ name: 'ZIP archive', extensions: ['zip'] }],
})
if (!outputPath) return false
await invoke('plugin:logs|logs_export_crash_context', { instanceId, outputPath })
return true
}

View File

@ -0,0 +1,27 @@
import { invoke } from '@tauri-apps/api/core'
export type MinecraftNewsItem = {
title: string
category?: string | null
tag?: string | null
date?: string | null
image_url?: string | null
read_more_url: string
}
const NEWS_TTL_MS = 30 * 60 * 1000
let cachedAt = 0
let cached: Promise<MinecraftNewsItem[]> | null = null
export function get_minecraft_news(limit = 12): Promise<MinecraftNewsItem[]> {
if (!cached || Date.now() - cachedAt > NEWS_TTL_MS) {
cachedAt = Date.now()
cached = invoke<MinecraftNewsItem[]>('plugin:utils|get_minecraft_news', { limit }).catch(
(error) => {
cached = null
throw error
},
)
}
return cached
}

View File

@ -0,0 +1,84 @@
import { invoke } from '@tauri-apps/api/core'
export type McArchiveGameVersion = {
id: number
name: string
versionType?: string | null
}
export type McArchiveFile = {
uuid: string
name: string
sha256?: string | null
archiveUrl?: string | null
directUrl?: string | null
redirectUrl?: string | null
pageUrl?: string | null
}
export type McArchiveModVersion = {
uuid: string
name: string
gameVersions: McArchiveGameVersion[]
files: McArchiveFile[]
}
export type McArchiveMod = {
uuid: string
slug: string
name: string
summary?: string | null
description?: string | null
pageUrl?: string | null
modVersions: McArchiveModVersion[]
}
export function getMcArchiveGameVersions() {
return invoke<McArchiveGameVersion[]>('plugin:mcarchive|mcarchive_get_game_versions')
}
export async function searchMcArchiveMods(query: string, gameVersion?: string | null) {
const keyword = query.trim()
const search = (value: string, includeGameVersion = true) =>
invoke<McArchiveMod[]>('plugin:mcarchive|mcarchive_search_mods', {
keyword: value,
...(includeGameVersion && gameVersion ? { gameVersion } : {}),
})
let results = await search(keyword)
if (results.length === 0 && gameVersion) {
results = await search(keyword, false)
}
if (results.length > 0) return results
// MCArchive's keyword matching does not treat punctuation or whitespace as
// interchangeable, while its project slugs commonly omit both.
const slugLikeKeyword = keyword.replace(/[\s_-]+/g, '')
if (slugLikeKeyword && slugLikeKeyword !== keyword) {
const slugResults = await search(slugLikeKeyword, !!gameVersion)
if (slugResults.length > 0) return slugResults
if (gameVersion) {
const unfilteredSlugResults = await search(slugLikeKeyword, false)
if (unfilteredSlugResults.length > 0) return unfilteredSlugResults
}
}
if (!keyword) return results
// The archive API's keyword matching is intentionally conservative. Fetch the
// small project index only when its direct query misses, then match locally.
const catalog = await search('', !!gameVersion)
const normalized = keyword.toLocaleLowerCase().replace(/[\s_-]+/g, '')
return catalog.filter((project) =>
[project.slug, project.name, project.description ?? ''].some((value) =>
value
.toLocaleLowerCase()
.replace(/[\s_-]+/g, '')
.includes(normalized),
),
)
}
export function getMcArchiveModBySlug(slug: string) {
return invoke<McArchiveMod>('plugin:mcarchive|mcarchive_get_mod_by_slug', { slug })
}

View File

@ -0,0 +1,13 @@
import { invoke } from '@tauri-apps/api/core'
/// Gets the game versions from daedalus
// Returns a VersionManifest
export async function get_game_versions() {
return await invoke('plugin:metadata|metadata_get_game_versions')
}
// Gets the given loader versions from daedalus
// Returns Manifest
export async function get_loader_versions(loader, gameVersion) {
return await invoke('plugin:metadata|metadata_get_loader_versions', { loader, gameVersion })
}

View File

@ -0,0 +1,69 @@
import { invoke } from '@tauri-apps/api/core'
import { check_mojang_services } from '@/helpers/auth.js'
import { ensureFallenAuthProxyArgs, removeFallenAuthProxyArgs } from '@/helpers/java-arguments'
import { type AppSettings, get, set } from '@/helpers/settings'
const DEFAULT_RETRIES = 3
const RETRY_DELAY_MS = 1000
const REQUIRED_MOJANG_SERVICES = new Set(['account', 'session', 'services', 'profiles'])
export async function checkMojangAuthServers(retries = DEFAULT_RETRIES): Promise<boolean> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const statuses = await check_mojang_services()
const requiredStatuses = statuses.filter((status) =>
REQUIRED_MOJANG_SERVICES.has(status.service),
)
if (requiredStatuses.length > 0 && requiredStatuses.every((status) => status.reachable)) {
return true
}
} catch {
if (attempt < retries - 1) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
}
}
}
return false
}
function sameArgs(left: string[], right: string[]) {
return left.length === right.length && left.every((arg, index) => arg === right[index])
}
export async function setMojangAuthUseMirror(useMirror: boolean, automatic: boolean) {
await invoke('plugin:auth|set_mojang_auth_use_mirror', { useMirror, automatic })
}
export async function reconcileMojangAuthSource(settings: AppSettings): Promise<boolean> {
const mode = settings.mojang_auth_source ?? 'auto'
const automatic = mode === 'auto' || mode === 'official_preferred'
let useMirror: boolean
if (mode === 'mirror_preferred') {
useMirror = true
} else if (mode === 'official_only') {
useMirror = false
} else {
// Automatic and official-preferred check the services used by current
// Minecraft versions, falling back only when one of them is down.
useMirror = !(await checkMojangAuthServers())
}
await setMojangAuthUseMirror(useMirror, automatic)
const nextArgs = useMirror
? ensureFallenAuthProxyArgs(settings.extra_launch_args)
: removeFallenAuthProxyArgs(settings.extra_launch_args)
if (sameArgs(nextArgs, settings.extra_launch_args)) return false
settings.extra_launch_args = nextArgs
return true
}
export async function reconcileMojangAuthSourceAtStartup(): Promise<void> {
const settings = await get()
if (await reconcileMojangAuthSource(settings)) {
await set(settings)
}
}

View File

@ -0,0 +1,29 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* 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'
export type ModrinthCredentials = {
session: string
expires: string
user_id: string
active: boolean
}
export async function login(): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login')
}
export async function logout(): Promise<void> {
return await invoke('plugin:mr-auth|logout')
}
export async function get(): Promise<ModrinthCredentials | null> {
return await invoke('plugin:mr-auth|get')
}
export async function cancelLogin(): Promise<void> {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}

View File

@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
type DetectedLanPort,
type HongshiNode,
selectedDetectedInstance,
selectedNodePreference,
storedMultiplayerProvider,
validLocalPort,
} from './multiplayer.ts'
const detectedPort = (instanceId: string): DetectedLanPort => ({
instance_id: instanceId,
instance_name: `Instance ${instanceId}`,
process_id: `process-${instanceId}`,
port: 25565,
detected_at: '2026-08-12 12:00:00',
})
const node = (name: string): HongshiNode => ({
name,
address: '203.0.113.1',
latency_ms: 20,
reachable: true,
cached: false,
})
test('defaults the provider preference to Terracotta', () => {
assert.equal(storedMultiplayerProvider(null), 'terracotta')
assert.equal(storedMultiplayerProvider('invalid'), 'terracotta')
assert.equal(storedMultiplayerProvider('hongshi'), 'hongshi')
})
test('accepts only complete ports in the Minecraft port range', () => {
assert.equal(validLocalPort('1'), 1)
assert.equal(validLocalPort('65535'), 65535)
for (const value of ['', '0', '65536', '25565abc', ' 25565']) {
assert.equal(validLocalPort(value), null)
}
})
test('auto-selects exactly one detected instance and preserves valid choices', () => {
assert.equal(selectedDetectedInstance('manual', [detectedPort('a')]), 'a')
assert.equal(selectedDetectedInstance('a', [detectedPort('a'), detectedPort('b')]), 'a')
assert.equal(
selectedDetectedInstance('missing', [detectedPort('a'), detectedPort('b')]),
'manual',
)
})
test('falls back to automatic node selection when a cached preference disappears', () => {
assert.equal(selectedNodePreference('Nanjing', [node('Nanjing')]), 'Nanjing')
assert.equal(selectedNodePreference('Missing', [node('Nanjing')]), 'auto')
})

View File

@ -0,0 +1,137 @@
import { invoke } from '@tauri-apps/api/core'
import type { TerracottaState } from '@/helpers/terracotta'
export type MultiplayerProvider = 'terracotta' | 'hongshi'
export type HongshiStatus =
| 'unsupported'
| 'idle'
| 'waiting_for_port'
| 'downloading'
| 'selecting_node'
| 'starting'
| 'open'
| 'closed'
| 'error'
export type HongshiErrorType =
| 'unsupported'
| 'node_list'
| 'node_unavailable'
| 'invalid_port'
| 'install'
| 'kernel_start'
| 'kernel_exit'
| 'status_file'
| 'unknown'
export interface HongshiNode {
name: string
address: string
latency_ms: number | null
reachable: boolean
cached: boolean
}
export interface DetectedLanPort {
instance_id: string
instance_name: string
process_id: string
port: number
detected_at: string
}
export interface HongshiState {
supported: boolean
status: HongshiStatus
local_port: number | null
node: HongshiNode | null
public_address: string | null
created_at: string | null
last_exit_code: number | null
error_type: HongshiErrorType | null
error_message: string | null
bound_instance_id: string | null
port_changed: boolean
binary_installed: boolean
download_progress: number | null
}
export interface ProviderCapabilities {
provider: MultiplayerProvider
supported: boolean
can_host: boolean
can_join: boolean
requires_local_port: boolean
unsupported_reason: string | null
}
export interface MultiplayerState {
active_provider: MultiplayerProvider | null
providers: ProviderCapabilities[]
terracotta: TerracottaState
hongshi: HongshiState
}
export function storedMultiplayerProvider(value: string | null): MultiplayerProvider {
return value === 'hongshi' ? 'hongshi' : 'terracotta'
}
export function validLocalPort(value: string): number | null {
if (!/^\d+$/.test(value)) return null
const port = Number(value)
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null
}
export function selectedDetectedInstance(current: string, ports: DetectedLanPort[]): string {
if (current !== 'manual' && ports.some((entry) => entry.instance_id === current)) {
return current
}
return ports.length === 1 ? ports[0].instance_id : 'manual'
}
export function selectedNodePreference(current: string, nodes: HongshiNode[]): string {
return current === 'auto' || nodes.some((node) => node.name === current) ? current : 'auto'
}
const command = (name: string) => `plugin:multiplayer|${name}`
export const multiplayer = {
getState: () => invoke<MultiplayerState>(command('multiplayer_get_state')),
getNodes: (forceRefresh = false) =>
invoke<HongshiNode[]>(command('multiplayer_get_nodes'), { forceRefresh }),
getDetectedPorts: () => invoke<DetectedLanPort[]>(command('multiplayer_get_detected_ports')),
downloadHongshi: () => invoke<void>(command('multiplayer_download_hongshi')),
switchProvider: (provider: MultiplayerProvider) =>
invoke<void>(command('multiplayer_switch_provider'), { provider }),
prepareTerracotta: () => invoke<void>(command('multiplayer_prepare_terracotta')),
hostTerracotta: (playerName: string) =>
invoke<void>(command('multiplayer_host'), {
request: {
provider: 'terracotta',
player_name: playerName.trim(),
room_code: null,
},
}),
joinTerracotta: (playerName: string, roomCode: string) =>
invoke<void>(command('multiplayer_join'), {
request: {
provider: 'terracotta',
player_name: playerName.trim(),
room_code: roomCode.trim(),
},
}),
hostHongshi: (localPort: number, nodeName: string | null, instanceId: string | null) =>
invoke<void>(command('multiplayer_host'), {
request: {
provider: 'hongshi',
local_port: localPort,
node_name: nodeName,
instance_id: instanceId,
},
}),
stop: () => invoke<void>(command('multiplayer_stop')),
reset: () => invoke<void>(command('multiplayer_reset')),
getPlayerName: () => invoke<string>(command('multiplayer_get_player_name')),
openHongshiLogs: () => invoke<void>(command('multiplayer_open_hongshi_logs')),
}

View File

@ -0,0 +1,40 @@
export type PackFormatRange = {
min: string
max?: string
}
/**
* Data pack `pack_format` → Minecraft version range (1.13+). Data pack formats
* are independent of resource pack formats; keep this table in sync with the
* game's data pack format history.
*/
const PACK_FORMAT_TO_VERSION: Record<number, PackFormatRange> = {
4: { min: '1.13', max: '1.14.4' },
5: { min: '1.15', max: '1.15.2' },
6: { min: '1.16', max: '1.16.1' },
7: { min: '1.16.2', max: '1.16.5' },
8: { min: '1.17', max: '1.17.1' },
9: { min: '1.18', max: '1.18.2' },
10: { min: '1.19', max: '1.19.2' },
12: { min: '1.19.3', max: '1.19.3' },
13: { min: '1.19.4', max: '1.19.4' },
15: { min: '1.20', max: '1.20.1' },
18: { min: '1.20.2', max: '1.20.2' },
26: { min: '1.20.3', max: '1.20.4' },
41: { min: '1.20.5', max: '1.20.6' },
48: { min: '1.21', max: '1.21.1' },
57: { min: '1.21.2', max: '1.21.3' },
61: { min: '1.21.4', max: '1.21.4' },
71: { min: '1.21.5', max: '1.21.5' },
80: { min: '1.21.6', max: '1.21.6' },
81: { min: '1.21.7', max: '1.21.7' },
88: { min: '1.21.9', max: '1.21.9' },
94: { min: '1.21.11', max: '1.21.11' },
101: { min: '26.1', max: '26.1' },
107: { min: '26.2', max: '26.2' },
}
export function getPackFormatRange(packFormat?: number): PackFormatRange | undefined {
if (packFormat == null) return undefined
return PACK_FORMAT_TO_VERSION[packFormat]
}

View File

@ -0,0 +1,43 @@
import { invoke } from '@tauri-apps/api/core'
export type PlanetMinecraftDownload = {
pageUrl: string
fileName?: string | null
directUrl?: string | null
sha256?: string | null
}
export type PlanetMinecraftVersion = {
id: string
name: string
gameVersions: string[]
download: PlanetMinecraftDownload
}
export type PlanetMinecraftProject = {
id: string
title: string
pageUrl: string
summary?: string | null
versions: PlanetMinecraftVersion[]
}
export function planetMinecraftConnectorAvailable() {
return invoke<boolean>('plugin:planet-minecraft|planet_minecraft_connector_available')
}
export function searchPlanetMinecraftProjects(query: string, gameVersion?: string | null) {
return invoke<PlanetMinecraftProject[]>(
'plugin:planet-minecraft|planet_minecraft_search_projects',
{
query,
gameVersion: gameVersion ?? null,
},
)
}
export function getPlanetMinecraftProject(id: string) {
return invoke<PlanetMinecraftProject>('plugin:planet-minecraft|planet_minecraft_get_project', {
id,
})
}

View File

@ -0,0 +1,111 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
postUpgradeWarningForContent,
shouldExpandUpgradeWarningsByDefault,
} from './post-upgrade-notice.ts'
const warning = {
code: 'keep_incompatible' as const,
contentId: 'content-a',
relativePath: 'mods/example.jar',
}
test('matches post-upgrade warnings by exact content id or normalized relative path', () => {
assert.equal(postUpgradeWarningForContent([warning], 'content-a', null), warning)
assert.equal(postUpgradeWarningForContent([warning], null, 'mods\\example.jar'), warning)
assert.equal(postUpgradeWarningForContent([warning], null, '././mods/example.jar'), warning)
assert.equal(postUpgradeWarningForContent([warning], null, 'mods/other.jar'), null)
})
test('matches local preserved resource packs using only their exact normalized path', () => {
const localWarning = {
code: 'keep_incompatible' as const,
contentId: null,
relativePath: 'resourcepacks/foo.zip',
}
assert.equal(
postUpgradeWarningForContent([localWarning], null, 'resourcepacks/foo.zip'),
localWarning,
)
assert.equal(
postUpgradeWarningForContent([localWarning], null, 'resourcepacks\\foo.zip'),
localWarning,
)
})
test('content id match takes priority over an earlier path-only match', () => {
const pathMatch = { ...warning, contentId: null }
const contentMatch = {
...warning,
contentId: 'content-a',
relativePath: 'resourcepacks/foo.zip',
}
assert.equal(
postUpgradeWarningForContent([pathMatch, contentMatch], 'content-a', 'mods/example.jar'),
contentMatch,
)
})
test('large warning collections default to collapsed', () => {
assert.equal(shouldExpandUpgradeWarningsByDefault(5), true)
assert.equal(shouldExpandUpgradeWarningsByDefault(30), false)
})
test('instance header and content page consume persisted target notice', () => {
const indexSource = readFileSync(new URL('../pages/instance/Index.vue', import.meta.url), 'utf8')
const modsSource = readFileSync(new URL('../pages/instance/Mods.vue', import.meta.url), 'utf8')
assert.match(indexSource, /v-if="postUpgradeNotice"/)
assert.match(indexSource, /postUpgradeNotice\.targetGameVersion/)
assert.match(indexSource, /usePostUpgradeNotice\(\(\) => instance\.value\?\.id \?\? props\.id\)/)
assert.match(modsSource, /usePostUpgradeNotice\(\(\) => props\.instance\.id\)/)
assert.match(modsSource, /item\.instanceEntryId,[\s\S]*item\.file_path/)
assert.match(modsSource, /postUpgradeWarningTooltip/)
})
test('notice query uses the target instance value and registered Tauri command', () => {
const querySource = readFileSync(
new URL('../composables/usePostUpgradeNotice.ts', import.meta.url),
'utf8',
)
const helperSource = readFileSync(new URL('./instance.ts', import.meta.url), 'utf8')
const tauriSource = readFileSync(
new URL('../../../app/src/api/instance.rs', import.meta.url),
'utf8',
)
assert.match(querySource, /\['post-upgrade-notice', instanceId\]/)
assert.match(querySource, /enabled: computed\(\(\) => toValue\(instanceId\)\.length > 0\)/)
assert.match(querySource, /throw error/)
assert.match(helperSource, /plugin:instance\|instance_get_post_upgrade_notice/)
assert.match(tauriSource, /tauri::generate_handler!\[[\s\S]*instance_get_post_upgrade_notice/)
})
test('result uses Modrinth Card and Accordion for collapsed compatibility warnings', () => {
const source = readFileSync(
new URL('../pages/instance/upgrade/UpgradeResultDetails.vue', import.meta.url),
'utf8',
)
assert.match(source, /<Card v-if="warningRows\.length"/)
assert.match(source, /<Accordion[\s\S]*:open-by-default="warningsExpandedByDefault"/)
assert.match(source, /<TriangleAlertIcon/)
})
test('runtime icon references use names exported by the assets package', () => {
const downloadsSource = readFileSync(new URL('../pages/Downloads.vue', import.meta.url), 'utf8')
const resultSource = readFileSync(
new URL('../pages/instance/upgrade/UpgradeResultDetails.vue', import.meta.url),
'utf8',
)
const assetsSource = readFileSync(
new URL('../../../../packages/assets/generated-icons.ts', import.meta.url),
'utf8',
)
assert.match(downloadsSource, /upgrade_unmanaged_instance' \? RefreshCwIcon/)
assert.doesNotMatch(downloadsSource, /UpdatedIcon/)
assert.match(resultSource, /import \{[^}]*TriangleAlertIcon[^}]*\} from '@modrinth\/assets'/)
assert.doesNotMatch(resultSource, /WarningIcon/)
assert.match(assetsSource, /export const RefreshCwIcon = _RefreshCwIcon/)
assert.match(assetsSource, /export const TriangleAlertIcon = _TriangleAlertIcon/)
})

View File

@ -0,0 +1,28 @@
import type { InstancePostUpgradeWarning } from './instance'
export function normalizePostUpgradePath(path: string): string {
return path.replaceAll('\\', '/').replace(/^(?:\.\/)+/, '')
}
export function postUpgradeWarningForContent(
warnings: InstancePostUpgradeWarning[],
contentId: string | null | undefined,
relativePath: string | null | undefined,
): InstancePostUpgradeWarning | null {
const normalizedPath = relativePath ? normalizePostUpgradePath(relativePath) : null
const contentMatch = contentId
? warnings.find((warning) => warning.contentId === contentId)
: undefined
if (contentMatch) return contentMatch
return normalizedPath
? (warnings.find(
(warning) =>
!!warning.relativePath &&
normalizePostUpgradePath(warning.relativePath) === normalizedPath,
) ?? null)
: null
}
export function shouldExpandUpgradeWarningsByDefault(count: number): boolean {
return count > 0 && count <= 10
}

View File

@ -0,0 +1,27 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* 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'
export function shouldShowMinecraftCrash(crashed) {
return crashed === true
}
/// Gets all running process IDs with a given instance ID
/// Returns [u32]
export async function get_by_instance_id(instanceId) {
return await invoke('plugin:process|process_get_by_instance_id', { instanceId })
}
/// Gets all running process IDs
/// Returns [u32]
export async function get_all() {
return await invoke('plugin:process|process_get_all')
}
/// Kills a process by UUID
export async function kill(uuid) {
return await invoke('plugin:process|process_kill', { uuid })
}

View File

@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { shouldShowMinecraftCrash } from './process.js'
test('only shows the crash dialog for an explicitly crashed process', () => {
assert.equal(shouldShowMinecraftCrash(true), true)
assert.equal(shouldShowMinecraftCrash(false), false)
assert.equal(shouldShowMinecraftCrash(undefined), false)
assert.equal(shouldShowMinecraftCrash(null), false)
assert.equal(shouldShowMinecraftCrash(1), false)
})

View File

@ -0,0 +1,39 @@
export const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
export interface ProjectGalleryImage {
title?: string
description?: string
}
export interface ProjectGalleryEntry<T extends ProjectGalleryImage> {
image: T
index: number
}
export type ProjectGalleryCaptionField = 'title' | 'description'
export function visibleProjectGallery<T extends ProjectGalleryImage>(
gallery: T[] | undefined,
): ProjectGalleryEntry<T>[] {
return (gallery ?? [])
.map((image, index) => ({ image, index }))
.filter(({ image }) => image.title !== MC_SERVER_BANNER_NAME)
}
export function projectGalleryTranslationSegmentId(
index: number,
field: ProjectGalleryCaptionField,
): string {
return `gallery-${index}-${field}`
}
export function projectGalleryTranslationSegments(gallery: ProjectGalleryImage[] | undefined) {
return visibleProjectGallery(gallery).flatMap(({ image, index }) =>
(['title', 'description'] as const).flatMap((field) => {
const text = image[field]?.trim()
return text
? [{ id: projectGalleryTranslationSegmentId(index, field), text, format: 'plain' as const }]
: []
}),
)
}

View File

@ -0,0 +1,25 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createProjectBrowseLocation } from './project-links.ts'
test('project sidebar loader links target the app browse route and loader filter', () => {
assert.deepEqual(createProjectBrowseLocation('mod', 'loader', 'forge'), {
path: '/browse/mod',
query: { g: 'categories:forge' },
})
})
test('project sidebar category links target the app browse route and category filter', () => {
assert.deepEqual(createProjectBrowseLocation('mod', 'category', 'library-api'), {
path: '/browse/mod',
query: { f: 'categories:library-api' },
})
})
test('server categories use the server browse route and server category filter', () => {
assert.deepEqual(createProjectBrowseLocation('minecraft_java_server', 'category', 'vanilla'), {
path: '/browse/server',
query: { sc: 'vanilla' },
})
})

View File

@ -0,0 +1,104 @@
import type { LocationQuery, LocationQueryRaw } from 'vue-router'
export type ProjectBrowseFilter = 'category' | 'loader'
export function createProjectBrowseLocation(
projectType: string,
filter: ProjectBrowseFilter,
value: string,
): { path: string; query: LocationQueryRaw } {
const browseProjectType = projectType === 'minecraft_java_server' ? 'server' : projectType
const query =
filter === 'loader'
? { g: `categories:${value}` }
: browseProjectType === 'server'
? { sc: value }
: { f: `categories:${value}` }
return {
path: `/browse/${browseProjectType}`,
query,
}
}
const MODRINTH_HOSTNAMES = new Set(['modrinth.com', 'www.modrinth.com'])
const SUPPORTED_PROJECT_TYPES = new Set([
'mod',
'modpack',
'resourcepack',
'datapack',
'plugin',
'shader',
'server',
'project',
])
export function parseModrinthLink(
href: string,
): { slug: string; pathSuffix: string; url: URL } | null {
let url: URL
try {
url = new URL(href)
} catch {
return null
}
if (!MODRINTH_HOSTNAMES.has(url.hostname.toLowerCase())) {
return null
}
const segments = url.pathname.split('/').filter((p) => p.length > 0)
if (segments.length < 2) {
return null
}
if (SUPPORTED_PROJECT_TYPES.has(segments[0].toLowerCase())) {
const slug = segments[1]
if (!slug) {
return null
}
const rest: string[] = segments.slice(2)
const pathSuffix = toValidAppSubpath(rest)
if (pathSuffix === null) {
return null
}
return { slug, pathSuffix, url }
} else {
return null
}
}
const SUPPORTED_SUBPATHS = ['versions', 'gallery']
function toValidAppSubpath(rest: string[]): string | null {
if (rest.length === 0) {
return ''
}
const subroute = rest[0].toLowerCase()
if (rest.length === 1 && SUPPORTED_SUBPATHS.includes(subroute)) {
return `/${subroute}`
}
if (rest.length === 2 && subroute === 'version') {
return `/version/${rest[1]}`
}
return null
}
export function mergeUrlQuery(routeQuery: LocationQuery, linkUrl: URL): LocationQueryRaw {
const newQuery: LocationQueryRaw = { ...routeQuery }
const keys = new Set<string>()
linkUrl.searchParams.forEach((_value, key) => {
keys.add(key)
})
for (const key of keys) {
const values = linkUrl.searchParams.getAll(key)
newQuery[key] = values.length === 1 ? values[0] : values
}
return newQuery
}

View File

@ -0,0 +1,66 @@
export type RemoteAnnouncement = {
id: string
title: string
summary: string | null
content: string
type: 'modal' | 'notification'
priority: 'low' | 'normal' | 'high' | 'critical'
starts_at: string
ends_at: string | null
published_at: string
action_url: string | null
action_label: string | null
}
export function safeAnnouncementUrl(value: unknown): string | null {
if (typeof value !== 'string') return null
try {
const url = new URL(value)
return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password
? url.href
: null
} catch {
return null
}
}
export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null {
if (!Array.isArray(value) || value.length > 200) return null
const items: RemoteAnnouncement[] = []
const ids = new Set<string>()
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue
const item = entry as Record<string, unknown>
if (
typeof item.id !== 'string' || item.id.length > 100 || ids.has(item.id) ||
typeof item.title !== 'string' || !item.title.trim() || item.title.length > 120 ||
typeof item.content !== 'string' || item.content.length > 20000 ||
(item.summary != null && (typeof item.summary !== 'string' || item.summary.length > 300)) ||
!['modal', 'notification'].includes(String(item.type)) ||
!['low', 'normal', 'high', 'critical'].includes(String(item.priority)) ||
typeof item.starts_at !== 'string' || !Number.isFinite(Date.parse(item.starts_at)) ||
typeof item.published_at !== 'string' || !Number.isFinite(Date.parse(item.published_at)) ||
(item.ends_at != null && (typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) ||
(item.action_label != null && (typeof item.action_label !== 'string' || item.action_label.length > 80)) ||
(item.action_url != null && !safeAnnouncementUrl(item.action_url))
) continue
ids.add(item.id)
items.push({
...item,
summary: item.summary ?? null,
ends_at: item.ends_at ?? null,
action_label: item.action_label ?? null,
action_url: item.action_url ?? null,
} as RemoteAnnouncement)
}
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
return items.sort((first, second) => rank[second.priority] - rank[first.priority] || Date.parse(second.published_at) - Date.parse(first.published_at))
}
export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now()) {
return Date.parse(item.starts_at) <= now && (!item.ends_at || Date.parse(item.ends_at) > now)
}
export function announcementKey(item: RemoteAnnouncement) {
return item.id + ':' + item.published_at
}

View File

@ -0,0 +1,167 @@
import { reactive } from 'vue'
import type { Skin } from '../skins'
import { get_normalized_skin_texture } from '../skins'
import { headStorage } from '../storage/head-storage'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
export interface RenderResult {
forwards: string
}
export interface RawRenderResult {
forwards: Blob
}
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headBlobUrlMap = reactive(new Map<string, string>())
const headRenderPromises = new Map<string, Promise<string>>()
const DEBUG_MODE = false
const HEAD_RENDER_VERSION = 8
export function getHeadRenderKey(textureKey: string): string {
return `${textureKey}-head-v${HEAD_RENDER_VERSION}`
}
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>()
const validHeadKeys = new Set<string>()
for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
const headKey = getHeadRenderKey(skin.texture_key)
validKeys.add(key)
validHeadKeys.add(headKey)
}
try {
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
await headStorage.cleanupInvalidKeys(validHeadKeys)
} catch (error) {
console.warn('Failed to cleanup unused skin previews:', error)
}
}
export async function generatePlayerHeadBlob(skinUrl: string): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
if (img.width !== 64 || img.height !== 64) {
throw new Error(`Expected normalized 64x64 skin texture, got ${img.width}x${img.height}`)
}
const outerLayerCanvas = document.createElement('canvas')
outerLayerCanvas.width = 8
outerLayerCanvas.height = 8
const outerLayerCtx = outerLayerCanvas.getContext('2d')
if (!outerLayerCtx) {
throw new Error('Could not get 2D context for outer skin layer')
}
outerLayerCtx.drawImage(img, 40, 8, 8, 8, 0, 0, 8, 8)
const hasOuterLayer = outerLayerCtx
.getImageData(0, 0, 8, 8)
.data.some((channel, index) => index % 4 === 3 && channel > 0)
const outputSize = hasOuterLayer ? 72 : 64
const baseOffset = hasOuterLayer ? 4 : 0
const outputCanvas = document.createElement('canvas')
outputCanvas.width = outputSize
outputCanvas.height = outputSize
const outputCtx = outputCanvas.getContext('2d')
if (!outputCtx) {
throw new Error('Could not get 2D context from output canvas')
}
outputCtx.imageSmoothingEnabled = false
outputCtx.drawImage(img, 8, 8, 8, 8, baseOffset, baseOffset, 64, 64)
if (hasOuterLayer) {
outputCtx.drawImage(outerLayerCanvas, 0, 0, 8, 8, 0, 0, outputSize, outputSize)
}
outputCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from canvas'))
}
}, 'image/png')
} catch (error) {
reject(error)
}
}
img.onerror = () => {
reject(new Error('Failed to load skin texture image'))
}
img.src = skinUrl
})
}
export async function generateHeadRender(skin: Skin): Promise<string> {
const headKey = getHeadRenderKey(skin.texture_key)
if (headBlobUrlMap.has(headKey)) {
if (DEBUG_MODE) {
const url = headBlobUrlMap.get(headKey)!
URL.revokeObjectURL(url)
headBlobUrlMap.delete(headKey)
} else {
return headBlobUrlMap.get(headKey)!
}
}
const pendingRender = headRenderPromises.get(headKey)
if (pendingRender) return await pendingRender
const renderPromise = loadHeadRender(skin, headKey)
headRenderPromises.set(headKey, renderPromise)
try {
return await renderPromise
} finally {
if (headRenderPromises.get(headKey) === renderPromise) {
headRenderPromises.delete(headKey)
}
}
}
async function loadHeadRender(skin: Skin, headKey: string): Promise<string> {
if (!DEBUG_MODE) {
try {
const cachedHeadUrl = await headStorage.retrieve(headKey)
if (cachedHeadUrl) {
headBlobUrlMap.set(headKey, cachedHeadUrl)
return cachedHeadUrl
}
} catch (error) {
console.warn('Failed to retrieve cached head render:', error)
}
}
const skinUrl = await get_normalized_skin_texture(skin)
const headBlob = await generatePlayerHeadBlob(skinUrl)
const headUrl = URL.createObjectURL(headBlob)
headBlobUrlMap.set(headKey, headUrl)
try {
await headStorage.store(headKey, headBlob)
} catch (error) {
console.warn('Failed to store head render in persistent storage:', error)
}
return headUrl
}
export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
return await generateHeadRender(skin)
}

View File

@ -0,0 +1,334 @@
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
import {
applyCapeTexture,
createTransparentTexture,
disposeCaches,
setupSkinModel,
} from '@modrinth/ui/src/utils/webgl/skin-rendering'
import * as THREE from 'three'
import type { Cape, Skin } from '../skins'
import { determineModelType, get_normalized_skin_texture } from '../skins'
import { headStorage } from '../storage/head-storage'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
import {
cleanupUnusedPreviews,
generateHeadRender,
getHeadRenderKey,
headBlobUrlMap,
type RawRenderResult,
type RenderResult,
skinBlobUrlMap,
} from './batch-skin-renderer'
class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer | null = null
private scene: THREE.Scene | null = null
private camera: THREE.PerspectiveCamera | null = null
private currentModel: THREE.Group | null = null
private transparentTexture: THREE.Texture | null = null
private readonly width: number
private readonly height: number
constructor(width: number = 360, height: number = 504) {
this.width = width
this.height = height
}
private initializeRenderer(): void {
if (this.renderer) return
const canvas = document.createElement('canvas')
canvas.width = this.width
canvas.height = this.height
this.renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
})
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
this.renderer.shadowMap.enabled = false
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0)
this.renderer.setSize(this.width, this.height)
this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = false
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
}
public async renderSkin(
textureUrl: string,
modelUrl: string,
capeUrl?: string,
): Promise<RawRenderResult> {
this.initializeRenderer()
this.clearScene()
await this.setupModel(modelUrl, textureUrl, capeUrl)
const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number]
if (headPart) {
const headPosition = new THREE.Vector3()
headPart.getWorldPosition(headPosition)
lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z]
} else {
throw new Error("Failed to find 'Head' object in model.")
}
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
return { forwards }
}
private async renderView(
cameraPosition: [number, number, number],
lookAtPosition: [number, number, number],
): Promise<Blob> {
if (!this.camera || !this.renderer || !this.scene) {
throw new Error('Renderer not initialized')
}
this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera)
return await new Promise<Blob>((resolve, reject) => {
this.renderer!.domElement.toBlob(
(blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from rendered canvas'))
}
},
'image/webp',
0.9,
)
})
}
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
if (!this.scene) {
throw new Error('Renderer not initialized')
}
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
if (!capeUrl) {
applyCapeTexture(model, null, this.getTransparentTexture())
}
const group = new THREE.Group()
group.add(model)
group.position.set(0, 0.3, 1.95)
group.scale.set(0.8, 0.8, 0.8)
this.scene.add(group)
this.currentModel = group
}
private getTransparentTexture(): THREE.Texture {
if (!this.transparentTexture) {
this.transparentTexture = createTransparentTexture()
}
return this.transparentTexture
}
private clearScene(): void {
if (!this.scene || !this.currentModel) return
this.currentModel.traverse((object) => {
const mesh = object as THREE.Mesh
if (mesh.isMesh && mesh.userData.threeDSkinLayersApplied) {
mesh.geometry.dispose()
}
})
this.scene.remove(this.currentModel)
this.currentModel.clear()
this.currentModel = null
}
public dispose(): void {
this.clearScene()
if (this.transparentTexture) {
this.transparentTexture.dispose()
this.transparentTexture = null
}
if (this.renderer) {
this.renderer.dispose()
}
this.renderer = null
this.scene = null
this.camera = null
disposeCaches()
}
}
function getModelUrlForVariant(variant: string): string {
switch (variant) {
case 'SLIM':
return SlimPlayerModel
case 'CLASSIC':
case 'UNKNOWN':
default:
return ClassicPlayerModel
}
}
const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null
let latestPreviewGeneration = 0
let previewGenerationQueue: Promise<void> = Promise.resolve()
function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer()
}
return sharedRenderer
}
export function disposeSharedRenderer(): void {
if (sharedRenderer) {
sharedRenderer.dispose()
sharedRenderer = null
}
}
export function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const generation = ++latestPreviewGeneration
const skinsSnapshot = [...skins]
const capesSnapshot = [...capes]
const generationPromise = previewGenerationQueue.then(() =>
generateSkinPreviewsForGeneration(skinsSnapshot, capesSnapshot, generation),
)
previewGenerationQueue = generationPromise.catch(() => {})
return generationPromise
}
async function generateSkinPreviewsForGeneration(
skins: Skin[],
capes: Cape[],
generation: number,
): Promise<void> {
const isCurrentGeneration = () => generation === latestPreviewGeneration
try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
)
const headKeys = skins.map((skin) => getHeadRenderKey(skin.texture_key))
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
skinPreviewStorage.batchRetrieve(skinKeys),
headStorage.batchRetrieve(headKeys),
])
if (!isCurrentGeneration()) return
for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i]
const headKey = headKeys[i]
const rawCached = cachedSkinPreviews[skinKey]
if (rawCached && !skinBlobUrlMap.has(skinKey)) {
const cached: RenderResult = {
forwards: URL.createObjectURL(rawCached.forwards),
}
skinBlobUrlMap.set(skinKey, cached)
}
const cachedHead = cachedHeadPreviews[headKey]
if (cachedHead && !headBlobUrlMap.has(headKey)) {
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
}
}
for (const skin of skins) {
if (!isCurrentGeneration()) return
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (skinBlobUrlMap.has(key)) {
if (DEBUG_MODE) {
const result = skinBlobUrlMap.get(key)!
URL.revokeObjectURL(result.forwards)
skinBlobUrlMap.delete(key)
} else continue
}
const renderer = getSharedRenderer()
let variant = skin.variant
if (variant === 'UNKNOWN') {
try {
variant = await determineModelType(skin.texture)
} catch (error) {
console.error(`Failed to determine model type for skin ${key}:`, error)
variant = 'CLASSIC'
}
}
const modelUrl = getModelUrlForVariant(variant)
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
const rawRenderResult = await renderer.renderSkin(
await get_normalized_skin_texture(skin),
modelUrl,
cape?.texture,
)
if (!isCurrentGeneration()) return
const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards),
}
skinBlobUrlMap.set(key, renderResult)
try {
await skinPreviewStorage.store(key, rawRenderResult)
} catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error)
}
const headKey = getHeadRenderKey(skin.texture_key)
if (!headBlobUrlMap.has(headKey)) {
await generateHeadRender(skin)
}
}
} finally {
disposeSharedRenderer()
if (isCurrentGeneration()) {
await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
}
}
}

View File

@ -0,0 +1,98 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
compactSearchText,
curseForgeQueryVariants,
expandSearchQuery,
modrinthQueryVariants,
normalizeSearchText,
slugifySearchText,
splitCamelCaseSearchText,
} from './search-query.ts'
test('normalizes whitespace, diacritics and case', () => {
assert.equal(normalizeSearchText(' Example Mod '), 'example mod')
assert.equal(normalizeSearchText('Bélicraft'), 'belicraft')
assert.equal(normalizeSearchText('Sodium Extra'), 'sodium extra')
})
test('compacts queries to alphanumerics only', () => {
assert.equal(compactSearchText('Example Mod!'), 'examplemod')
assert.equal(compactSearchText('sodium_extra'), 'sodiumextra')
assert.equal(compactSearchText('铁 锭'), '铁锭')
})
test('slugifies queries for CurseForge', () => {
assert.equal(slugifySearchText('Example Mod!'), 'example-mod')
assert.equal(slugifySearchText('Sodium Extra'), 'sodium-extra')
assert.equal(slugifySearchText('--sodium--'), 'sodium')
})
test('splits camelCase and PascalCase words', () => {
assert.equal(splitCamelCaseSearchText('SodiumExtra'), 'Sodium Extra')
assert.equal(splitCamelCaseSearchText('AE2Stuff'), 'AE2 Stuff')
assert.equal(splitCamelCaseSearchText('sodiumextra'), 'sodiumextra')
})
test('modrinth variants preserve the typed form first', () => {
assert.deepEqual(modrinthQueryVariants('example mod'), ['example mod', 'examplemod'])
assert.deepEqual(modrinthQueryVariants('sodium-extra'), [
'sodium-extra',
'sodium extra',
'sodiumextra',
])
assert.deepEqual(modrinthQueryVariants('SodiumExtra'), ['sodiumextra', 'sodium extra'])
assert.deepEqual(modrinthQueryVariants(''), [])
})
test('curseforge variants put the slug form first', () => {
assert.deepEqual(curseForgeQueryVariants('example mod'), [
'example-mod',
'example mod',
'examplemod',
])
assert.deepEqual(curseForgeQueryVariants('sodium-extra'), ['sodium-extra', 'sodiumextra'])
assert.deepEqual(curseForgeQueryVariants('SodiumExtra'), ['sodiumextra', 'sodium-extra'])
assert.deepEqual(curseForgeQueryVariants('sodium extra'), [
'sodium-extra',
'sodium extra',
'sodiumextra',
])
assert.deepEqual(curseForgeQueryVariants(''), [])
})
test('expandSearchQuery returns null for empty and whitespace-only queries', () => {
assert.equal(expandSearchQuery(''), null)
assert.equal(expandSearchQuery(' '), null)
})
test('expandSearchQuery flags compact queries', () => {
const expansion = expandSearchQuery('sodiumextra')
assert.ok(expansion)
assert.equal(expansion.compact, true)
assert.equal(expansion.normalized, 'sodiumextra')
assert.deepEqual(expansion.modrinthVariants, ['sodiumextra'])
assert.deepEqual(expansion.curseforgeVariants, ['sodiumextra'])
})
test('expandSearchQuery never treats spaced queries as compact', () => {
const expansion = expandSearchQuery('sodium extra')
assert.ok(expansion)
assert.equal(expansion.compact, false)
})
test('expansion of hyphenated queries covers both providers', () => {
const expansion = expandSearchQuery('sodium-extra')
assert.ok(expansion)
assert.deepEqual(expansion.modrinthVariants, ['sodium-extra', 'sodium extra', 'sodiumextra'])
assert.deepEqual(expansion.curseforgeVariants, ['sodium-extra', 'sodiumextra'])
})
test('expansion dedupes and caps variants', () => {
const expansion = expandSearchQuery('a b c')
assert.ok(expansion)
assert.ok(expansion.modrinthVariants.length <= 3)
assert.ok(expansion.curseforgeVariants.length <= 3)
assert.equal(new Set(expansion.modrinthVariants).size, expansion.modrinthVariants.length)
})

View File

@ -0,0 +1,134 @@
/**
* Provider-aware query normalization and variant expansion.
*
* Modrinth (Labrinth) search tokenizes queries and prefix-matches tokens, so a
* query like `examplemod` can never match a project named "Example Mod", while
* CurseForge's `searchFilter` is only reliable in slug form (`example-mod`).
* Since neither server can be changed, the desktop client rewrites the query
* before it leaves the app: the primary form preserves what the user typed,
* and fallback variants cover the forms the providers actually understand.
*/
export interface SearchQueryExpansion {
/** Trimmed, diacritics-stripped, lowercased query. */
normalized: string
/** True when the query contains no whitespace or separator characters. */
compact: boolean
/**
* Ordered Modrinth query candidates, primary first. Each candidate is a
* normalized, deduplicated, non-empty string.
*/
modrinthVariants: string[]
/** Ordered CurseForge query candidates, primary (slug form) first. */
curseforgeVariants: string[]
}
const SEPARATOR_PATTERN = /[-_.+~,;:'"()[\]{}]+/gu
function dedupe(values: string[]): string[] {
return [...new Set(values.filter((value) => value.length > 0))]
}
/**
* Normalizes free text: trims, collapses whitespace, strips combining marks
* (diacritics) and lowercases. Punctuation is preserved.
*/
export function normalizeSearchText(value: string): string {
return value
.trim()
.normalize('NFD')
.replace(/\p{M}/gu, '')
.toLocaleLowerCase()
.replace(/\s+/g, ' ')
}
/**
* Removes every non-letter/non-digit character (spaces, hyphens, punctuation),
* producing the compact form of a query (`example mod` → `examplemod`).
*/
export function compactSearchText(value: string): string {
return compactSearchTextPreservingCase(value).toLocaleLowerCase()
}
function compactSearchTextPreservingCase(value: string): string {
return value
.normalize('NFD')
.replace(/\p{M}/gu, '')
.replace(/[^\p{L}\p{N}]+/gu, '')
}
/**
* Converts free text into a CurseForge-friendly slug form
* (`Example Mod!` → `example-mod`). CurseForge's search filter matches slugs,
* where spaces and punctuation do not work.
*/
export function slugifySearchText(value: string): string {
return normalizeSearchText(value)
.replace(SEPARATOR_PATTERN, ' ')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
}
/**
* Splits concatenated camelCase/PascalCase text into words
* (`SodiumExtra` → `Sodium Extra`). The result is not lowercased; callers
* normalize it as needed.
*/
export function splitCamelCaseSearchText(value: string): string {
return value
.replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, '$1 $2')
.replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, '$1 $2')
}
/**
* Builds the ordered Modrinth query candidates for a base query: primary is
* the query as normalized, then separator-free forms (spaces for hyphens,
* camelCase splits, and the fully compact form) for engines that tokenize
* differently. At most three candidates are returned.
*/
export function modrinthQueryVariants(base: string): string[] {
const normalized = normalizeSearchText(base)
if (!normalized) return []
const compact = compactSearchText(base)
const compactCased = compactSearchTextPreservingCase(base)
const spaced = normalized.replace(SEPARATOR_PATTERN, ' ').replace(/\s+/g, ' ')
const camelCaseSplit = splitCamelCaseSearchText(compactCased).toLocaleLowerCase().trim()
return dedupe([normalized, spaced, camelCaseSplit, compact]).slice(0, 3)
}
/**
* Builds the ordered CurseForge query candidates for a base query. The slug
* form is primary because CurseForge search filters match project slugs;
* the other variants cover cases where CurseForge accepts plain text.
*/
export function curseForgeQueryVariants(base: string): string[] {
const normalized = normalizeSearchText(base)
if (!normalized) return []
const slug = slugifySearchText(normalized)
const camelCaseSlug = slugifySearchText(
splitCamelCaseSearchText(
compactSearchTextPreservingCase(base),
).toLocaleLowerCase(),
)
const compact = compactSearchText(base)
return dedupe([slug, normalized, camelCaseSlug, compact]).slice(0, 3)
}
/**
* Expands a raw browse query into provider-specific search candidates.
* Returns `null` for empty queries (browsing without a query must stay
* unfiltered).
*/
export function expandSearchQuery(raw: string): SearchQueryExpansion | null {
const normalized = normalizeSearchText(raw)
if (!normalized) return null
const compact = compactSearchText(raw)
const isCompact = !/\s/u.test(normalized) && normalized === compact
return {
normalized,
compact: isCompact,
modrinthVariants: modrinthQueryVariants(normalized),
curseforgeVariants: curseForgeQueryVariants(normalized),
}
}

View File

@ -0,0 +1,147 @@
import type { ServerTypeId } from '@modrinth/server'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
export interface ModpackInfoData {
projectId: string
versionId: string
title: string
iconUrl?: string
}
export interface ServerManifestData {
id: string
name: string
serverType: ServerTypeId
gameVersion: string
loaderVersion?: string
jarName?: string
iconPath?: string
modpack?: ModpackInfoData
installState?: 'incomplete' | 'failed' | null
installError?: string | null
javaPath?: string
memoryMb?: number
jvmArgs: string[]
createdAt: string
lastStartedAt?: string
lastExitCrashed: boolean
}
export interface ServerInfoData extends ServerManifestData {
path: string
running: boolean
eulaExists: boolean
eulaAccepted: boolean
port: number | null
}
export type ServerExitReason = 'eula'
export type ServerEventPayload =
| { event: 'log'; line: string }
| { event: 'console_output'; data: string }
| { event: 'download_progress'; downloaded: number; total?: number }
| { event: 'started' }
| { event: 'stopped'; crashed: boolean; reason?: ServerExitReason }
| { event: 'eula_required'; server_id: string; eula_text: string }
export interface PortProcessInfoData {
pid: number
name?: string | null
}
export interface InstallModpackOptions {
mrpackUrl: string
mrpackSha1?: string
jarUrl: string
jarFilename: string
jarSha1?: string
modpackProjectId?: string
modpackVersionId?: string
modpackTitle?: string
modpackIconUrl?: string
}
const command = (name: string) => `plugin:servers|${name}`
export const servers = {
list: () => invoke<ServerInfoData[]>(command('servers_list')),
get: (serverId: string) => invoke<ServerInfoData>(command('servers_get'), { serverId }),
create: (options: {
name: string
serverType: ServerTypeId
gameVersion: string
loaderVersion?: string
javaPath?: string
memoryMb?: number
}) => invoke<ServerManifestData>(command('servers_create'), options),
updateSettings: (
serverId: string,
options: {
name?: string
javaPath?: string
memoryMb?: number
jvmArgs?: string[]
},
) => invoke<ServerManifestData>(command('servers_update_settings'), { serverId, ...options }),
setIcon: (serverId: string, iconPath: string | null) =>
invoke<ServerManifestData>(command('servers_set_icon'), { serverId, iconPath }),
delete: (serverId: string) => invoke<void>(command('servers_delete'), { serverId }),
readFile: (serverId: string, file: string) =>
invoke<string>(command('servers_read_file'), { serverId, file }),
writeFile: (serverId: string, file: string, contents: string) =>
invoke<void>(command('servers_write_file'), { serverId, file, contents }),
downloadFile: (serverId: string, url: string, filename: string, expectedSha1?: string) =>
invoke<void>(command('servers_download_file'), {
serverId,
url,
filename,
expectedSha1,
}),
installModpack: (serverId: string, options: InstallModpackOptions) =>
invoke<void>(command('servers_install_modpack'), { serverId, ...options }),
installForge: (serverId: string, mcVersion: string, build: string, javaPath?: string) =>
invoke<void>(command('servers_install_forge'), { serverId, mcVersion, build, javaPath }),
start: (
serverId: string,
options?: { javaPath?: string; memoryMb?: number; jvmArgs?: string[] },
) => invoke<void>(command('servers_start'), { serverId, ...options }),
sendCommand: (serverId: string, commandText: string) =>
invoke<void>(command('servers_send_command'), { serverId, command: commandText }),
sendConsoleInput: (serverId: string, data: Uint8Array) =>
invoke<void>(command('servers_send_console_input'), {
serverId,
data: bytesToBase64(data),
}),
resizeConsole: (serverId: string, cols: number, rows: number) =>
invoke<void>(command('servers_resize_console'), { serverId, cols, rows }),
stop: (serverId: string) => invoke<void>(command('servers_stop'), { serverId }),
kill: (serverId: string) => invoke<void>(command('servers_kill'), { serverId }),
killPortProcess: (port: number) => invoke<void>(command('servers_kill_port_process'), { port }),
portProcess: (port: number) =>
invoke<PortProcessInfoData | null>(command('servers_port_process'), { port }),
getLogBuffer: (serverId: string) =>
invoke<string[]>(command('servers_get_log_buffer'), { serverId }),
clearLog: (serverId: string) => invoke<void>(command('servers_clear_log'), { serverId }),
}
export function base64ToBytes(data: string): Uint8Array {
return Uint8Array.from(atob(data), (character) => character.charCodeAt(0))
}
function bytesToBase64(data: Uint8Array): string {
let binary = ''
for (const byte of data) binary += String.fromCharCode(byte)
return btoa(binary)
}
export async function serverEventListener(
callback: (serverId: string, payload: ServerEventPayload) => void,
): Promise<() => void> {
const unlisten = await listen<{ serverId: string; event: string } & ServerEventPayload>(
'server',
(event) => callback(event.payload.serverId, event.payload),
)
return unlisten
}

View File

@ -0,0 +1,110 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getLastBrowseContentDisplayMode,
getLastBrowseContentProjectType,
setLastBrowseContentDisplayMode,
setLastBrowseContentProjectType,
} from './browse-display-mode.ts'
import { getLastLibraryDisplayMode, setLastLibraryDisplayMode } from './library-display-mode.ts'
import { getSidebarExpanded, setSidebarExpanded } from './sidebar-state.ts'
const storageKey = 'axolotl-browse-content-display-mode'
const projectTypeStorageKey = 'axolotl-browse-content-project-type'
const libraryDisplayModeStorageKey = 'axolotl-library-display-mode'
const sidebarStorageKey = 'axolotl-right-sidebar-expanded'
const originalStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
function installMemoryStorage() {
const values = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
})
return values
}
function restoreStorage() {
if (originalStorageDescriptor) {
Object.defineProperty(globalThis, 'localStorage', originalStorageDescriptor)
} else {
delete (globalThis as { localStorage?: Storage }).localStorage
}
}
test('browse display mode persists valid values and falls back to the list', () => {
const values = installMemoryStorage()
try {
assert.equal(getLastBrowseContentDisplayMode(), 'list')
setLastBrowseContentDisplayMode('compact')
assert.equal(getLastBrowseContentDisplayMode(), 'compact')
setLastBrowseContentDisplayMode('grid')
assert.equal(getLastBrowseContentDisplayMode(), 'grid')
values.set(storageKey, 'invalid')
assert.equal(getLastBrowseContentDisplayMode(), 'list')
} finally {
restoreStorage()
}
})
test('browse project type persists content types and rejects non-content routes', () => {
const values = installMemoryStorage()
try {
assert.equal(getLastBrowseContentProjectType(), 'modpack')
setLastBrowseContentProjectType('mod')
assert.equal(getLastBrowseContentProjectType(), 'mod')
setLastBrowseContentProjectType('world')
assert.equal(getLastBrowseContentProjectType(), 'world')
values.set(projectTypeStorageKey, 'server')
assert.equal(getLastBrowseContentProjectType(), 'modpack')
} finally {
restoreStorage()
}
})
test('library display mode persists cards and falls back to the standard grid', () => {
const values = installMemoryStorage()
try {
assert.equal(getLastLibraryDisplayMode(), 'standard')
setLastLibraryDisplayMode('cards')
assert.equal(getLastLibraryDisplayMode(), 'cards')
values.set(libraryDisplayModeStorageKey, 'invalid')
assert.equal(getLastLibraryDisplayMode(), 'standard')
} finally {
restoreStorage()
}
})
test('right sidebar expansion persists and defaults to expanded', () => {
const values = installMemoryStorage()
try {
assert.equal(getSidebarExpanded(), true)
setSidebarExpanded(false)
assert.equal(getSidebarExpanded(), false)
setSidebarExpanded(true)
assert.equal(getSidebarExpanded(), true)
values.set(sidebarStorageKey, 'invalid')
assert.equal(getSidebarExpanded(), true)
} finally {
restoreStorage()
}
})

View File

@ -0,0 +1,330 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* 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 { HomeDashboardConfig } from '@/components/home/home-dashboard'
import type { Hooks, MemorySettings } from '@/helpers/types'
import type { AccentColorSetting, ColorTheme, FeatureFlag, HomeLayout } from '@/store/theme.ts'
import { DEFAULT_FEATURE_FLAGS } from '@/store/theme.ts'
export type { BrowseContentDisplayMode, BrowseContentProjectType } from './browse-display-mode.ts'
export {
getLastBrowseContentDisplayMode,
getLastBrowseContentProjectType,
isBrowseContentProjectType,
setLastBrowseContentDisplayMode,
setLastBrowseContentProjectType,
} from './browse-display-mode.ts'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
export type UpdateChannel = 'release' | 'beta'
export type UpdatePreferences = {
immediateUpdateFetch: boolean
updatesPaused: boolean
}
export type DownloadSourceMode =
| 'auto'
| 'official_only'
| 'mirror_preferred'
| 'official_preferred'
export type DownloadEngine = 'legacy' | 'xmcl'
export type ProxyMode = 'none' | 'system' | 'custom'
export type ProxyConfig = {
mode: ProxyMode
url: string
username: string
password: string
}
export type ProxyTestResult = {
success: boolean
latency_ms: number | null
message: string
}
export async function getUpdateChannel(): Promise<UpdateChannel> {
const channel = await invoke<string>('get_update_channel')
return channel === 'beta' ? 'beta' : 'release'
}
export async function setUpdateChannel(channel: UpdateChannel): Promise<void> {
await invoke('set_update_channel', { channel })
}
export async function copyReleaseDatabaseToBeta(): Promise<void> {
await invoke('copy_release_database_to_beta')
}
export async function betaDatabaseExists(): Promise<boolean> {
return await invoke('beta_database_exists')
}
export async function getCurrentAppDatabasePath(): Promise<string> {
return await invoke('get_current_app_database_path')
}
export async function copyDatabaseBetweenChannels(
sourceChannel: UpdateChannel,
targetChannel: UpdateChannel,
): Promise<void> {
await invoke('copy_database_between_channels', { sourceChannel, targetChannel })
}
export async function getUpdatePreferences(): Promise<UpdatePreferences> {
return await invoke('get_update_preferences')
}
export async function setUpdatePreferences(preferences: UpdatePreferences): Promise<void> {
await invoke('set_update_preferences', preferences)
}
export type BrowseContentSource =
| 'all'
| 'modrinth'
| 'curseforge'
| 'mcarchive'
| 'planet_minecraft'
const BROWSE_CONTENT_SOURCE_STORAGE_KEY = 'axolotl-browse-content-source'
const BROWSE_DEFAULT_INSTANCE_STORAGE_KEY = 'axolotl-browse-default-instance'
export function getLastBrowseContentSource(): BrowseContentSource | null {
const value = localStorage.getItem(BROWSE_CONTENT_SOURCE_STORAGE_KEY)
return value === 'all' ||
value === 'modrinth' ||
value === 'curseforge' ||
value === 'mcarchive' ||
value === 'planet_minecraft'
? value
: null
}
export function setLastBrowseContentSource(source: BrowseContentSource) {
localStorage.setItem(BROWSE_CONTENT_SOURCE_STORAGE_KEY, source)
}
export function getBrowseDefaultInstanceId(): string | null {
return localStorage.getItem(BROWSE_DEFAULT_INSTANCE_STORAGE_KEY)
}
export function setBrowseDefaultInstanceId(instanceId: string | null) {
if (instanceId) {
localStorage.setItem(BROWSE_DEFAULT_INSTANCE_STORAGE_KEY, instanceId)
} else {
localStorage.removeItem(BROWSE_DEFAULT_INSTANCE_STORAGE_KEY)
}
}
export type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
download_engine: DownloadEngine
auto_concurrent_downloads: boolean
minecraft_metadata_source: DownloadSourceMode
minecraft_file_source: DownloadSourceMode
modrinth_source: DownloadSourceMode
curseforge_source: DownloadSourceMode
bypass_curseforge_download_restrictions: boolean
mojang_auth_source: DownloadSourceMode
theme: ColorTheme
accent_color: AccentColorSetting
locale: string
default_page: 'Home' | 'DiscoverContent' | 'Library'
collapsed_navigation: boolean
hide_nametag_skins_page: boolean
advanced_rendering: boolean
native_decorations: boolean
toggle_sidebar: boolean
custom_background_path: string | null
custom_background_blur: number
custom_background_opacity: number
transparent_background: boolean
transparent_background_opacity: number
transparent_background_blur: boolean
sidebar_instance_count: number
auto_hide_downloads_button: boolean
home_layout: HomeLayout
minimal_home_instance_id: string | null
close_behavior: 'ask' | 'close' | 'lightweight'
home_widgets: HomeDashboardConfig | null
terracotta_public_nodes: string[]
telemetry: boolean
telemetry_consent_version: number
discord_rpc: boolean
onboarded: boolean
onboarding_version: number
onboarding_instance_tour_completed: boolean
extra_launch_args: string[]
custom_env_vars: [string, string][]
memory: MemorySettings
force_fullscreen: boolean
maximize_window: boolean
game_resolution: [number, number]
hide_on_process_start: boolean
enter_lightweight_mode_on_game_launch: boolean
auto_set_java_high_performance_mode: boolean
hooks: Hooks
custom_dir?: string | null
prev_custom_dir?: string | null
migrated: boolean
developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean>
skipped_update: string | null
pending_update_toast_for_version: string | null
auto_download_updates: boolean | null
version: number
}
export type PrivacySettings = {
telemetry: boolean
discord_rpc: boolean
consent_version: number
}
type LegacyMirrorSettings = {
use_minecraft_mirror?: boolean
use_modrinth_mirror?: boolean
use_curseforge_mirror?: boolean
}
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
settings.close_behavior ??= 'ask'
const hasLegacySettings =
typeof settings.use_minecraft_mirror === 'boolean' &&
typeof settings.use_modrinth_mirror === 'boolean' &&
typeof settings.use_curseforge_mirror === 'boolean'
const usesLegacyDefaults =
hasLegacySettings &&
!settings.use_minecraft_mirror &&
!settings.use_modrinth_mirror &&
settings.use_curseforge_mirror
const legacySource = (enabled: boolean | undefined): DownloadSourceMode =>
enabled ? 'mirror_preferred' : 'official_only'
settings.auto_concurrent_downloads ??= true
settings.download_engine ??= 'legacy'
settings.auto_set_java_high_performance_mode ??= true
settings.minecraft_metadata_source ??=
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_minecraft_mirror)
settings.minecraft_file_source ??=
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_minecraft_mirror)
settings.modrinth_source ??=
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_modrinth_mirror)
settings.curseforge_source ??=
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_curseforge_mirror)
settings.bypass_curseforge_download_restrictions ??= true
settings.mojang_auth_source ??= 'auto'
settings.terracotta_public_nodes ??= ['wss://center.node.1tmc.top']
settings.feature_flags ??= { ...DEFAULT_FEATURE_FLAGS }
for (const [key, value] of Object.entries(DEFAULT_FEATURE_FLAGS)) {
settings.feature_flags[key as FeatureFlag] ??= value
}
return settings
}
function syncLegacyMirrorSettings(settings: AppSettings & LegacyMirrorSettings) {
const legacyValue = (source: DownloadSourceMode, current: boolean | undefined) => {
if (source === 'mirror_preferred') return true
if (source === 'official_only') return false
return current ?? false
}
if (typeof settings.use_minecraft_mirror === 'boolean') {
settings.use_minecraft_mirror = legacyValue(
settings.minecraft_file_source,
settings.use_minecraft_mirror,
)
}
if (typeof settings.use_modrinth_mirror === 'boolean') {
settings.use_modrinth_mirror = legacyValue(
settings.modrinth_source,
settings.use_modrinth_mirror,
)
}
if (typeof settings.use_curseforge_mirror === 'boolean') {
settings.use_curseforge_mirror = legacyValue(
settings.curseforge_source,
settings.use_curseforge_mirror,
)
}
}
// Get full settings object
export async function get() {
const settings = normalizeDownloadSettings(
(await invoke('plugin:settings|settings_get')) as AppSettings & LegacyMirrorSettings,
)
return settings
}
// Set full settings object
export async function set(settings: AppSettings) {
syncLegacyMirrorSettings(settings)
const result = await invoke('plugin:settings|settings_set', { settings })
return result
}
export async function cancel_directory_change(): Promise<void> {
return await invoke('plugin:settings|cancel_directory_change')
}
export async function getPrivacySettings(): Promise<PrivacySettings> {
return await invoke('plugin:settings|privacy_get')
}
export async function savePrivacySettings(privacy: PrivacySettings): Promise<PrivacySettings> {
return await invoke('plugin:settings|privacy_set', { privacy })
}
export async function setTelemetryEnabled(enabled: boolean): Promise<PrivacySettings> {
return await invoke('plugin:settings|telemetry_set', { enabled })
}
export async function setDiscordRpcEnabled(enabled: boolean): Promise<PrivacySettings> {
return await invoke('plugin:settings|discord_rpc_set', { enabled })
}
export async function getProxyConfig(): Promise<ProxyConfig> {
return await invoke('plugin:settings|proxy_get')
}
export async function setProxyConfig(config: ProxyConfig): Promise<void> {
await invoke('plugin:settings|proxy_set', { config })
}
export async function testProxyConfig(config: ProxyConfig): Promise<ProxyTestResult> {
return await invoke('plugin:settings|proxy_test', { config })
}

View File

@ -0,0 +1,10 @@
const SIDEBAR_EXPANDED_STORAGE_KEY = 'axolotl-right-sidebar-expanded'
export function getSidebarExpanded(): boolean {
const value = localStorage.getItem(SIDEBAR_EXPANDED_STORAGE_KEY)
return value !== 'false'
}
export function setSidebarExpanded(expanded: boolean) {
localStorage.setItem(SIDEBAR_EXPANDED_STORAGE_KEY, String(expanded))
}

View File

@ -0,0 +1,194 @@
import { arrayBufferToBase64 } from '@modrinth/utils'
import { invoke } from '@tauri-apps/api/core'
export interface Cape {
id: string
name: string
texture: string
is_equipped: boolean
}
export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN'
export type SkinSource = 'default' | 'custom_external' | 'custom'
export interface Skin {
texture_key: string
name?: string
section?: string
variant: SkinModel
cape_id?: string
texture: string
source: SkinSource
is_equipped: boolean
}
export interface SkinTextureUrl {
original: string
normalized: string
}
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
export const DEFAULT_MODELS: Record<string, SkinModel> = {
Steve: 'CLASSIC',
Alex: 'SLIM',
Zuri: 'CLASSIC',
Sunny: 'CLASSIC',
Noor: 'SLIM',
Makena: 'SLIM',
Kai: 'CLASSIC',
Efe: 'SLIM',
Ari: 'CLASSIC',
}
export function filterSavedSkins(list: Skin[]) {
const customSkins = list.filter((s) => s.source !== 'default')
fixUnknownSkins(customSkins)
return customSkins
}
export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) {
return reject(new Error('Failed to create canvas rendering context.'))
}
const image = new Image()
image.crossOrigin = 'anonymous'
image.src = texture
image.onload = () => {
canvas.width = image.width
canvas.height = image.height
context.drawImage(image, 0, 0)
const armX = 54
const armY = 20
const armWidth = 2
const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
if (imageData[alphaIndex] !== 0) {
resolve('CLASSIC')
return
}
}
canvas.remove()
resolve('SLIM')
}
image.onerror = () => {
canvas.remove()
reject(new Error('Failed to load the image.'))
}
})
}
export async function fixUnknownSkins(list: Skin[]) {
const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN')
for (const unknownSkin of unknownSkins) {
unknownSkin.variant = await determineModelType(unknownSkin.texture)
}
}
export function filterDefaultSkins(list: Skin[]) {
return list
.filter(
(s) =>
s.source === 'default' &&
(!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]),
)
.sort((a, b) => {
const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
})
}
export async function get_available_capes(): Promise<Cape[]> {
return invoke('plugin:minecraft-skins|get_available_capes', {})
}
export async function get_available_skins(): Promise<Skin[]> {
return invoke('plugin:minecraft-skins|get_available_skins', {})
}
export async function add_and_equip_custom_skin(
textureBlob: Uint8Array,
variant: SkinModel,
cape?: Cape,
): Promise<Skin> {
return await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
textureBlob,
variant,
cape,
})
}
export async function equip_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|equip_skin', {
skin,
})
}
export async function remove_custom_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|remove_custom_skin', {
skin,
})
}
export async function set_custom_skin_order(textureKeys: string[]): Promise<void> {
await invoke('plugin:minecraft-skins|set_custom_skin_order', {
textureKeys,
})
}
export async function save_custom_skin(
skin: Skin,
textureBlob: Uint8Array,
variant: SkinModel,
cape: Cape | undefined,
replaceTexture: boolean,
): Promise<Skin> {
return await invoke('plugin:minecraft-skins|save_custom_skin', {
skin,
textureBlob,
variant,
cape,
replaceTexture,
})
}
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
const data = await normalize_skin_texture(skin.texture)
const base64 = arrayBufferToBase64(data)
return `data:image/png;base64,${base64}`
}
export async function normalize_skin_texture(texture: Uint8Array | string): Promise<Uint8Array> {
return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
}
export async function unequip_skin(): Promise<void> {
await invoke('plugin:minecraft-skins|unequip_skin')
}
export async function flush_pending_skin_change(): Promise<void> {
await invoke('plugin:minecraft-skins|flush_pending_skin_change')
}
export async function flush_pending_skin_change_for_profile(profileId: string): Promise<void> {
await invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', {
profileId,
})
}
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
return new Uint8Array(data)
}

View File

@ -0,0 +1,62 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* 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'
export interface LoadingBarType {
type?: string
version?: string
instance_id?: string
instance_name?: string
pack_name?: string
icon?: string | null
}
export interface LoadingBar {
id?: string | number
loading_bar_uuid?: string | number
title?: string
message?: string
current?: number
total?: number
bar_type?: LoadingBarType
}
export type OpeningCommandEvent =
| 'RunMRPack'
| 'InstallServer'
| 'InstallVersion'
| 'InstallMod'
| 'InstallModpack'
| string
export interface OpeningCommand {
event: OpeningCommandEvent
id?: string
path?: string
}
// 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 set_discord_activity(activity: string) {
return await invoke<void>('set_discord_activity', { activity })
}
// Gets active progress bars
export async function progress_bars_list() {
return await invoke<Record<string, LoadingBar>>('plugin:utils|progress_bars_list')
}
// Get opening command
// For example, if a user clicks on an .mrpack to open the app.
// This should be called once and only when the app is done booting up and ready to receive a command
// Returns a Command struct- see events.js
export async function get_opening_command() {
return await invoke<OpeningCommand | null>('plugin:utils|get_opening_command')
}

View File

@ -0,0 +1,31 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type {
StorageNode,
StoragePath,
StorageTree,
} from '@/components/ui/settings/storage/storageData'
export type StorageScanEvent =
| { kind: 'started' }
| { kind: 'category'; payload: { category: StorageNode } }
| { kind: 'complete'; payload: { tree: StorageTree } }
| { kind: 'error'; payload: { message: string } }
export interface StorageOpenResult {
opened: string[]
failed: { path: string; reason: string }[]
}
export function startStorageScan(force: boolean): Promise<void> {
return invoke('plugin:storage|storage_scan_start', { force })
}
export function openStoragePaths(paths: StoragePath[]): Promise<StorageOpenResult> {
return invoke('plugin:storage|storage_open_paths', { paths })
}
export function listenStorageScan(handler: (event: StorageScanEvent) => void): Promise<UnlistenFn> {
return listen('storage-scan', (event) => handler(event.payload as StorageScanEvent))
}

View File

@ -0,0 +1,229 @@
interface StoredHead {
blob: Blob
timestamp: number
}
export class HeadStorage {
private dbName = 'head-storage'
private version = 1
private db: IDBDatabase | null = null
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains('heads')) {
db.createObjectStore('heads')
}
}
})
}
async store(key: string, blob: Blob): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
const storedHead: StoredHead = {
blob,
timestamp: Date.now(),
}
return new Promise((resolve, reject) => {
const request = store.put(storedHead, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async retrieve(key: string): Promise<string | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (!result) {
resolve(null)
return
}
const url = URL.createObjectURL(result.blob)
resolve(url)
}
request.onerror = () => reject(request.error)
})
}
async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
const results: Record<string, Blob | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (result) {
results[key] = result.blob
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
let deletedCount = 0
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
if (!validKeys.has(key)) {
const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = () => {
console.warn('Failed to delete invalid head entry:', key)
}
}
cursor.continue()
} else {
resolve(deletedCount)
}
}
request.onerror = () => reject(request.error)
})
}
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredHead
const entrySize = value.blob.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Head Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
async clearAll(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
}
export const headStorage = new HeadStorage()

View File

@ -0,0 +1,218 @@
import type { RawRenderResult } from '../rendering/batch-skin-renderer'
interface StoredPreview {
forwards: Blob
timestamp: number
}
export class SkinPreviewStorage {
// Changing the database name invalidates thumbnails rendered with the old
// flat outer-layer geometry after the 3D Skin Layers update.
private dbName = 'skin-previews-v2'
private version = 1
private db: IDBDatabase | null = null
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains('previews')) {
db.createObjectStore('previews')
}
}
})
}
async store(key: string, result: RawRenderResult): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews')
const storedPreview: StoredPreview = {
forwards: result.forwards,
timestamp: Date.now(),
}
return new Promise((resolve, reject) => {
const request = store.put(storedPreview, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async retrieve(key: string): Promise<RawRenderResult | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
return new Promise((resolve, reject) => {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredPreview | undefined
if (!result) {
resolve(null)
return
}
resolve({ forwards: result.forwards })
}
request.onerror = () => reject(request.error)
})
}
async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
const results: Record<string, RawRenderResult | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredPreview | undefined
if (result) {
results[key] = { forwards: result.forwards }
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews')
let deletedCount = 0
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
if (!validKeys.has(key)) {
const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = () => {
console.warn('Failed to delete invalid entry:', key)
}
}
cursor.continue()
} else {
resolve(deletedCount)
}
}
request.onerror = () => reject(request.error)
})
}
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredPreview
const entrySize = value.forwards.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Skin Preview Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
}
export const skinPreviewStorage = new SkinPreviewStorage()

View File

@ -0,0 +1,53 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
export function readStudioText(instanceId: string, filePath: string): Promise<string> {
return invoke('plugin:files|studio_read_text', { instanceId, filePath })
}
export function readStudioBinary(instanceId: string, filePath: string): Promise<Uint8Array> {
return invoke<number[] | Uint8Array | ArrayBuffer>('plugin:files|studio_read_binary', {
instanceId,
filePath,
}).then((bytes) => {
if (bytes instanceof Uint8Array) return bytes
if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes)
return new Uint8Array(bytes)
})
}
export function writeStudioBinary(
instanceId: string,
filePath: string,
bytes: Uint8Array,
): Promise<void> {
return invoke('plugin:files|studio_write_binary', {
instanceId,
filePath,
bytes: Array.from(bytes),
})
}
export function trashStudioFile(instanceId: string, filePath: string): Promise<void> {
return invoke('plugin:files|studio_trash', { instanceId, filePath })
}
export interface StudioFilesChangedEvent {
instanceId: string
registrationId: string
paths: string[]
}
export function registerStudioWatcher(instanceId: string): Promise<string> {
return invoke('plugin:files|studio_watch_register', { instanceId })
}
export function unregisterStudioWatcher(instanceId: string, registrationId: string): Promise<void> {
return invoke('plugin:files|studio_watch_unregister', { instanceId, registrationId })
}
export function listenStudioFilesChanged(
handler: (event: StudioFilesChangedEvent) => void,
): Promise<UnlistenFn> {
return listen<StudioFilesChangedEvent>('studio-files-changed', (event) => handler(event.payload))
}

View File

@ -0,0 +1,31 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* 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'
// Gets cached category tags
export async function get_categories() {
return await invoke('plugin:tags|tags_get_categories')
}
// Gets cached loaders tags
export async function get_loaders() {
return await invoke('plugin:tags|tags_get_loaders')
}
// Gets cached game_versions tags
export async function get_game_versions() {
return await invoke('plugin:tags|tags_get_game_versions')
}
// Gets cached donation_platforms tags
export async function get_donation_platforms() {
return await invoke('plugin:tags|tags_get_donation_platforms')
}
// Gets cached licenses tags
export async function get_report_types() {
return await invoke('plugin:tags|tags_get_report_types')
}

View File

@ -0,0 +1,7 @@
import { invoke } from '@tauri-apps/api/core'
export function installTelemetryHandlers(): void {
window.addEventListener('online', () => {
void invoke('plugin:telemetry|notify_online').catch(() => undefined)
})
}

View File

@ -0,0 +1,25 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseTerracottaPublicNodes } from './terracotta.ts'
test('parses Terracotta public nodes from lines and commas', () => {
assert.deepEqual(
parseTerracottaPublicNodes(
'wss://center.node.1tmc.top\ntcp://example.com:11010, udp://example.net:11010',
),
{
nodes: ['wss://center.node.1tmc.top', 'tcp://example.com:11010', 'udp://example.net:11010'],
invalidNode: null,
},
)
})
test('allows an empty Terracotta public node list', () => {
assert.deepEqual(parseTerracottaPublicNodes(' \n'), { nodes: [], invalidNode: null })
})
test('rejects unsupported or incomplete Terracotta public nodes', () => {
assert.equal(parseTerracottaPublicNodes('ftp://example.com').invalidNode, 'ftp://example.com')
assert.equal(parseTerracottaPublicNodes('wss://').invalidNode, 'wss://')
})

View File

@ -0,0 +1,112 @@
import { invoke } from '@tauri-apps/api/core'
export type TerracottaStatus =
| 'idle'
| 'starting'
| 'downloading'
| 'waiting'
| 'host_scanning'
| 'host_starting'
| 'host_ready'
| 'guest_connecting'
| 'guest_starting'
| 'guest_ready'
| 'error'
| 'fatal'
export type TerracottaDownloadStage =
| 'preparing'
| 'downloading'
| 'verifying'
| 'extracting'
| 'installing'
| 'complete'
export type TerracottaErrorType = 'os' | 'network' | 'install' | 'terracotta' | 'unknown'
export interface TerracottaPlayer {
machine_id: string
name: string
vendor: string
kind: 'HOST' | 'GUEST' | 'UNKNOWN'
}
export interface TerracottaState {
status: TerracottaStatus
http_port: number | null
room_code: string | null
server_port: number | null
players: TerracottaPlayer[]
download_progress: number | null
download_stage: TerracottaDownloadStage | null
binary_installed: boolean
installed_version: string | null
error_type: TerracottaErrorType | null
error_message: string | null
profile_index: number | null
}
export interface TerracottaUpdate {
installed_version: string | null
latest_version: string
update_available: boolean
}
const TERRACOTTA_ROOM_CODE_PATTERN = /^U\/[A-Z0-9]{4}(?:-[A-Z0-9]{4}){3}$/i
const TERRACOTTA_PUBLIC_NODE_SCHEMES = new Set([
'http:',
'https:',
'tcp:',
'tls:',
'udp:',
'ws:',
'wss:',
])
const command = (name: string) => `plugin:terracotta|${name}`
export function isValidTerracottaRoomCode(roomCode: string): boolean {
return TERRACOTTA_ROOM_CODE_PATTERN.test(roomCode.trim())
}
export function parseTerracottaPublicNodes(value: string): {
nodes: string[]
invalidNode: string | null
} {
const nodes = value
.split(/[\n,]+/)
.map((node) => node.trim())
.filter(Boolean)
for (const node of nodes) {
try {
const url = new URL(node)
if (!TERRACOTTA_PUBLIC_NODE_SCHEMES.has(url.protocol) || !url.hostname) {
return { nodes, invalidNode: node }
}
} catch {
return { nodes, invalidNode: node }
}
}
return { nodes, invalidNode: null }
}
export const terracotta = {
getState: () => invoke<TerracottaState>(command('terracotta_get_state')),
getPlatformKey: () => invoke<string>(command('terracotta_get_platform_key')),
checkForUpdate: () => invoke<TerracottaUpdate>(command('terracotta_check_for_update')),
getPlayerName: () => invoke<string>(command('terracotta_get_player_name')),
getDiagnosticReport: () => invoke<string>(command('terracotta_get_diagnostic_report')),
start: () => invoke<void>(command('terracotta_start'), { autoDownload: true }),
host: (playerName: string) =>
invoke<void>(command('terracotta_host'), { playerName: playerName.trim() }),
join: (playerName: string, roomCode: string) =>
invoke<void>(command('terracotta_join'), {
playerName: playerName.trim(),
roomCode: roomCode.trim(),
}),
reset: () => invoke<void>(command('terracotta_reset')),
download: () => invoke<void>(command('terracotta_download')),
update: () => invoke<TerracottaUpdate>(command('terracotta_update')),
}

View File

@ -0,0 +1,153 @@
export type TranslationTextFormat = 'plain' | 'html'
export interface TranslationSegment {
id: string
text: string
format: TranslationTextFormat
}
export interface TranslationRequest {
source_language: string
target_language: string
context: {
title: string
description: string
}
segments: TranslationSegment[]
}
export interface TranslationResponse {
segments: Array<{ id: string; text: string }>
}
export const DEFAULT_TRANSLATION_BATCH_CHARACTERS = 1000
export const DEFAULT_TRANSLATION_BATCH_ITEMS = 4
export const DEFAULT_TRANSLATION_CONCURRENCY = 4
const INVISIBLE_TRANSLATION_CHARACTERS_REGEX = /[\u200B-\u200D\uFEFF]/g
export function prepareTranslationText(value: string | null | undefined): string {
return value?.replace(INVISIBLE_TRANSLATION_CHARACTERS_REGEX, '').trim() ?? ''
}
export function createTranslationBatches(
segments: TranslationSegment[],
maxCharacters = DEFAULT_TRANSLATION_BATCH_CHARACTERS,
maxItems = DEFAULT_TRANSLATION_BATCH_ITEMS,
): TranslationSegment[][] {
const batches: TranslationSegment[][] = []
let current: TranslationSegment[] = []
let characters = 0
for (const segment of segments) {
const text = prepareTranslationText(segment.text)
if (!text) continue
const prepared = { ...segment, text }
if (
current.length &&
(current.length >= maxItems || characters + text.length > maxCharacters)
) {
batches.push(current)
current = []
characters = 0
}
current.push(prepared)
characters += text.length
}
if (current.length) batches.push(current)
return batches
}
function hasCompleteBatchResult(batch: TranslationSegment[], response: TranslationResponse) {
const expected = new Set(batch.map(({ id }) => id))
return (
response.segments.length === batch.length &&
response.segments.every(({ id }) => expected.delete(id)) &&
expected.size === 0
)
}
function createLimitedExecutor<TInput, TOutput>(
execute: (input: TInput) => Promise<TOutput>,
concurrency: number,
): (input: TInput) => Promise<TOutput> {
let active = 0
const waiting: Array<() => void> = []
return (input) =>
new Promise<TOutput>((resolve, reject) => {
const run = async () => {
active++
try {
resolve(await execute(input))
} catch (error) {
reject(error)
} finally {
active--
waiting.shift()?.()
}
}
if (active < concurrency) void run()
else waiting.push(() => void run())
})
}
async function mapWithConcurrency<TInput, TOutput>(
items: TInput[],
concurrency: number,
execute: (input: TInput) => Promise<TOutput>,
): Promise<TOutput[]> {
const results = new Array<TOutput>(items.length)
let nextIndex = 0
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (nextIndex < items.length) {
const index = nextIndex++
results[index] = await execute(items[index])
}
})
await Promise.all(workers)
return results
}
export async function translateInBatches(
request: TranslationRequest,
onBatch: ((response: TranslationResponse) => void) | undefined,
execute: (request: TranslationRequest) => Promise<TranslationResponse>,
): Promise<TranslationResponse> {
const result: TranslationResponse = {
segments: request.segments
.filter((segment) => !prepareTranslationText(segment.text))
.map(({ id }) => ({ id, text: '' })),
}
const batches = createTranslationBatches(request.segments)
const limitedExecute = createLimitedExecutor(execute, DEFAULT_TRANSLATION_CONCURRENCY)
const translatedBatches = await mapWithConcurrency(
batches,
DEFAULT_TRANSLATION_CONCURRENCY,
async (batch) => {
const response = await limitedExecute({ ...request, segments: batch })
if (hasCompleteBatchResult(batch, response)) {
onBatch?.(response)
return response.segments
}
const fallbacks = await Promise.all(
batch.map(async (segment) => {
const fallback = await limitedExecute({ ...request, segments: [segment] })
if (!hasCompleteBatchResult([segment], fallback)) {
throw new Error(`Translation provider returned an incomplete result for ${segment.id}`)
}
onBatch?.(fallback)
return fallback.segments[0]
}),
)
return fallbacks
},
)
result.segments.push(...translatedBatches.flat())
return result
}

View File

@ -0,0 +1,127 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createTranslationBatches,
prepareTranslationText,
translateInBatches,
type TranslationRequest,
type TranslationResponse,
} from './translation-batching.ts'
function request(ids: string[]): TranslationRequest {
return {
source_language: 'auto',
target_language: 'zh-CN',
context: { title: '', description: '' },
segments: ids.map((id) => ({ id, text: id, format: 'plain' })),
}
}
test('cleans invisible translation characters and trims surrounding whitespace', () => {
assert.equal(prepareTranslationText(' \u200BHello\uFEFF '), 'Hello')
})
test('uses Read Frog batch limits for item count and character count', () => {
const itemBatches = createTranslationBatches(request(['a', 'b', 'c', 'd', 'e']).segments)
assert.deepEqual(
itemBatches.map((batch) => batch.map(({ id }) => id)),
[['a', 'b', 'c', 'd'], ['e']],
)
const characterBatches = createTranslationBatches([
{ id: 'a', text: 'a'.repeat(600), format: 'plain' },
{ id: 'b', text: 'b'.repeat(401), format: 'plain' },
])
assert.deepEqual(
characterBatches.map((batch) => batch.map(({ id }) => id)),
[['a'], ['b']],
)
})
test('keeps Read Frog batching semantics for empty and oversized segments', () => {
const batches = createTranslationBatches([
{ id: 'empty', text: ' \u200B\uFEFF ', format: 'plain' },
{ id: 'oversized', text: 'x'.repeat(1001), format: 'plain' },
{ id: 'next', text: ' next ', format: 'plain' },
])
assert.deepEqual(
batches.map((batch) => batch.map(({ id, text }) => ({ id, text }))),
[[{ id: 'oversized', text: 'x'.repeat(1001) }], [{ id: 'next', text: 'next' }]],
)
})
test('falls back to individual requests only when a batch result is incomplete', async () => {
const calls: string[][] = []
const execute = async (input: TranslationRequest): Promise<TranslationResponse> => {
const ids = input.segments.map(({ id }) => id)
calls.push(ids)
if (ids.length > 1) return { segments: [{ id: ids[0], text: `translated-${ids[0]}` }] }
return { segments: [{ id: ids[0], text: `translated-${ids[0]}` }] }
}
const result = await translateInBatches(request(['a', 'b']), undefined, execute)
assert.deepEqual(calls, [['a', 'b'], ['a'], ['b']])
assert.deepEqual(result.segments, [
{ id: 'a', text: 'translated-a' },
{ id: 'b', text: 'translated-b' },
])
})
test('does not retry provider errors as individual requests', async () => {
let calls = 0
await assert.rejects(
translateInBatches(request(['a', 'b']), undefined, async () => {
calls++
throw new Error('AI_RATE_LIMITED')
}),
/AI_RATE_LIMITED/,
)
assert.equal(calls, 1)
})
test('translates Read Frog batches concurrently while preserving result order', async () => {
let active = 0
let maxActive = 0
const execute = async (input: TranslationRequest): Promise<TranslationResponse> => {
active++
maxActive = Math.max(maxActive, active)
await new Promise((resolve) => setTimeout(resolve, input.segments[0].id === 'a' ? 10 : 0))
active--
return {
segments: input.segments.map(({ id }) => ({ id, text: `translated-${id}` })),
}
}
const result = await translateInBatches(request(['a', 'b', 'c', 'd', 'e']), undefined, execute)
assert.equal(maxActive, 2)
assert.deepEqual(
result.segments.map(({ id }) => id),
['a', 'b', 'c', 'd', 'e'],
)
})
test('falls back when a batch returns duplicate segment ids', async () => {
const calls: string[][] = []
const execute = async (input: TranslationRequest): Promise<TranslationResponse> => {
const ids = input.segments.map(({ id }) => id)
calls.push(ids)
if (ids.length > 1) {
return {
segments: [
{ id: ids[0], text: 'first' },
{ id: ids[0], text: 'duplicate' },
],
}
}
return { segments: [{ id: ids[0], text: `translated-${ids[0]}` }] }
}
const result = await translateInBatches(request(['a', 'b']), undefined, execute)
assert.deepEqual(calls, [['a', 'b'], ['a'], ['b']])
assert.deepEqual(result.segments, [
{ id: 'a', text: 'translated-a' },
{ id: 'b', text: 'translated-b' },
])
})

View File

@ -0,0 +1,498 @@
import { renderHighlightedString } from '@modrinth/utils'
import { configuredXss } from '@modrinth/utils/parse'
import { invoke } from '@tauri-apps/api/core'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import {
createTranslationBatches,
translateInBatches as executeTranslationBatches,
type TranslationRequest,
type TranslationResponse,
type TranslationSegment,
} from './translation-batching'
export {
createTranslationBatches,
DEFAULT_TRANSLATION_BATCH_CHARACTERS,
DEFAULT_TRANSLATION_BATCH_ITEMS,
DEFAULT_TRANSLATION_CONCURRENCY,
prepareTranslationText,
type TranslationRequest,
type TranslationResponse,
type TranslationSegment,
type TranslationTextFormat,
} from './translation-batching'
/** Minimal shape of a search hit object that has a translatable title and description. */
export interface TranslatableHit {
/** Unique identifier — `project_id` on search hits, `id` on SearchResult. */
project_id?: string
id?: string
title?: string
description?: string
/** Server search hits use `name` / `summary` instead of `title` / `description`. */
name?: string
summary?: string
provider?: 'modrinth' | 'curseforge'
provider_project_id?: string
}
export type TranslationProvider = 'google' | 'deepl' | 'ai'
export type TranslationMode = 'bilingual' | 'translation-only'
export type TranslationStyle =
| 'default'
| 'blur'
| 'blockquote'
| 'weakened'
| 'dashed-line'
| 'border'
| 'text-color'
| 'background'
export type DescriptionSourceFormat = 'markdown' | 'html'
export interface TranslationSettings {
provider: TranslationProvider
target_language: string
mode: TranslationMode
auto_translate: boolean
style: TranslationStyle
ai_provider_id: string
ai_model_id: string
ai_system_prompt: string
deepl_api_endpoint: string
deepl_api_key: string | null
}
export async function translateInBatches(
request: TranslationRequest,
onBatch?: (response: TranslationResponse) => void,
execute: (request: TranslationRequest) => Promise<TranslationResponse> = translate,
): Promise<TranslationResponse> {
return executeTranslationBatches(request, onBatch, execute)
}
interface ProtectedElement {
tagName: string
attributes: Array<[string, string]>
innerHtml?: string
outerHtml?: string
}
export interface PreparedDescriptionBlock {
id: string
originalHtml: string
translatable: boolean
protectedElements: Record<string, ProtectedElement>
}
export interface PreparedDescription {
blocks: PreparedDescriptionBlock[]
segments: TranslationSegment[]
}
export async function getTranslationSettings(): Promise<TranslationSettings> {
return await invoke('plugin:translation|translation_get_settings')
}
export async function updateTranslationSettings(settings: TranslationSettings): Promise<void> {
await invoke('plugin:translation|translation_update_settings', { settings })
}
export async function testTranslationProvider(provider: TranslationProvider): Promise<string> {
return await invoke('plugin:translation|translation_test_provider', { provider })
}
export async function translate(request: TranslationRequest): Promise<TranslationResponse> {
return await invoke('plugin:translation|translation_translate', { request })
}
export async function clearTranslationCache(): Promise<void> {
await invoke('plugin:translation|translation_clear_cache')
}
export async function getGoogleIpPoolSize(): Promise<number> {
return await invoke('plugin:translation|translation_google_ip_pool_size')
}
export type TranslationErrorKind =
| 'rate-limited'
| 'authentication'
| 'content-too-long'
| 'network'
| 'provider'
function translationErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
) {
return error.message
}
return String(error)
}
export function getTranslationErrorKind(error: unknown): TranslationErrorKind {
const message = translationErrorMessage(error)
if (message.includes('TRANSLATION_RATE_LIMITED') || message.includes('AI_RATE_LIMITED')) {
return 'rate-limited'
}
if (
message.includes('TRANSLATION_AUTHENTICATION_FAILED') ||
message.includes('AI_AUTHENTICATION_FAILED')
) {
return 'authentication'
}
if (message.includes('TRANSLATION_CONTENT_TOO_LONG')) return 'content-too-long'
if (message.includes('TRANSLATION_NETWORK_FAILED') || message.includes('AI_NETWORK_FAILED')) {
return 'network'
}
return 'provider'
}
function containsReadableText(element: Element): boolean {
if (
element.matches('pre, script, style, img, picture, source, video, audio, iframe, canvas, svg')
) {
return false
}
const clone = element.cloneNode(true) as Element
clone.querySelectorAll('pre, code, script, style').forEach((node) => node.remove())
clone.querySelectorAll('a').forEach((node) => {
if (isUrlOnlyText(node.textContent ?? '')) node.remove()
})
return (clone.textContent ?? '').trim().length > 0
}
function isUrlOnlyText(value: string): boolean {
return /^(?:https?:\/\/|www\.|mailto:)[^\s]+$/i.test(value.trim())
}
const NON_TRANSLATABLE_MEDIA_SELECTOR = 'img, picture, source, video, audio, iframe, canvas, svg'
function protectElementAttributes(
element: Element,
blockIndex: number,
): Record<string, ProtectedElement> {
const protectedElements: Record<string, ProtectedElement> = {}
const elements = [element, ...Array.from(element.querySelectorAll('*'))]
elements.forEach((current, elementIndex) => {
if (current !== element && !element.contains(current)) return
const marker = `${blockIndex}-${elementIndex}`
if (current.matches(NON_TRANSLATABLE_MEDIA_SELECTOR)) {
protectedElements[marker] = {
tagName: 'SPAN',
attributes: [],
outerHtml: current.outerHTML,
}
const placeholder = current.ownerDocument.createElement('span')
placeholder.setAttribute('data-ax-translation-attr', marker)
placeholder.setAttribute('translate', 'no')
current.replaceWith(placeholder)
return
}
const attributes = Array.from(current.attributes).map(
(attribute) => [attribute.name, attribute.value] as [string, string],
)
protectedElements[marker] = {
tagName: current.tagName,
attributes,
...(current.matches('code, pre') ||
(current.matches('a') && isUrlOnlyText(current.textContent ?? ''))
? { innerHtml: current.innerHTML }
: {}),
}
Array.from(current.attributes).forEach((attribute) => current.removeAttribute(attribute.name))
current.setAttribute('data-ax-translation-attr', marker)
if (protectedElements[marker].innerHtml !== undefined) {
current.setAttribute('translate', 'no')
current.innerHTML = ''
}
})
return protectedElements
}
export function prepareDescription(
description: string,
sourceFormat: DescriptionSourceFormat = 'markdown',
): PreparedDescription {
const renderedDescription =
sourceFormat === 'html'
? configuredXss.process(description ?? '')
: renderHighlightedString(description ?? '')
const document = new DOMParser().parseFromString(
`<body>${renderedDescription}</body>`,
'text/html',
)
const blocks: PreparedDescriptionBlock[] = []
const segments: TranslationSegment[] = []
Array.from(document.body.children).forEach((source, index) => {
const id = `body-${index}`
const originalHtml = configuredXss.process(source.outerHTML)
const translatable = containsReadableText(source)
const clone = source.cloneNode(true) as Element
const protectedElements = translatable ? protectElementAttributes(clone, index) : {}
blocks.push({ id, originalHtml, translatable, protectedElements })
if (translatable) {
segments.push({ id, text: clone.outerHTML, format: 'html' })
}
})
return { blocks, segments }
}
function restoreTranslatedBlock(block: PreparedDescriptionBlock, translatedHtml: string): string {
const document = new DOMParser().parseFromString(`<body>${translatedHtml}</body>`, 'text/html')
const root = document.body.firstElementChild
const translatedElements = document.body.querySelectorAll('*')
if (
!root ||
document.body.children.length !== 1 ||
translatedElements.length !== Object.keys(block.protectedElements).length ||
Array.from(translatedElements).some(
(element) => !element.hasAttribute('data-ax-translation-attr'),
)
) {
throw new Error(`Translation markup changed for block ${block.id}`)
}
for (const [marker, protectedElement] of Object.entries(block.protectedElements)) {
const matches = document.body.querySelectorAll(`[data-ax-translation-attr="${marker}"]`)
if (matches.length !== 1 || matches[0].tagName !== protectedElement.tagName) {
throw new Error(`Translation markup changed for block ${block.id}`)
}
const element = matches[0]
if (protectedElement.outerHtml !== undefined) {
const mediaDocument = new DOMParser().parseFromString(
`<body>${protectedElement.outerHtml}</body>`,
'text/html',
)
const media = mediaDocument.body.firstElementChild
if (!media) throw new Error(`Translation media changed for block ${block.id}`)
element.replaceWith(media)
continue
}
Array.from(element.attributes).forEach((attribute) => element.removeAttribute(attribute.name))
protectedElement.attributes.forEach(([name, value]) => element.setAttribute(name, value))
if (protectedElement.innerHtml !== undefined) element.innerHTML = protectedElement.innerHtml
}
return configuredXss.process(root.outerHTML)
}
function translationStyleClass(style: TranslationStyle): string {
return `ax-translation-style-${style}`
}
function restorePreparedDescription(
prepared: PreparedDescription,
translations: Record<string, string>,
): Map<string, string> {
const restored = new Map<string, string>()
for (const block of prepared.blocks) {
if (!block.translatable) continue
const translated = translations[block.id]
if (!translated) throw new Error(`Missing translated block ${block.id}`)
restored.set(block.id, restoreTranslatedBlock(block, translated))
}
return restored
}
export function validateTranslatedDescription(
prepared: PreparedDescription,
translations: Record<string, string>,
): void {
restorePreparedDescription(prepared, translations)
}
export function renderTranslatedDescription(
prepared: PreparedDescription,
translations: Record<string, string>,
mode: TranslationMode,
style: TranslationStyle,
): string {
let restored: Map<string, string>
try {
restored = restorePreparedDescription(prepared, translations)
} catch {
return prepared.blocks.map((block) => block.originalHtml).join('')
}
return prepared.blocks
.map((block) => {
if (!block.translatable) return block.originalHtml
const translated = restored.get(block.id) ?? block.originalHtml
if (mode === 'translation-only') return translated
return `${block.originalHtml}<div class="ax-translation-block ${translationStyleClass(style)}">${translated}</div>`
})
.join('')
}
const MIRROR_API_BASE = 'https://mod.mcimirror.top/translate'
/** Cache: key → translated description string. Key format: `cf:{provider_project_id}` or `mr:{project_id}`. */
const descriptionCache = new Map<string, string>()
function mirrorCacheKey(hit: TranslatableHit): string | null {
if (hit.provider === 'curseforge') {
const id = hit.provider_project_id
if (!id) return null
return `cf:${id}`
}
if (hit.provider === 'modrinth') {
const id = hit.project_id
if (!id) return null
return `mr:${id}`
}
return null
}
interface MirrorTranslationResponse {
modid?: number
project_id?: string
translated: string
original: string
translated_at: string
}
/** Fetch translated description for a single project from the mcimirror API. */
async function fetchMirrorDescription(hit: TranslatableHit): Promise<string | null> {
const cacheKey = mirrorCacheKey(hit)
if (!cacheKey) return null
const cached = descriptionCache.get(cacheKey)
if (cached !== undefined) return cached || null
const id = hit.provider === 'curseforge' ? hit.provider_project_id : hit.project_id
if (!id || !hit.provider) return null
const url = `${MIRROR_API_BASE}/${hit.provider}/${encodeURIComponent(id)}`
try {
const response = await tauriFetch(url)
if (!response.ok) return null
const data = (await response.json()) as MirrorTranslationResponse
const translated = data.translated?.trim() || null
descriptionCache.set(cacheKey, translated ?? '')
return translated
} catch {
descriptionCache.set(cacheKey, '')
return null
}
}
/**
* Rewrites search hit descriptions to Chinese. Project hits use the mcimirror
* translation API; server hits use the configured backend provider.
*
* @param hits Search result hits that carry at least provider + ID and
* description fields.
* @param locale Fallback target locale when no target language is configured.
* @param force - When true, skip the `auto_translate` setting check;
* always translate.
* @param useServer - If true, sends the description via the Rust backend;
* otherwise, uses mcimirror API.
*/
export async function translateSearchDescriptions<T extends TranslatableHit>(
hits: T[],
locale: string,
_force = false,
useServer = false,
): Promise<T[]> {
if (hits.length === 0) return hits
let targetLanguage = locale
const settings = await getTranslationSettings()
if (!_force && !settings.auto_translate) return hits
targetLanguage = settings.target_language?.trim() || locale
if (!targetLanguage || targetLanguage === 'en-US') return hits
// mcimirror 镜像只缓存中文翻译,非中文目标改走服务端路径
if (!useServer && targetLanguage !== 'zh-CN' && targetLanguage !== 'zh') {
useServer = true
}
if (useServer) {
const segments: TranslationSegment[] = hits.map((hit) => ({
text: hit.description ?? hit.summary ?? '',
id: hit.project_id ?? hit.provider_project_id ?? '',
format: 'html',
}))
const request: TranslationRequest = {
target_language: targetLanguage,
source_language: 'auto',
segments,
context: {
title: hits[0]?.title ?? '',
description: hits[0]?.description ?? hits[0]?.summary ?? '',
},
}
// 等所有批次结束(含失败批次)再决定:保留成功批次,全部失败才抛错
const translated: TranslationResponse['segments'] = []
const results = await Promise.allSettled(
createTranslationBatches(request.segments).map((batch) =>
translateInBatches({ ...request, segments: batch }, (batchResponse) =>
translated.push(...batchResponse.segments),
),
),
)
if (translated.length === 0) {
const failed = results.find((result) => result.status === 'rejected')
throw failed ? failed.reason : new Error('搜索描述翻译失败')
}
const response: TranslationResponse = { segments: translated }
const translatedHits = hits.map((hit) => {
const segment = response.segments.find(
(s) => s.id === (hit.project_id ?? hit.provider_project_id ?? ''),
)
if (!segment) return hit
return {
...hit,
description: segment.text,
summary: segment.text,
}
})
return translatedHits as T[]
}
const entries = hits.map((hit) => ({ hit, index: hits.indexOf(hit) }))
const results = await Promise.allSettled(
entries.map(async ({ hit, index }) => {
const originalDesc = hit.description ?? hit.summary ?? ''
if (!originalDesc) return { index, hit }
const translation = await fetchMirrorDescription(hit)
if (!translation) return { index, hit }
return {
index,
hit: {
...hit,
description: translation,
summary: translation,
} as T,
}
}),
)
const translatedHits = [...hits]
let translatedAny = false
for (const result of results) {
if (result.status === 'rejected') continue
if (result.value.hit === hits[result.value.index]) continue
translatedHits[result.value.index] = result.value.hit
translatedAny = true
}
return translatedAny ? translatedHits : hits
}

242
apps/app-frontend/src/helpers/types.d.ts vendored Normal file
View File

@ -0,0 +1,242 @@
import type { ModrinthId } from '@modrinth/utils'
export type GameInstance = {
id: string
path: string
install_stage: InstallStage
launcher_feature_version: string
name: string
icon_path?: string
symlink_target?: string | null
game_dir_override?: string | null
linked_launcher?: string | null
linked_launcher_root?: string | null
linked_dot_minecraft?: string | null
linked_version_id?: string | null
linked_version_json_path?: string | null
linked_game_dir_mode?: 'automatic' | 'isolated' | 'shared' | null
game_version: string
protocol_version?: number
loader: InstanceLoader
loader_version?: string
loader_components: LoaderComponent[]
groups: string[]
link?: InstanceLink | null
update_channel: ReleaseChannel
created: Date
modified: Date
last_played?: Date
pinned_at?: Date
submitted_time_played: number
recent_time_played: number
java_path?: string
extra_launch_args?: string[]
custom_env_vars?: [string, string][]
memory?: MemorySettings
force_fullscreen?: boolean
maximize_window?: boolean
game_resolution?: [number, number]
launch_preparation_timeout?: number | null
hooks: Hooks
}
type InstallStage =
| 'installed'
| 'minecraft_installing'
| 'pack_installed'
| 'pack_installing'
| 'not_installed'
type InstanceLinkIdentity = {
project_id?: ModrinthId | null
version_id?: ModrinthId | null
server_project_id?: ModrinthId | null
content_project_id?: ModrinthId | null
content_version_id?: ModrinthId | null
}
export type InstanceLink = InstanceLinkIdentity &
(
| {
type: 'modrinth_modpack'
project_id: ModrinthId
version_id: ModrinthId
}
| {
type: 'curseforge_modpack'
project_id: string
version_id: string
}
| {
type: 'server_project'
project_id: ModrinthId
}
| {
type: 'server_project_modpack'
server_project_id: ModrinthId
content_project_id?: ModrinthId | null
content_version_id: ModrinthId
project_id?: ModrinthId
version_id?: ModrinthId
}
| {
type: 'imported_modpack'
project_id?: ModrinthId | null
version_id?: ModrinthId | null
name?: string | null
version_number?: string | null
filename?: string | null
}
| {
type: 'shared_instance'
shared_instance_id: string
}
)
export type Instance = GameInstance
type ReleaseChannel = 'release' | 'beta' | 'alpha'
export type InstanceLoader =
| 'vanilla'
| 'forge'
| 'fabric'
| 'quilt'
| 'neoforge'
| 'optifine'
| 'lite_loader'
| 'cleanroom'
| 'legacy_fabric'
| 'babric'
export type LoaderComponent = {
instanceId: string
kind: InstanceLoader | 'optifabric'
version?: string | null
role: 'primary' | 'adjunct'
providerMetadata?: unknown
}
type ContentFile = {
enabled: boolean
modrinth?: {
project_id: string
version_id: string
}
provider_refs: Array<{
provider: 'modrinth' | 'curseforge' | 'mcarchive'
project_id: string | number
version_id?: string | null
file_id?: string | number | null
}>
origin_provider: 'modrinth' | 'curseforge' | 'mcarchive' | null
}
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack' | 'schematic'
type CacheBehaviour =
// Serve expired data. If fetch fails / launcher is offline, errors are ignored
| 'stale_while_revalidate_skip_offline'
| 'cache_only'
// Serve expired data, revalidate in background
| 'stale_while_revalidate'
// Must revalidate if data is expired
| 'must_revalidate'
// Ignore cache- always fetch updated data from origin
| 'bypass'
type MemorySettings = {
maximum: number
automatic: boolean
optimize_before_launch: boolean
}
type WindowSize = {
width: number
height: number
}
type Hooks = {
pre_launch?: string
wrapper?: string
post_exit?: string
}
type Manifest = {
gameVersions: ManifestGameVersion[]
versionGroups?: ManifestVersionGroup[]
}
type ManifestGameVersion = {
id: string
stable: boolean
versionGroup?: string
loaders: ManifestLoaderVersion[]
}
type ManifestVersionGroup = {
id: string
loaders: ManifestLoaderVersion[]
}
type ManifestLoaderVersion = {
id: string
url: string
stable: boolean
}
type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: 'dark' | 'light' | 'oled' | 'system'
accent_color: 'pink' | 'orange' | 'green' | 'blue' | 'purple' | 'system' | `custom:#${string}`
default_page: 'Home' | 'DiscoverContent' | 'Library'
collapsed_navigation: boolean
advanced_rendering: boolean
native_decorations: boolean
custom_background_path: string | null
custom_background_blur: number
custom_background_opacity: number
transparent_background: boolean
transparent_background_opacity: number
transparent_background_blur: boolean
auto_hide_downloads_button: boolean
worlds_in_home: boolean
home_layout: 'standard' | 'minimal'
minimal_home_instance_id: string | null
close_behavior: 'ask' | 'close' | 'lightweight'
home_widgets: import('@/components/home/home-dashboard').HomeDashboardConfig | null
telemetry: boolean
discord_rpc: boolean
developer_mode: boolean
onboarded: boolean
onboarding_version: number
onboarding_instance_tour_completed: boolean
extra_launch_args: string[]
custom_env_vars: [string, string][]
memory: MemorySettings
force_fullscreen: boolean
maximize_window: boolean
game_resolution: [number, number]
hide_on_process_start: boolean
enter_lightweight_mode_on_game_launch: boolean
auto_set_java_high_performance_mode: boolean
hooks: Hooks
mojang_auth_source: 'auto' | 'official_only' | 'mirror_preferred' | 'official_preferred'
custom_dir?: string
prev_custom_dir?: string
migrated: boolean
}

View File

@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getUpgradeChangelogTranslation,
setUpgradeChangelogTranslation,
shouldUpgradeChangelogStayOpen,
upgradeChangelogTranslationCacheKey,
upgradeExternalChangelogUrl,
} from './upgrade-changelog.ts'
test('external changelog links only allow HTTP protocols', () => {
assert.equal(upgradeExternalChangelogUrl('https://example.com/path'), 'https://example.com/path')
assert.equal(
upgradeExternalChangelogUrl('https://example.com/path).'),
'https://example.com/path',
)
assert.equal(upgradeExternalChangelogUrl('http://example.com/path,'), 'http://example.com/path')
assert.equal(upgradeExternalChangelogUrl('javascript:alert(1)'), null)
})
test('changelog popover stays open while trigger or popup owns hover or focus', () => {
const empty = {
triggerHovered: false,
triggerFocused: false,
popupHovered: false,
popupFocused: false,
}
assert.equal(shouldUpgradeChangelogStayOpen({ ...empty, triggerHovered: true }), true)
assert.equal(shouldUpgradeChangelogStayOpen({ ...empty, popupHovered: true }), true)
assert.equal(
shouldUpgradeChangelogStayOpen({ ...empty, triggerHovered: false, popupHovered: true }),
true,
)
assert.equal(shouldUpgradeChangelogStayOpen({ ...empty, popupFocused: true }), true)
assert.equal(shouldUpgradeChangelogStayOpen(empty), false)
})
test('translated changelog cache separates target languages and stays lazy', () => {
const chinese = upgradeChangelogTranslationCacheKey('modrinth', 'project', 'release', 'zh-CN')
const english = upgradeChangelogTranslationCacheKey('modrinth', 'project', 'release', 'en-US')
assert.equal(getUpgradeChangelogTranslation(chinese), undefined)
setUpgradeChangelogTranslation(chinese, 'translated')
assert.equal(getUpgradeChangelogTranslation(chinese), 'translated')
assert.equal(getUpgradeChangelogTranslation(english), undefined)
})

View File

@ -0,0 +1,38 @@
const translationCache = new Map<string, string>()
export interface UpgradeChangelogPopoverOwnership {
triggerHovered: boolean
triggerFocused: boolean
popupHovered: boolean
popupFocused: boolean
}
export function shouldUpgradeChangelogStayOpen(state: UpgradeChangelogPopoverOwnership): boolean {
return state.triggerHovered || state.triggerFocused || state.popupHovered || state.popupFocused
}
export function upgradeChangelogTranslationCacheKey(
provider: string,
projectId: string,
releaseId: string,
targetLanguage: string,
): string {
return `${provider}:${projectId}:${releaseId}:${targetLanguage}`
}
export function getUpgradeChangelogTranslation(key: string): string | undefined {
return translationCache.get(key)
}
export function setUpgradeChangelogTranslation(key: string, value: string): void {
translationCache.set(key, value)
}
export function upgradeExternalChangelogUrl(href: string): string | null {
try {
const url = new URL(href.replace(/[),.;!?]+$/u, ''))
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null
} catch {
return null
}
}

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