feat: Client side js mods. Modding! (#255)

This commit is contained in:
Vitaly 2025-04-23 09:17:33 +03:00 committed by GitHub
commit 28faa9417a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1494 additions and 29 deletions

39
assets/config.html Normal file
View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configure client</title>
<script>
function removeSettings() {
if (confirm('Are you sure you want to RESET ALL SETTINGS?')) {
localStorage.setItem('options', '{}');
location.reload();
}
}
function removeAllData() {
localStorage.removeItem('serversList')
localStorage.removeItem('serversHistory')
localStorage.removeItem('authenticatedAccounts')
localStorage.removeItem('modsAutoUpdateLastCheck')
localStorage.removeItem('firstModsPageVisit')
localStorage.removeItem('proxiesData')
localStorage.removeItem('keybindings')
localStorage.removeItem('username')
localStorage.removeItem('customCommands')
localStorage.removeItem('options')
}
</script>
</head>
<body>
<div style="display: flex;gap: 10px;">
<button onclick="removeSettings()">Reset all settings</button>
<button onclick="removeAllData()">Remove all user data (but not mods or worlds)</button>
<!-- <button>Remove all user data (worlds, resourcepacks)</button> -->
<!-- <button>Remove all mods</button> -->
<!-- <button>Remove all mod repositories</button> -->
</div>
<input />
</body>
</html>

View file

@ -551,3 +551,4 @@ export class EntityMesh {
}
}
}
window.EntityMesh = EntityMesh

View file

@ -1,8 +1,8 @@
import * as THREE from 'three'
import { Vec3 } from 'vec3'
import { proxy } from 'valtio'
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer'
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../../../src/appViewer'
import { ProgressReporter } from '../../../src/core/progressReporter'
import { showNotification } from '../../../src/react/NotificationProvider'
import { WorldRendererThree } from './worldrendererThree'
import { DocumentRenderer } from './documentRenderer'
import { PanoramaRenderer } from './panorama'
@ -53,12 +53,14 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
let panoramaRenderer: PanoramaRenderer | null = null
let worldRenderer: WorldRendererThree | null = null
const startPanorama = () => {
const startPanorama = async () => {
if (worldRenderer) return
if (!panoramaRenderer) {
panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE)
void panoramaRenderer.start()
window.panoramaRenderer = panoramaRenderer
callModsMethod('panoramaCreated', panoramaRenderer)
await panoramaRenderer.start()
callModsMethod('panoramaReady', panoramaRenderer)
}
}
@ -79,6 +81,7 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
worldRenderer?.render(sizeChanged)
}
window.world = worldRenderer
callModsMethod('worldReady', worldRenderer)
}
const disconnect = () => {
@ -120,8 +123,24 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
}
}
globalThis.threeJsBackend = backend
globalThis.resourcesManager = initOptions.resourcesManager
callModsMethod('default', backend)
return backend
}
const callModsMethod = (method: string, ...args: any[]) => {
for (const mod of Object.values((window.loadedMods ?? {}) as Record<string, any>)) {
try {
mod.threeJsBackendModule?.[method]?.(...args)
} catch (err) {
const errorMessage = `[mod three.js] Error calling ${method} on ${mod.name}: ${err}`
showNotification(errorMessage, 'error')
throw new Error(errorMessage)
}
}
}
createGraphicsBackend.id = 'threejs'
export default createGraphicsBackend

View file

@ -173,6 +173,7 @@ const appConfig = defineConfig({
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
fs.copyFileSync('./assets/playground.html', './dist/playground.html')
fs.copyFileSync('./assets/manifest.json', './dist/manifest.json')
fs.copyFileSync('./assets/config.html', './dist/config.html')
fs.copyFileSync('./assets/loading-bg.jpg', './dist/loading-bg.jpg')
if (fs.existsSync('./assets/release.json')) {
fs.copyFileSync('./assets/release.json', './dist/release.json')

View file

@ -29,6 +29,7 @@ export type AppConfig = {
defaultLanguage?: string
displayLanguageSelector?: boolean
supportedLanguages?: string[]
showModsButton?: boolean
}
export const loadAppConfig = (appConfig: AppConfig) => {

View file

@ -89,6 +89,8 @@ export interface GraphicsBackend {
}
export class AppViewer {
waitBackendLoadPromises = [] as Array<Promise<void>>
resourcesManager = new ResourcesManager()
worldView: WorldDataEmitter | undefined
readonly config: GraphicsBackendConfig = {
@ -114,11 +116,14 @@ export class AppViewer {
this.disconnectBackend()
}
loadBackend (loader: GraphicsBackendLoader) {
async loadBackend (loader: GraphicsBackendLoader) {
if (this.backend) {
this.disconnectBackend()
}
await Promise.all(this.waitBackendLoadPromises)
this.waitBackendLoadPromises = []
this.backendLoader = loader
const rendererSpecificSettings = {} as Record<string, any>
const rendererSettingsKey = `renderer.${this.backendLoader?.id}`

View file

@ -9,25 +9,27 @@ import { showNotification } from './react/NotificationProvider'
const backends = [
createGraphicsBackend,
]
const loadBackend = () => {
const loadBackend = async () => {
let backend = backends.find(backend => backend.id === options.activeRenderer)
if (!backend) {
showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true)
backend = backends[0]
}
appViewer.loadBackend(backend)
await appViewer.loadBackend(backend)
}
window.loadBackend = loadBackend
if (process.env.SINGLE_FILE_BUILD_MODE) {
const unsub = subscribeKey(miscUiState, 'fsReady', () => {
if (miscUiState.fsReady) {
// don't do it earlier to load fs and display menu faster
loadBackend()
void loadBackend()
unsub()
}
})
} else {
loadBackend()
setTimeout(() => {
void loadBackend()
})
}
const animLoop = () => {
@ -40,10 +42,10 @@ watchOptionsAfterViewerInit()
// reset backend when renderer changes
subscribeKey(options, 'activeRenderer', () => {
subscribeKey(options, 'activeRenderer', async () => {
if (appViewer.currentDisplay === 'world' && bot) {
appViewer.resetBackend(true)
loadBackend()
await loadBackend()
void appViewer.startWithBot()
}
})

582
src/clientMods.ts Normal file
View file

@ -0,0 +1,582 @@
/* eslint-disable no-await-in-loop */
import { openDB } from 'idb'
import * as React from 'react'
import * as valtio from 'valtio'
import * as valtioUtils from 'valtio/utils'
import { gt } from 'semver'
import { proxy } from 'valtio'
import { options } from './optionsStorage'
import { appStorage } from './react/appStorageProvider'
import { showInputsModal, showOptionsModal } from './react/SelectOption'
import { ProgressReporter } from './core/progressReporter'
let sillyProtection = false
const protectRuntime = () => {
if (sillyProtection) return
sillyProtection = true
const sensetiveKeys = new Set(['authenticatedAccounts', 'serversList', 'username'])
const proxy = new Proxy(window.localStorage, {
get (target, prop) {
if (typeof prop === 'string') {
if (sensetiveKeys.has(prop)) {
console.warn(`Access to sensitive key "${prop}" was blocked`)
return null
}
if (prop === 'getItem') {
return (key: string) => {
if (sensetiveKeys.has(key)) {
console.warn(`Access to sensitive key "${key}" via getItem was blocked`)
return null
}
return target.getItem(key)
}
}
if (prop === 'setItem') {
return (key: string, value: string) => {
if (sensetiveKeys.has(key)) {
console.warn(`Attempt to set sensitive key "${key}" via setItem was blocked`)
return
}
target.setItem(key, value)
}
}
if (prop === 'removeItem') {
return (key: string) => {
if (sensetiveKeys.has(key)) {
console.warn(`Attempt to delete sensitive key "${key}" via removeItem was blocked`)
return
}
target.removeItem(key)
}
}
if (prop === 'clear') {
console.warn('Attempt to clear localStorage was blocked')
return () => {}
}
}
return Reflect.get(target, prop)
},
set (target, prop, value) {
if (typeof prop === 'string' && sensetiveKeys.has(prop)) {
console.warn(`Attempt to set sensitive key "${prop}" was blocked`)
return false
}
return Reflect.set(target, prop, value)
},
deleteProperty (target, prop) {
if (typeof prop === 'string' && sensetiveKeys.has(prop)) {
console.warn(`Attempt to delete sensitive key "${prop}" was blocked`)
return false
}
return Reflect.deleteProperty(target, prop)
}
})
Object.defineProperty(window, 'localStorage', {
value: proxy,
writable: false,
configurable: false,
})
}
// #region Database
const dbPromise = openDB('mods-db', 1, {
upgrade (db) {
db.createObjectStore('mods', {
keyPath: 'name',
})
db.createObjectStore('repositories', {
keyPath: 'url',
})
},
})
// mcraft-repo.json
export interface McraftRepoFile {
packages: ClientModDefinition[]
/** @default true */
prefix?: string | boolean
name?: string // display name
description?: string
mirrorUrls?: string[]
autoUpdateOverride?: boolean
lastUpdated?: number
}
export interface Repository extends McraftRepoFile {
url: string
}
export interface ClientMod {
name: string; // unique identifier like owner.name
version: string
enabled?: boolean
scriptMainUnstable?: string;
serverPlugin?: string
// serverPlugins?: string[]
// mesherThread?: string
stylesGlobal?: string
threeJsBackend?: string // three.js
// stylesLocal?: string
requiresNetwork?: boolean
fullyOffline?: boolean
description?: string
author?: string
section?: string
autoUpdateOverride?: boolean
lastUpdated?: number
wasModifiedLocally?: boolean
// todo depends, hashsum
}
const cleanupFetchedModData = (mod: ClientModDefinition | Record<string, any>) => {
delete mod['enabled']
delete mod['repo']
delete mod['autoUpdateOverride']
delete mod['lastUpdated']
delete mod['wasModifiedLocally']
return mod
}
export type ClientModDefinition = Omit<ClientMod, 'enabled' | 'wasModifiedLocally'> & {
scriptMainUnstable?: boolean
stylesGlobal?: boolean
serverPlugin?: boolean
threeJsBackend?: boolean
}
export async function saveClientModData (data: ClientMod) {
const db = await dbPromise
data.lastUpdated = Date.now()
await db.put('mods', data)
modsReactiveUpdater.counter++
}
async function getPlugin (name: string) {
const db = await dbPromise
return db.get('mods', name) as Promise<ClientMod | undefined>
}
async function getAllMods () {
const db = await dbPromise
return db.getAll('mods') as Promise<ClientMod[]>
}
async function deletePlugin (name) {
const db = await dbPromise
await db.delete('mods', name)
modsReactiveUpdater.counter++
}
async function removeAllMods () {
const db = await dbPromise
await db.clear('mods')
modsReactiveUpdater.counter++
}
// ---
async function saveRepository (data: Repository) {
const db = await dbPromise
data.lastUpdated = Date.now()
await db.put('repositories', data)
}
async function getRepository (url: string) {
const db = await dbPromise
return db.get('repositories', url) as Promise<Repository | undefined>
}
async function getAllRepositories () {
const db = await dbPromise
return db.getAll('repositories') as Promise<Repository[]>
}
window.getAllRepositories = getAllRepositories
async function deleteRepository (url) {
const db = await dbPromise
await db.delete('repositories', url)
}
// ---
// #endregion
window.mcraft = {
version: process.env.RELEASE_TAG,
build: process.env.BUILD_VERSION,
ui: {},
React,
valtio: {
...valtio,
...valtioUtils,
},
// openDB
}
const activateMod = async (mod: ClientMod, reason: string) => {
if (mod.enabled === false) return false
protectRuntime()
console.debug(`Activating mod ${mod.name} (${reason})...`)
window.loadedMods ??= {}
if (window.loadedMods[mod.name]) {
console.warn(`Mod is ${mod.name} already loaded, skipping activation...`)
return false
}
if (mod.stylesGlobal) {
const style = document.createElement('style')
style.textContent = mod.stylesGlobal
style.id = `mod-${mod.name}`
document.head.appendChild(style)
}
if (mod.scriptMainUnstable) {
const blob = new Blob([mod.scriptMainUnstable], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
// eslint-disable-next-line no-useless-catch
try {
const module = await import(/* webpackIgnore: true */ url)
module.default?.(structuredClone(mod))
window.loadedMods[mod.name] ??= {}
window.loadedMods[mod.name].mainUnstableModule = module
} catch (e) {
throw e
}
URL.revokeObjectURL(url)
}
if (mod.threeJsBackend) {
const blob = new Blob([mod.threeJsBackend], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
// eslint-disable-next-line no-useless-catch
try {
const module = await import(/* webpackIgnore: true */ url)
// todo
window.loadedMods[mod.name] ??= {}
// for accessing global world var
window.loadedMods[mod.name].threeJsBackendModule = module
} catch (e) {
throw e
}
URL.revokeObjectURL(url)
}
mod.enabled = true
return true
}
export const appStartup = async () => {
void checkModsUpdates()
const mods = await getAllMods()
for (const mod of mods) {
await activateMod(mod, 'autostart').catch(e => {
modsErrors[mod.name] ??= []
modsErrors[mod.name].push(`startup: ${String(e)}`)
console.error(`Error activating mod on startup ${mod.name}:`, e)
})
}
}
export const modsUpdateStatus = proxy({} as Record<string, [string, string]>)
export const modsWaitingReloadStatus = proxy({} as Record<string, boolean>)
export const modsErrors = proxy({} as Record<string, string[]>)
const normalizeRepoUrl = (url: string) => {
if (url.startsWith('https://')) return url
if (url.startsWith('http://')) return url
if (url.startsWith('//')) return `https:${url}`
return `https://raw.githubusercontent.com/${url}/master`
}
const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, activate = true, progress?: ProgressReporter) => {
// eslint-disable-next-line no-useless-catch
try {
const fetchData = async (urls: string[]) => {
const errored = [] as string[]
// eslint-disable-next-line no-unreachable-loop
for (const urlTemplate of urls) {
const modNameOnly = mod.name.split('.').pop()
const modFolder = repo.prefix === false ? modNameOnly : typeof repo.prefix === 'string' ? `${repo.prefix}/${modNameOnly}` : mod.name
const url = new URL(`${modFolder}/${urlTemplate}`, normalizeRepoUrl(repo.url).replace(/\/$/, '') + '/').href
// eslint-disable-next-line no-useless-catch
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
return await response.text()
} catch (e) {
// errored.push(String(e))
throw e
}
}
console.warn(`[${mod.name}] Error installing component of ${urls[0]}: ${errored.join(', ')}`)
return undefined
}
if (mod.stylesGlobal) {
await progress?.executeWithMessage(
`Downloading ${mod.name} styles`,
async () => {
mod.stylesGlobal = await fetchData(['global.css']) as any
}
)
}
if (mod.scriptMainUnstable) {
await progress?.executeWithMessage(
`Downloading ${mod.name} script`,
async () => {
mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any
}
)
}
if (mod.threeJsBackend) {
await progress?.executeWithMessage(
`Downloading ${mod.name} three.js backend`,
async () => {
mod.threeJsBackend = await fetchData(['three.js']) as any
}
)
}
if (mod.serverPlugin) {
if (mod.name.endsWith('.disabled')) throw new Error(`Mod name ${mod.name} can't end with .disabled`)
await progress?.executeWithMessage(
`Downloading ${mod.name} server plugin`,
async () => {
mod.serverPlugin = await fetchData(['serverPlugin.js']) as any
}
)
}
if (activate) {
// todo try to de-activate mod if it's already loaded
if (window.loadedMods?.[mod.name]) {
modsWaitingReloadStatus[mod.name] = true
} else {
await activateMod(mod as ClientMod, 'install')
}
}
await saveClientModData(mod as ClientMod)
delete modsUpdateStatus[mod.name]
} catch (e) {
// console.error(`Error installing mod ${mod.name}:`, e)
throw e
}
}
const checkRepositoryUpdates = async (repo: Repository) => {
for (const mod of repo.packages) {
const modExisting = await getPlugin(mod.name)
if (modExisting?.version && gt(mod.version, modExisting.version)) {
modsUpdateStatus[mod.name] = [modExisting.version, mod.version]
if (options.modsAutoUpdate === 'always' && (!repo.autoUpdateOverride && !modExisting.autoUpdateOverride)) {
void installOrUpdateMod(repo, mod).catch(e => {
console.error(`Error updating mod ${mod.name}:`, e)
})
}
}
}
}
export const fetchRepository = async (urlOriginal: string, url: string, hasMirrors = false) => {
const fetchUrl = normalizeRepoUrl(url).replace(/\/$/, '') + '/mcraft-repo.json'
try {
const response = await fetch(fetchUrl).then(async res => res.json())
if (!response.packages) throw new Error(`No packages field in the response json of the repository: ${fetchUrl}`)
response.autoUpdateOverride = (await getRepository(urlOriginal))?.autoUpdateOverride
response.url = urlOriginal
void saveRepository(response)
modsReactiveUpdater.counter++
return true
} catch (e) {
console.warn(`Error fetching repository (trying other mirrors) ${url}:`, e)
return false
}
}
export const fetchAllRepositories = async () => {
const repositories = await getAllRepositories()
await Promise.all(repositories.map(async (repo) => {
const allUrls = [repo.url, ...(repo.mirrorUrls || [])]
for (const [i, url] of allUrls.entries()) {
const isLast = i === allUrls.length - 1
if (await fetchRepository(repo.url, url, !isLast)) break
}
}))
appStorage.modsAutoUpdateLastCheck = Date.now()
}
const checkModsUpdates = async () => {
await autoRefreshModRepositories()
for (const repo of await getAllRepositories()) {
await checkRepositoryUpdates(repo)
}
}
const autoRefreshModRepositories = async () => {
if (options.modsAutoUpdate === 'never') return
const lastCheck = appStorage.modsAutoUpdateLastCheck
if (lastCheck && Date.now() - lastCheck < 1000 * 60 * 60 * options.modsUpdatePeriodCheck) return
await fetchAllRepositories()
// todo think of not updating check timestamp on offline access
}
export const installModByName = async (repoUrl: string, name: string, progress?: ProgressReporter) => {
progress?.beginStage('main', `Installing ${name}`)
const repo = await getRepository(repoUrl)
if (!repo) throw new Error(`Repository ${repoUrl} not found`)
const mod = repo.packages.find(m => m.name === name)
if (!mod) throw new Error(`Mod ${name} not found in repository ${repoUrl}`)
await installOrUpdateMod(repo, mod, undefined, progress)
progress?.endStage('main')
}
export const uninstallModAction = async (name: string) => {
const choice = await showOptionsModal(`Uninstall mod ${name}?`, ['Yes'])
if (!choice) return
await deletePlugin(name)
window.loadedMods ??= {}
if (window.loadedMods[name]) {
// window.loadedMods[name].default?.(null)
delete window.loadedMods[name]
modsWaitingReloadStatus[name] = true
}
// Clear any errors associated with the mod
delete modsErrors[name]
}
export const setEnabledModAction = async (name: string, newEnabled: boolean) => {
const mod = await getPlugin(name)
if (!mod) throw new Error(`Mod ${name} not found`)
if (newEnabled) {
mod.enabled = true
if (!window.loadedMods?.[mod.name]) {
await activateMod(mod, 'manual')
}
} else {
// todo deactivate mod
mod.enabled = false
if (window.loadedMods?.[mod.name]) {
if (window.loadedMods[mod.name]?.threeJsBackendModule) {
window.loadedMods[mod.name].threeJsBackendModule.deactivate()
delete window.loadedMods[mod.name].threeJsBackendModule
}
if (window.loadedMods[mod.name]?.mainUnstableModule) {
window.loadedMods[mod.name].mainUnstableModule.deactivate()
delete window.loadedMods[mod.name].mainUnstableModule
}
if (Object.keys(window.loadedMods[mod.name]).length === 0) {
delete window.loadedMods[mod.name]
}
}
}
await saveClientModData(mod)
}
export const modsReactiveUpdater = proxy({
counter: 0
})
export const getAllModsDisplayList = async () => {
const repos = await getAllRepositories()
const installedMods = await getAllMods()
const modsWithoutRepos = installedMods.filter(mod => !repos.some(repo => repo.packages.some(m => m.name === mod.name)))
const mapMods = (mapMods: ClientMod[]) => mapMods.map(mod => ({
...mod,
installed: installedMods.find(m => m.name === mod.name),
activated: !!window.loadedMods?.[mod.name],
installedVersion: installedMods.find(m => m.name === mod.name)?.version,
canBeActivated: mod.scriptMainUnstable || mod.stylesGlobal,
}))
return {
repos: repos.map(repo => ({
...repo,
packages: mapMods(repo.packages as ClientMod[]),
})),
modsWithoutRepos: mapMods(modsWithoutRepos),
}
}
export const removeRepositoryAction = async (url: string) => {
// todo remove mods
const choice = await showOptionsModal('Remove repository? Installed mods wont be automatically removed.', ['Yes'])
if (!choice) return
await deleteRepository(url)
modsReactiveUpdater.counter++
}
export const selectAndRemoveRepository = async () => {
const repos = await getAllRepositories()
const choice = await showOptionsModal('Select repository to remove', repos.map(repo => repo.url))
if (!choice) return
await removeRepositoryAction(choice)
}
export const addRepositoryAction = async () => {
const { url } = await showInputsModal('Add repository', {
url: {
type: 'text',
label: 'Repository URL or slug',
placeholder: 'github-owner/repo-name',
},
})
if (!url) return
await fetchRepository(url, url)
}
export const getServerPlugin = async (plugin: string) => {
const mod = await getPlugin(plugin)
if (!mod) return null
if (mod.serverPlugin) {
return {
content: mod.serverPlugin,
version: mod.version
}
}
return null
}
export const getAvailableServerPlugins = async () => {
const mods = await getAllMods()
return mods.filter(mod => mod.serverPlugin)
}
window.inspectInstalledMods = getAllMods
type ModifiableField = {
field: string
label: string
language: string
getContent?: () => string
}
// ---
export const getAllModsModifiableFields = () => {
const fields: ModifiableField[] = [
{
field: 'scriptMainUnstable',
label: 'Main Thread Script (unstable)',
language: 'js'
},
{
field: 'stylesGlobal',
label: 'Global CSS Styles',
language: 'css'
},
{
field: 'threeJsBackend',
label: 'Three.js Renderer Backend Thread',
language: 'js'
},
{
field: 'serverPlugin',
label: 'Built-in server plugin',
language: 'js'
}
]
return fields
}
export const getModModifiableFields = (mod: ClientMod): ModifiableField[] => {
return getAllModsModifiableFields().filter(field => mod[field.field])
}

View file

@ -1,6 +1,7 @@
import { setLoadingScreenStatus } from '../appStatus'
import { appStatusState } from '../react/AppStatusProvider'
import { hideNotification, showNotification } from '../react/NotificationProvider'
import { pixelartIcons } from '../react/PixelartIcon'
export interface ProgressReporter {
currentMessage: string | undefined
@ -170,7 +171,7 @@ export const createNotificationProgressReporter = (endMessage?: string): Progres
},
end () {
if (endMessage) {
showNotification(endMessage, '', false, '', undefined, true)
showNotification(endMessage, '', false, pixelartIcons.check, undefined, true)
} else {
hideNotification(id)
}

View file

@ -8,6 +8,7 @@ module.exports = {
'gameMode': 0,
'difficulty': 0,
'worldFolder': 'world',
'pluginsFolder': true,
// todo set sid, disable entities auto-spawn
'generation': {
// grass_field

View file

@ -81,6 +81,7 @@ import { updateAuthenticatedAccountData, updateLoadedServerData, updateServerCon
import { mainMenuState } from './react/MainMenuRenderApp'
import './mobileShim'
import { parseFormattedMessagePacket } from './botUtils'
import { appStartup } from './clientMods'
import { getViewerVersionData, getWsProtocolStream, onBotCreatedViewerHandler } from './viewerConnector'
import { getWebsocketStream } from './mineflayer/websocket-core'
import { appQueryParams, appQueryParamsArray } from './appParams'
@ -95,6 +96,7 @@ import './appViewerLoad'
import { registerOpenBenchmarkListener } from './benchmark'
import { tryHandleBuiltinCommand } from './builtinCommands'
import { loadingTimerState } from './react/LoadingTimer'
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
window.debug = debug
window.beforeRenderFrame = []
@ -369,6 +371,16 @@ export async function connect (connectOptions: ConnectOptions) {
// Client (class) of flying-squid (in server/login.js of mc-protocol): onLogin handler: skip most logic & go to loginClient() which assigns uuid and sends 'success' back to client (onLogin handler) and emits 'login' on the server (login.js in flying-squid handler)
// flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer
const serverPlugins = new URLSearchParams(location.search).getAll('serverPlugin')
if (serverPlugins.length > 0 && !serverOptions.worldFolder) {
console.log('Placing server plugins', serverPlugins)
serverOptions.worldFolder ??= '/temp'
await loadPluginsIntoWorld('/temp', serverPlugins)
console.log('Server plugins placed')
}
localServer = window.localServer = window.server = startLocalServer(serverOptions)
connectOptions?.connectEvents?.serverCreated?.()
// todo need just to call quit if started
@ -669,7 +681,7 @@ export async function connect (connectOptions: ConnectOptions) {
bot.once('login', () => {
loadingTimerState.networkOnlyStart = 0
setLoadingScreenStatus('Loading world')
progress.setMessage('Loading world')
})
let worldWasReady = false
@ -984,4 +996,5 @@ if (initialLoader) {
window.pageLoaded = true
void possiblyHandleStateVariable()
appViewer.waitBackendLoadPromises.push(appStartup())
registerOpenBenchmarkListener()

View file

@ -14,6 +14,7 @@ import { openFilePicker, resetLocalStorage } from './browserfs'
import { completeResourcepackPackInstall, getResourcePackNames, resourcepackReload, resourcePackState, uninstallResourcePack } from './resourcePack'
import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy'
import { showInputsModal, showOptionsModal } from './react/SelectOption'
import { modsUpdateStatus } from './clientMods'
import supportedVersions from './supportedVersions.mjs'
import { getVersionAutoSelect } from './connect'
import { createNotificationProgressReporter } from './core/progressReporter'
@ -227,6 +228,15 @@ export const guiOptionsScheme: {
return <Button label='Advanced...' onClick={() => openOptionsMenu('advanced')} inScreen />
},
},
{
custom () {
const { appConfig } = useSnapshot(miscUiState)
const modsUpdateSnapshot = useSnapshot(modsUpdateStatus)
if (appConfig?.showModsButton === false) return null
return <Button label={`Client Mods: ${Object.keys(window.loadedMods ?? {}).length} (${Object.keys(modsUpdateSnapshot).length})`} onClick={() => showModal({ reactType: 'mods' })} inScreen />
},
},
{
custom () {
return <Button label='VR...' onClick={() => openOptionsMenu('VR')} inScreen />

View file

@ -66,6 +66,9 @@ const defaultOptions = {
// todo ui setting, maybe enable by default?
waitForChunksRender: false as 'sp-only' | boolean,
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
modsSupport: false,
modsAutoUpdate: 'check' as 'check' | 'never' | 'always',
modsUpdatePeriodCheck: 24, // hours
preventBackgroundTimeoutKick: false,
preventSleep: false,
debugContro: false,

View file

@ -1,11 +1,14 @@
import { useEffect, useState } from 'react'
import { proxy, useSnapshot } from 'valtio'
import { filesize } from 'filesize'
import { getAvailableServerPlugins } from '../clientMods'
import { showModal } from '../globalState'
import Input from './Input'
import Screen from './Screen'
import Button from './Button'
import SelectGameVersion from './SelectGameVersion'
import styles from './createWorld.module.css'
import { InputOption, showInputsModal, showOptionsModal } from './SelectOption'
// const worldTypes = ['default', 'flat', 'largeBiomes', 'amplified', 'customized', 'buffet', 'debug_all_block_states']
const worldTypes = ['default', 'flat'/* , 'void' */]
@ -15,13 +18,14 @@ export const creatingWorldState = proxy({
title: '',
type: worldTypes[0],
gameMode: gameModes[0],
version: ''
version: '',
plugins: [] as string[]
})
export default ({ cancelClick, createClick, customizeClick, versions, defaultVersion }) => {
const [quota, setQuota] = useState('')
const { title, type, version, gameMode } = useSnapshot(creatingWorldState)
const { title, type, version, gameMode, plugins } = useSnapshot(creatingWorldState)
useEffect(() => {
creatingWorldState.version = defaultVersion
void navigator.storage?.estimate?.().then(({ quota, usage }) => {
@ -69,7 +73,38 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer
creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1]
}}
>
Gamemode: {gameMode}
Game Mode: {gameMode}
</Button>
</div>
<div style={{ display: 'flex' }}>
<Button onClick={async () => {
const availableServerPlugins = await getAvailableServerPlugins()
const availableModNames = availableServerPlugins.map(mod => mod.name)
const choices: Record<string, InputOption> = Object.fromEntries(availableServerPlugins.map(mod => [mod.name, {
type: 'checkbox' as const,
defaultValue: creatingWorldState.plugins.includes(mod.name),
label: mod.name
}]))
choices.installMore = {
type: 'button' as const,
onButtonClick () {
showModal({ reactType: 'mods' })
}
}
const choice = await showInputsModal('Select server plugins from mods to install:', choices)
if (!choice) return
creatingWorldState.plugins = availableModNames.filter(modName => choice[modName])
}}
>Use Mods ({plugins.length})
</Button>
<Button
onClick={() => {
const index = gameModes.indexOf(gameMode)
creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1]
}}
disabled
>
Save Type: Java
</Button>
</div>
<div className='muted' style={{ fontSize: 8 }}>Default and other world types are WIP</div>
@ -80,7 +115,11 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer
}}
>Cancel
</Button>
<Button disabled={!title} onClick={createClick}>Create</Button>
<Button disabled={!title} onClick={createClick}>
<b>
Create
</b>
</Button>
</div>
<div className='muted' style={{ fontSize: 9 }}>Note: save important worlds in folders on your hard drive!</div>
<div className='muted' style={{ fontSize: 9 }}>{quota}</div>

View file

@ -1,7 +1,10 @@
import fs from 'fs'
import path from 'path'
import { hideCurrentModal, showModal } from '../globalState'
import defaultLocalServerOptions from '../defaultLocalServerOptions'
import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs'
import supportedVersions from '../supportedVersions.mjs'
import { getServerPlugin } from '../clientMods'
import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld'
import { getWorldsPath } from './SingleplayerProvider'
import { useIsModalActive } from './utilsApp'
@ -14,7 +17,7 @@ export default () => {
const versions = Object.values(versionsPerMinor).map(x => {
return {
version: x,
label: x === defaultLocalServerOptions.version ? `${x} (available offline)` : x
label: x === defaultLocalServerOptions.version ? `${x} (default)` : x
}
})
return <CreateWorld
@ -24,10 +27,11 @@ export default () => {
}}
createClick={async () => {
// create new world
const { title, type, version, gameMode } = creatingWorldState
const { title, type, version, gameMode, plugins } = creatingWorldState
// todo display path in ui + disable if exist
const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath())
await mkdirRecursive(savePath)
await loadPluginsIntoWorld(savePath, plugins)
let generation
if (type === 'flat') {
generation = {
@ -68,3 +72,16 @@ export default () => {
}
return null
}
export const loadPluginsIntoWorld = async (worldPath: string, plugins: string[]) => {
for (const plugin of plugins) {
// eslint-disable-next-line no-await-in-loop
const { content, version } = await getServerPlugin(plugin) ?? {}
if (content) {
// eslint-disable-next-line no-await-in-loop
await mkdirRecursive(path.join(worldPath, 'plugins'))
// eslint-disable-next-line no-await-in-loop
await fs.promises.writeFile(path.join(worldPath, 'plugins', `${plugin}-${version}.js`), content)
}
}
}

View file

@ -35,7 +35,6 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w
return <div id='input-container' className={styles.container} style={rootStyles}>
<input
ref={ref}
className={styles.input}
autoComplete='off'
autoCapitalize='off'
autoCorrect='off'
@ -43,6 +42,7 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w
spellCheck='false'
style={{ ...validationStyle }}
{...inputProps}
className={styles.input + ' ' + (inputProps.className ?? '')}
value={value}
onChange={(e) => {
setValue(e.target.value)

483
src/react/ModsPage.tsx Normal file
View file

@ -0,0 +1,483 @@
import { useEffect, useState, useMemo, useRef } from 'react'
import { useSnapshot } from 'valtio'
import { openURL } from 'renderer/viewer/lib/simpleUtils'
import { addRepositoryAction, setEnabledModAction, getAllModsDisplayList, installModByName, selectAndRemoveRepository, uninstallModAction, fetchAllRepositories, modsReactiveUpdater, modsErrors, fetchRepository, getModModifiableFields, saveClientModData, getAllModsModifiableFields } from '../clientMods'
import { createNotificationProgressReporter, ProgressReporter } from '../core/progressReporter'
import { hideModal } from '../globalState'
import { useIsModalActive } from './utilsApp'
import Input from './Input'
import Button from './Button'
import styles from './mods.module.css'
import { showOptionsModal, showInputsModal } from './SelectOption'
import Screen from './Screen'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
import { showNotification } from './NotificationProvider'
import { usePassesScaledDimensions } from './UIProvider'
import { appStorage } from './appStorageProvider'
type ModsData = Awaited<ReturnType<typeof getAllModsDisplayList>>
const ModListItem = ({
mod,
onClick,
hasError
}: {
mod: ModsData['repos'][0]['packages'][0],
onClick: () => void,
hasError: boolean
}) => (
<div
className={styles.modRow}
onClick={onClick}
data-enabled={mod.installed ? '' : mod.activated}
data-has-error={hasError}
>
<div className={styles.modRowTitle}>
{mod.name}
{mod.installedVersion && mod.installedVersion !== mod.version && (
<PixelartIcon
iconName={pixelartIcons['arrow-up-box']}
styles={{ fontSize: 14, marginLeft: 3 }}
/>
)}
</div>
<div className={styles.modRowInfo}>
{mod.description}
{mod.author && ` • By ${mod.author}`}
{mod.version && ` • v${mod.version}`}
{mod.serverPlugin && ` • World plugin`}
</div>
</div>
)
const ModSidebar = ({ mod }: { mod: (ModsData['repos'][0]['packages'][0] & { repo?: string }) | null }) => {
const errors = useSnapshot(modsErrors)
const [editingField, setEditingField] = useState<{ name: string, content: string, language: string } | null>(null)
const handleAction = async (action: () => Promise<void>, errorMessage: string, progress?: ProgressReporter) => {
try {
await action()
progress?.end()
} catch (error) {
console.error(error)
progress?.end()
showNotification(errorMessage, error.message, true)
}
}
if (!mod) {
return <div className={styles.modInfoText}>Select a mod to view details</div>
}
const modifiableFields = mod.installed ? getModModifiableFields(mod.installed) : []
const handleSaveField = async (newContents: string) => {
if (!editingField) return
try {
mod[editingField.name] = newContents
mod.wasModifiedLocally = true
await saveClientModData(mod)
setEditingField(null)
showNotification('Success', 'Contents saved successfully')
} catch (error) {
showNotification('Error', 'Failed to save contents: ' + error.message, true)
}
}
if (editingField) {
return (
<EditingCodeWindow
contents={editingField.content}
language={editingField.language}
onClose={newContents => {
if (newContents === undefined) {
setEditingField(null)
return
}
void handleSaveField(newContents)
}}
/>
)
}
return (
<>
<div className={styles.modInfo}>
<div className={styles.modInfoTitle}>
{mod.name} {mod.installed?.wasModifiedLocally ? '(modified)' : ''}
</div>
<div className={styles.modInfoText}>
{mod.description}
</div>
<div className={styles.modInfoText}>
{mod.author && `Author: ${mod.author}\n`}
{mod.version && `Version: ${mod.version}\n`}
{mod.installedVersion && mod.installedVersion !== mod.version && `Installed version: ${mod.installedVersion}\n`}
{mod.section && `Section: ${mod.section}\n`}
</div>
{errors[mod.name]?.length > 0 && (
<div className={styles.modErrorList}>
<ul>
{errors[mod.name].map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
)}
</div>
<div className={styles.modActions}>
{mod.installed ? (
<>
{mod.activated ? (
<Button
onClick={async () => handleAction(
async () => setEnabledModAction(mod.name, false),
'Failed to disable mod:'
)}
icon={pixelartIcons['remove-box']}
title="Disable"
/>
) : (
<Button
onClick={async () => handleAction(
async () => setEnabledModAction(mod.name, true),
'Failed to enable mod:'
)}
icon={pixelartIcons['add-box']}
title="Enable"
disabled={!mod.canBeActivated}
/>
)}
<Button
onClick={async () => handleAction(
async () => uninstallModAction(mod.name),
'Failed to uninstall mod:'
)}
icon={pixelartIcons.trash}
title="Delete"
/>
{mod.installedVersion && mod.installedVersion !== mod.version && (
<Button
onClick={async () => {
if (!mod.repo) return
const progress = createNotificationProgressReporter(`${mod.name} updated and activated`)
await handleAction(
async () => {
await installModByName(mod.repo!, mod.name, progress)
},
'Failed to update mod:',
progress
)
}}
icon={pixelartIcons['arrow-up-box']}
title="Update"
/>
)}
{mod.serverPlugin && (
<Button
onClick={async () => {
const url = new URL(window.location.href)
url.searchParams.set('sp', '1')
url.searchParams.set('serverPlugin', mod.name)
openURL(url.toString())
}}
icon={pixelartIcons.play}
title="Try in blank world"
/>
)}
</>
) : (
<Button
onClick={async () => {
if (!mod.repo) return
const progress = createNotificationProgressReporter(`${mod.name} installed and enabled`)
await handleAction(
async () => {
await installModByName(mod.repo!, mod.name, progress)
},
'Failed to install & activate mod:',
progress
)
}}
icon={pixelartIcons.download}
title="Install"
/>
)}
{modifiableFields.length > 0 && (
<Button
onClick={async (e) => {
const fields = e.shiftKey ? getAllModsModifiableFields() : modifiableFields
const result = await showInputsModal('Edit Mod Field', Object.fromEntries(fields.map(field => {
return [field.field, {
type: 'button' as const,
label: field.label,
onButtonClick () {
setEditingField({
name: field.field,
content: field.getContent?.() || mod.installed![field.field] || '',
language: field.language
})
}
}]
})), {
showConfirm: false
})
}}
icon={pixelartIcons['edit']}
title="Edit Mod"
/>
)}
</div>
</>
)
}
const EditingCodeWindow = ({
contents,
language,
onClose
}: {
contents: string,
language: string,
onClose: (newContents?: string) => void
}) => {
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopImmediatePropagation()
}
}
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
}, [])
return <Screen title="Editing code">
<div className="">
<textarea
ref={ref}
className={styles.fieldEditorTextarea}
defaultValue={contents}
/>
<Button
style={{ position: 'absolute', bottom: 10, left: 10, backgroundColor: 'red' }}
onClick={() => onClose(undefined)}
icon={pixelartIcons.close}
title="Cancel"
/>
<Button
style={{ position: 'absolute', bottom: 10, right: 10, backgroundColor: '#4CAF50' }}
onClick={() => onClose(ref.current?.value)}
icon={pixelartIcons.check}
title="Save"
/>
</div>
</Screen>
}
export default () => {
const isModalActive = useIsModalActive('mods', true)
const [modsData, setModsData] = useState<ModsData | null>(null)
const [search, setSearch] = useState('')
const [showOnlyInstalled, setShowOnlyInstalled] = useState(false)
const [showOnlyEnabled, setShowOnlyEnabled] = useState(false)
const [selectedModIndex, setSelectedModIndex] = useState<number | null>(null)
const [expandedRepos, setExpandedRepos] = useState<Record<string, boolean>>({})
const useHorizontalLayout = usePassesScaledDimensions(400)
const { counter } = useSnapshot(modsReactiveUpdater)
const errors = useSnapshot(modsErrors)
const allModsArray = useMemo(() => {
if (!modsData) return []
return [
...modsData.repos.flatMap(repo => repo.packages.map(mod => ({ ...mod, repo: repo.url }))),
...modsData.modsWithoutRepos
]
}, [modsData])
useEffect(() => {
if (isModalActive) {
if (appStorage.firstModsPageVisit) {
appStorage.firstModsPageVisit = false
const defaultRepo = 'zardoy/mcraft-client-mods'
void fetchRepository(defaultRepo, defaultRepo)
}
void getAllModsDisplayList().then(mods => {
setModsData(mods)
// Update selected mod index if needed
if (selectedModIndex !== null && selectedModIndex < allModsArray.length) {
setSelectedModIndex(selectedModIndex)
}
})
}
}, [isModalActive, counter])
if (!isModalActive) return null
const toggleRepo = (repoUrl: string) => {
setExpandedRepos(prev => ({
...prev,
[repoUrl]: !prev[repoUrl]
}))
}
const modFilter = (mod: ModsData['repos'][0]['packages'][0]) => {
const matchesSearch = mod.name.toLowerCase().includes(search.toLowerCase()) ||
mod.description?.toLowerCase().includes(search.toLowerCase())
const matchesInstalledFilter = !showOnlyInstalled || mod.installed
const matchesEnabledFilter = !showOnlyEnabled || mod.activated
return matchesSearch && matchesInstalledFilter && matchesEnabledFilter
}
const filteredMods = modsData ? {
repos: modsData.repos.map(repo => ({
...repo,
packages: repo.packages.filter(modFilter)
})),
modsWithoutRepos: modsData.modsWithoutRepos.filter(modFilter)
} : null
const filteredModsCount = filteredMods ?
filteredMods.repos.reduce((acc, repo) => acc + repo.packages.length, 0) + filteredMods.modsWithoutRepos.length : 0
const totalRepos = modsData?.repos.length ?? 0
const getStatsText = () => {
if (!filteredMods) return 'Loading...'
if (showOnlyEnabled) {
return `Showing ${filteredMods.repos.reduce((acc, repo) => acc + repo.packages.filter(mod => mod.activated).length, 0)} enabled mods in ${totalRepos} repos`
} else if (showOnlyInstalled) {
return `Showing ${filteredMods.repos.reduce((acc, repo) => acc + repo.packages.filter(mod => mod.installed).length, 0)} installed mods in ${totalRepos} repos`
}
return `Showing all ${totalRepos} repos with ${filteredModsCount} mods`
}
const selectedMod = selectedModIndex === null ? null : allModsArray[selectedModIndex]
return <Screen backdrop="dirt" title="Client Mods (Preview)" titleMarginTop={0} contentStyle={{ paddingTop: 15, height: '100%', width: '100%' }}>
<Button
icon={pixelartIcons['close']}
onClick={() => {
hideModal()
}}
style={{
color: '#ff5d5d',
position: 'fixed',
top: 10,
left: 20
}}
/>
<div className={styles.root}>
<div className={styles.header}>
<Button
style={{}}
icon={pixelartIcons['sliders']}
onClick={() => {
if (showOnlyEnabled) {
setShowOnlyEnabled(false)
} else if (showOnlyInstalled) {
setShowOnlyInstalled(false)
setShowOnlyEnabled(true)
} else {
setShowOnlyInstalled(true)
}
}}
title={showOnlyEnabled ? 'Show all mods' : showOnlyInstalled ? 'Show enabled mods' : 'Show installed mods'}
/>
<Button
onClick={async () => {
// const refreshButton = `Refresh repositories (last update)`
const refreshButton = `Refresh repositories`
const choice = await showOptionsModal(`Manage repositories (${modsData?.repos.length ?? '-'} repos)`, ['Add repository', 'Remove repository', refreshButton])
switch (choice) {
case 'Add repository': {
await addRepositoryAction()
break
}
case 'Remove repository': {
await selectAndRemoveRepository()
break
}
case refreshButton: {
await fetchAllRepositories()
break
}
case undefined:
break
}
}}
icon={pixelartIcons['list-box']}
title="Manage repositories"
/>
<Input
className={styles.searchBar}
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search mods in added repositories..."
autoFocus
/>
</div>
<div className={styles.statsRow}>
{getStatsText()}
</div>
<div className={`${styles.content} ${useHorizontalLayout ? '' : styles.verticalContent}`}>
<div className={styles.modList}>
{filteredMods ? (
<>
{filteredMods.repos.map(repo => (
<div key={repo.url}>
<div
className={styles.repoHeader}
onClick={() => toggleRepo(repo.url)}
>
<span>{expandedRepos[repo.url] ? '▼' : '▶'}</span>
<span>{repo.name || repo.url}</span>
<span>({repo.packages.length})</span>
</div>
{expandedRepos[repo.url] && (
<div className={styles.repoContent}>
{repo.packages.map((mod) => (
<ModListItem
key={mod.name}
mod={mod}
onClick={() => setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))}
hasError={errors[mod.name]?.length > 0}
/>
))}
</div>
)}
</div>
))}
{filteredMods.modsWithoutRepos.length > 0 && (
<div>
<div className={styles.repoHeader}>
<span></span>
<span>Other Mods</span>
<span>({filteredMods.modsWithoutRepos.length})</span>
</div>
<div className={styles.repoContent}>
{filteredMods.modsWithoutRepos.map(mod => (
<ModListItem
key={mod.name}
mod={mod}
onClick={() => setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))}
hasError={errors[mod.name]?.length > 0}
/>
))}
</div>
</div>
)}
</>
) : (
<div className={styles.modRowInfo}>Loading mods...</div>
)}
</div>
<div className={styles.sidebar}>
<ModSidebar mod={selectedMod} />
</div>
</div>
</div>
</Screen>
}

View file

@ -6,14 +6,15 @@ interface Props {
className?: string
titleSelectable?: boolean
titleMarginTop?: number
contentStyle?: React.CSSProperties
}
export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop }: Props) => {
export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop, contentStyle }: Props) => {
return (
<>
{backdrop === 'dirt' ? <div className='dirt-bg' /> : backdrop ? <div className="backdrop" /> : null}
<div className={`fullscreen ${className}`} style={{ overflow: 'auto', ...style }}>
<div className="screen-content" style={titleMarginTop === undefined ? {} : { marginTop: titleMarginTop }}>
<div className="screen-content" style={{ ...contentStyle, ...(titleMarginTop === undefined ? {} : { marginTop: titleMarginTop }) }}>
<div className={`screen-title ${titleSelectable ? 'text-select' : ''}`}>{title}</div>
{children}
</div>

View file

@ -48,21 +48,33 @@ export const showOptionsModal = async <T extends string> (
})
}
type InputOption = {
type: 'text' | 'checkbox'
export type InputOption = {
type: 'text' | 'checkbox' | 'button'
defaultValue?: string | boolean
label?: string
placeholder?: string
onButtonClick?: () => void
}
export const showInputsModal = async <T extends Record<string, InputOption>>(
title: string,
inputs: T,
{ cancel = true, minecraftJsonMessage }: { cancel?: boolean, minecraftJsonMessage? } = {}
{
cancel = true,
minecraftJsonMessage,
showConfirm = true
}: {
cancel?: boolean,
minecraftJsonMessage?
showConfirm?: boolean
} = {}
): Promise<{
[K in keyof T]: T[K] extends { type: 'text' }
? string
: T[K] extends { type: 'checkbox' }
? boolean
: never
: T[K] extends { type: 'button' }
? string
: never
}> => {
showModal({ reactType: 'general-select' })
let minecraftJsonMessageParsed
@ -81,7 +93,7 @@ export const showInputsModal = async <T extends Record<string, InputOption>>(
showCancel: cancel,
minecraftJsonMessage: minecraftJsonMessageParsed,
options: [],
inputsConfirmButton: 'Confirm'
inputsConfirmButton: showConfirm ? 'Confirm' : ''
})
})
}
@ -130,6 +142,7 @@ export default () => {
autoFocus
type='text'
defaultValue={input.defaultValue as string}
placeholder={input.placeholder}
onChange={(e) => {
inputValues.current[key] = e.target.value
}}
@ -148,6 +161,15 @@ export default () => {
{label}
</label>
)}
{input.type === 'button' && (
<Button
onClick={() => {
resolveClose(inputValues.current)
input.onButtonClick?.()
}}
>{label}
</Button>
)}
</div>
})}
</div>

View file

@ -7,6 +7,7 @@ import type { BaseServerInfo } from './AddServerOrConnect'
// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts
const localStoragePrefix = process.env?.SINGLE_FILE_BUILD ? 'minecraft-web-client:' : ''
const { localStorage } = window
export interface SavedProxiesData {
proxies: string[]
@ -39,6 +40,8 @@ type StorageData = {
serversHistory: ServerHistoryEntry[]
authenticatedAccounts: AuthenticatedAccount[]
serversList: StoreServerItem[] | undefined
modsAutoUpdateLastCheck: number | undefined
firstModsPageVisit: boolean
}
const oldKeysAliases: Partial<Record<keyof StorageData, string>> = {
@ -79,6 +82,8 @@ const defaultStorageData: StorageData = {
serversHistory: [],
authenticatedAccounts: [],
serversList: undefined,
modsAutoUpdateLastCheck: undefined,
firstModsPageVisit: true,
}
export const setStorageDataOnAppConfigLoad = () => {
@ -86,7 +91,6 @@ export const setStorageDataOnAppConfigLoad = () => {
}
export const appStorage = proxy({ ...defaultStorageData })
window.appStorage = appStorage
// Restore data from localStorage
for (const key of Object.keys(defaultStorageData)) {

193
src/react/mods.module.css Normal file
View file

@ -0,0 +1,193 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
padding: 10px;
padding-top: 0;
box-sizing: border-box;
gap: 10px;
}
.header {
display: flex;
gap: 5px;
}
.statsRow {
color: #999;
font-size: 10px;
margin-bottom: 8px;
}
.statsRow {
color: #999;
font-size: 10px;
margin-bottom: 8px;
}
.searchBar {
flex: 1;
}
.content {
display: flex;
flex: 1;
gap: 10px;
overflow: hidden;
min-height: 0; /* Important for Firefox */
}
.verticalContent {
flex-direction: column;
}
.verticalContent .modList {
height: 50%;
min-height: 200px;
}
.verticalContent .sidebar {
height: 50%;
width: 100%;
}
.modList {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 5px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 5px;
min-height: 0; /* Important for Firefox */
height: 100%;
}
.sidebar {
width: 200px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
flex-shrink: 0;
height: 100%;
}
.modInfo {
display: flex;
flex-direction: column;
gap: 5px;
}
.modInfoTitle {
font-size: 12px;
font-weight: bold;
color: white;
}
.modInfoText {
font-size: 10px;
white-space: pre-wrap;
color: #bcbcbc;
}
.modActions {
display: flex;
gap: 5px;
}
.modRow {
display: flex;
flex-direction: column;
padding: 8px;
border-radius: 4px;
cursor: pointer;
}
.modRow:hover {
background: rgba(0, 0, 0, 0.2);
}
.modRowTitle {
font-size: 12px;
color: white;
margin-bottom: 4px;
display: flex;
}
.modRowInfo {
font-size: 10px;
color: #bcbcbc;
}
.repoHeader {
display: flex;
align-items: center;
gap: 4px;
color: #bcbcbc;
font-size: 8px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
}
.repoHeader:hover {
background: rgba(0, 0, 0, 0.2);
}
.repoContent {
margin-left: 10px;
display: flex;
flex-direction: column;
gap: 5px;
}
/* Mod state styles */
.modRow[data-enabled="false"] {
opacity: 0.5;
}
.modRow[data-enabled="true"] {
color: lime;
}
.modRow[data-enabled="true"] .modRowTitle {
color: lime;
}
/* Error state styles */
.modRow[data-has-error="true"] {
background: rgba(255, 0, 0, 0.1);
}
.modRow[data-has-error="true"] .modRowTitle {
color: #ff6b6b;
}
.modErrorList {
font-size: 8px;
color: #ff6b6b;
margin-top: 5px;
padding-left: 10px;
list-style-type: disc;
}
.fieldEditorTextarea {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
font-size: 7px;
}
.fieldEditorTextarea {
position: absolute;
width: 100%;
height: 100%;
padding: 10px;
}

25
src/react/mods.module.css.d.ts vendored Normal file
View file

@ -0,0 +1,25 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
content: string;
fieldEditorTextarea: string;
header: string;
modActions: string;
modErrorList: string;
modInfo: string;
modInfoText: string;
modInfoTitle: string;
modList: string;
modRow: string;
modRowInfo: string;
modRowTitle: string;
repoContent: string;
repoHeader: string;
root: string;
searchBar: string;
sidebar: string;
statsRow: string;
verticalContent: string;
}
declare const cssExports: CssExports;
export default cssExports;

View file

@ -45,6 +45,7 @@ import SignInMessageProvider from './react/SignInMessageProvider'
import BookProvider from './react/BookProvider'
import { options } from './optionsStorage'
import BossBarOverlayProvider from './react/BossBarOverlayProvider'
import ModsPage from './react/ModsPage'
import DebugEdges from './react/DebugEdges'
import GameInteractionOverlay from './react/GameInteractionOverlay'
import MineflayerPluginHud from './react/MineflayerPluginHud'
@ -226,7 +227,6 @@ const App = () => {
<CreateWorldProvider />
<AppStatusProvider />
<KeybindingsScreenProvider />
<SelectOption />
<ServersListProvider />
<OptionsRenderApp />
<MainMenuRenderApp />
@ -235,6 +235,9 @@ const App = () => {
<SignInMessageProvider />
<PacketsReplayProvider />
<NotificationProvider />
<ModsPage />
<SelectOption />
<NoModalFoundProvider />
</RobustPortal>