optimize: crash guard, yt-dlp url validation, dead-key cleanup

전체 코드 재검토 기반의 안전한 개선 1차 배치.
- 메인 설치기에 전역 uncaughtException/unhandledRejection 가드 추가. nat-upnp
  (detectExternalIpUpnp)의 비동기 소켓 오류로 앱이 조용히 종료되던 잠재 크래시
  방지(포트포워딩 도구 v0.3.18 과 동일 대비).
- 서버: fetchVideoMeta/fetchPlaylistEntries 에 http(s) URL 검증 추가(운영자 입력이
  yt-dlp 플래그로 오인되는 인자 주입 차단). /file/mods/:folder/index.json 의 async
  throw → next(error) 로 위임(unhandledRejection 방지). index 라우트 pack 정의
  병렬 로드.
- 죽은 i18n 키 정리: installer 로케일의 UPnP/run.bat 관련 19개 + pf 로케일 2개
  제거(코드에서 미참조 확인). 크래시 가드용 log.internalError, youtube.invalidUrl 추가.
v0.3.23.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:10:55 +09:00
parent 2212195b3c
commit 704d58d3ac
8 changed files with 30 additions and 29 deletions

View File

@@ -98,6 +98,16 @@ function sendLog(line: string): void {
mainWindow.webContents.send('log', stamped)
}
// nat-upnp(detectExternalIpUpnp) 등이 콜백 밖에서 비동기 소켓 오류를 내면 처리되지
// 않아 Electron 메인이 종료되며 창이 갑자기 닫힐 수 있다. 전역 가드로 잡아 로그만
// 남기고 앱은 계속 살려 둔다(포트포워딩 전용 도구 v0.3.18 과 동일한 대비책).
process.on('uncaughtException', (err) => {
try { sendLog(t('log.internalError', { message: (err as Error)?.message || String(err) })) } catch {}
})
process.on('unhandledRejection', (reason) => {
try { sendLog(t('log.internalError', { message: reason instanceof Error ? reason.message : String(reason) })) } catch {}
})
function fetchBuffer(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const target = new URL(url)

View File

@@ -143,7 +143,7 @@ app.use((req, res, next) => {
})
// 모드 폴더 안의 .jar 파일 목록을 JSON으로 반환. 설치기가 자동 다운로드용으로 사용.
app.get('/file/mods/:folder/index.json', async (req, res) => {
app.get('/file/mods/:folder/index.json', async (req, res, next) => {
const folder = req.params.folder
if (!/^[a-zA-Z0-9_\-]+$/.test(folder)) {
res.status(404).json({ files: [] })
@@ -162,7 +162,8 @@ app.get('/file/mods/:folder/index.json', async (req, res) => {
res.status(404).json({ files: [] })
return
}
throw error
// async 핸들러의 throw 는 Express4 가 잡지 못해 unhandledRejection 이 되므로 next 로 위임.
next(error)
}
})

View File

@@ -8,9 +8,9 @@ indexRouter.get('/', async (_req, res, next) => {
const manifest = await readManifest()
const definitionMap = new Map<string, Awaited<ReturnType<typeof loadPackDefinition>>>()
const keys = await listPackKeys()
for (const key of keys) {
definitionMap.set(key, await loadPackDefinition(key))
}
// 팩 정의를 병렬 로드(op.ts 와 동일 패턴). 순차 await 보다 빠름.
const definitions = await Promise.all(keys.map((key) => loadPackDefinition(key)))
keys.forEach((key, i) => definitionMap.set(key, definitions[i]))
const packs = manifest.packs.map((entry) => ({
name: entry.name,
file: entry.file,

View File

@@ -299,7 +299,15 @@ async function runYtDlp(args: string[], makeError: (code: string, detail: string
* 단일 영상 URL 의 메타데이터를 가져온다.
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
*/
/** 운영자 입력 URL 이 yt-dlp 인자(플래그)로 오인되지 않도록 http(s) 스킴만 허용. */
function assertHttpUrl(url: string): void {
if (!/^https?:\/\//i.test(url.trim())) {
throw new Error(t('youtube.invalidUrl'))
}
}
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
assertHttpUrl(url)
const stdout = await runYtDlp(
['--dump-json', '--no-warnings', '--no-playlist', '--skip-download', url],
(code, detail) => new Error(t('youtube.ytdlpVideoFailed', { code, detail }))
@@ -327,6 +335,7 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
*/
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
assertHttpUrl(url)
const stdout = await runYtDlp(
['--flat-playlist', '--dump-json', '--no-warnings', url],
(code, detail) => new Error(t('youtube.ytdlpPlaylistFailed', { code, detail }))