feat: refactor all app storage managment (#310)

This commit is contained in:
Vitaly 2025-03-17 16:05:04 +03:00 committed by GitHub
commit 36bf18b02f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 526 additions and 305 deletions

View file

@ -1,6 +1,7 @@
import { disabledSettings, options, qsOptions } from './optionsStorage'
import { miscUiState } from './globalState'
import { setLoadingScreenStatus } from './appStatus'
import { setStorageDataOnAppConfigLoad } from './react/appStorageProvider'
export type AppConfig = {
// defaultHost?: string
@ -42,6 +43,8 @@ export const loadAppConfig = (appConfig: AppConfig) => {
}
}
}
setStorageDataOnAppConfigLoad()
}
export const isBundledConfigUsed = !!process.env.INLINED_APP_CONFIG

View file

@ -15,6 +15,7 @@ import { getFixedFilesize } from './downloadAndOpenFile'
import { packetsReplayState } from './react/state/packetsReplayState'
import { createFullScreenProgressReporter } from './core/progressReporter'
import { showNotification } from './react/NotificationProvider'
import { resetAppStorage } from './react/appStorageProvider'
const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive')
browserfs.install(window)
@ -620,24 +621,13 @@ export const openWorldZip = async (...args: Parameters<typeof openWorldZipInner>
}
}
export const resetLocalStorageWorld = () => {
for (const key of Object.keys(localStorage)) {
if (/^[\da-fA-F]{8}(?:\b-[\da-fA-F]{4}){3}\b-[\da-fA-F]{12}$/g.test(key) || key === '/') {
localStorage.removeItem(key)
}
}
}
export const resetLocalStorageWithoutWorld = () => {
for (const key of Object.keys(localStorage)) {
if (!/^[\da-fA-F]{8}(?:\b-[\da-fA-F]{4}){3}\b-[\da-fA-F]{12}$/g.test(key) && key !== '/') {
localStorage.removeItem(key)
}
}
export const resetLocalStorage = () => {
resetOptions()
resetAppStorage()
}
window.resetLocalStorageWorld = resetLocalStorageWorld
window.resetLocalStorage = resetLocalStorage
export const openFilePicker = (specificCase?: 'resourcepack') => {
// create and show input picker
let picker: HTMLInputElement = document.body.querySelector('input#file-zip-picker')!

View file

@ -25,11 +25,12 @@ import { showNotification } from './react/NotificationProvider'
import { lastConnectOptions } from './react/AppStatusProvider'
import { onCameraMove, onControInit } from './cameraRotationControls'
import { createNotificationProgressReporter } from './core/progressReporter'
import { appStorage } from './react/appStorageProvider'
export const customKeymaps = proxy(JSON.parse(localStorage.keymap || '{}')) as UserOverridesConfig
export const customKeymaps = proxy(appStorage.keybindings)
subscribe(customKeymaps, () => {
localStorage.keymap = JSON.stringify(customKeymaps)
appStorage.keybindings = customKeymaps
})
const controlOptions = {

View file

@ -3,19 +3,21 @@ import { useSnapshot } from 'valtio'
import { openURL } from 'renderer/viewer/lib/simpleUtils'
import { noCase } from 'change-case'
import { gameAdditionalState, miscUiState, openOptionsMenu, showModal } from './globalState'
import { AppOptions, options } from './optionsStorage'
import { AppOptions, getChangedSettings, options, resetOptions } from './optionsStorage'
import Button from './react/Button'
import { OptionMeta, OptionSlider } from './react/OptionsItems'
import Slider from './react/Slider'
import { getScreenRefreshRate } from './utils'
import { setLoadingScreenStatus } from './appStatus'
import { openFilePicker, resetLocalStorageWithoutWorld } from './browserfs'
import { openFilePicker, resetLocalStorage } from './browserfs'
import { completeResourcepackPackInstall, getResourcePackNames, resourcePackState, uninstallResourcePack } from './resourcePack'
import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy'
import { showOptionsModal } from './react/SelectOption'
import { showInputsModal, showOptionsModal } from './react/SelectOption'
import supportedVersions from './supportedVersions.mjs'
import { getVersionAutoSelect } from './connect'
import { createNotificationProgressReporter } from './core/progressReporter'
import { customKeymaps } from './controls'
import { appStorage } from './react/appStorageProvider'
export const guiOptionsScheme: {
[t in OptionsGroupType]: Array<{ [K in keyof AppOptions]?: Partial<OptionMeta<AppOptions[K]>> } & { custom? }>
@ -450,9 +452,19 @@ export const guiOptionsScheme: {
return <Button
inScreen
onClick={() => {
if (confirm('Are you sure you want to reset all settings?')) resetLocalStorageWithoutWorld()
if (confirm('Are you sure you want to reset all settings?')) resetOptions()
}}
>Reset all settings</Button>
>Reset settings</Button>
},
},
{
custom () {
return <Button
inScreen
onClick={() => {
if (confirm('Are you sure you want to remove all data (settings, keybindings, servers, username, auth, proxies)?')) resetLocalStorage()
}}
>Remove all data</Button>
},
},
{
@ -460,6 +472,11 @@ export const guiOptionsScheme: {
return <Category>Developer</Category>
},
},
{
custom () {
return <Button label='Export/Import...' onClick={() => openOptionsMenu('export-import')} inScreen />
}
},
{
custom () {
const { active } = useSnapshot(packetsRecordingState)
@ -521,8 +538,91 @@ export const guiOptionsScheme: {
},
},
],
'export-import': [
{
custom () {
return <Category>Export/Import Data</Category>
}
},
{
custom () {
return <Button
inScreen
disabled={true}
onClick={() => {}}
>Import Data</Button>
}
},
{
custom () {
return <Button
inScreen
onClick={async () => {
const data = await showInputsModal('Export Profile', {
profileName: {
type: 'text',
},
exportSettings: {
type: 'checkbox',
defaultValue: true,
},
exportKeybindings: {
type: 'checkbox',
defaultValue: true,
},
exportServers: {
type: 'checkbox',
defaultValue: true,
},
saveUsernameAndProxy: {
type: 'checkbox',
defaultValue: true,
},
})
const fileName = `${data.profileName ? `${data.profileName}-` : ''}web-client-profile.json`
const json = {
_about: 'Minecraft Web Client (mcraft.fun) Profile',
...data.exportSettings ? {
options: getChangedSettings(),
} : {},
...data.exportKeybindings ? {
keybindings: customKeymaps,
} : {},
...data.saveUsernameAndProxy ? {
username: appStorage.username,
proxy: appStorage.proxiesData?.selected,
} : {},
}
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}}
>Export Data</Button>
}
},
{
custom () {
return <Button
inScreen
disabled
>Export Worlds</Button>
}
},
{
custom () {
return <Button
inScreen
disabled
>Export Resource Pack</Button>
}
}
],
}
export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced' | 'VR'
export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced' | 'VR' | 'export-import'
const Category = ({ children }) => <div style={{
fontSize: 9,

View file

@ -1,11 +1,9 @@
// todo implement async options storage
import { proxy, subscribe } from 'valtio/vanilla'
// weird webpack configuration bug: it cant import valtio/utils in this file
import { subscribeKey } from 'valtio/utils'
import { omitObj } from '@zardoy/utils'
import { appQueryParamsArray } from './appParams'
import type { AppConfig } from './appConfig'
import { appStorage } from './react/appStorageProvider'
const isDev = process.env.NODE_ENV === 'development'
const initialAppConfig = process.env?.INLINED_APP_CONFIG as AppConfig ?? {}
@ -164,12 +162,31 @@ const migrateOptions = (options: Partial<AppOptions & Record<string, any>>) => {
export type AppOptions = typeof defaultOptions
// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts
const localStorageKey = process.env?.SINGLE_FILE_BUILD ? 'minecraftWebClientOptions' : 'options'
const isDeepEqual = (a: any, b: any): boolean => {
if (a === b) return true
if (typeof a !== typeof b) return false
if (typeof a !== 'object') return false
if (a === null || b === null) return a === b
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false
return a.every((item, index) => isDeepEqual(item, b[index]))
}
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
return keysA.every(key => isDeepEqual(a[key], b[key]))
}
export const getChangedSettings = () => {
return Object.fromEntries(
Object.entries(options).filter(([key, value]) => !isDeepEqual(defaultOptions[key], value))
)
}
export const options: AppOptions = proxy({
...defaultOptions,
...initialAppConfig.defaultSettings,
...migrateOptions(JSON.parse(localStorage[localStorageKey] || '{}')),
...migrateOptions(appStorage.options),
...qsOptions
})
@ -181,14 +198,14 @@ export const resetOptions = () => {
Object.defineProperty(window, 'debugChangedOptions', {
get () {
return Object.fromEntries(Object.entries(options).filter(([key, v]) => defaultOptions[key] !== v))
return getChangedSettings()
},
})
subscribe(options, () => {
// Don't save disabled settings to localStorage
const saveOptions = omitObj(options, [...disabledSettings.value] as any)
localStorage[localStorageKey] = JSON.stringify(saveOptions)
appStorage.options = saveOptions
})
type WatchValue = <T extends Record<string, any>>(proxy: T, callback: (p: T, isChanged: boolean) => void) => () => void

View file

@ -3,7 +3,7 @@ import { appQueryParams } from '../appParams'
import { fetchServerStatus, isServerValid } from '../api/mcStatusApi'
import { parseServerAddress } from '../parseServerAddress'
import Screen from './Screen'
import Input from './Input'
import Input, { INPUT_LABEL_WIDTH, InputWithLabel } from './Input'
import Button from './Button'
import SelectGameVersion from './SelectGameVersion'
import { usePassesScaledDimensions } from './UIProvider'
@ -32,8 +32,6 @@ interface Props {
allowAutoConnect?: boolean
}
const ELEMENTS_WIDTH = 190
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => {
const isSmallHeight = !usePassesScaledDimensions(null, 350)
const qsParamName = parseQs ? appQueryParams.name : undefined
@ -256,20 +254,8 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
const ButtonWrapper = ({ ...props }: React.ComponentProps<typeof Button>) => {
props.style ??= {}
props.style.width = ELEMENTS_WIDTH
props.style.width = INPUT_LABEL_WIDTH
return <Button {...props} />
}
const InputWithLabel = ({ label, span, ...props }: React.ComponentProps<typeof Input> & { label, span? }) => {
return <div style={{
display: 'flex',
flexDirection: 'column',
gridRow: span ? 'span 2 / span 2' : undefined,
}}
>
<label style={{ fontSize: 12, marginBottom: 1, color: 'lightgray' }}>{label}</label>
<Input rootStyles={{ width: ELEMENTS_WIDTH }} {...props} />
</div>
}
const fallbackIfNotFound = (index: number) => (index === -1 ? undefined : index)

View file

@ -90,11 +90,11 @@ div.chat-wrapper {
::-webkit-scrollbar {
width: 5px;
height: 5px;
background-color: rgb(24, 24, 24);
background-color: #272727;
}
::-webkit-scrollbar-thumb {
background-color: rgb(50, 50, 50);
background-color: #747474;
}
.chat-completions-items>div {
@ -160,7 +160,6 @@ input[type=text],
padding-bottom: 1px;
padding-left: 2px;
padding-right: 2px;
height: 15px;
}
.chat-mobile-input-hidden {

View file

@ -10,7 +10,7 @@ interface Props extends Omit<React.ComponentProps<'input'>, 'width'> {
width?: number
}
export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, width, ...inputProps }: Props) => {
const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, width, ...inputProps }: Props) => {
if (width) rootStyles = { ...rootStyles, width }
const ref = useRef<HTMLInputElement>(null!)
@ -51,3 +51,19 @@ export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue,
/>
</div>
}
export default Input
export const INPUT_LABEL_WIDTH = 190
export const InputWithLabel = ({ label, span, ...props }: React.ComponentProps<typeof Input> & { label, span? }) => {
return <div style={{
display: 'flex',
flexDirection: 'column',
gridRow: span ? 'span 2 / span 2' : undefined,
}}
>
<label style={{ fontSize: 12, marginBottom: 1, color: 'lightgray' }}>{label}</label>
<Input rootStyles={{ width: INPUT_LABEL_WIDTH }} {...props} />
</div>
}

View file

@ -122,24 +122,6 @@ export default () => {
singleplayerAvailable={singleplayerAvailable}
connectToServerAction={() => showModal({ reactType: 'serversList' })}
singleplayerAction={async () => {
const oldFormatSave = fs.existsSync('./world/level.dat')
if (oldFormatSave) {
setLoadingScreenStatus('Migrating old save, don\'t close the page')
try {
await mkdirRecursive('/data/worlds/local')
await copyFilesAsync('/world/', '/data/worlds/local')
try {
await removeFileRecursiveAsync('/world/')
} catch (err) {
console.error(err)
}
} catch (err) {
console.warn(err)
alert('Failed to migrate world from localStorage')
} finally {
setLoadingScreenStatus(undefined)
}
}
showModal({ reactType: 'singleplayer' })
}}
githubAction={() => openGithub()}

View file

@ -1,10 +1,14 @@
import { proxy, useSnapshot } from 'valtio'
import { useEffect, useRef } from 'react'
import { noCase } from 'change-case'
import { titleCase } from 'title-case'
import { hideCurrentModal, showModal } from '../globalState'
import { parseFormattedMessagePacket } from '../botUtils'
import Screen from './Screen'
import { useIsModalActive } from './utilsApp'
import Button from './Button'
import MessageFormattedString from './MessageFormattedString'
import Input, { InputWithLabel } from './Input'
const state = proxy({
title: '',
@ -12,6 +16,8 @@ const state = proxy({
showCancel: true,
minecraftJsonMessage: null as null | Record<string, any>,
behavior: 'resolve-close' as 'resolve-close' | 'close-resolve',
inputs: {} as Record<string, InputOption>,
inputsConfirmButton: ''
})
let resolve
@ -35,17 +41,63 @@ export const showOptionsModal = async <T extends string> (
title,
options,
showCancel: cancel,
minecraftJsonMessage: minecraftJsonMessageParsed
minecraftJsonMessage: minecraftJsonMessageParsed,
inputs: {},
inputsConfirmButton: ''
})
})
}
type InputOption = {
type: 'text' | 'checkbox'
defaultValue?: string | boolean
label?: string
}
export const showInputsModal = async <T extends Record<string, InputOption>>(
title: string,
inputs: T,
{ cancel = true, minecraftJsonMessage }: { cancel?: boolean, minecraftJsonMessage? } = {}
): Promise<{
[K in keyof T]: T[K] extends { type: 'text' }
? string
: T[K] extends { type: 'checkbox' }
? boolean
: never
}> => {
showModal({ reactType: 'general-select' })
let minecraftJsonMessageParsed
if (minecraftJsonMessage) {
const parseResult = parseFormattedMessagePacket(minecraftJsonMessage)
minecraftJsonMessageParsed = parseResult.formatted
if (parseResult.plain) {
title += ` (${parseResult.plain})`
}
}
return new Promise((_resolve) => {
resolve = _resolve
Object.assign(state, {
title,
inputs,
showCancel: cancel,
minecraftJsonMessage: minecraftJsonMessageParsed,
options: [],
inputsConfirmButton: 'Confirm'
})
})
}
export default () => {
const { title, options, showCancel, minecraftJsonMessage } = useSnapshot(state)
const { title, options, showCancel, minecraftJsonMessage, inputs, inputsConfirmButton } = useSnapshot(state)
const isModalActive = useIsModalActive('general-select')
const inputValues = useRef({})
useEffect(() => {
inputValues.current = Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, input.defaultValue ?? (input.type === 'checkbox' ? false : '')]))
}, [inputs])
if (!isModalActive) return
const resolveClose = (value: string | undefined) => {
const resolveClose = (value: any) => {
if (state.behavior === 'resolve-close') {
resolve(value)
hideCurrentModal()
@ -59,17 +111,66 @@ export default () => {
{minecraftJsonMessage && <div style={{ textAlign: 'center', }}>
<MessageFormattedString message={minecraftJsonMessage} />
</div>}
{options.map(option => <Button
key={option} onClick={() => {
resolveClose(option)
}}
>{option}
</Button>)}
{showCancel && <Button
style={{ marginTop: 30 }} onClick={() => {
resolveClose(undefined)
}}
>Cancel
</Button>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
{options.length > 0 && <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{options.map(option => <Button
key={option} onClick={() => {
resolveClose(option)
}}
>{option}
</Button>)}
</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{Object.entries(inputs).map(([key, input]) => {
const label = input.label ?? titleCase(noCase(key))
return <div key={key}>
{input.type === 'text' && (
<InputWithLabel
label={label}
autoFocus
type='text'
defaultValue={input.defaultValue as string}
onChange={(e) => {
inputValues.current[key] = e.target.value
}}
/>
)}
{input.type === 'checkbox' && (
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
<input
type='checkbox'
style={{ marginBottom: -1, }}
defaultChecked={input.defaultValue as boolean}
onChange={(e) => {
inputValues.current[key] = e.target.checked
}}
/>
{label}
</label>
)}
</div>
})}
</div>
{inputs && inputsConfirmButton && (
<Button
// style={{ marginTop: 30 }}
onClick={() => {
resolveClose(inputValues.current)
}}
>
{inputsConfirmButton}
</Button>
)}
{showCancel && (
<Button
// style={{ marginTop: 30 }}
onClick={() => {
resolveClose(undefined)
}}
>
Cancel
</Button>
)}
</div>
</Screen>
}

View file

@ -1,64 +1,61 @@
import React from 'react'
import React, { useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { miscUiState } from '../globalState'
import { appQueryParams } from '../appParams'
import Singleplayer from './Singleplayer'
import Input from './Input'
import Button from './Button'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
import Select from './Select'
import { BaseServerInfo } from './AddServerOrConnect'
import { useIsSmallWidth } from './simpleHooks'
import { appStorage, SavedProxiesData, ServerHistoryEntry } from './appStorageProvider'
const getInitialProxies = () => {
const proxies = [] as string[]
if (miscUiState.appConfig?.defaultProxy) {
proxies.push(miscUiState.appConfig.defaultProxy)
}
return proxies
}
export const getCurrentProxy = (): string | undefined => {
return appQueryParams.proxy ?? appStorage.proxiesData?.selected ?? getInitialProxies()[0]
}
export const getCurrentUsername = () => {
return appQueryParams.username ?? appStorage.username
}
interface Props extends React.ComponentProps<typeof Singleplayer> {
joinServer: (info: BaseServerInfo | string, additional: {
shouldSave?: boolean
index?: number
}) => void
initialProxies: SavedProxiesLocalStorage
updateProxies: (proxies: SavedProxiesLocalStorage) => void
username: string
setUsername: (username: string) => void
onProfileClick?: () => void
setQuickConnectIp?: (ip: string) => void
serverHistory?: Array<{
ip: string
versionOverride?: string
numConnects: number
}>
}
export interface SavedProxiesLocalStorage {
proxies: readonly string[]
selected: string
}
type ProxyStatusResult = {
time: number
ping: number
status: 'success' | 'error' | 'unknown'
}
export default ({
initialProxies,
updateProxies: updateProxiesProp,
joinServer,
username,
setUsername,
onProfileClick,
setQuickConnectIp,
serverHistory,
...props
}: Props) => {
const [proxies, setProxies] = React.useState(initialProxies)
const updateProxies = (newData: SavedProxiesLocalStorage) => {
setProxies(newData)
updateProxiesProp(newData)
}
const snap = useSnapshot(appStorage)
const username = useMemo(() => getCurrentUsername(), [appQueryParams.username, appStorage.username])
const [serverIp, setServerIp] = React.useState('')
const [save, setSave] = React.useState(true)
const [activeHighlight, setActiveHighlight] = React.useState(undefined as 'quick-connect' | 'server-list' | undefined)
const updateProxies = (newData: SavedProxiesData) => {
appStorage.proxiesData = newData
}
const setUsername = (username: string) => {
appStorage.username = username
}
const getActiveHighlightStyles = (type: typeof activeHighlight) => {
const styles: React.CSSProperties = {
transition: 'filter 0.2s',
@ -71,6 +68,8 @@ export default ({
const isSmallWidth = useIsSmallWidth()
const initialProxies = getInitialProxies()
const proxiesData = snap.proxiesData ?? { proxies: initialProxies, selected: initialProxies[0] }
return <Singleplayer
{...props}
firstRowChildrenOverride={<form
@ -85,7 +84,6 @@ export default ({
onMouseEnter={() => setActiveHighlight('quick-connect')}
onMouseLeave={() => setActiveHighlight(undefined)}
>
{/* todo history */}
<Input
required
placeholder='Quick Connect IP (:version)'
@ -102,8 +100,8 @@ export default ({
spellCheck="false"
/>
<datalist id="server-history">
{serverHistory?.map((server) => (
<option key={server.ip} value={`${server.ip}${server.versionOverride ? `:${server.versionOverride}` : ''}`} />
{[...(snap.serversHistory ?? [])].sort((a, b) => b.numConnects - a.numConnects).map((server) => (
<option key={server.ip} value={`${server.ip}${server.version ? `:${server.version}` : ''}`} />
))}
</datalist>
<label style={{ fontSize: 10, display: 'flex', alignItems: 'center', gap: 5, height: '100%', marginTop: '-1px' }}>
@ -126,10 +124,10 @@ export default ({
? <PixelartIcon iconName={pixelartIcons.server} styles={{ fontSize: 14, color: 'lightgray', marginLeft: 2 }} onClick={onProfileClick} />
: <span style={{ color: 'lightgray', fontSize: 14 }}>Proxy:</span>}
<Select
initialOptions={proxies.proxies.map(p => { return { value: p, label: p } })}
defaultValue={{ value: proxies.selected, label: proxies.selected }}
initialOptions={proxiesData.proxies.map(p => { return { value: p, label: p } })}
defaultValue={{ value: proxiesData.selected, label: proxiesData.selected }}
updateOptions={(newSel) => {
updateProxies({ proxies: [...proxies.proxies], selected: newSel })
updateProxies({ proxies: [...proxiesData.proxies], selected: newSel })
}}
containerStyle={{
width: isSmallWidth ? 140 : 180,
@ -139,6 +137,7 @@ export default ({
<Input
rootStyles={{ width: 80 }}
value={username}
disabled={appQueryParams.username !== undefined}
onChange={({ target: { value } }) => setUsername(value)}
autoCorrect="off"
autoCapitalize="off"

View file

@ -8,13 +8,14 @@ import { appQueryParams } from '../appParams'
import { fetchServerStatus, isServerValid } from '../api/mcStatusApi'
import { getServerInfo } from '../mineflayer/mc-protocol'
import { parseServerAddress } from '../parseServerAddress'
import ServersList from './ServersList'
import ServersList, { getCurrentProxy, getCurrentUsername } from './ServersList'
import AddServerOrConnect, { BaseServerInfo } from './AddServerOrConnect'
import { useDidUpdateEffect } from './utils'
import { useIsModalActive } from './utilsApp'
import { showOptionsModal } from './SelectOption'
import { useCopyKeybinding } from './simpleHooks'
import { AuthenticatedAccount, getInitialServersList, getServerConnectionHistory, setNewServersList, StoreServerItem } from './serversStorage'
import { AuthenticatedAccount, getInitialServersList, getServerConnectionHistory, setNewServersList } from './serversStorage'
import { appStorage, StoreServerItem } from './appStorageProvider'
type AdditionalDisplayData = {
textNameRightGrayed: string
@ -25,19 +26,6 @@ type AdditionalDisplayData = {
}
const serversListQs = appQueryParams.serversList
const proxyQs = appQueryParams.proxy
const getInitialProxies = () => {
const proxies = [] as string[]
if (miscUiState.appConfig?.defaultProxy) {
proxies.push(miscUiState.appConfig.defaultProxy)
}
if (localStorage['proxy']) {
proxies.push(localStorage['proxy'])
localStorage.removeItem('proxy')
}
return proxies
}
// todo move to base
const normalizeIp = (ip: string) => ip.replace(/https?:\/\//, '').replace(/\/(:|$)/, '')
@ -46,45 +34,30 @@ const FETCH_DELAY = 100 // ms between each request
const MAX_CONCURRENT_REQUESTS = 10
const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersList?: string[] }) => {
const [proxies, setProxies] = useState<readonly string[]>(localStorage['proxies'] ? JSON.parse(localStorage['proxies']) : getInitialProxies())
const [selectedProxy, setSelectedProxy] = useState(proxyQs ?? localStorage.getItem('selectedProxy') ?? proxies?.[0] ?? '')
const [serverEditScreen, setServerEditScreen] = useState<StoreServerItem | true | null>(null) // true for add
const [defaultUsername, _setDefaultUsername] = useState(localStorage['username'] ?? (`mcrafter${Math.floor(Math.random() * 1000)}`))
const [authenticatedAccounts, _setAuthenticatedAccounts] = useState<AuthenticatedAccount[]>(JSON.parse(localStorage['authenticatedAccounts'] || '[]'))
const { authenticatedAccounts } = useSnapshot(appStorage)
const [quickConnectIp, setQuickConnectIp] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
// Save username to localStorage when component mounts if it doesn't exist
useEffect(() => {
if (!localStorage['username']) {
localStorage.setItem('username', defaultUsername)
}
}, [])
const { serversList: savedServersList } = useSnapshot(appStorage)
const setAuthenticatedAccounts = (newState: typeof authenticatedAccounts) => {
_setAuthenticatedAccounts(newState)
localStorage.setItem('authenticatedAccounts', JSON.stringify(newState))
}
const serversListDisplay = useMemo(() => {
return (
customServersList
? customServersList.map((row): StoreServerItem => {
const [ip, name] = row.split(' ')
const [_ip, _port, version] = ip.split(':')
return {
ip,
versionOverride: version,
name,
}
})
: [...getInitialServersList()]
)
}, [customServersList, savedServersList])
const setDefaultUsername = (newState: typeof defaultUsername) => {
_setDefaultUsername(newState)
localStorage.setItem('username', newState)
}
const saveNewProxy = () => {
if (!selectedProxy || proxyQs) return
localStorage.setItem('selectedProxy', selectedProxy)
}
useEffect(() => {
if (proxies.length) {
localStorage.setItem('proxies', JSON.stringify(proxies))
}
saveNewProxy()
}, [proxies])
const [serversList, setServersList] = useState<StoreServerItem[]>(() => (customServersList ? [] : getInitialServersList()))
const [additionalData, setAdditionalData] = useState<Record<string, AdditionalDisplayData>>({})
const [additionalServerData, setAdditionalServerData] = useState<Record<string, AdditionalDisplayData>>({})
// Add keyboard handler for moving servers
useEffect(() => {
@ -92,49 +65,31 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
if (['input', 'textarea', 'select'].includes((e.target as HTMLElement)?.tagName?.toLowerCase())) return
if (!e.shiftKey || selectedIndex === undefined) return
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
if (customServersList) return
e.preventDefault()
e.stopImmediatePropagation()
const newIndex = e.key === 'ArrowUp'
? Math.max(0, selectedIndex - 1)
: Math.min(serversList.length - 1, selectedIndex + 1)
: Math.min(serversListDisplay.length - 1, selectedIndex + 1)
if (newIndex === selectedIndex) return
// Move server in the list
const newList = [...serversList]
const newList = [...serversListDisplay]
const oldItem = newList[selectedIndex]
newList[selectedIndex] = newList[newIndex]
newList[newIndex] = oldItem
setServersList(newList)
appStorage.serversList = newList
setSelectedIndex(newIndex)
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [selectedIndex, serversList])
}, [selectedIndex, serversListDisplay])
useEffect(() => {
if (customServersList) {
setServersList(customServersList.map(row => {
const [ip, name] = row.split(' ')
const [_ip, _port, version] = ip.split(':')
return {
ip,
versionOverride: version,
name,
}
}))
}
}, [customServersList])
useDidUpdateEffect(() => {
// save data only on user changes
setNewServersList(serversList)
}, [serversList])
const serversListSorted = useMemo(() => serversList.map((server, index) => ({ ...server, index })), [serversList])
const serversListSorted = useMemo(() => serversListDisplay.map((server, index) => ({ ...server, index })), [serversListDisplay])
// by lastJoined
// const serversListSorted = useMemo(() => {
// return serversList.map((server, index) => ({ ...server, index })).sort((a, b) => (b.lastJoined ?? 0) - (a.lastJoined ?? 0))
@ -185,7 +140,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
data = await fetchServerStatus(server.ip/* , signal */) // DONT ADD SIGNAL IT WILL CRUSH JS RUNTIME
}
if (data) {
setAdditionalData(old => ({
setAdditionalServerData(old => ({
...old,
[server.ip]: data
}))
@ -224,7 +179,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
}, [isEditScreenModal])
useCopyKeybinding(() => {
const item = serversList[selectedIndex]
const item = serversListDisplay[selectedIndex]
if (!item) return
let str = `${item.ip}`
if (item.versionOverride) {
@ -236,8 +191,8 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
const editModalJsx = isEditScreenModal ? <AddServerOrConnect
allowAutoConnect={miscUiState.appConfig?.allowAutoConnect}
placeholders={{
proxyOverride: selectedProxy,
usernameOverride: defaultUsername,
proxyOverride: getCurrentProxy(),
usernameOverride: getCurrentUsername(),
}}
parseQs={!serverEditScreen}
onBack={() => {
@ -247,12 +202,13 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
if (!serverEditScreen) return
if (serverEditScreen === true) {
const server: StoreServerItem = { ...info, lastJoined: Date.now() } // so it appears first
setServersList(old => [...old, server])
appStorage.serversList = [...(appStorage.serversList ?? []), server]
} else {
const index = serversList.indexOf(serverEditScreen)
const { lastJoined } = serversList[index]
serversList[index] = { ...info, lastJoined }
setServersList([...serversList])
const index = appStorage.serversList?.indexOf(serverEditScreen)
if (index !== undefined) {
const { lastJoined } = appStorage.serversList![index]
appStorage.serversList![index] = { ...info, lastJoined }
}
}
setServerEditScreen(null)
}}
@ -262,9 +218,9 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
} : serverEditScreen}
onQsConnect={(info) => {
const connectOptions: ConnectOptions = {
username: info.usernameOverride || defaultUsername,
username: info.usernameOverride || getCurrentUsername() || '',
server: normalizeIp(info.ip),
proxy: info.proxyOverride || selectedProxy,
proxy: info.proxyOverride || getCurrentProxy(),
botVersion: info.versionOverride,
ignoreQs: true,
}
@ -304,11 +260,11 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
}
const lastJoinedUsername = serversListSorted.find(s => s.usernameOverride)?.usernameOverride
let username = overrides.usernameOverride || defaultUsername
let username = overrides.usernameOverride || getCurrentUsername() || ''
if (!username) {
username = prompt('Username', lastJoinedUsername || '')
if (!username) return
setDefaultUsername(username)
const promptUsername = prompt('Enter username', lastJoinedUsername || '')
if (!promptUsername) return
username = promptUsername
}
let authenticatedAccount: AuthenticatedAccount | true | undefined
if (overrides.authenticatedAccountOverride) {
@ -321,15 +277,15 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
const options = {
username,
server: normalizeIp(ip),
proxy: overrides.proxyOverride || selectedProxy,
proxy: overrides.proxyOverride || getCurrentProxy(),
botVersion: overrides.versionOverride ?? /* legacy */ overrides['version'],
ignoreQs: true,
autoLoginPassword: server?.autoLogin?.[username],
authenticatedAccount,
saveServerToHistory: shouldSave,
onSuccessfulPlay () {
if (shouldSave && !serversList.some(s => s.ip === ip)) {
const newServersList: StoreServerItem[] = [...serversList, {
if (shouldSave && !serversListDisplay.some(s => s.ip === ip)) {
const newServersList: StoreServerItem[] = [...serversListDisplay, {
ip,
lastJoined: Date.now(),
versionOverride: overrides.versionOverride,
@ -341,10 +297,10 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
if (shouldSave === undefined) { // loading saved
// find and update
const server = serversList.find(s => s.ip === ip)
const server = serversListDisplay.find(s => s.ip === ip)
if (server) {
// move to top
const newList = [...serversList]
const newList = [...serversListDisplay]
const index = newList.indexOf(server)
const thisItem = newList[index]
newList.splice(index, 1)
@ -352,40 +308,31 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
server.lastJoined = Date.now()
server.numConnects = (server.numConnects || 0) + 1
setNewServersList(serversList)
setNewServersList(newList)
}
}
// save new selected proxy (if new)
if (!proxies.includes(selectedProxy)) {
// setProxies([...proxies, selectedProxy])
localStorage.setItem('proxies', JSON.stringify([...proxies, selectedProxy]))
}
saveNewProxy()
},
serverIndex: shouldSave ? serversList.length.toString() : indexOrIp // assume last
serverIndex: shouldSave ? serversListDisplay.length.toString() : indexOrIp // assume last
} satisfies ConnectOptions
dispatchEvent(new CustomEvent('connect', { detail: options }))
// qsOptions
}}
lockedEditing={!!customServersList}
username={defaultUsername}
setUsername={setDefaultUsername}
setQuickConnectIp={setQuickConnectIp}
onProfileClick={async () => {
const username = await showOptionsModal('Select authenticated account to remove', authenticatedAccounts.map(a => a.username))
if (!username) return
setAuthenticatedAccounts(authenticatedAccounts.filter(a => a.username !== username))
appStorage.authenticatedAccounts = authenticatedAccounts.filter(a => a.username !== username)
}}
onWorldAction={(action, index) => {
const server = serversList[index]
const server = serversListDisplay[index]
if (!server) return
if (action === 'edit') {
setServerEditScreen(server)
}
if (action === 'delete') {
setServersList(old => old.filter(s => s !== server))
appStorage.serversList = appStorage.serversList!.filter(s => s !== server)
}
}}
onGeneralAction={(action) => {
@ -397,7 +344,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
}
}}
worldData={serversListSorted.map(server => {
const additional = additionalData[server.ip]
const additional = additionalServerData[server.ip]
return {
name: server.index.toString(),
title: server.name || server.ip,
@ -410,27 +357,11 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
group: 'Your Servers'
}
})}
initialProxies={{
proxies,
selected: selectedProxy,
}}
updateProxies={({ proxies, selected }) => {
// new proxy is saved in joinServer
setProxies(proxies)
setSelectedProxy(selected)
}}
hidden={hidden}
onRowSelect={(_, i) => {
setSelectedIndex(i)
}}
selectedRow={selectedIndex}
serverHistory={getServerConnectionHistory()
.sort((a, b) => b.numConnects - a.numConnects)
.map(server => ({
ip: server.ip,
versionOverride: server.version,
numConnects: server.numConnects
}))}
/>
return <>
{serversListJsx}

View file

@ -0,0 +1,135 @@
import { proxy, ref, subscribe } from 'valtio'
import { UserOverridesConfig } from 'contro-max/build/types/store'
import { subscribeKey } from 'valtio/utils'
import { CustomCommand } from './KeybindingsCustom'
import { AuthenticatedAccount } from './serversStorage'
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:' : ''
export interface SavedProxiesData {
proxies: string[]
selected: string
}
export interface ServerHistoryEntry {
ip: string
version?: string
numConnects: number
lastConnected: number
}
export interface StoreServerItem extends BaseServerInfo {
lastJoined?: number
description?: string
optionsOverride?: Record<string, any>
autoLogin?: Record<string, string>
numConnects?: number // Track number of connections
}
type StorageData = {
customCommands: Record<string, CustomCommand> | undefined
username: string | undefined
keybindings: UserOverridesConfig | undefined
options: any
proxiesData: SavedProxiesData | undefined
serversHistory: ServerHistoryEntry[]
authenticatedAccounts: AuthenticatedAccount[]
serversList: StoreServerItem[] | undefined
}
const oldKeysAliases: Partial<Record<keyof StorageData, string>> = {
serversHistory: 'serverConnectionHistory',
}
const migrateLegacyData = () => {
const proxies = localStorage.getItem('proxies')
const selectedProxy = localStorage.getItem('selectedProxy')
if (proxies && selectedProxy) {
appStorage.proxiesData = {
proxies: JSON.parse(proxies),
selected: selectedProxy,
}
}
const username = localStorage.getItem('username')
if (username && !username.startsWith('"')) {
appStorage.username = username
}
const serversHistoryLegacy = localStorage.getItem('serverConnectionHistory')
if (serversHistoryLegacy) {
appStorage.serversHistory = JSON.parse(serversHistoryLegacy)
}
localStorage.removeItem('proxies')
localStorage.removeItem('selectedProxy')
localStorage.removeItem('serverConnectionHistory')
}
const defaultStorageData: StorageData = {
customCommands: undefined,
username: undefined,
keybindings: undefined,
options: {},
proxiesData: undefined,
serversHistory: [],
authenticatedAccounts: [],
serversList: undefined,
}
export const setStorageDataOnAppConfigLoad = () => {
appStorage.username ??= `mcrafter${Math.floor(Math.random() * 1000)}`
}
export const appStorage = proxy({ ...defaultStorageData })
window.appStorage = appStorage
// Restore data from localStorage
for (const key of Object.keys(defaultStorageData)) {
const prefixedKey = `${localStoragePrefix}${key}`
const aliasedKey = oldKeysAliases[key]
const storedValue = localStorage.getItem(prefixedKey) ?? (aliasedKey ? localStorage.getItem(aliasedKey) : undefined)
if (storedValue) {
try {
const parsed = JSON.parse(storedValue)
// appStorage[key] = parsed && typeof parsed === 'object' ? ref(parsed) : parsed
appStorage[key] = parsed
} catch (e) {
console.error(`Failed to parse stored value for ${key}:`, e)
}
}
}
const saveKey = (key: keyof StorageData) => {
const prefixedKey = `${localStoragePrefix}${key}`
const value = appStorage[key]
if (value === undefined) {
localStorage.removeItem(prefixedKey)
} else {
localStorage.setItem(prefixedKey, JSON.stringify(value))
}
}
subscribe(appStorage, (ops) => {
for (const op of ops) {
const [type, path, value] = op
const key = path[0]
saveKey(key as keyof StorageData)
}
})
// Subscribe to changes and save to localStorage
export const resetAppStorage = () => {
for (const key of Object.keys(appStorage)) {
appStorage[key as keyof StorageData] = defaultStorageData[key as keyof StorageData]
}
for (const key of Object.keys(localStorage)) {
if (key.startsWith(localStoragePrefix)) {
localStorage.removeItem(key)
}
}
}
migrateLegacyData()

View file

@ -1,16 +1,10 @@
import { appQueryParams } from '../appParams'
import { miscUiState } from '../globalState'
import { BaseServerInfo } from './AddServerOrConnect'
import { appStorage, StoreServerItem } from './appStorageProvider'
const serversListQs = appQueryParams.serversList
export interface StoreServerItem extends BaseServerInfo {
lastJoined?: number
description?: string
optionsOverride?: Record<string, any>
autoLogin?: Record<string, string>
numConnects?: number // Track number of connections
}
export interface AuthenticatedAccount {
// type: 'microsoft'
username: string
@ -19,6 +13,7 @@ export interface AuthenticatedAccount {
expiresOn: number
}
}
export interface ServerConnectionHistory {
ip: string
numConnects: number
@ -28,7 +23,7 @@ export interface ServerConnectionHistory {
export function updateServerConnectionHistory (ip: string, version?: string) {
try {
const history: ServerConnectionHistory[] = JSON.parse(localStorage.getItem('serverConnectionHistory') || '[]')
const history = [...(appStorage.serversHistory ?? [])]
const existingServer = history.find(s => s.ip === ip)
if (existingServer) {
existingServer.numConnects++
@ -42,53 +37,35 @@ export function updateServerConnectionHistory (ip: string, version?: string) {
version
})
}
localStorage.setItem('serverConnectionHistory', JSON.stringify(history))
appStorage.serversHistory = history
} catch (err) {
console.error('Failed to update server connection history:', err)
}
}
export const updateLoadedServerData = (callback: (data: StoreServerItem) => StoreServerItem, index = miscUiState.loadedServerIndex) => {
if (!index) index = miscUiState.loadedServerIndex
if (!index) return
// function assumes component is not mounted to avoid sync issues after save
const servers = getInitialServersList()
const servers = [...(appStorage.serversList ?? [])]
const server = servers[index]
servers[index] = callback(server)
setNewServersList(servers)
}
export const setNewServersList = (serversList: StoreServerItem[], force = false) => {
if (serversListQs && !force) return
localStorage['serversList'] = JSON.stringify(serversList)
// cleanup legacy
localStorage.removeItem('serverHistory')
localStorage.removeItem('server')
localStorage.removeItem('password')
localStorage.removeItem('version')
appStorage.serversList = serversList
}
export const getInitialServersList = () => {
if (localStorage['serversList']) return JSON.parse(localStorage['serversList']) as StoreServerItem[]
// If we already have servers in appStorage, use those
if (appStorage.serversList) return appStorage.serversList
const servers = [] as StoreServerItem[]
const legacyServersList = localStorage['serverHistory'] ? JSON.parse(localStorage['serverHistory']) as string[] : null
if (legacyServersList) {
for (const server of legacyServersList) {
if (!server || localStorage['server'] === server) continue
servers.push({ ip: server, lastJoined: Date.now() })
}
}
if (localStorage['server']) {
const legacyLastJoinedServer: StoreServerItem = {
ip: localStorage['server'],
versionOverride: localStorage['version'],
lastJoined: Date.now()
}
servers.push(legacyLastJoinedServer)
}
if (servers.length === 0) { // server list is empty, let's suggest some
if (servers.length === 0) {
// server list is empty, let's suggest some
for (const server of miscUiState.appConfig?.promoteServers ?? []) {
servers.push({
ip: server.ip,
@ -100,16 +77,13 @@ export const getInitialServersList = () => {
return servers
}
export const updateAuthenticatedAccountData = (callback: (data: AuthenticatedAccount[]) => AuthenticatedAccount[]) => {
const accounts = JSON.parse(localStorage['authenticatedAccounts'] || '[]') as AuthenticatedAccount[]
const accounts = appStorage.authenticatedAccounts
const newAccounts = callback(accounts)
localStorage['authenticatedAccounts'] = JSON.stringify(newAccounts)
appStorage.authenticatedAccounts = newAccounts
}
export function getServerConnectionHistory (): ServerConnectionHistory[] {
try {
return JSON.parse(localStorage.getItem('serverConnectionHistory') || '[]')
} catch {
return []
}
return appStorage.serversHistory ?? []
}

View file

@ -1,13 +0,0 @@
import { CustomCommand } from './KeybindingsCustom'
type StorageData = {
customCommands: Record<string, CustomCommand>
// ...
}
export const getStoredValue = <T extends keyof StorageData> (name: T): StorageData[T] | undefined => {
return localStorage[name] ? JSON.parse(localStorage[name]) : undefined
}
export const setStoredValue = <T extends keyof StorageData> (name: T, value: StorageData[T]) => {
localStorage[name] = JSON.stringify(value)
}