feat:移除了弹窗,服务器添加sls
This commit is contained in:
36
apps/app-frontend/src/helpers/accounts.ts
Normal file
36
apps/app-frontend/src/helpers/accounts.ts
Normal 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)
|
||||
}
|
||||
141
apps/app-frontend/src/helpers/ai.ts
Normal file
141
apps/app-frontend/src/helpers/ai.ts
Normal 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 })
|
||||
}
|
||||
75
apps/app-frontend/src/helpers/analytics.ts
Normal file
75
apps/app-frontend/src/helpers/analytics.ts
Normal 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])
|
||||
}
|
||||
}
|
||||
123
apps/app-frontend/src/helpers/auth.js
Normal file
123
apps/app-frontend/src/helpers/auth.js
Normal 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 })
|
||||
}
|
||||
10
apps/app-frontend/src/helpers/breadcrumb-label.ts
Normal file
10
apps/app-frontend/src/helpers/breadcrumb-label.ts
Normal 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)
|
||||
}
|
||||
33
apps/app-frontend/src/helpers/browse-display-mode.ts
Normal file
33
apps/app-frontend/src/helpers/browse-display-mode.ts
Normal 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)
|
||||
}
|
||||
81
apps/app-frontend/src/helpers/browse-filter-memory.test.ts
Normal file
81
apps/app-frontend/src/helpers/browse-filter-memory.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
59
apps/app-frontend/src/helpers/browse-filter-memory.ts
Normal file
59
apps/app-frontend/src/helpers/browse-filter-memory.ts
Normal 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))
|
||||
}
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
141
apps/app-frontend/src/helpers/browse-merge.ts
Normal file
141
apps/app-frontend/src/helpers/browse-merge.ts
Normal 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)
|
||||
}
|
||||
66
apps/app-frontend/src/helpers/browse-project-tabs.test.ts
Normal file
66
apps/app-frontend/src/helpers/browse-project-tabs.test.ts
Normal 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 },
|
||||
)
|
||||
})
|
||||
91
apps/app-frontend/src/helpers/browse-project-tabs.ts
Normal file
91
apps/app-frontend/src/helpers/browse-project-tabs.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
}
|
||||
60
apps/app-frontend/src/helpers/browse-return-state.test.ts
Normal file
60
apps/app-frontend/src/helpers/browse-return-state.test.ts
Normal 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)
|
||||
})
|
||||
51
apps/app-frontend/src/helpers/browse-return-state.ts
Normal file
51
apps/app-frontend/src/helpers/browse-return-state.ts
Normal 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
|
||||
}
|
||||
87
apps/app-frontend/src/helpers/cache.js
Normal file
87
apps/app-frontend/src/helpers/cache.js
Normal 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,
|
||||
})
|
||||
}
|
||||
20
apps/app-frontend/src/helpers/content-favorites.test.ts
Normal file
20
apps/app-frontend/src/helpers/content-favorites.test.ts
Normal 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)
|
||||
})
|
||||
50
apps/app-frontend/src/helpers/content-favorites.ts
Normal file
50
apps/app-frontend/src/helpers/content-favorites.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
94
apps/app-frontend/src/helpers/content-identity.test.ts
Normal file
94
apps/app-frontend/src/helpers/content-identity.test.ts
Normal 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')
|
||||
})
|
||||
250
apps/app-frontend/src/helpers/content-identity.ts
Normal file
250
apps/app-frontend/src/helpers/content-identity.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
51
apps/app-frontend/src/helpers/content-item-state.test.ts
Normal file
51
apps/app-frontend/src/helpers/content-item-state.test.ts
Normal 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')
|
||||
})
|
||||
36
apps/app-frontend/src/helpers/content-item-state.ts
Normal file
36
apps/app-frontend/src/helpers/content-item-state.ts
Normal 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)
|
||||
}
|
||||
171
apps/app-frontend/src/helpers/content-search.ts
Normal file
171
apps/app-frontend/src/helpers/content-search.ts
Normal 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) } }
|
||||
})
|
||||
}
|
||||
555
apps/app-frontend/src/helpers/curseforge-category-map.ts
Normal file
555
apps/app-frontend/src/helpers/curseforge-category-map.ts
Normal 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)
|
||||
}
|
||||
174
apps/app-frontend/src/helpers/curseforge-manual.ts
Normal file
174
apps/app-frontend/src/helpers/curseforge-manual.ts
Normal 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
|
||||
}
|
||||
58
apps/app-frontend/src/helpers/curseforge.test.ts
Normal file
58
apps/app-frontend/src/helpers/curseforge.test.ts
Normal 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)
|
||||
})
|
||||
510
apps/app-frontend/src/helpers/curseforge.ts
Normal file
510
apps/app-frontend/src/helpers/curseforge.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
52
apps/app-frontend/src/helpers/datapacks.ts
Normal file
52
apps/app-frontend/src/helpers/datapacks.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
37
apps/app-frontend/src/helpers/direct-link-sync.ts
Normal file
37
apps/app-frontend/src/helpers/direct-link-sync.ts
Normal 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 }),
|
||||
)
|
||||
}
|
||||
}
|
||||
265
apps/app-frontend/src/helpers/downloads-scanner.test.ts
Normal file
265
apps/app-frontend/src/helpers/downloads-scanner.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
252
apps/app-frontend/src/helpers/downloads-scanner.ts
Normal file
252
apps/app-frontend/src/helpers/downloads-scanner.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
185
apps/app-frontend/src/helpers/drop.ts
Normal file
185
apps/app-frontend/src/helpers/drop.ts
Normal 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 })
|
||||
}
|
||||
171
apps/app-frontend/src/helpers/events.js
Normal file
171
apps/app-frontend/src/helpers/events.js
Normal 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))
|
||||
}
|
||||
79
apps/app-frontend/src/helpers/friends.ts
Normal file
79
apps/app-frontend/src/helpers/friends.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
13
apps/app-frontend/src/helpers/gc-notice.ts
Normal file
13
apps/app-frontend/src/helpers/gc-notice.ts
Normal 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
|
||||
}
|
||||
144
apps/app-frontend/src/helpers/gc/auto-selector.test.ts
Normal file
144
apps/app-frontend/src/helpers/gc/auto-selector.test.ts
Normal 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')
|
||||
})
|
||||
101
apps/app-frontend/src/helpers/gc/auto-selector.ts
Normal file
101
apps/app-frontend/src/helpers/gc/auto-selector.ts
Normal 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 < 21,ZGC 非分代模式性能不佳')
|
||||
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'
|
||||
43
apps/app-frontend/src/helpers/gc/context.ts
Normal file
43
apps/app-frontend/src/helpers/gc/context.ts
Normal 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
|
||||
}
|
||||
104
apps/app-frontend/src/helpers/gc/gc-presets.ts
Normal file
104
apps/app-frontend/src/helpers/gc/gc-presets.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
15
apps/app-frontend/src/helpers/gc/index.ts
Normal file
15
apps/app-frontend/src/helpers/gc/index.ts
Normal 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'
|
||||
149
apps/app-frontend/src/helpers/gc/strategies.test.ts
Normal file
149
apps/app-frontend/src/helpers/gc/strategies.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
160
apps/app-frontend/src/helpers/gc/strategies.ts
Normal file
160
apps/app-frontend/src/helpers/gc/strategies.ts
Normal 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 }
|
||||
}
|
||||
41
apps/app-frontend/src/helpers/gc/types.ts
Normal file
41
apps/app-frontend/src/helpers/gc/types.ts
Normal 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[]
|
||||
}
|
||||
99
apps/app-frontend/src/helpers/import.js
Normal file
99
apps/app-frontend/src/helpers/import.js
Normal 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 })
|
||||
}
|
||||
190
apps/app-frontend/src/helpers/install-progress.test.ts
Normal file
190
apps/app-frontend/src/helpers/install-progress.test.ts
Normal 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' },
|
||||
)
|
||||
})
|
||||
115
apps/app-frontend/src/helpers/install-progress.ts
Normal file
115
apps/app-frontend/src/helpers/install-progress.ts
Normal 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' }
|
||||
}
|
||||
584
apps/app-frontend/src/helpers/install.ts
Normal file
584
apps/app-frontend/src/helpers/install.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
237
apps/app-frontend/src/helpers/instance-cache.ts
Normal file
237
apps/app-frontend/src/helpers/instance-cache.ts
Normal 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 key,UI 状态写入 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))
|
||||
}
|
||||
196
apps/app-frontend/src/helpers/instance-content.ts
Normal file
196
apps/app-frontend/src/helpers/instance-content.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
73
apps/app-frontend/src/helpers/instance-icon-frame.ts
Normal file
73
apps/app-frontend/src/helpers/instance-icon-frame.ts
Normal 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
|
||||
}
|
||||
430
apps/app-frontend/src/helpers/instance-icons.ts
Normal file
430
apps/app-frontend/src/helpers/instance-icons.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
275
apps/app-frontend/src/helpers/instance-upgrade.ts
Normal file
275
apps/app-frontend/src/helpers/instance-upgrade.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
1122
apps/app-frontend/src/helpers/instance.ts
Normal file
1122
apps/app-frontend/src/helpers/instance.ts
Normal file
File diff suppressed because it is too large
Load Diff
65
apps/app-frontend/src/helpers/java-argument-presets.ts
Normal file
65
apps/app-frontend/src/helpers/java-argument-presets.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
28
apps/app-frontend/src/helpers/java-arguments.ts
Normal file
28
apps/app-frontend/src/helpers/java-arguments.ts
Normal 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))
|
||||
}
|
||||
116
apps/app-frontend/src/helpers/jre.js
Normal file
116
apps/app-frontend/src/helpers/jre.js
Normal 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 })
|
||||
}
|
||||
53
apps/app-frontend/src/helpers/lab-preferences.ts
Normal file
53
apps/app-frontend/src/helpers/lab-preferences.ts
Normal 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)
|
||||
}
|
||||
12
apps/app-frontend/src/helpers/library-display-mode.ts
Normal file
12
apps/app-frontend/src/helpers/library-display-mode.ts
Normal 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)
|
||||
}
|
||||
238
apps/app-frontend/src/helpers/loader-metadata.test.ts
Normal file
238
apps/app-frontend/src/helpers/loader-metadata.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
113
apps/app-frontend/src/helpers/logs.js
Normal file
113
apps/app-frontend/src/helpers/logs.js
Normal 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
|
||||
}
|
||||
27
apps/app-frontend/src/helpers/mc_news.ts
Normal file
27
apps/app-frontend/src/helpers/mc_news.ts
Normal 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
|
||||
}
|
||||
84
apps/app-frontend/src/helpers/mcarchive.ts
Normal file
84
apps/app-frontend/src/helpers/mcarchive.ts
Normal 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 })
|
||||
}
|
||||
13
apps/app-frontend/src/helpers/metadata.js
Normal file
13
apps/app-frontend/src/helpers/metadata.js
Normal 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 })
|
||||
}
|
||||
69
apps/app-frontend/src/helpers/mojang-auth.ts
Normal file
69
apps/app-frontend/src/helpers/mojang-auth.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
29
apps/app-frontend/src/helpers/mr_auth.ts
Normal file
29
apps/app-frontend/src/helpers/mr_auth.ts
Normal 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')
|
||||
}
|
||||
55
apps/app-frontend/src/helpers/multiplayer.test.ts
Normal file
55
apps/app-frontend/src/helpers/multiplayer.test.ts
Normal 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')
|
||||
})
|
||||
137
apps/app-frontend/src/helpers/multiplayer.ts
Normal file
137
apps/app-frontend/src/helpers/multiplayer.ts
Normal 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')),
|
||||
}
|
||||
40
apps/app-frontend/src/helpers/pack-formats.ts
Normal file
40
apps/app-frontend/src/helpers/pack-formats.ts
Normal 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]
|
||||
}
|
||||
43
apps/app-frontend/src/helpers/planet-minecraft.ts
Normal file
43
apps/app-frontend/src/helpers/planet-minecraft.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
111
apps/app-frontend/src/helpers/post-upgrade-notice.test.ts
Normal file
111
apps/app-frontend/src/helpers/post-upgrade-notice.test.ts
Normal 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/)
|
||||
})
|
||||
28
apps/app-frontend/src/helpers/post-upgrade-notice.ts
Normal file
28
apps/app-frontend/src/helpers/post-upgrade-notice.ts
Normal 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
|
||||
}
|
||||
27
apps/app-frontend/src/helpers/process.js
Normal file
27
apps/app-frontend/src/helpers/process.js
Normal 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 })
|
||||
}
|
||||
12
apps/app-frontend/src/helpers/process.test.ts
Normal file
12
apps/app-frontend/src/helpers/process.test.ts
Normal 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)
|
||||
})
|
||||
39
apps/app-frontend/src/helpers/project-gallery.ts
Normal file
39
apps/app-frontend/src/helpers/project-gallery.ts
Normal 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 }]
|
||||
: []
|
||||
}),
|
||||
)
|
||||
}
|
||||
25
apps/app-frontend/src/helpers/project-links.test.ts
Normal file
25
apps/app-frontend/src/helpers/project-links.test.ts
Normal 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' },
|
||||
})
|
||||
})
|
||||
104
apps/app-frontend/src/helpers/project-links.ts
Normal file
104
apps/app-frontend/src/helpers/project-links.ts
Normal 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
|
||||
}
|
||||
66
apps/app-frontend/src/helpers/remote-announcements.ts
Normal file
66
apps/app-frontend/src/helpers/remote-announcements.ts
Normal 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
|
||||
}
|
||||
167
apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
Normal file
167
apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
Normal 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)
|
||||
}
|
||||
334
apps/app-frontend/src/helpers/rendering/skin-preview-renderer.ts
Normal file
334
apps/app-frontend/src/helpers/rendering/skin-preview-renderer.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
98
apps/app-frontend/src/helpers/search-query.test.ts
Normal file
98
apps/app-frontend/src/helpers/search-query.test.ts
Normal 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)
|
||||
})
|
||||
134
apps/app-frontend/src/helpers/search-query.ts
Normal file
134
apps/app-frontend/src/helpers/search-query.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
147
apps/app-frontend/src/helpers/servers.ts
Normal file
147
apps/app-frontend/src/helpers/servers.ts
Normal 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
|
||||
}
|
||||
110
apps/app-frontend/src/helpers/settings.test.ts
Normal file
110
apps/app-frontend/src/helpers/settings.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
330
apps/app-frontend/src/helpers/settings.ts
Normal file
330
apps/app-frontend/src/helpers/settings.ts
Normal 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 })
|
||||
}
|
||||
10
apps/app-frontend/src/helpers/sidebar-state.ts
Normal file
10
apps/app-frontend/src/helpers/sidebar-state.ts
Normal 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))
|
||||
}
|
||||
194
apps/app-frontend/src/helpers/skins.ts
Normal file
194
apps/app-frontend/src/helpers/skins.ts
Normal 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)
|
||||
}
|
||||
62
apps/app-frontend/src/helpers/state.ts
Normal file
62
apps/app-frontend/src/helpers/state.ts
Normal 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')
|
||||
}
|
||||
31
apps/app-frontend/src/helpers/storage.ts
Normal file
31
apps/app-frontend/src/helpers/storage.ts
Normal 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))
|
||||
}
|
||||
229
apps/app-frontend/src/helpers/storage/head-storage.ts
Normal file
229
apps/app-frontend/src/helpers/storage/head-storage.ts
Normal 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()
|
||||
218
apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
Normal file
218
apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
Normal 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()
|
||||
53
apps/app-frontend/src/helpers/studio.ts
Normal file
53
apps/app-frontend/src/helpers/studio.ts
Normal 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))
|
||||
}
|
||||
31
apps/app-frontend/src/helpers/tags.js
Normal file
31
apps/app-frontend/src/helpers/tags.js
Normal 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')
|
||||
}
|
||||
7
apps/app-frontend/src/helpers/telemetry.ts
Normal file
7
apps/app-frontend/src/helpers/telemetry.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
25
apps/app-frontend/src/helpers/terracotta.test.ts
Normal file
25
apps/app-frontend/src/helpers/terracotta.test.ts
Normal 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://')
|
||||
})
|
||||
112
apps/app-frontend/src/helpers/terracotta.ts
Normal file
112
apps/app-frontend/src/helpers/terracotta.ts
Normal 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')),
|
||||
}
|
||||
153
apps/app-frontend/src/helpers/translation-batching.ts
Normal file
153
apps/app-frontend/src/helpers/translation-batching.ts
Normal 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
|
||||
}
|
||||
127
apps/app-frontend/src/helpers/translation.test.ts
Normal file
127
apps/app-frontend/src/helpers/translation.test.ts
Normal 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' },
|
||||
])
|
||||
})
|
||||
498
apps/app-frontend/src/helpers/translation.ts
Normal file
498
apps/app-frontend/src/helpers/translation.ts
Normal 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
242
apps/app-frontend/src/helpers/types.d.ts
vendored
Normal 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
|
||||
}
|
||||
46
apps/app-frontend/src/helpers/upgrade-changelog.test.ts
Normal file
46
apps/app-frontend/src/helpers/upgrade-changelog.test.ts
Normal 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)
|
||||
})
|
||||
38
apps/app-frontend/src/helpers/upgrade-changelog.ts
Normal file
38
apps/app-frontend/src/helpers/upgrade-changelog.ts
Normal 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
Reference in New Issue
Block a user