From eedd9f1b8f11ee21797d23f87aebbf591e95f29d Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 27 Jun 2025 16:28:15 +0300 Subject: [PATCH 1/9] feat: Now settings and servers list synced via top-domain cookies! Eg different subdomains like s.mcraft.fun and mcraft.fun will now share the same settings! Can be disabled. feat: Now its possible to import data! --- rsbuild.config.ts | 1 + src/core/importExport.ts | 219 +++++++++++++++++++++ src/defaultOptions.ts | 152 +++++++++++++++ src/globalState.ts | 2 + src/optionsGuiScheme.tsx | 76 +++----- src/optionsStorage.ts | 165 +--------------- src/react/StorageConflictModal.tsx | 94 +++++++++ src/react/appStorageProvider.ts | 294 +++++++++++++++++++++++++++-- src/reactUi.tsx | 2 + 9 files changed, 780 insertions(+), 225 deletions(-) create mode 100644 src/core/importExport.ts create mode 100644 src/defaultOptions.ts create mode 100644 src/react/StorageConflictModal.tsx diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 5e76646e..548be4e2 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -140,6 +140,7 @@ const appConfig = defineConfig({ 'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog), 'process.env.DISABLE_SERVICE_WORKER': JSON.stringify(disableServiceWorker), 'process.env.INLINED_APP_CONFIG': JSON.stringify(configSource === 'BUNDLED' ? configJson : null), + 'process.env.ENABLE_COOKIE_STORAGE': JSON.stringify(process.env.ENABLE_COOKIE_STORAGE || true), }, }, server: { diff --git a/src/core/importExport.ts b/src/core/importExport.ts new file mode 100644 index 00000000..b3e26347 --- /dev/null +++ b/src/core/importExport.ts @@ -0,0 +1,219 @@ +import { appStorage } from '../react/appStorageProvider' +import { getChangedSettings, options } from '../optionsStorage' +import { customKeymaps } from '../controls' +import { showInputsModal } from '../react/SelectOption' + +interface ExportedFile { + _about: string + options?: Record + keybindings?: Record + servers?: any[] + username?: string + proxy?: string + proxies?: string[] + accountTokens?: any[] +} + +export const importData = async () => { + try { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.json' + input.click() + + const file = await new Promise((resolve) => { + input.onchange = () => { + if (!input.files?.[0]) return + resolve(input.files[0]) + } + }) + + const text = await file.text() + const data = JSON.parse(text) + + if (!data._about?.includes('Minecraft Web Client')) { + const doContinue = confirm('This file does not appear to be a Minecraft Web Client profile. Continue anyway?') + if (!doContinue) return + } + + // Build available data types for selection + const availableData: Record, { present: boolean, description: string }> = { + options: { present: !!data.options, description: 'Game settings and preferences' }, + keybindings: { present: !!data.keybindings, description: 'Custom key mappings' }, + servers: { present: !!data.servers, description: 'Saved server list' }, + username: { present: !!data.username, description: 'Username' }, + proxy: { present: !!data.proxy, description: 'Selected proxy server' }, + proxies: { present: !!data.proxies, description: 'Global proxies list' }, + accountTokens: { present: !!data.accountTokens, description: 'Account authentication tokens' }, + } + + // Filter to only present data types + const presentTypes = Object.fromEntries(Object.entries(availableData) + .filter(([_, info]) => info.present) + .map(([key, info]) => [key, info])) + + if (Object.keys(presentTypes).length === 0) { + alert('No compatible data found in the imported file.') + return + } + + const importChoices = await showInputsModal('Select Data to Import', { + mergeData: { + type: 'checkbox', + label: 'Merge with existing data (uncheck to remove old data)', + defaultValue: true, + }, + ...Object.fromEntries(Object.entries(presentTypes).map(([key, info]) => [key, { + type: 'checkbox', + label: info.description, + defaultValue: true, + }])) + }) as { mergeData: boolean } & Record + + if (!importChoices) return + + const importedTypes: string[] = [] + const shouldMerge = importChoices.mergeData + + if (importChoices.options && data.options) { + if (shouldMerge) { + Object.assign(options, data.options) + } else { + for (const key of Object.keys(options)) { + if (key in data.options) { + options[key as any] = data.options[key] + } + } + } + importedTypes.push('settings') + } + + if (importChoices.keybindings && data.keybindings) { + if (shouldMerge) { + Object.assign(customKeymaps, data.keybindings) + } else { + for (const key of Object.keys(customKeymaps)) delete customKeymaps[key] + Object.assign(customKeymaps, data.keybindings) + } + importedTypes.push('keybindings') + } + + if (importChoices.servers && data.servers) { + if (shouldMerge && appStorage.serversList) { + // Merge by IP, update existing entries and add new ones + const existingIps = new Set(appStorage.serversList.map(s => s.ip)) + const newServers = data.servers.filter(s => !existingIps.has(s.ip)) + appStorage.serversList = [...appStorage.serversList, ...newServers] + } else { + appStorage.serversList = data.servers + } + importedTypes.push('servers') + } + + if (importChoices.username && data.username) { + appStorage.username = data.username + importedTypes.push('username') + } + + if ((importChoices.proxy && data.proxy) || (importChoices.proxies && data.proxies)) { + if (!appStorage.proxiesData) { + appStorage.proxiesData = { proxies: [], selected: '' } + } + + if (importChoices.proxies && data.proxies) { + if (shouldMerge) { + // Merge unique proxies + const uniqueProxies = new Set([...appStorage.proxiesData.proxies, ...data.proxies]) + appStorage.proxiesData.proxies = [...uniqueProxies] + } else { + appStorage.proxiesData.proxies = data.proxies + } + importedTypes.push('proxies list') + } + + if (importChoices.proxy && data.proxy) { + appStorage.proxiesData.selected = data.proxy + importedTypes.push('selected proxy') + } + } + + if (importChoices.accountTokens && data.accountTokens) { + if (shouldMerge && appStorage.authenticatedAccounts) { + // Merge by unique identifier (assuming accounts have some unique ID or username) + const existingAccounts = new Set(appStorage.authenticatedAccounts.map(a => a.username)) + const newAccounts = data.accountTokens.filter(a => !existingAccounts.has(a.username)) + appStorage.authenticatedAccounts = [...appStorage.authenticatedAccounts, ...newAccounts] + } else { + appStorage.authenticatedAccounts = data.accountTokens + } + importedTypes.push('account tokens') + } + + alert(`Profile imported successfully! Imported data: ${importedTypes.join(', ')}.\nYou may need to reload the page for some changes to take effect.`) + } catch (err) { + console.error('Failed to import profile:', err) + alert('Failed to import profile: ' + (err.message || err)) + } +} + +export const exportData = 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, + }, + exportGlobalProxiesList: { + type: 'checkbox', + defaultValue: false, + }, + exportAccountTokens: { + type: 'checkbox', + defaultValue: false, + }, + }) + const fileName = `${data.profileName ? `${data.profileName}-` : ''}web-client-profile.json` + const json: ExportedFile = { + _about: 'Minecraft Web Client (mcraft.fun) Profile', + ...data.exportSettings ? { + options: getChangedSettings(), + } : {}, + ...data.exportKeybindings ? { + keybindings: customKeymaps, + } : {}, + ...data.exportServers ? { + servers: appStorage.serversList, + } : {}, + ...data.saveUsernameAndProxy ? { + username: appStorage.username, + proxy: appStorage.proxiesData?.selected, + } : {}, + ...data.exportGlobalProxiesList ? { + proxies: appStorage.proxiesData?.proxies, + } : {}, + ...data.exportAccountTokens ? { + accountTokens: appStorage.authenticatedAccounts, + } : {}, + } + 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) +} diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts new file mode 100644 index 00000000..d2d510ec --- /dev/null +++ b/src/defaultOptions.ts @@ -0,0 +1,152 @@ +export const defaultOptions = { + renderDistance: 3, + keepChunksDistance: 1, + multiplayerRenderDistance: 3, + closeConfirmation: true, + autoFullScreen: false, + mouseRawInput: true, + autoExitFullscreen: false, + localUsername: 'wanderer', + mouseSensX: 50, + mouseSensY: -1, + chatWidth: 320, + chatHeight: 180, + chatScale: 100, + chatOpacity: 100, + chatOpacityOpened: 100, + messagesLimit: 200, + volume: 50, + enableMusic: false, + // fov: 70, + fov: 75, + guiScale: 3, + autoRequestCompletions: true, + touchButtonsSize: 40, + touchButtonsOpacity: 80, + touchButtonsPosition: 12, + touchControlsPositions: getDefaultTouchControlsPositions(), + touchControlsSize: getTouchControlsSize(), + touchMovementType: 'modern' as 'modern' | 'classic', + touchInteractionType: 'classic' as 'classic' | 'buttons', + gpuPreference: 'default' as 'default' | 'high-performance' | 'low-power', + backgroundRendering: '20fps' as 'full' | '20fps' | '5fps', + /** @unstable */ + disableAssets: false, + /** @unstable */ + debugLogNotFrequentPackets: false, + unimplementedContainers: false, + dayCycleAndLighting: true, + loadPlayerSkins: true, + renderEars: true, + lowMemoryMode: false, + starfieldRendering: true, + enabledResourcepack: null as string | null, + useVersionsTextures: 'latest', + serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never', + showHand: true, + viewBobbing: true, + displayRecordButton: true, + packetsLoggerPreset: 'all' as 'all' | 'no-buffers', + serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string, + customChannels: false, + remoteContentNotSameOrigin: false as boolean | string[], + packetsRecordingAutoStart: false, + language: 'auto', + preciseMouseInput: false, + // 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, + debugChatScroll: false, + chatVanillaRestrictions: true, + debugResponseTimeIndicator: false, + chatPingExtension: true, + // antiAliasing: false, + topRightTimeDisplay: 'only-fullscreen' as 'only-fullscreen' | 'always' | 'never', + + clipWorldBelowY: undefined as undefined | number, // will be removed + disableSignsMapsSupport: false, + singleplayerAutoSave: false, + showChunkBorders: false, // todo rename option + frameLimit: false as number | false, + alwaysBackupWorldBeforeLoading: undefined as boolean | undefined | null, + alwaysShowMobileControls: false, + excludeCommunicationDebugEvents: [], + preventDevReloadWhilePlaying: false, + numWorkers: 4, + localServerOptions: { + gameMode: 1 + } as any, + preferLoadReadonly: false, + disableLoadPrompts: false, + guestUsername: 'guest', + askGuestName: true, + errorReporting: true, + /** Actually might be useful */ + showCursorBlockInSpectator: false, + renderEntities: true, + smoothLighting: true, + newVersionsLighting: false, + chatSelect: true, + autoJump: 'auto' as 'auto' | 'always' | 'never', + autoParkour: false, + vrSupport: true, // doesn't directly affect the VR mode, should only disable the button which is annoying to android users + vrPageGameRendering: false, + renderDebug: 'basic' as 'none' | 'advanced' | 'basic', + rendererPerfDebugOverlay: false, + + // advanced bot options + autoRespawn: false, + mutedSounds: [] as string[], + plugins: [] as Array<{ enabled: boolean, name: string, description: string, script: string }>, + /** Wether to popup sign editor on server action */ + autoSignEditor: true, + wysiwygSignEditor: 'auto' as 'auto' | 'always' | 'never', + showMinimap: 'never' as 'always' | 'singleplayer' | 'never', + minimapOptimizations: true, + displayBossBars: true, + disabledUiParts: [] as string[], + neighborChunkUpdates: true, + highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic', + activeRenderer: 'threejs', + rendererSharedOptions: { + _experimentalSmoothChunkLoading: true, + _renderByChunks: false + } +} + +function getDefaultTouchControlsPositions () { + return { + action: [ + 70, + 76 + ], + sneak: [ + 84, + 76 + ], + break: [ + 70, + 57 + ], + jump: [ + 84, + 57 + ], + } as Record +} + +function getTouchControlsSize () { + return { + joystick: 55, + action: 36, + break: 36, + jump: 36, + sneak: 36, + } +} diff --git a/src/globalState.ts b/src/globalState.ts index 671d7907..b8982de7 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -46,6 +46,8 @@ export const showModal = (elem: /* (HTMLElement & Record) | */{ re activeModalStack.push(resolved) } +window.showModal = showModal + /** * * @returns true if previous modal was restored diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 3409dc76..ba52e333 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -20,6 +20,7 @@ import { getVersionAutoSelect } from './connect' import { createNotificationProgressReporter } from './core/progressReporter' import { customKeymaps } from './controls' import { appStorage } from './react/appStorageProvider' +import { exportData, importData } from './core/importExport' export const guiOptionsScheme: { [t in OptionsGroupType]: Array<{ [K in keyof AppOptions]?: Partial> } & { custom? }> @@ -532,6 +533,30 @@ export const guiOptionsScheme: { return } }, @@ -646,53 +670,7 @@ export const guiOptionsScheme: { custom () { return } }, diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 882610f8..22d5ef26 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -5,161 +5,10 @@ import { appQueryParams, appQueryParamsArray } from './appParams' import type { AppConfig } from './appConfig' import { appStorage } from './react/appStorageProvider' import { miscUiState } from './globalState' +import { defaultOptions } from './defaultOptions' const isDev = process.env.NODE_ENV === 'development' const initialAppConfig = process.env?.INLINED_APP_CONFIG as AppConfig ?? {} -const defaultOptions = { - renderDistance: 3, - keepChunksDistance: 1, - multiplayerRenderDistance: 3, - closeConfirmation: true, - autoFullScreen: false, - mouseRawInput: true, - autoExitFullscreen: false, - localUsername: 'wanderer', - mouseSensX: 50, - mouseSensY: -1, - chatWidth: 320, - chatHeight: 180, - chatScale: 100, - chatOpacity: 100, - chatOpacityOpened: 100, - messagesLimit: 200, - volume: 50, - enableMusic: false, - // fov: 70, - fov: 75, - guiScale: 3, - autoRequestCompletions: true, - touchButtonsSize: 40, - touchButtonsOpacity: 80, - touchButtonsPosition: 12, - touchControlsPositions: getDefaultTouchControlsPositions(), - touchControlsSize: getTouchControlsSize(), - touchMovementType: 'modern' as 'modern' | 'classic', - touchInteractionType: 'classic' as 'classic' | 'buttons', - gpuPreference: 'default' as 'default' | 'high-performance' | 'low-power', - backgroundRendering: '20fps' as 'full' | '20fps' | '5fps', - /** @unstable */ - disableAssets: false, - /** @unstable */ - debugLogNotFrequentPackets: false, - unimplementedContainers: false, - dayCycleAndLighting: true, - loadPlayerSkins: true, - renderEars: true, - lowMemoryMode: false, - starfieldRendering: true, - enabledResourcepack: null as string | null, - useVersionsTextures: 'latest', - serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never', - showHand: true, - viewBobbing: true, - displayRecordButton: true, - packetsLoggerPreset: 'all' as 'all' | 'no-buffers', - serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string, - customChannels: false, - remoteContentNotSameOrigin: false as boolean | string[], - packetsRecordingAutoStart: false, - language: 'auto', - preciseMouseInput: false, - // 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, - debugChatScroll: false, - chatVanillaRestrictions: true, - debugResponseTimeIndicator: false, - chatPingExtension: true, - // antiAliasing: false, - topRightTimeDisplay: 'only-fullscreen' as 'only-fullscreen' | 'always' | 'never', - - clipWorldBelowY: undefined as undefined | number, // will be removed - disableSignsMapsSupport: false, - singleplayerAutoSave: false, - showChunkBorders: false, // todo rename option - frameLimit: false as number | false, - alwaysBackupWorldBeforeLoading: undefined as boolean | undefined | null, - alwaysShowMobileControls: false, - excludeCommunicationDebugEvents: [], - preventDevReloadWhilePlaying: false, - numWorkers: 4, - localServerOptions: { - gameMode: 1 - } as any, - preferLoadReadonly: false, - disableLoadPrompts: false, - guestUsername: 'guest', - askGuestName: true, - errorReporting: true, - /** Actually might be useful */ - showCursorBlockInSpectator: false, - renderEntities: true, - smoothLighting: true, - newVersionsLighting: false, - chatSelect: true, - autoJump: 'auto' as 'auto' | 'always' | 'never', - autoParkour: false, - vrSupport: true, // doesn't directly affect the VR mode, should only disable the button which is annoying to android users - vrPageGameRendering: false, - renderDebug: 'basic' as 'none' | 'advanced' | 'basic', - rendererPerfDebugOverlay: false, - - // advanced bot options - autoRespawn: false, - mutedSounds: [] as string[], - plugins: [] as Array<{ enabled: boolean, name: string, description: string, script: string }>, - /** Wether to popup sign editor on server action */ - autoSignEditor: true, - wysiwygSignEditor: 'auto' as 'auto' | 'always' | 'never', - showMinimap: 'never' as 'always' | 'singleplayer' | 'never', - minimapOptimizations: true, - displayBossBars: true, - disabledUiParts: [] as string[], - neighborChunkUpdates: true, - highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic', - activeRenderer: 'threejs', - rendererSharedOptions: { - _experimentalSmoothChunkLoading: true, - _renderByChunks: false - } -} - -function getDefaultTouchControlsPositions () { - return { - action: [ - 70, - 76 - ], - sneak: [ - 84, - 76 - ], - break: [ - 70, - 57 - ], - jump: [ - 84, - 57 - ], - } as Record -} - -function getTouchControlsSize () { - return { - joystick: 55, - action: 36, - break: 36, - jump: 36, - sneak: 36, - } -} // const qsOptionsRaw = new URLSearchParams(location.search).getAll('setting') const qsOptionsRaw = appQueryParamsArray.setting ?? [] @@ -191,15 +40,15 @@ const migrateOptions = (options: Partial>) => { return options } const migrateOptionsLocalStorage = () => { - if (Object.keys(appStorage.options).length) { - for (const key of Object.keys(appStorage.options)) { + if (Object.keys(appStorage['options'] ?? {}).length) { + for (const key of Object.keys(appStorage['options'])) { if (!(key in defaultOptions)) continue // drop unknown options const defaultValue = defaultOptions[key] - if (JSON.stringify(defaultValue) !== JSON.stringify(appStorage.options[key])) { - appStorage.changedSettings[key] = appStorage.options[key] + if (JSON.stringify(defaultValue) !== JSON.stringify(appStorage['options'][key])) { + appStorage.changedSettings[key] = appStorage['options'][key] } } - appStorage.options = {} + delete appStorage['options'] } } @@ -311,3 +160,5 @@ export const getAppLanguage = () => { } return options.language } + +export { defaultOptions } from './defaultOptions' diff --git a/src/react/StorageConflictModal.tsx b/src/react/StorageConflictModal.tsx new file mode 100644 index 00000000..9e20ca2d --- /dev/null +++ b/src/react/StorageConflictModal.tsx @@ -0,0 +1,94 @@ +import { useSnapshot } from 'valtio' +import { activeModalStack, hideCurrentModal } from '../globalState' +import { resolveStorageConflicts, getStorageConflicts } from './appStorageProvider' +import { useIsModalActive } from './utilsApp' +import Screen from './Screen' +import Button from './Button' + +const formatTimestamp = (timestamp?: number) => { + if (!timestamp) return 'Unknown time' + return new Date(timestamp).toLocaleString() +} + +export default () => { + const isModalActive = useIsModalActive('storage-conflict') + const conflicts = getStorageConflicts() + + if (!isModalActive/* || conflicts.length === 0 */) return null + + const conflictText = conflicts.map(conflict => { + const localTime = formatTimestamp(conflict.localStorageTimestamp) + const cookieTime = formatTimestamp(conflict.cookieTimestamp) + return `${conflict.key}: LocalStorage (${localTime}) vs Cookie (${cookieTime})` + }).join('\n') + + return ( +
+
+
+ Data Conflict Found +
+ +
+ You have conflicting data between localStorage (old) and cookies (new, domain-synced) for the following settings: + {'\n\n'} + {conflictText} + {'\n\n'} + Please choose which version to keep: +
+ +
+
{ + resolveStorageConflicts(true) // Use localStorage + hideCurrentModal() + }} + style={{ + border: '1px solid #654321', + padding: '8px 16px', + cursor: 'pointer' + }} + > + Use Local Storage & Disable Cookie Sync +
+ +
{ + resolveStorageConflicts(false) // Use cookies + hideCurrentModal() + }} + style={{ + border: '1px solid #654321', + padding: '8px 16px', + cursor: 'pointer' + }} + > + Use Cookie Data & Remove Local Data +
+
+
+
+ ) +} diff --git a/src/react/appStorageProvider.ts b/src/react/appStorageProvider.ts index 499ec71c..bee8e408 100644 --- a/src/react/appStorageProvider.ts +++ b/src/react/appStorageProvider.ts @@ -8,7 +8,9 @@ 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 cookiePrefix = '' const { localStorage } = window +const migrateRemoveLocalStorage = false export interface SavedProxiesData { proxies: string[] @@ -31,12 +33,19 @@ export interface StoreServerItem extends BaseServerInfo { isRecommended?: boolean } +interface StorageConflict { + key: string + localStorageValue: any + localStorageTimestamp?: number + cookieValue: any + cookieTimestamp?: number +} + type StorageData = { + cookieStorage: boolean | { ignoreKeys: Array } customCommands: Record | undefined username: string | undefined keybindings: UserOverridesConfig | undefined - /** @deprecated */ - options: any changedSettings: any proxiesData: SavedProxiesData | undefined serversHistory: ServerHistoryEntry[] @@ -46,10 +55,115 @@ type StorageData = { firstModsPageVisit: boolean } +const cookieStoreKeys: Array = [ + 'customCommands', + 'username', + 'keybindings', + 'changedSettings', + 'serversList', +] + const oldKeysAliases: Partial> = { serversHistory: 'serverConnectionHistory', } +// Cookie storage functions +const getCookieValue = (key: string): string | null => { + const cookie = document.cookie.split(';').find(c => c.trimStart().startsWith(`${cookiePrefix}${key}=`)) + if (cookie) { + return decodeURIComponent(cookie.split('=')[1]) + } + return null +} + +const topLevelDomain = window.location.hostname.split('.').slice(-2).join('.') +const cookieBase = `; Domain=.${topLevelDomain}; Path=/; SameSite=Strict; Secure` + +const setCookieValue = (key: string, value: string): boolean => { + try { + const cookieKey = `${cookiePrefix}${key}` + let cookie = `${cookieKey}=${encodeURIComponent(value)}` + cookie += `${cookieBase}; Max-Age=2147483647` + + // Test if cookie exceeds size limit + if (cookie.length > 4096) { + throw new Error(`Cookie size limit exceeded for key '${key}'. Cookie size: ${cookie.length} bytes, limit: 4096 bytes.`) + } + + document.cookie = cookie + return true + } catch (error) { + console.error(`Failed to set cookie for key '${key}':`, error) + window.showNotification(`Failed to save data to cookies: ${error.message}`, 'Consider switching to localStorage in advanced settings.', true) + return false + } +} + +const deleteCookie = (key: string) => { + const cookieKey = `${cookiePrefix}${key}` + document.cookie = `${cookieKey}=; ${cookieBase}; expires=Thu, 01 Jan 1970 00:00:00 UTC;` +} + +// Storage conflict detection and resolution +let storageConflicts: StorageConflict[] = [] + +const detectStorageConflicts = (): StorageConflict[] => { + const conflicts: StorageConflict[] = [] + + for (const key of cookieStoreKeys) { + const localStorageKey = `${localStoragePrefix}${key}` + const localStorageValue = localStorage.getItem(localStorageKey) + const cookieValue = getCookieValue(key) + + if (localStorageValue && cookieValue) { + try { + const localParsed = JSON.parse(localStorageValue) + const cookieParsed = JSON.parse(cookieValue) + + if (localParsed?.migrated) { + continue + } + + // Extract timestamps if they exist + const localTimestamp = localParsed?.timestamp + const cookieTimestamp = cookieParsed?.timestamp + + // Compare the actual data (excluding timestamp) + const localData = localTimestamp ? { ...localParsed } : localParsed + const cookieData = cookieTimestamp ? { ...cookieParsed } : cookieParsed + delete localData.timestamp + delete cookieData.timestamp + + if (JSON.stringify(localData) !== JSON.stringify(cookieData)) { + conflicts.push({ + key, + localStorageValue: localData, + localStorageTimestamp: localTimestamp, + cookieValue: cookieData, + cookieTimestamp + }) + } + } catch (e) { + console.error(`Failed to parse storage values for conflict detection on key '${key}':`, e, localStorageValue, cookieValue) + } + } + } + + return conflicts +} + +const showStorageConflictModal = () => { + // Import showModal dynamically to avoid circular dependency + const showModal = (window as any).showModal || ((modal: any) => { + console.error('Modal system not available:', modal) + console.warn('Storage conflicts detected but modal system not available:', storageConflicts) + }) + + setTimeout(() => { + showModal({ reactType: 'storage-conflict', conflicts: storageConflicts }) + }, 100) +} + const migrateLegacyData = () => { const proxies = localStorage.getItem('proxies') const selectedProxy = localStorage.getItem('selectedProxy') @@ -75,10 +189,10 @@ const migrateLegacyData = () => { } const defaultStorageData: StorageData = { + cookieStorage: !!process.env.ENABLE_COOKIE_STORAGE && !process.env?.SINGLE_FILE_BUILD, customCommands: undefined, username: undefined, keybindings: undefined, - options: {}, changedSettings: {}, proxiesData: undefined, serversHistory: [], @@ -108,29 +222,140 @@ export const getRandomUsername = (appConfig: AppConfig) => { export const appStorage = proxy({ ...defaultStorageData }) -// 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) { +// Check if cookie storage should be used (will be set by options) +const shouldUseCookieStorage = () => { + const isSecureCookiesAvailable = () => { + // either https or localhost + return window.location.protocol === 'https:' || window.location.hostname === 'localhost' + } + if (!isSecureCookiesAvailable()) { + return false + } + + const localStorageValue = localStorage.getItem(`${localStoragePrefix}cookieStorage`) + if (localStorageValue === null) { + return appStorage.cookieStorage === true + } + return localStorageValue === 'true' +} + +// Restore data from storage with conflict detection +const restoreStorageData = () => { + const useCookieStorage = shouldUseCookieStorage() + + if (useCookieStorage) { + // Detect conflicts first + storageConflicts = detectStorageConflicts() + + if (storageConflicts.length > 0) { + // Show conflict resolution modal + showStorageConflictModal() + return // Don't restore data until conflict is resolved + } + } + + for (const key of Object.keys(defaultStorageData)) { + const typedKey = key + const prefixedKey = `${localStoragePrefix}${key}` + const aliasedKey = oldKeysAliases[typedKey] + + let storedValue: string | null = null + let cookieValueCanBeUsed = false + let usingLocalStorageValue = false + + // Try cookie storage first if enabled and key is in cookieStoreKeys + if (useCookieStorage && cookieStoreKeys.includes(typedKey)) { + storedValue = getCookieValue(key) + cookieValueCanBeUsed = true + } + + // Fallback to localStorage if no cookie value found + if (storedValue === null) { + storedValue = localStorage.getItem(prefixedKey) ?? (aliasedKey ? localStorage.getItem(aliasedKey) : null) + usingLocalStorageValue = true + } + + if (storedValue) { + try { + let parsed = JSON.parse(storedValue) + + // Handle timestamped data + if (parsed && typeof parsed === 'object' && parsed.timestamp) { + delete parsed.timestamp + // If it was a wrapped primitive, unwrap it + if ('data' in parsed && Object.keys(parsed).length === 1) { + parsed = parsed.data + } + } + + appStorage[typedKey] = parsed + + if (usingLocalStorageValue && cookieValueCanBeUsed) { + // migrate localStorage to cookie + saveKey(key) + markLocalStorageAsMigrated(key) + } + } catch (e) { + console.error(`Failed to parse stored value for ${key}:`, e) + } + } + } +} + +const markLocalStorageAsMigrated = (key: keyof StorageData) => { + const localStorageKey = `${localStoragePrefix}${key}` + if (migrateRemoveLocalStorage) { + localStorage.removeItem(localStorageKey) + return + } + + const data = localStorage.getItem(localStorageKey) + if (data) { 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) + localStorage.setItem(localStorageKey, JSON.stringify({ ...JSON.parse(data), migrated: Date.now() })) + } catch (err) { } } } const saveKey = (key: keyof StorageData) => { + const useCookieStorage = shouldUseCookieStorage() const prefixedKey = `${localStoragePrefix}${key}` const value = appStorage[key] - if (value === undefined) { - localStorage.removeItem(prefixedKey) - } else { - localStorage.setItem(prefixedKey, JSON.stringify(value)) + + const dataToSave = value === undefined ? undefined : ( + value && typeof value === 'object' && !Array.isArray(value) + ? { ...value, timestamp: Date.now() } + : { data: value, timestamp: Date.now() } + ) + + const serialized = dataToSave === undefined ? undefined : JSON.stringify(dataToSave) + + let useLocalStorage = true + // Save to cookie if enabled and key is in cookieStoreKeys + if (useCookieStorage && cookieStoreKeys.includes(key)) { + useLocalStorage = false + if (serialized === undefined) { + deleteCookie(key) + } else { + const success = setCookieValue(key, serialized) + if (success) { + // Remove from localStorage if cookie save was successful + markLocalStorageAsMigrated(key) + } else { + // Disabling for now so no confusing conflicts modal after page reload + // useLocalStorage = true + } + } + } + + if (useLocalStorage) { + // Save to localStorage + if (value === undefined) { + localStorage.removeItem(prefixedKey) + } else { + localStorage.setItem(prefixedKey, JSON.stringify(value)) + } } } @@ -141,7 +366,6 @@ subscribe(appStorage, (ops) => { saveKey(key as keyof StorageData) } }) -// Subscribe to changes and save to localStorage export const resetAppStorage = () => { for (const key of Object.keys(appStorage)) { @@ -153,6 +377,38 @@ export const resetAppStorage = () => { localStorage.removeItem(key) } } + + if (!shouldUseCookieStorage()) return + const shouldContinue = window.confirm(`Removing all synced cookies will remove all data from all ${topLevelDomain} subdomains websites. Continue?`) + if (!shouldContinue) return + + // Clear cookies + for (const key of cookieStoreKeys) { + deleteCookie(key) + } } +// Export functions for conflict resolution +export const resolveStorageConflicts = (useLocalStorage: boolean) => { + if (useLocalStorage) { + // Disable cookie storage and use localStorage data + appStorage.cookieStorage = false + } else { + // Remove localStorage data and continue using cookie storage + for (const conflict of storageConflicts) { + const prefixedKey = `${localStoragePrefix}${conflict.key}` + localStorage.removeItem(prefixedKey) + } + } + + // Clear conflicts and restore data + storageConflicts = [] + restoreStorageData() +} + +export const getStorageConflicts = () => storageConflicts + migrateLegacyData() + +// Restore data after checking for conflicts +restoreStorageData() diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 8e94b35d..59ed9124 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -65,6 +65,7 @@ import RendererDebugMenu from './react/RendererDebugMenu' import CreditsAboutModal from './react/CreditsAboutModal' import GlobalOverlayHints from './react/GlobalOverlayHints' import FullscreenTime from './react/FullscreenTime' +import StorageConflictModal from './react/StorageConflictModal' const isFirefox = ua.getBrowser().name === 'Firefox' if (isFirefox) { @@ -247,6 +248,7 @@ const App = () => { + From af5a0b2835312945ea251014ba231977070bb70a Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 27 Jun 2025 16:50:44 +0300 Subject: [PATCH 2/9] fix: fix camera desync updates in 3rd view and starfield --- renderer/viewer/lib/worldrendererCommon.ts | 6 +-- renderer/viewer/three/entities.ts | 2 +- renderer/viewer/three/worldrendererThree.ts | 47 +++++++++++++++++---- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index dfa4f43b..4441201c 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -106,7 +106,7 @@ export abstract class WorldRendererCommon }> customTexturesDataUrl = undefined as string | undefined workers: any[] = [] - viewerPosition?: Vec3 + viewerChunkPosition?: Vec3 lastCamUpdate = 0 droppedFpsPercentage = 0 initialChunkLoadWasStartedIn: number | undefined @@ -499,7 +499,7 @@ export abstract class WorldRendererCommon timeUpdated? (newTime: number): void updateViewerPosition (pos: Vec3) { - this.viewerPosition = pos + this.viewerChunkPosition = pos for (const [key, value] of Object.entries(this.loadedChunks)) { if (!value) continue this.updatePosDataChunk?.(key) @@ -513,7 +513,7 @@ export abstract class WorldRendererCommon } getDistance (posAbsolute: Vec3) { - const [botX, botZ] = chunkPos(this.viewerPosition!) + const [botX, botZ] = chunkPos(this.viewerChunkPosition!) const dx = Math.abs(botX - Math.floor(posAbsolute.x / 16)) const dz = Math.abs(botZ - Math.floor(posAbsolute.z / 16)) return [dx, dz] as [number, number] diff --git a/renderer/viewer/three/entities.ts b/renderer/viewer/three/entities.ts index fea7f710..b4de4f1b 100644 --- a/renderer/viewer/three/entities.ts +++ b/renderer/viewer/three/entities.ts @@ -344,7 +344,7 @@ export class Entities { } const dt = this.clock.getDelta() - const botPos = this.worldRenderer.viewerPosition + const botPos = this.worldRenderer.viewerChunkPosition const VISIBLE_DISTANCE = 10 * 10 // Update regular entities diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index b4ae4961..c4482052 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -88,7 +88,7 @@ export class WorldRendererThree extends WorldRendererCommon { this.renderer = renderer displayOptions.rendererState.renderer = WorldRendererThree.getRendererInfo(renderer) ?? '...' - this.starField = new StarField(this.scene) + this.starField = new StarField(this) this.cursorBlock = new CursorBlock(this) this.holdingBlock = new HoldingBlock(this) this.holdingBlockLeft = new HoldingBlock(this, true) @@ -318,10 +318,11 @@ export class WorldRendererThree extends WorldRendererCommon { section.renderOrder = 500 - chunkDistance } - updateViewerPosition (pos: Vec3): void { - this.viewerPosition = pos - const cameraPos = this.cameraObject.position.toArray().map(x => Math.floor(x / 16)) as [number, number, number] - this.cameraSectionPos = new Vec3(...cameraPos) + override updateViewerPosition (pos: Vec3): void { + this.viewerChunkPosition = pos + } + + cameraSectionPositionUpdate () { // eslint-disable-next-line guard-for-in for (const key in this.sectionObjects) { const value = this.sectionObjects[key] @@ -447,11 +448,35 @@ export class WorldRendererThree extends WorldRendererCommon { return tex } + getCameraPosition () { + const worldPos = new THREE.Vector3() + this.camera.getWorldPosition(worldPos) + return worldPos + } + + getWorldCameraPosition () { + const pos = this.getCameraPosition() + return new Vec3( + Math.floor(pos.x / 16), + Math.floor(pos.y / 16), + Math.floor(pos.z / 16) + ) + } + + updateCameraSectionPos () { + const newSectionPos = this.getWorldCameraPosition() + if (!this.cameraSectionPos.equals(newSectionPos)) { + this.cameraSectionPos = newSectionPos + this.cameraSectionPositionUpdate() + } + } + setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) { const yOffset = this.playerStateReactive.eyeHeight this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch) this.media.tryIntersectMedia() + this.updateCameraSectionPos() } getThirdPersonCamera (pos: THREE.Vector3 | null, yaw: number, pitch: number) { @@ -636,6 +661,8 @@ export class WorldRendererThree extends WorldRendererCommon { } } } + + this.updateCameraSectionPos() } debugChunksVisibilityOverride () { @@ -988,7 +1015,9 @@ class StarField { } } - constructor (private readonly scene: THREE.Scene) { + constructor ( + private readonly worldRenderer: WorldRendererThree + ) { } addToScene () { @@ -1030,11 +1059,11 @@ class StarField { // Create points and add them to the scene this.points = new THREE.Points(geometry, material) - this.scene.add(this.points) + this.worldRenderer.scene.add(this.points) const clock = new THREE.Clock() this.points.onBeforeRender = (renderer, scene, camera) => { - this.points?.position.copy?.(camera.position) + this.points?.position.copy?.(this.worldRenderer.getCameraPosition()) material.uniforms.time.value = clock.getElapsedTime() * speed } this.points.renderOrder = -1 @@ -1044,7 +1073,7 @@ class StarField { if (this.points) { this.points.geometry.dispose(); (this.points.material as THREE.Material).dispose() - this.scene.remove(this.points) + this.worldRenderer.scene.remove(this.points) this.points = undefined } From 83d783226fb2c726aac107b0c3c6b0b59727bdfc Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 27 Jun 2025 18:08:03 +0300 Subject: [PATCH 3/9] fix migration marking --- src/react/appStorageProvider.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/react/appStorageProvider.ts b/src/react/appStorageProvider.ts index bee8e408..a9bac660 100644 --- a/src/react/appStorageProvider.ts +++ b/src/react/appStorageProvider.ts @@ -312,7 +312,12 @@ const markLocalStorageAsMigrated = (key: keyof StorageData) => { const data = localStorage.getItem(localStorageKey) if (data) { try { - localStorage.setItem(localStorageKey, JSON.stringify({ ...JSON.parse(data), migrated: Date.now() })) + const parsed = JSON.parse(data) + localStorage.setItem( + localStorageKey, JSON.stringify(typeof parsed === 'object' ? { + ...parsed, migrated: Date.now() + } : { data: parsed, migrated: Date.now() }) + ) } catch (err) { } } From 3336680a0ee5d8b3f5bc030388de19f57c17373c Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 27 Jun 2025 18:08:33 +0300 Subject: [PATCH 4/9] fix z index of modal --- src/react/StorageConflictModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/react/StorageConflictModal.tsx b/src/react/StorageConflictModal.tsx index 9e20ca2d..0b4224f3 100644 --- a/src/react/StorageConflictModal.tsx +++ b/src/react/StorageConflictModal.tsx @@ -32,7 +32,8 @@ export default () => { margin: '10px', color: '#FFFFFF', fontFamily: 'minecraft, monospace', - textAlign: 'center' + textAlign: 'center', + zIndex: 1000, }}>
Date: Fri, 27 Jun 2025 22:06:10 +0300 Subject: [PATCH 5/9] feat: add support for /ping command, fix chat fading! --- src/react/Chat.css | 11 ++++--- src/react/Chat.tsx | 47 +++++++++++++++++------------- src/react/ChatProvider.tsx | 23 +++++++++++---- src/react/StorageConflictModal.tsx | 3 ++ src/react/appStorageProvider.ts | 15 ++-------- src/reactUi.tsx | 2 +- 6 files changed, 55 insertions(+), 46 deletions(-) diff --git a/src/react/Chat.css b/src/react/Chat.css index f1e92338..47394948 100644 --- a/src/react/Chat.css +++ b/src/react/Chat.css @@ -189,23 +189,22 @@ input[type=text], background-color: rgba(0, 0, 0, 0.5); list-style: none; overflow-wrap: break-word; -} - -.chat-message-fadeout { opacity: 1; - transition: all 3s; } -.chat-message-fade { +.chat-message-fading { opacity: 0; + transition: opacity 3s ease-in-out; } .chat-message-faded { - transition: none !important; + display: none; } +/* Ensure messages are always visible when chat is open */ .chat.opened .chat-message { opacity: 1 !important; + display: block !important; transition: none !important; } diff --git a/src/react/Chat.tsx b/src/react/Chat.tsx index 41a6cb6f..7c1c8633 100644 --- a/src/react/Chat.tsx +++ b/src/react/Chat.tsx @@ -11,19 +11,37 @@ import { useScrollBehavior } from './hooks/useScrollBehavior' export type Message = { parts: MessageFormatPart[], id: number - fading?: boolean - faded?: boolean + timestamp?: number } -const MessageLine = ({ message, currentPlayerName }: { message: Message, currentPlayerName?: string }) => { +const MessageLine = ({ message, currentPlayerName, chatOpened }: { message: Message, currentPlayerName?: string, chatOpened?: boolean }) => { + const [fadeState, setFadeState] = useState<'visible' | 'fading' | 'faded'>('visible') + + useEffect(() => { + // Start fading after 5 seconds + const fadeTimeout = setTimeout(() => { + setFadeState('fading') + }, 5000) + + // Remove after fade animation (3s) completes + const removeTimeout = setTimeout(() => { + setFadeState('faded') + }, 8000) + + // Cleanup timeouts if component unmounts + return () => { + clearTimeout(fadeTimeout) + clearTimeout(removeTimeout) + } + }, []) // Empty deps array since we only want this to run once when message is added + const classes = { - 'chat-message-fadeout': message.fading, - 'chat-message-fade': message.fading, - 'chat-message-faded': message.faded, - 'chat-message': true + 'chat-message': true, + 'chat-message-fading': !chatOpened && fadeState === 'fading', + 'chat-message-faded': !chatOpened && fadeState === 'faded' } - return
  • val).map(([name]) => name).join(' ')}> + return
  • val).map(([name]) => name).join(' ')} data-time={message.timestamp ? new Date(message.timestamp).toLocaleString('en-US', { hour12: false }) : undefined}> {message.parts.map((msg, i) => { // Check if this is a text part that might contain a mention if (msg.text && currentPlayerName) { @@ -70,17 +88,6 @@ export const chatInputValueGlobal = proxy({ value: '' }) -export const fadeMessage = (message: Message, initialTimeout: boolean, requestUpdate: () => void) => { - setTimeout(() => { - message.fading = true - requestUpdate() - setTimeout(() => { - message.faded = true - requestUpdate() - }, 3000) - }, initialTimeout ? 5000 : 0) -} - export default ({ messages, opacity = 1, @@ -372,7 +379,7 @@ export default ({
  • )} {messages.map((m) => ( - + ))} || undefined} diff --git a/src/react/ChatProvider.tsx b/src/react/ChatProvider.tsx index 83691b83..0bb13285 100644 --- a/src/react/ChatProvider.tsx +++ b/src/react/ChatProvider.tsx @@ -5,7 +5,7 @@ import { getBuiltinCommandsList, tryHandleBuiltinCommand } from '../builtinComma import { gameAdditionalState, hideCurrentModal, miscUiState } from '../globalState' import { options } from '../optionsStorage' import { viewerVersionState } from '../viewerConnector' -import Chat, { Message, fadeMessage } from './Chat' +import Chat, { Message } from './Chat' import { useIsModalActive } from './utilsApp' import { hideNotification, notificationProxy, showNotification } from './NotificationProvider' import { getServerIndex, updateLoadedServerData } from './serversStorage' @@ -16,6 +16,7 @@ export default () => { const [messages, setMessages] = useState([] as Message[]) const isChatActive = useIsModalActive('chat') const lastMessageId = useRef(0) + const lastPingTime = useRef(0) const usingTouch = useSnapshot(miscUiState).currentTouch const { chatSelect, messagesLimit, chatOpacity, chatOpacityOpened, chatVanillaRestrictions, debugChatScroll, chatPingExtension } = useSnapshot(options) const isUsingMicrosoftAuth = useMemo(() => !!lastConnectOptions.value?.authenticatedAccount, []) @@ -29,18 +30,23 @@ export default () => { jsonMsg = jsonMsg['unsigned'] } const parts = formatMessage(jsonMsg) + const messageText = parts.map(part => part.text).join('') + + // Handle ping response + if (messageText === 'Pong!' && lastPingTime.current > 0) { + const latency = Date.now() - lastPingTime.current + parts.push({ text: ` Latency: ${latency}ms`, color: '#00ff00' }) + lastPingTime.current = 0 + } setMessages(m => { lastMessageId.current++ const newMessage: Message = { parts, id: lastMessageId.current, - faded: false, + timestamp: Date.now() } - fadeMessage(newMessage, true, () => { - // eslint-disable-next-line max-nested-callbacks - setMessages(m => [...m]) - }) + return [...m, newMessage].slice(-messagesLimit) }) }) @@ -61,6 +67,11 @@ export default () => { return players.filter(name => (!value || name.toLowerCase().includes(value.toLowerCase())) && name !== bot.username).map(name => `@${name}`) }} sendMessage={async (message) => { + // Record ping command time + if (message === '/ping') { + lastPingTime.current = Date.now() + } + const builtinHandled = tryHandleBuiltinCommand(message) if (getServerIndex() !== undefined && (message.startsWith('/login') || message.startsWith('/register'))) { showNotification('Click here to save your password in browser for auto-login', undefined, false, undefined, () => { diff --git a/src/react/StorageConflictModal.tsx b/src/react/StorageConflictModal.tsx index 0b4224f3..ac78d90a 100644 --- a/src/react/StorageConflictModal.tsx +++ b/src/react/StorageConflictModal.tsx @@ -34,6 +34,9 @@ export default () => { fontFamily: 'minecraft, monospace', textAlign: 'center', zIndex: 1000, + position: 'fixed', + left: 0, + right: 0 }}>
    { const localParsed = JSON.parse(localStorageValue) const cookieParsed = JSON.parse(cookieValue) - if (localParsed?.migrated) { + if (localStorage.getItem(`${localStorageKey}:migrated`)) { continue } @@ -309,18 +309,7 @@ const markLocalStorageAsMigrated = (key: keyof StorageData) => { return } - const data = localStorage.getItem(localStorageKey) - if (data) { - try { - const parsed = JSON.parse(data) - localStorage.setItem( - localStorageKey, JSON.stringify(typeof parsed === 'object' ? { - ...parsed, migrated: Date.now() - } : { data: parsed, migrated: Date.now() }) - ) - } catch (err) { - } - } + localStorage.setItem(`${localStorageKey}:migrated`, 'true') } const saveKey = (key: keyof StorageData) => { diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 59ed9124..b15cb79d 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -229,6 +229,7 @@ const App = () => {
    + @@ -248,7 +249,6 @@ const App = () => { - From e161426caf15a29439b4bc77987c3630b1733c6f Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 27 Jun 2025 22:11:49 +0300 Subject: [PATCH 6/9] always dipslay close buttons from settings --- src/react/OptionsItems.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/react/OptionsItems.tsx b/src/react/OptionsItems.tsx index 4759b3d7..32f99b9e 100644 --- a/src/react/OptionsItems.tsx +++ b/src/react/OptionsItems.tsx @@ -189,18 +189,16 @@ interface Props { } export default ({ items, title, backButtonAction }: Props) => { - const { currentTouch } = useSnapshot(miscUiState) return
    - {currentTouch && ( -
    -
    - )} +
    +
    + {items.map((element, i) => { // make sure its unique! return From 369166e0942cf234d34f31b587c4a08424129b09 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 28 Jun 2025 00:45:54 +0300 Subject: [PATCH 7/9] fix tsc, up readme --- README.MD | 6 ++++-- src/react/Chat.stories.tsx | 11 +---------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/README.MD b/README.MD index 4769192a..5f7a83f6 100644 --- a/README.MD +++ b/README.MD @@ -6,9 +6,11 @@ Minecraft **clone** rewritten in TypeScript using the best modern web technologi You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable. -Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely with the JS ecosystem. Have fun! +> For Turkey/Russia use [ru.mcraft.fun](https://ru.mcraft.fun/) (since Cloudflare is blocked) -For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md). +Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, meanwhile this project is aimed for *device-compatiiblity* and better performance so it feels portable, flexible and lightweight. It's also a very strong example on how to build true HTML games for the web at scale entirely with the JS ecosystem. Have fun! + +For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md) (also for comparison with Eaglercraft). ### Big Features diff --git a/src/react/Chat.stories.tsx b/src/react/Chat.stories.tsx index 192d5cb4..e1aaf761 100644 --- a/src/react/Chat.stories.tsx +++ b/src/react/Chat.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react' import { useEffect, useState } from 'react' import { formatMessage } from '../chatUtils' -import Chat, { fadeMessage, chatInputValueGlobal } from './Chat' +import Chat, { chatInputValueGlobal } from './Chat' import Button from './Button' window.spamMessage = window.spamMessage ?? '' @@ -63,14 +63,6 @@ const meta: Meta = { return () => clearInterval(interval) }, [autoSpam]) - const fadeMessages = () => { - for (const m of messages) { - fadeMessage(m, false, () => { - setMessages([...messages]) - }) - } - } - return
    = { }} /> -
    From fec887c28da80e2f50e28412ae81b89b2c6a76bf Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 29 Jun 2025 00:56:37 +0300 Subject: [PATCH 8/9] deeply stringify gui items to avoid futher modifications --- pnpm-lock.yaml | 26 +++++++++++++------------- src/inventoryWindows.ts | 13 +++++++++++-- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbdf89ac..a034c919 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,7 +136,7 @@ importers: version: 4.17.21 mcraft-fun-mineflayer: specifier: ^0.1.23 - version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13)) + version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d(encoding@0.1.13)) minecraft-data: specifier: 3.89.0 version: 3.89.0 @@ -338,10 +338,10 @@ importers: version: 0.2.59 minecraft-inventory-gui: specifier: github:zardoy/minecraft-inventory-gui#next - version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d(@types/react@18.3.18)(react@18.3.1) + version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/5494f356b1e59eddc876c3ae05ff395f12a46379(@types/react@18.3.18)(react@18.3.1) mineflayer: specifier: github:zardoy/mineflayer#gen-the-master - version: https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13) + version: https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d(encoding@0.1.13) mineflayer-mouse: specifier: ^0.1.10 version: 0.1.10(@types/debug@4.1.12)(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) @@ -6705,8 +6705,8 @@ packages: minecraft-folder-path@1.2.0: resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==} - minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d: - resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d} + minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/5494f356b1e59eddc876c3ae05ff395f12a46379: + resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/5494f356b1e59eddc876c3ae05ff395f12a46379} version: 1.0.1 minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284: @@ -6736,8 +6736,8 @@ packages: resolution: {integrity: sha512-3bxph4jfbkBh5HpeouorxzrfSLNV+i+1gugNJ2jf52HW+rt+tW7eiiFPxrJEsOVkPT/3O/dEIW7j93LRlojMkQ==} engines: {node: '>=22'} - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b: - resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b} + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d: + resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d} version: 4.27.0 engines: {node: '>=22'} @@ -13346,7 +13346,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color optional: true @@ -16285,7 +16285,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color optional: true @@ -17127,12 +17127,12 @@ snapshots: maxrects-packer: '@zardoy/maxrects-packer@2.7.4' zod: 3.24.2 - mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13)): + mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=09def7a73311f10b6aa8ff9f9b76129589578fa154a8b846b06909ca748c4762)(encoding@0.1.13) - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d(encoding@0.1.13) prismarine-item: 1.16.0 ws: 8.18.1 transitivePeerDependencies: @@ -17443,7 +17443,7 @@ snapshots: minecraft-folder-path@1.2.0: {} - minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d(@types/react@18.3.18)(react@18.3.1): + minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/5494f356b1e59eddc876c3ae05ff395f12a46379(@types/react@18.3.18)(react@18.3.1): dependencies: valtio: 1.13.2(@types/react@18.3.18)(react@18.3.1) transitivePeerDependencies: @@ -17569,7 +17569,7 @@ snapshots: - encoding - supports-color - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13): + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/0380bed150fe03db4ac37f0194e0cee98356647d(encoding@0.1.13): dependencies: '@nxg-org/mineflayer-physics-util': 1.8.10 minecraft-data: 3.89.0 diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index 05426d15..01d1691d 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -159,7 +159,9 @@ const getImage = ({ path = undefined as string | undefined, texture = undefined if (image) { return image } - if (!path && !texture) throw new Error('Either pass path or texture') + if (!path && !texture) { + throw new Error('Either pass path or texture') + } const loadPath = (blockData ? 'blocks' : path ?? texture)! if (loadedImagesCache.has(loadPath)) { onLoad() @@ -201,6 +203,11 @@ const itemToVisualKey = (slot: RenderItem | Item | null) => { ].join('|') return keys } +const validateSlot = (slot: any, index: number) => { + if (!slot.texture) { + throw new Error(`Slot has no texture: ${index} ${slot.name}`) + } +} const mapSlots = (slots: Array, isJei = false) => { const newSlots = slots.map((slot, i) => { if (!slot) return null @@ -210,6 +217,7 @@ const mapSlots = (slots: Array, isJei = false) => { const newKey = itemToVisualKey(slot) slot['cacheKey'] = i + '|' + newKey if (oldKey && oldKey === newKey) { + validateSlot(lastMappedSlots[i], i) return lastMappedSlots[i] } } @@ -228,12 +236,13 @@ const mapSlots = (slots: Array, isJei = false) => { const { icon, ...rest } = slot return rest } + validateSlot(slot, i) } catch (err) { inGameError(err) } return slot }) - lastMappedSlots = newSlots + lastMappedSlots = JSON.parse(JSON.stringify(newSlots)) return newSlots } From 34eecc166f9a39cc5438b649e9c972e51c0490ee Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 29 Jun 2025 02:38:19 +0300 Subject: [PATCH 9/9] feat: rework singleplayer generators types. now any generator can be used internally. add a few --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/loadSave.ts | 21 --------------------- src/react/CreateWorld.tsx | 2 +- src/react/CreateWorldProvider.tsx | 22 +++------------------- src/sounds/botSoundSystem.ts | 1 + 6 files changed, 11 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 25cce1a5..516d57f3 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "esbuild-plugin-polyfill-node": "^0.3.0", "express": "^4.18.2", "filesize": "^10.0.12", - "flying-squid": "npm:@zardoy/flying-squid@^0.0.59", + "flying-squid": "npm:@zardoy/flying-squid@^0.0.62", "framer-motion": "^12.9.2", "fs-extra": "^11.1.1", "google-drive-browserfs": "github:zardoy/browserfs#google-drive", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a034c919..f954791a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,8 +117,8 @@ importers: specifier: ^10.0.12 version: 10.1.6 flying-squid: - specifier: npm:@zardoy/flying-squid@^0.0.59 - version: '@zardoy/flying-squid@0.0.59(encoding@0.1.13)' + specifier: npm:@zardoy/flying-squid@^0.0.62 + version: '@zardoy/flying-squid@0.0.62(encoding@0.1.13)' framer-motion: specifier: ^12.9.2 version: 12.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3410,8 +3410,8 @@ packages: engines: {node: '>=8'} hasBin: true - '@zardoy/flying-squid@0.0.59': - resolution: {integrity: sha512-Ztrmv127csGovqJEWEtT19y1wGEB5tIVfneQ3+p/TirP/bTGYpLlW+Ns4sSAc4KrewUP9PW/6L0AtB69CWhQFQ==} + '@zardoy/flying-squid@0.0.62': + resolution: {integrity: sha512-M6icydO/yrmwevBhmgKcqEPC63AhWfU/Es9N/uadVrmKaxGm2FQMMLcybbutRYm1xZ6qsdxDUOUZnN56PsVwfQ==} engines: {node: '>=8'} hasBin: true @@ -13249,7 +13249,7 @@ snapshots: - encoding - supports-color - '@zardoy/flying-squid@0.0.59(encoding@0.1.13)': + '@zardoy/flying-squid@0.0.62(encoding@0.1.13)': dependencies: '@tootallnate/once': 2.0.0 chalk: 5.4.1 diff --git a/src/loadSave.ts b/src/loadSave.ts index 7c9f7277..f1676cff 100644 --- a/src/loadSave.ts +++ b/src/loadSave.ts @@ -85,7 +85,6 @@ export const loadSave = async (root = '/world', connectOptions?: Partial { const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath()) await mkdirRecursive(savePath) await loadPluginsIntoWorld(savePath, plugins) - let generation - if (type === 'flat') { - generation = { - name: 'superflat', - } - } - if (type === 'void') { - generation = { - name: 'superflat', - layers: [], - noDefaults: true - } - } - if (type === 'nether') { - generation = { - name: 'nether' - } - } hideCurrentModal() window.dispatchEvent(new CustomEvent('singleplayer', { detail: { levelName: title, version, - generation, + generation: { + name: type + }, 'worldFolder': savePath, gameMode: gameMode === 'survival' ? 0 : 1, }, diff --git a/src/sounds/botSoundSystem.ts b/src/sounds/botSoundSystem.ts index 225aa345..0e23a98a 100644 --- a/src/sounds/botSoundSystem.ts +++ b/src/sounds/botSoundSystem.ts @@ -136,6 +136,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { let lastStepSound = 0 const movementHappening = async () => { if (!bot.entity || !soundMap) return // no info yet + if (appViewer.playerState.reactive.gameMode === 'spectator') return // Don't play step sounds in spectator mode const VELOCITY_THRESHOLD = 0.1 const RUN_THRESHOLD = 0.15 const { x, z, y } = bot.entity.velocity