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!
This commit is contained in:
parent
0b1bc76327
commit
eedd9f1b8f
9 changed files with 780 additions and 225 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
219
src/core/importExport.ts
Normal file
219
src/core/importExport.ts
Normal file
|
|
@ -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<string, any>
|
||||
keybindings?: Record<string, any>
|
||||
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<File>((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<keyof Omit<ExportedFile, '_about'>, { 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<any>(([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<keyof ExportedFile, boolean>
|
||||
|
||||
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)
|
||||
}
|
||||
152
src/defaultOptions.ts
Normal file
152
src/defaultOptions.ts
Normal file
|
|
@ -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<string, [number, number]>
|
||||
}
|
||||
|
||||
function getTouchControlsSize () {
|
||||
return {
|
||||
joystick: 55,
|
||||
action: 36,
|
||||
break: 36,
|
||||
jump: 36,
|
||||
sneak: 36,
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,8 @@ export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ re
|
|||
activeModalStack.push(resolved)
|
||||
}
|
||||
|
||||
window.showModal = showModal
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns true if previous modal was restored
|
||||
|
|
|
|||
|
|
@ -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<OptionMeta<AppOptions[K]>> } & { custom? }>
|
||||
|
|
@ -532,6 +533,30 @@ export const guiOptionsScheme: {
|
|||
return <Button label='Export/Import...' onClick={() => openOptionsMenu('export-import')} inScreen />
|
||||
}
|
||||
},
|
||||
{
|
||||
custom () {
|
||||
const { cookieStorage } = useSnapshot(appStorage)
|
||||
return <Button
|
||||
label={`Storage: ${cookieStorage ? 'Synced Cookies' : 'Local Storage'}`} onClick={() => {
|
||||
appStorage.cookieStorage = !cookieStorage
|
||||
alert('Reload the page to apply this change')
|
||||
}}
|
||||
inScreen
|
||||
/>
|
||||
}
|
||||
},
|
||||
{
|
||||
custom () {
|
||||
const { cookieStorage } = useSnapshot(appStorage)
|
||||
return <Button
|
||||
label={`Storage: ${cookieStorage ? 'Synced Cookies' : 'Local Storage'}`} onClick={() => {
|
||||
appStorage.cookieStorage = !cookieStorage
|
||||
alert('Reload the page to apply this change')
|
||||
}}
|
||||
inScreen
|
||||
/>
|
||||
}
|
||||
},
|
||||
{
|
||||
custom () {
|
||||
return <Category>Server Connection</Category>
|
||||
|
|
@ -637,8 +662,7 @@ export const guiOptionsScheme: {
|
|||
custom () {
|
||||
return <Button
|
||||
inScreen
|
||||
disabled={true}
|
||||
onClick={() => {}}
|
||||
onClick={importData}
|
||||
>Import Data</Button>
|
||||
}
|
||||
},
|
||||
|
|
@ -646,53 +670,7 @@ export const guiOptionsScheme: {
|
|||
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.exportServers ? {
|
||||
servers: appStorage.serversList,
|
||||
} : {},
|
||||
...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)
|
||||
}}
|
||||
onClick={exportData}
|
||||
>Export Data</Button>
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string, [number, number]>
|
||||
}
|
||||
|
||||
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<AppOptions & Record<string, any>>) => {
|
|||
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'
|
||||
|
|
|
|||
94
src/react/StorageConflictModal.tsx
Normal file
94
src/react/StorageConflictModal.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
>
|
||||
<div style={{
|
||||
background: '#dcb58f',
|
||||
border: '2px solid #654321',
|
||||
padding: '20px',
|
||||
margin: '10px',
|
||||
color: '#FFFFFF',
|
||||
fontFamily: 'minecraft, monospace',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#000000',
|
||||
marginBottom: '15px'
|
||||
}}>
|
||||
Data Conflict Found
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
marginBottom: '20px',
|
||||
whiteSpace: 'pre-line',
|
||||
// backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
color: '#642323',
|
||||
padding: '10px',
|
||||
// border: '1px solid #654321'
|
||||
}}>
|
||||
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:
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'center', fontSize: '8px', color: 'black' }}>
|
||||
<div
|
||||
onClick={() => {
|
||||
resolveStorageConflicts(true) // Use localStorage
|
||||
hideCurrentModal()
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid #654321',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Use Local Storage & Disable Cookie Sync
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => {
|
||||
resolveStorageConflicts(false) // Use cookies
|
||||
hideCurrentModal()
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid #654321',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Use Cookie Data & Remove Local Data
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<keyof StorageData> }
|
||||
customCommands: Record<string, CustomCommand> | 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<keyof StorageData> = [
|
||||
'customCommands',
|
||||
'username',
|
||||
'keybindings',
|
||||
'changedSettings',
|
||||
'serversList',
|
||||
]
|
||||
|
||||
const oldKeysAliases: Partial<Record<keyof StorageData, string>> = {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
|
||||
<SelectOption />
|
||||
<CreditsAboutModal />
|
||||
<StorageConflictModal />
|
||||
<NoModalFoundProvider />
|
||||
</RobustPortal>
|
||||
<RobustPortal to={document.body}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue