Skip to content

Commit eedd9f1

Browse files
committed
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!
1 parent 0b1bc76 commit eedd9f1

File tree

9 files changed

+780
-225
lines changed

9 files changed

+780
-225
lines changed

rsbuild.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ const appConfig = defineConfig({
140140
'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog),
141141
'process.env.DISABLE_SERVICE_WORKER': JSON.stringify(disableServiceWorker),
142142
'process.env.INLINED_APP_CONFIG': JSON.stringify(configSource === 'BUNDLED' ? configJson : null),
143+
'process.env.ENABLE_COOKIE_STORAGE': JSON.stringify(process.env.ENABLE_COOKIE_STORAGE || true),
143144
},
144145
},
145146
server: {

src/core/importExport.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { appStorage } from '../react/appStorageProvider'
2+
import { getChangedSettings, options } from '../optionsStorage'
3+
import { customKeymaps } from '../controls'
4+
import { showInputsModal } from '../react/SelectOption'
5+
6+
interface ExportedFile {
7+
_about: string
8+
options?: Record<string, any>
9+
keybindings?: Record<string, any>
10+
servers?: any[]
11+
username?: string
12+
proxy?: string
13+
proxies?: string[]
14+
accountTokens?: any[]
15+
}
16+
17+
export const importData = async () => {
18+
try {
19+
const input = document.createElement('input')
20+
input.type = 'file'
21+
input.accept = '.json'
22+
input.click()
23+
24+
const file = await new Promise<File>((resolve) => {
25+
input.onchange = () => {
26+
if (!input.files?.[0]) return
27+
resolve(input.files[0])
28+
}
29+
})
30+
31+
const text = await file.text()
32+
const data = JSON.parse(text)
33+
34+
if (!data._about?.includes('Minecraft Web Client')) {
35+
const doContinue = confirm('This file does not appear to be a Minecraft Web Client profile. Continue anyway?')
36+
if (!doContinue) return
37+
}
38+
39+
// Build available data types for selection
40+
const availableData: Record<keyof Omit<ExportedFile, '_about'>, { present: boolean, description: string }> = {
41+
options: { present: !!data.options, description: 'Game settings and preferences' },
42+
keybindings: { present: !!data.keybindings, description: 'Custom key mappings' },
43+
servers: { present: !!data.servers, description: 'Saved server list' },
44+
username: { present: !!data.username, description: 'Username' },
45+
proxy: { present: !!data.proxy, description: 'Selected proxy server' },
46+
proxies: { present: !!data.proxies, description: 'Global proxies list' },
47+
accountTokens: { present: !!data.accountTokens, description: 'Account authentication tokens' },
48+
}
49+
50+
// Filter to only present data types
51+
const presentTypes = Object.fromEntries(Object.entries(availableData)
52+
.filter(([_, info]) => info.present)
53+
.map<any>(([key, info]) => [key, info]))
54+
55+
if (Object.keys(presentTypes).length === 0) {
56+
alert('No compatible data found in the imported file.')
57+
return
58+
}
59+
60+
const importChoices = await showInputsModal('Select Data to Import', {
61+
mergeData: {
62+
type: 'checkbox',
63+
label: 'Merge with existing data (uncheck to remove old data)',
64+
defaultValue: true,
65+
},
66+
...Object.fromEntries(Object.entries(presentTypes).map(([key, info]) => [key, {
67+
type: 'checkbox',
68+
label: info.description,
69+
defaultValue: true,
70+
}]))
71+
}) as { mergeData: boolean } & Record<keyof ExportedFile, boolean>
72+
73+
if (!importChoices) return
74+
75+
const importedTypes: string[] = []
76+
const shouldMerge = importChoices.mergeData
77+
78+
if (importChoices.options && data.options) {
79+
if (shouldMerge) {
80+
Object.assign(options, data.options)
81+
} else {
82+
for (const key of Object.keys(options)) {
83+
if (key in data.options) {
84+
options[key as any] = data.options[key]
85+
}
86+
}
87+
}
88+
importedTypes.push('settings')
89+
}
90+
91+
if (importChoices.keybindings && data.keybindings) {
92+
if (shouldMerge) {
93+
Object.assign(customKeymaps, data.keybindings)
94+
} else {
95+
for (const key of Object.keys(customKeymaps)) delete customKeymaps[key]
96+
Object.assign(customKeymaps, data.keybindings)
97+
}
98+
importedTypes.push('keybindings')
99+
}
100+
101+
if (importChoices.servers && data.servers) {
102+
if (shouldMerge && appStorage.serversList) {
103+
// Merge by IP, update existing entries and add new ones
104+
const existingIps = new Set(appStorage.serversList.map(s => s.ip))
105+
const newServers = data.servers.filter(s => !existingIps.has(s.ip))
106+
appStorage.serversList = [...appStorage.serversList, ...newServers]
107+
} else {
108+
appStorage.serversList = data.servers
109+
}
110+
importedTypes.push('servers')
111+
}
112+
113+
if (importChoices.username && data.username) {
114+
appStorage.username = data.username
115+
importedTypes.push('username')
116+
}
117+
118+
if ((importChoices.proxy && data.proxy) || (importChoices.proxies && data.proxies)) {
119+
if (!appStorage.proxiesData) {
120+
appStorage.proxiesData = { proxies: [], selected: '' }
121+
}
122+
123+
if (importChoices.proxies && data.proxies) {
124+
if (shouldMerge) {
125+
// Merge unique proxies
126+
const uniqueProxies = new Set([...appStorage.proxiesData.proxies, ...data.proxies])
127+
appStorage.proxiesData.proxies = [...uniqueProxies]
128+
} else {
129+
appStorage.proxiesData.proxies = data.proxies
130+
}
131+
importedTypes.push('proxies list')
132+
}
133+
134+
if (importChoices.proxy && data.proxy) {
135+
appStorage.proxiesData.selected = data.proxy
136+
importedTypes.push('selected proxy')
137+
}
138+
}
139+
140+
if (importChoices.accountTokens && data.accountTokens) {
141+
if (shouldMerge && appStorage.authenticatedAccounts) {
142+
// Merge by unique identifier (assuming accounts have some unique ID or username)
143+
const existingAccounts = new Set(appStorage.authenticatedAccounts.map(a => a.username))
144+
const newAccounts = data.accountTokens.filter(a => !existingAccounts.has(a.username))
145+
appStorage.authenticatedAccounts = [...appStorage.authenticatedAccounts, ...newAccounts]
146+
} else {
147+
appStorage.authenticatedAccounts = data.accountTokens
148+
}
149+
importedTypes.push('account tokens')
150+
}
151+
152+
alert(`Profile imported successfully! Imported data: ${importedTypes.join(', ')}.\nYou may need to reload the page for some changes to take effect.`)
153+
} catch (err) {
154+
console.error('Failed to import profile:', err)
155+
alert('Failed to import profile: ' + (err.message || err))
156+
}
157+
}
158+
159+
export const exportData = async () => {
160+
const data = await showInputsModal('Export Profile', {
161+
profileName: {
162+
type: 'text',
163+
},
164+
exportSettings: {
165+
type: 'checkbox',
166+
defaultValue: true,
167+
},
168+
exportKeybindings: {
169+
type: 'checkbox',
170+
defaultValue: true,
171+
},
172+
exportServers: {
173+
type: 'checkbox',
174+
defaultValue: true,
175+
},
176+
saveUsernameAndProxy: {
177+
type: 'checkbox',
178+
defaultValue: true,
179+
},
180+
exportGlobalProxiesList: {
181+
type: 'checkbox',
182+
defaultValue: false,
183+
},
184+
exportAccountTokens: {
185+
type: 'checkbox',
186+
defaultValue: false,
187+
},
188+
})
189+
const fileName = `${data.profileName ? `${data.profileName}-` : ''}web-client-profile.json`
190+
const json: ExportedFile = {
191+
_about: 'Minecraft Web Client (mcraft.fun) Profile',
192+
...data.exportSettings ? {
193+
options: getChangedSettings(),
194+
} : {},
195+
...data.exportKeybindings ? {
196+
keybindings: customKeymaps,
197+
} : {},
198+
...data.exportServers ? {
199+
servers: appStorage.serversList,
200+
} : {},
201+
...data.saveUsernameAndProxy ? {
202+
username: appStorage.username,
203+
proxy: appStorage.proxiesData?.selected,
204+
} : {},
205+
...data.exportGlobalProxiesList ? {
206+
proxies: appStorage.proxiesData?.proxies,
207+
} : {},
208+
...data.exportAccountTokens ? {
209+
accountTokens: appStorage.authenticatedAccounts,
210+
} : {},
211+
}
212+
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' })
213+
const url = URL.createObjectURL(blob)
214+
const a = document.createElement('a')
215+
a.href = url
216+
a.download = fileName
217+
a.click()
218+
URL.revokeObjectURL(url)
219+
}

src/defaultOptions.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
export const defaultOptions = {
2+
renderDistance: 3,
3+
keepChunksDistance: 1,
4+
multiplayerRenderDistance: 3,
5+
closeConfirmation: true,
6+
autoFullScreen: false,
7+
mouseRawInput: true,
8+
autoExitFullscreen: false,
9+
localUsername: 'wanderer',
10+
mouseSensX: 50,
11+
mouseSensY: -1,
12+
chatWidth: 320,
13+
chatHeight: 180,
14+
chatScale: 100,
15+
chatOpacity: 100,
16+
chatOpacityOpened: 100,
17+
messagesLimit: 200,
18+
volume: 50,
19+
enableMusic: false,
20+
// fov: 70,
21+
fov: 75,
22+
guiScale: 3,
23+
autoRequestCompletions: true,
24+
touchButtonsSize: 40,
25+
touchButtonsOpacity: 80,
26+
touchButtonsPosition: 12,
27+
touchControlsPositions: getDefaultTouchControlsPositions(),
28+
touchControlsSize: getTouchControlsSize(),
29+
touchMovementType: 'modern' as 'modern' | 'classic',
30+
touchInteractionType: 'classic' as 'classic' | 'buttons',
31+
gpuPreference: 'default' as 'default' | 'high-performance' | 'low-power',
32+
backgroundRendering: '20fps' as 'full' | '20fps' | '5fps',
33+
/** @unstable */
34+
disableAssets: false,
35+
/** @unstable */
36+
debugLogNotFrequentPackets: false,
37+
unimplementedContainers: false,
38+
dayCycleAndLighting: true,
39+
loadPlayerSkins: true,
40+
renderEars: true,
41+
lowMemoryMode: false,
42+
starfieldRendering: true,
43+
enabledResourcepack: null as string | null,
44+
useVersionsTextures: 'latest',
45+
serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never',
46+
showHand: true,
47+
viewBobbing: true,
48+
displayRecordButton: true,
49+
packetsLoggerPreset: 'all' as 'all' | 'no-buffers',
50+
serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string,
51+
customChannels: false,
52+
remoteContentNotSameOrigin: false as boolean | string[],
53+
packetsRecordingAutoStart: false,
54+
language: 'auto',
55+
preciseMouseInput: false,
56+
// todo ui setting, maybe enable by default?
57+
waitForChunksRender: false as 'sp-only' | boolean,
58+
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
59+
modsSupport: false,
60+
modsAutoUpdate: 'check' as 'check' | 'never' | 'always',
61+
modsUpdatePeriodCheck: 24, // hours
62+
preventBackgroundTimeoutKick: false,
63+
preventSleep: false,
64+
debugContro: false,
65+
debugChatScroll: false,
66+
chatVanillaRestrictions: true,
67+
debugResponseTimeIndicator: false,
68+
chatPingExtension: true,
69+
// antiAliasing: false,
70+
topRightTimeDisplay: 'only-fullscreen' as 'only-fullscreen' | 'always' | 'never',
71+
72+
clipWorldBelowY: undefined as undefined | number, // will be removed
73+
disableSignsMapsSupport: false,
74+
singleplayerAutoSave: false,
75+
showChunkBorders: false, // todo rename option
76+
frameLimit: false as number | false,
77+
alwaysBackupWorldBeforeLoading: undefined as boolean | undefined | null,
78+
alwaysShowMobileControls: false,
79+
excludeCommunicationDebugEvents: [],
80+
preventDevReloadWhilePlaying: false,
81+
numWorkers: 4,
82+
localServerOptions: {
83+
gameMode: 1
84+
} as any,
85+
preferLoadReadonly: false,
86+
disableLoadPrompts: false,
87+
guestUsername: 'guest',
88+
askGuestName: true,
89+
errorReporting: true,
90+
/** Actually might be useful */
91+
showCursorBlockInSpectator: false,
92+
renderEntities: true,
93+
smoothLighting: true,
94+
newVersionsLighting: false,
95+
chatSelect: true,
96+
autoJump: 'auto' as 'auto' | 'always' | 'never',
97+
autoParkour: false,
98+
vrSupport: true, // doesn't directly affect the VR mode, should only disable the button which is annoying to android users
99+
vrPageGameRendering: false,
100+
renderDebug: 'basic' as 'none' | 'advanced' | 'basic',
101+
rendererPerfDebugOverlay: false,
102+
103+
// advanced bot options
104+
autoRespawn: false,
105+
mutedSounds: [] as string[],
106+
plugins: [] as Array<{ enabled: boolean, name: string, description: string, script: string }>,
107+
/** Wether to popup sign editor on server action */
108+
autoSignEditor: true,
109+
wysiwygSignEditor: 'auto' as 'auto' | 'always' | 'never',
110+
showMinimap: 'never' as 'always' | 'singleplayer' | 'never',
111+
minimapOptimizations: true,
112+
displayBossBars: true,
113+
disabledUiParts: [] as string[],
114+
neighborChunkUpdates: true,
115+
highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic',
116+
activeRenderer: 'threejs',
117+
rendererSharedOptions: {
118+
_experimentalSmoothChunkLoading: true,
119+
_renderByChunks: false
120+
}
121+
}
122+
123+
function getDefaultTouchControlsPositions () {
124+
return {
125+
action: [
126+
70,
127+
76
128+
],
129+
sneak: [
130+
84,
131+
76
132+
],
133+
break: [
134+
70,
135+
57
136+
],
137+
jump: [
138+
84,
139+
57
140+
],
141+
} as Record<string, [number, number]>
142+
}
143+
144+
function getTouchControlsSize () {
145+
return {
146+
joystick: 55,
147+
action: 36,
148+
break: 36,
149+
jump: 36,
150+
sneak: 36,
151+
}
152+
}

src/globalState.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ re
4646
activeModalStack.push(resolved)
4747
}
4848

49+
window.showModal = showModal
50+
4951
/**
5052
*
5153
* @returns true if previous modal was restored

0 commit comments

Comments
 (0)