Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/main/core/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ export async function downloadFile(url: string, dest: string, opts: DownloadOpts
const source = Readable.fromWeb(r.body as Parameters<typeof Readable.fromWeb>[0])
await pipeline(source, tap, createWriteStream(dest))

// A 0-byte body (dead mirror, 404-that-redirects-to-nothing, changed API shape)
// would otherwise be hashed to the well-known SHA-256 of empty input
// (e3b0c442…) and reported as a baffling "checksum mismatch". Fail honestly and
// early instead — this guards every provider, not just the one that regressed.
if (received === 0) {
await rm(dest, { force: true })
throw new Error(`empty-download: ${url} returned 0 bytes`)
}

if (hash) {
const digest = hash.digest('hex').toLowerCase()
const expected = (opts.sha256 ?? opts.sha1 ?? '').toLowerCase()
Expand Down
38 changes: 38 additions & 0 deletions src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type { Product } from '@shared/web'
import { getProvider } from './core/versions'
import { createServer } from './core/createServer'
import { pickForgeRunJar } from './core/serverDetect'
import { downloadFile } from './core/net'
import { createServer as httpCreateServer } from 'node:http'
import { removeServer } from './core/serverRegistry'
import * as sf from './core/serverFiles'
import * as playersMod from './core/players'
Expand Down Expand Up @@ -2675,6 +2677,42 @@ export async function runWizardSmoke(): Promise<void> {
console.log('WIZARD-SMOKE: forge run-jar fallback OK (universal/loader/none, installer excluded)')
}

// 4. net.ts empty-download guard: a reachable 200 with a 0-byte body must fail
// as `empty-download`, not as a baffling checksum mismatch against the hash
// of empty input (the confusing Mohist symptom in #43). #43
{
const srv = httpCreateServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/java-archive' })
res.end() // zero bytes, chunked (no content-length) so fetch still yields a body
})
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()))
const addr = srv.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
const dest = join(app.getPath('temp'), 'msms-empty-dl-test.jar')
let msg = ''
try {
await downloadFile(`http://127.0.0.1:${port}/x.jar`, dest, {
sha256: '5ad74546004d0e5b9a5b0f6f8e2b1c3d4e5f60718293a4b5c6d7e8f9011223344'
})
} catch (e) {
msg = String((e as Error)?.message ?? e)
}
srv.close()
try {
rmSync(dest, { force: true })
} catch {
/* ignore */
}
if (!msg.includes('empty-download')) {
return fail('0-byte download should throw empty-download, got: ' + (msg || '(no error)'))
}
if (/checksum mismatch/i.test(msg)) {
return fail('0-byte download surfaced as a checksum mismatch instead of empty-download')
}
if (existsSync(dest)) return fail('empty-download must not leave a partial file behind')
console.log('WIZARD-SMOKE: empty-download guard OK (0-byte body fails clearly, no checksum confusion, dest removed)')
}

console.log('WIZARD-SMOKE: PASS')
app.exit(0)
}
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,11 @@ export default {
progressConfiguring: 'Configuring…',
progressDone: 'Done!',
createdOk: 'Server "{{name}}" created',
createFailed: 'Creation failed: {{error}}'
createFailed: 'Creation failed: {{error}}',
errNoBuild: 'No build is available for this Minecraft version yet — pick another version.',
errEmptyDownload: 'The download server returned an empty file. That build may be temporarily unavailable — try another build or version.',
errFolderExists: 'A server folder with that name already exists — choose a different name.',
errNoLauncher: 'The installer finished but produced no launchable server. This loader/version combination may be unsupported.'
},
players: {
title: 'Players',
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/locales/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,11 @@ const tr: typeof en = {
progressConfiguring: 'Yapılandırılıyor…',
progressDone: 'Tamamlandı!',
createdOk: '"{{name}}" sunucusu oluşturuldu',
createFailed: 'Oluşturma başarısız: {{error}}'
createFailed: 'Oluşturma başarısız: {{error}}',
errNoBuild: 'Bu Minecraft sürümü için henüz bir yapı (build) yok — başka bir sürüm seçin.',
errEmptyDownload: 'İndirme sunucusu boş bir dosya döndürdü. O yapı geçici olarak erişilemez olabilir — başka bir yapı veya sürüm deneyin.',
errFolderExists: 'Bu adda bir sunucu klasörü zaten var — farklı bir ad seçin.',
errNoLauncher: 'Yükleyici tamamlandı ama çalıştırılabilir bir sunucu üretmedi. Bu yükleyici/sürüm birleşimi desteklenmiyor olabilir.'
},
players: {
title: 'Oyuncular',
Expand Down
21 changes: 19 additions & 2 deletions src/renderer/src/views/CreateView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
import { Sparkles, Download, Loader2, ExternalLink, ChevronRight, Check } from 'lucide-react'
import { useStore } from '../store'
import { CREATABLE_TYPES, HAS_BUILDS } from '@shared/versions'
Expand All @@ -9,6 +10,22 @@ import type { JavaPreset, ServerType } from '@shared/types'
const PROXY_TYPES: ServerType[] = ['velocity', 'waterfall', 'bungeecord']
const PRESETS: JavaPreset[] = ['aikars', 'aikars-large', 'basic']

/**
* Turn a raw createServer error code into a human message. The backend surfaces
* short codes (e.g. `no-mohist-build` when the upstream API lists a version but
* has no build for it, or `empty-download` when a mirror serves a 0-byte body);
* shown verbatim these read as gibberish to a non-technical user. Unknown codes
* fall through unchanged.
*/
function friendlyCreateError(error: string | undefined, t: TFunction): string {
const e = error ?? '?'
if (/^no-[a-z]+-build$/.test(e)) return t('wizard.errNoBuild')
if (e.startsWith('empty-download')) return t('wizard.errEmptyDownload')
if (e === 'folder-exists') return t('wizard.errFolderExists')
if (e === 'installer-args-not-found') return t('wizard.errNoLauncher')
return e
}

export function CreateView(): JSX.Element {
const { t } = useTranslation()
const config = useStore((s) => s.config)
Expand Down Expand Up @@ -111,7 +128,7 @@ export function CreateView(): JSX.Element {
await selectServer(res.server.id)
setView('console')
} else {
toast('error', 'wizard.createFailed', { error: res.error ?? '?' })
toast('error', 'wizard.createFailed', { error: friendlyCreateError(res.error, t) })
setProgress({ stage: 'error', message: res.error })
}
}
Expand All @@ -130,7 +147,7 @@ export function CreateView(): JSX.Element {
case 'done':
return t('wizard.progressDone')
case 'error':
return t('wizard.createFailed', { error: progress.message ?? '?' })
return t('wizard.createFailed', { error: friendlyCreateError(progress.message, t) })
}
}, [progress, t])

Expand Down
Loading