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:
Vitaly Turovsky 2025-06-27 16:28:15 +03:00
commit eedd9f1b8f
9 changed files with 780 additions and 225 deletions

View file

@ -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
View 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
View 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,
}
}

View file

@ -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

View file

@ -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>
}
},

View file

@ -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'

View 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>
)
}

View file

@ -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()

View file

@ -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}>