* fix: files.vue bugs before styling changes * feat: move files tab to shared layout structure * fix: qa * fix: qa * fix: bugs * fix: lint * fix: admonition cleanup with progress + actions * fix: cleanup * fix: modals * fix: admon title * fix: i18n standard * fix: lint + i18n pass * fix: remove transition * fix: type errors * feat: files tab in app * fix: qa * fix: backup item minmax * fix: use ContentPageHeader for server panel * fix: lint * fix: lint * fix: lint * feat: page leave safety * fix: lint * fix: cargo fmt fix * fix: blank in prod * fix: content card table stuff * Revert "fix: blank in prod" This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace. * fix: import * feat: browse worlds/servers flow * fix: worlds tab parity with content tab * fix: perf bug + shader filter pill copy * feat: singleplayer filter * fix: ordering * fix: breadcrumbs * fix: lint * fix: qa * feat: store server proj id when adding to a non-linked instance * fix: lint * fix: i18n + qa * fix: conflict * qa: already installed modal + placeholders not server-specific * fix: qa * fix: add + edit server modals * fix: qa * fix: security * fix: devin flags * fix: lint * chore: change file to break build cache * fix: admon * fix: import path stuff * feat: qa * fix: fmt fmt idiot --------- Signed-off-by: Calum H. <calum@modrinth.com>
507 lines
13 KiB
TypeScript
507 lines
13 KiB
TypeScript
import type { GameVersion } from '@modrinth/ui'
|
|
import { autoToHTML } from '@sfirew/minecraft-motd-parser'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import dayjs from 'dayjs'
|
|
|
|
import { get_full_path } from '@/helpers/profile'
|
|
import { openPath } from '@/helpers/utils'
|
|
|
|
type BaseWorld = {
|
|
name: string
|
|
last_played?: string
|
|
icon?: string
|
|
display_status: DisplayStatus
|
|
type: WorldType
|
|
}
|
|
|
|
export type WorldType = 'singleplayer' | 'server'
|
|
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
|
|
|
|
export type SingleplayerWorld = BaseWorld & {
|
|
type: 'singleplayer'
|
|
path: string
|
|
game_mode: SingleplayerGameMode
|
|
hardcore: boolean
|
|
locked: boolean
|
|
}
|
|
|
|
export type ServerWorld = BaseWorld & {
|
|
type: 'server'
|
|
index: number
|
|
address: string
|
|
pack_status: ServerPackStatus
|
|
project_id?: string
|
|
content_kind?: string
|
|
}
|
|
|
|
export type World = SingleplayerWorld | ServerWorld
|
|
|
|
export type WorldWithProfile = {
|
|
profile: string
|
|
} & World
|
|
|
|
export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
|
|
export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt'
|
|
|
|
export type ServerStatus = {
|
|
// https://minecraft.wiki/w/Text_component_format
|
|
description?: string | Chat
|
|
players?: {
|
|
max: number
|
|
online: number
|
|
sample: { name: string; id: string }[]
|
|
}
|
|
version?: {
|
|
name: string
|
|
protocol: number
|
|
legacy: boolean
|
|
}
|
|
favicon?: string
|
|
enforces_secure_chat: boolean
|
|
ping?: number
|
|
}
|
|
|
|
export interface Chat {
|
|
text: string
|
|
bold: boolean
|
|
italic: boolean
|
|
underlined: boolean
|
|
strikethrough: boolean
|
|
obfuscated: boolean
|
|
color?: string
|
|
extra: Chat[]
|
|
}
|
|
|
|
export type ServerData = {
|
|
refreshing: boolean
|
|
lastSuccessfulRefresh?: number
|
|
status?: ServerStatus
|
|
rawMotd?: string | Chat
|
|
renderedMotd?: string
|
|
}
|
|
|
|
export type ProtocolVersion = {
|
|
version: number
|
|
legacy: boolean
|
|
}
|
|
|
|
export async function get_recent_worlds(
|
|
limit: number,
|
|
displayStatuses?: DisplayStatus[],
|
|
): Promise<WorldWithProfile[]> {
|
|
return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
|
|
}
|
|
|
|
export async function get_profile_worlds(path: string): Promise<World[]> {
|
|
return await invoke('plugin:worlds|get_profile_worlds', { path })
|
|
}
|
|
|
|
export async function get_singleplayer_world(
|
|
instance: string,
|
|
world: string,
|
|
): Promise<SingleplayerWorld> {
|
|
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
|
|
}
|
|
|
|
export async function set_world_display_status(
|
|
instance: string,
|
|
worldType: WorldType,
|
|
worldId: string,
|
|
displayStatus: DisplayStatus,
|
|
): Promise<void> {
|
|
return await invoke('plugin:worlds|set_world_display_status', {
|
|
instance,
|
|
worldType,
|
|
worldId,
|
|
displayStatus,
|
|
})
|
|
}
|
|
|
|
export async function rename_world(
|
|
instance: string,
|
|
world: string,
|
|
newName: string,
|
|
): Promise<void> {
|
|
return await invoke('plugin:worlds|rename_world', { instance, world, newName })
|
|
}
|
|
|
|
export async function reset_world_icon(instance: string, world: string): Promise<void> {
|
|
return await invoke('plugin:worlds|reset_world_icon', { instance, world })
|
|
}
|
|
|
|
export async function backup_world(instance: string, world: string): Promise<number> {
|
|
return await invoke('plugin:worlds|backup_world', { instance, world })
|
|
}
|
|
|
|
export async function delete_world(instance: string, world: string): Promise<void> {
|
|
return await invoke('plugin:worlds|delete_world', { instance, world })
|
|
}
|
|
|
|
export async function add_server_to_profile(
|
|
path: string,
|
|
name: string,
|
|
address: string,
|
|
packStatus: ServerPackStatus,
|
|
projectId?: string,
|
|
contentKind?: string,
|
|
): Promise<number> {
|
|
return await invoke('plugin:worlds|add_server_to_profile', {
|
|
path,
|
|
name,
|
|
address,
|
|
packStatus,
|
|
projectId,
|
|
contentKind,
|
|
})
|
|
}
|
|
|
|
export async function edit_server_in_profile(
|
|
path: string,
|
|
index: number,
|
|
name: string,
|
|
address: string,
|
|
packStatus: ServerPackStatus,
|
|
): Promise<void> {
|
|
return await invoke('plugin:worlds|edit_server_in_profile', {
|
|
path,
|
|
index,
|
|
name,
|
|
address,
|
|
packStatus,
|
|
})
|
|
}
|
|
|
|
export async function remove_server_from_profile(path: string, index: number): Promise<void> {
|
|
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
|
}
|
|
|
|
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
|
|
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
|
}
|
|
|
|
export async function get_server_status(
|
|
address: string,
|
|
protocolVersion: ProtocolVersion | null = null,
|
|
): Promise<ServerStatus> {
|
|
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
|
}
|
|
|
|
export async function start_join_singleplayer_world(path: string, world: string): Promise<unknown> {
|
|
return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
|
|
}
|
|
|
|
export async function start_join_server(path: string, address: string): Promise<unknown> {
|
|
return await invoke('plugin:worlds|start_join_server', { path, address })
|
|
}
|
|
|
|
export async function showWorldInFolder(instancePath: string, worldPath: string) {
|
|
const fullPath = await get_full_path(instancePath)
|
|
return await openPath(fullPath + '/saves/' + worldPath)
|
|
}
|
|
|
|
export function getWorldIdentifier(world: World) {
|
|
return world.type === 'singleplayer' ? world.path : world.address
|
|
}
|
|
|
|
export function sortWorlds(worlds: World[]) {
|
|
worlds.sort((a, b) => {
|
|
if (!a.last_played) {
|
|
return 1
|
|
}
|
|
if (!b.last_played) {
|
|
return -1
|
|
}
|
|
return dayjs(b.last_played).diff(dayjs(a.last_played))
|
|
})
|
|
}
|
|
|
|
export function isSingleplayerWorld(world: World): world is SingleplayerWorld {
|
|
return world.type === 'singleplayer'
|
|
}
|
|
|
|
export function isServerWorld(world: World): world is ServerWorld {
|
|
return world.type === 'server'
|
|
}
|
|
|
|
const DEFAULT_MINECRAFT_SERVER_PORT = 25565
|
|
|
|
function parseServerPort(port: string): number | null {
|
|
const parsed = Number.parseInt(port, 10)
|
|
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : null
|
|
}
|
|
|
|
function parseServerHost(address: string): string {
|
|
const trimmedAddress = address.trim()
|
|
if (!trimmedAddress) return ''
|
|
|
|
if (trimmedAddress.startsWith('[')) {
|
|
const closingBracket = trimmedAddress.indexOf(']')
|
|
if (closingBracket > 0) {
|
|
return trimmedAddress.slice(1, closingBracket).trim().toLowerCase()
|
|
}
|
|
}
|
|
|
|
const firstColon = trimmedAddress.indexOf(':')
|
|
const lastColon = trimmedAddress.lastIndexOf(':')
|
|
|
|
if (firstColon !== -1 && firstColon === lastColon) {
|
|
return trimmedAddress.slice(0, firstColon).trim().toLowerCase()
|
|
}
|
|
|
|
return trimmedAddress.toLowerCase()
|
|
}
|
|
|
|
function isIPv4Host(host: string): boolean {
|
|
const segments = host.split('.')
|
|
if (segments.length !== 4) return false
|
|
|
|
return segments.every((segment) => {
|
|
if (!/^\d+$/.test(segment)) return false
|
|
const value = Number.parseInt(segment, 10)
|
|
return value >= 0 && value <= 255
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Normalization converts addresses to a canonical form (lowercase-host:port, default port 25565)
|
|
*/
|
|
export function normalizeServerAddress(address: string): string {
|
|
const trimmedAddress = address.trim()
|
|
const host = parseServerHost(trimmedAddress)
|
|
if (!host) return ''
|
|
let port = DEFAULT_MINECRAFT_SERVER_PORT
|
|
|
|
// ipv6 address
|
|
if (trimmedAddress.startsWith('[')) {
|
|
const closingBracket = trimmedAddress.indexOf(']')
|
|
if (closingBracket > 0) {
|
|
const suffix = trimmedAddress.slice(closingBracket + 1)
|
|
if (suffix.startsWith(':')) {
|
|
const parsedPort = parseServerPort(suffix.slice(1))
|
|
if (parsedPort != null) {
|
|
port = parsedPort
|
|
}
|
|
}
|
|
}
|
|
|
|
// ipv4 address or hostname
|
|
} else {
|
|
const firstColon = trimmedAddress.indexOf(':')
|
|
const lastColon = trimmedAddress.lastIndexOf(':')
|
|
if (firstColon !== -1 && firstColon === lastColon) {
|
|
const parsedPort = parseServerPort(trimmedAddress.slice(firstColon + 1))
|
|
if (parsedPort != null) {
|
|
port = parsedPort
|
|
}
|
|
}
|
|
}
|
|
|
|
return `${host}:${port}`
|
|
}
|
|
|
|
/**
|
|
* Domain key used for deduping server entries by removing a single leading subdomain.
|
|
* Example: test.cobblemon.gg and cobblemon.gg map to cobblemon.gg
|
|
*/
|
|
export function getServerDomainKey(address: string): string {
|
|
const normalizedAddress = normalizeServerAddress(address)
|
|
if (!normalizedAddress) return ''
|
|
|
|
const separator = normalizedAddress.lastIndexOf(':')
|
|
if (separator <= 0 || separator === normalizedAddress.length - 1) return normalizedAddress
|
|
|
|
const host = normalizedAddress.slice(0, separator).replace(/\.+$/, '')
|
|
if (!host) return normalizedAddress
|
|
if (host.includes(':') || isIPv4Host(host)) return normalizedAddress
|
|
|
|
const segments = host.split('.').filter(Boolean)
|
|
if (segments.length <= 2) return host
|
|
|
|
return segments.slice(1).join('.')
|
|
}
|
|
|
|
export function resolveManagedServerWorld(
|
|
worlds: World[],
|
|
managedName: string | null | undefined,
|
|
managedAddress: string | null | undefined,
|
|
): ServerWorld | null {
|
|
if (!managedName || !managedAddress) return null
|
|
|
|
const normalizedManagedAddress = normalizeServerAddress(managedAddress)
|
|
if (!normalizedManagedAddress) return null
|
|
|
|
const servers = worlds
|
|
.filter(isServerWorld)
|
|
.slice()
|
|
.sort((a, b) => a.index - b.index)
|
|
|
|
const exactMatch = servers.find(
|
|
(server) =>
|
|
server.name === managedName &&
|
|
normalizeServerAddress(server.address) === normalizedManagedAddress,
|
|
)
|
|
if (exactMatch) return exactMatch
|
|
|
|
return (
|
|
servers.find((server) => normalizeServerAddress(server.address) === normalizedManagedAddress) ??
|
|
null
|
|
)
|
|
}
|
|
|
|
export async function getServerLatency(
|
|
address: string,
|
|
protocolVersion: ProtocolVersion | null = null,
|
|
): Promise<number | undefined> {
|
|
const pings: number[] = []
|
|
for (let i = 0; i < 3; i++) {
|
|
try {
|
|
const status = await get_server_status(address, protocolVersion)
|
|
if (status.ping != null) {
|
|
pings.push(status.ping)
|
|
}
|
|
} catch {
|
|
// Ignore individual ping failures
|
|
}
|
|
}
|
|
if (pings.length === 0) return undefined
|
|
return Math.round(pings.reduce((sum, p) => sum + p, 0) / pings.length)
|
|
}
|
|
|
|
export async function refreshServerData(
|
|
serverData: ServerData,
|
|
protocolVersion: ProtocolVersion | null,
|
|
address: string,
|
|
): Promise<void> {
|
|
const refreshTime = Date.now()
|
|
serverData.refreshing = true
|
|
await get_server_status(address, protocolVersion)
|
|
.then((status) => {
|
|
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
|
|
// Don't update if there was a more recent successful refresh
|
|
return
|
|
}
|
|
serverData.lastSuccessfulRefresh = Date.now()
|
|
serverData.status = status
|
|
if (status.description) {
|
|
serverData.rawMotd = status.description
|
|
serverData.renderedMotd = autoToHTML(status.description)
|
|
}
|
|
})
|
|
.finally(() => {
|
|
serverData.refreshing = false
|
|
})
|
|
.catch((err) => {
|
|
console.error(`Refreshing addr ${address}`, protocolVersion, err)
|
|
if (!protocolVersion?.legacy) {
|
|
refreshServerData(serverData, { version: 74, legacy: true }, address)
|
|
}
|
|
})
|
|
}
|
|
|
|
export function refreshServers(
|
|
worlds: World[],
|
|
serverData: Record<string, ServerData>,
|
|
protocolVersion: ProtocolVersion | null,
|
|
) {
|
|
const servers = worlds.filter(isServerWorld)
|
|
servers.forEach((server) => {
|
|
if (!serverData[server.address]) {
|
|
serverData[server.address] = {
|
|
refreshing: true,
|
|
}
|
|
} else {
|
|
serverData[server.address].refreshing = true
|
|
}
|
|
})
|
|
|
|
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
|
|
Object.keys(serverData).forEach((address) =>
|
|
refreshServerData(serverData[address], protocolVersion, address),
|
|
)
|
|
}
|
|
|
|
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
|
|
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
|
|
const newWorld = await get_singleplayer_world(instancePath, worldPath)
|
|
if (index !== -1) {
|
|
worlds[index] = newWorld
|
|
} else {
|
|
console.info(`Adding new world at path: ${worldPath}.`)
|
|
worlds.push(newWorld)
|
|
}
|
|
sortWorlds(worlds)
|
|
}
|
|
|
|
export async function handleDefaultProfileUpdateEvent(
|
|
worlds: World[],
|
|
instancePath: string,
|
|
e: ProfileEvent,
|
|
) {
|
|
if (e.event === 'world_updated') {
|
|
await refreshWorld(worlds, instancePath, e.world)
|
|
}
|
|
|
|
if (e.event === 'server_joined') {
|
|
const world = worlds.find(
|
|
(w) =>
|
|
w.type === 'server' &&
|
|
(w.address === `${e.host}:${e.port}` || (e.port == 25565 && w.address == e.host)),
|
|
)
|
|
if (world) {
|
|
world.last_played = e.timestamp
|
|
sortWorlds(worlds)
|
|
} else {
|
|
console.error(`Could not find world for server join event: ${e.host}:${e.port}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function refreshWorlds(instancePath: string): Promise<World[]> {
|
|
const worlds = await get_profile_worlds(instancePath).catch((err) => {
|
|
console.error(`Error refreshing worlds for instance: ${instancePath}`, err)
|
|
})
|
|
if (worlds) {
|
|
sortWorlds(worlds)
|
|
}
|
|
|
|
return worlds ?? []
|
|
}
|
|
|
|
export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
|
if (!gameVersions.length) {
|
|
return true
|
|
}
|
|
|
|
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
|
const targetIndex = gameVersions.findIndex((v) => v.version === 'a1.0.5_01')
|
|
|
|
return versionIndex === -1 || targetIndex === -1 || versionIndex <= targetIndex
|
|
}
|
|
|
|
export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
|
if (!gameVersions.length) {
|
|
return false
|
|
}
|
|
|
|
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
|
const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
|
|
|
|
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
|
|
}
|
|
|
|
export type ProfileEvent = { profile_path_id: string } & (
|
|
| {
|
|
event: 'servers_updated'
|
|
}
|
|
| {
|
|
event: 'world_updated'
|
|
world: string
|
|
}
|
|
| {
|
|
event: 'server_joined'
|
|
host: string
|
|
port: number
|
|
timestamp: string
|
|
}
|
|
)
|