From eedd9f1b8f11ee21797d23f87aebbf591e95f29d Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 27 Jun 2025 16:28:15 +0300 Subject: [PATCH] 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 = () => { +