feat: 최종 리소스팩(선택 설치) + 약관 표시대상 추가

- pack.finalResourcepackPath 추가(/file/resourcepacks/outputs/, "."=없음, 기본 ".").
  편집기에 "최종 리소스팩 (.zip)" 입력 필드 추가.
- 약관 표시대상에 showInFinalResourcepack("최종 리소스팩에 표시") 3번째 토글 추가
  (스키마/시드/마이그레이션/편집기 체크박스·배지·저장 라우트, index.json 노출).
- 간편설치기 완료 단계: 기존 "직접 적용" 경고 제거 → 최종 리소스팩이 "."이 아니면
  "리소스팩을 설치하시겠습니까?" 예/아니요. 아니요→완료, 예→최종 리소스팩 약관
  (showInFinalResourcepack) 동의→/file/resourcepacks/outputs/ 에서 다운로드→완료.
  "." 이면 이 페이지 자체를 건너뜀.
- 0.4.15→0.4.16.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 00:33:07 +09:00
parent f53e54493d
commit e5dcc47548
13 changed files with 247 additions and 25 deletions

View File

@@ -255,6 +255,50 @@ ipcMain.handle('terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ ki
}
})
// 최종 리소스팩 다운로드용 약관 목록. index.json 에서 showInFinalResourcepack=true 만 추린다.
ipcMain.handle('terms:listFinal', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => {
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
try {
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json`
const buf = await fetchBuffer(url)
const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown }
const items = Array.isArray(parsed.terms) ? parsed.terms : []
const terms: Array<{ kind: string; label: string }> = []
for (const it of items) {
if (!it || typeof it !== 'object') continue
const entry = it as Record<string, unknown>
if (entry.showInFinalResourcepack !== true) continue
const kind = typeof entry.kind === 'string' ? entry.kind : ''
const label = typeof entry.label === 'string' ? entry.label : ''
if (!TERM_KIND_RE.test(kind) || label.length === 0) continue
terms.push({ kind, label })
}
return { ok: true, terms }
} catch (error) {
return { ok: false, message: (error as Error).message }
}
})
// 최종 리소스팩(zip)을 /file/resourcepacks/outputs/ 에서 받아 .mc_custom/resourcepacks/ 에 저장.
ipcMain.handle('finalResourcepack:install', async (): Promise<{ ok: boolean; path?: string; message?: string }> => {
const pack = state.selectedKey ? state.packs.get(state.selectedKey) : undefined
if (!pack) return { ok: false, message: t('errors.packNotFound2') }
const finalName = pack.pack.finalResourcepackPath
if (!finalName || finalName === '.') return { ok: false, message: 'no final resourcepack' }
try {
const cleaned = path.basename(finalName.replace(/^\/+/, ''))
const url = `${state.baseUrl}/file/resourcepacks/outputs/${encodeURIComponent(cleaned)}`
const destDir = path.join(getAppDataDir(), getMcCustomDirName(), 'resourcepacks')
const dest = path.join(destDir, cleaned)
sendLog(t('log.finalResourcepackDownload', { url }))
await downloadFile(url, dest)
sendLog(t('log.finalResourcepackSaved', { path: dest }))
return { ok: true, path: dest }
} catch (error) {
return { ok: false, message: (error as Error).message }
}
})
ipcMain.handle('packs:select', async (_event, packKey: string) => {
if (!state.packs.has(packKey)) {
throw new Error(t('errors.packNotFound'))

View File

@@ -17,6 +17,12 @@ const api = {
// 메인 인스톨러용 약관 목록 (사이트의 visibility 토글에 따라 필터링됨)
getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
ipcRenderer.invoke('terms:list'),
// 최종 리소스팩 다운로드용 약관 목록 (showInFinalResourcepack 필터)
getFinalTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
ipcRenderer.invoke('terms:listFinal'),
// 최종 리소스팩 다운로드 실행
installFinalResourcepack: (): Promise<{ ok: boolean; path?: string; message?: string }> =>
ipcRenderer.invoke('finalResourcepack:install'),
// 3-1
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),

View File

@@ -535,6 +535,7 @@ opRouter.get('/op/agreement/:packName/:kind', requireAuth, async (req, res, next
label: entry.label,
showInInstaller: entry.showInInstaller,
showInInstallerRp: entry.showInInstallerRp,
showInFinalResourcepack: entry.showInFinalResourcepack,
content
})
} catch (error) {
@@ -562,10 +563,12 @@ opRouter.post('/op/agreement/:packName/:kind', requireAuth, async (req, res, nex
if (
typeof req.body?.showInInstaller === 'boolean'
|| typeof req.body?.showInInstallerRp === 'boolean'
|| typeof req.body?.showInFinalResourcepack === 'boolean'
) {
await setTermVisibility(packKey, kind, {
showInInstaller: req.body.showInInstaller === true,
showInInstallerRp: req.body.showInInstallerRp === true
showInInstallerRp: req.body.showInInstallerRp === true,
showInFinalResourcepack: req.body.showInFinalResourcepack === true
})
}
res.json({ ok: true })
@@ -593,6 +596,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
} as PackDefinition['platform'] & { loaderVersion?: string },
modsFolder: pickFirstValue(req.body.modsFolder),
resourcepackPath: pickFirstValue(req.body.resourcepackPath),
finalResourcepackPath: pickFirstValue(req.body.finalResourcepackPath),
outputPackName: pickFirstValue(req.body.outputPackName),
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),

View File

@@ -47,6 +47,7 @@ export function defaultPackDefinition(name: string): PackDefinition {
platform: { type: 'vanilla' },
modsFolder: '',
resourcepackPath: '',
finalResourcepackPath: '.',
outputPackName: '',
serverMinRam: 2048,
serverMaxRam: 4096,
@@ -97,6 +98,13 @@ export function normalizeRecommendedJdk(input: unknown): number {
return Number.isFinite(n) && n >= 8 && n <= 99 ? n : DEFAULT_JDK_MAJOR
}
/** "최종 리소스팩" 값 보정. 빈 값/`.` 은 "없음"을 뜻하는 `.` 으로, 그 외는 zip 파일명으로. */
export function normalizeFinalResourcepack(input: unknown): string {
const raw = typeof input === 'string' ? input.trim() : ''
if (raw === '' || raw === '.') return '.'
return sanitizeZipFileName(raw)
}
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
const fallback = defaultPackDefinition(typeof input.name === 'string' ? input.name : 'new')
const platform = (input.platform ?? {}) as Partial<PackDefinition['platform']>
@@ -125,6 +133,7 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
},
modsFolder: sanitizeFolderName(input.modsFolder),
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
finalResourcepackPath: normalizeFinalResourcepack(input.finalResourcepackPath),
// 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서.
outputPackName: typeof input.outputPackName === 'string' ? input.outputPackName.trim() : '',
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
@@ -393,12 +402,13 @@ const DEFAULT_TERM_SEEDS: Array<{
label: string
showInInstaller: boolean
showInInstallerRp: boolean
showInFinalResourcepack: boolean
}> = [
{ kind: 'map', label: '맵 약관', showInInstaller: true, showInInstallerRp: false },
{ kind: 'mod', label: '모드 약관', showInInstaller: true, showInInstallerRp: false },
{ kind: 'installer', label: '설치기 약관', showInInstaller: true, showInInstallerRp: false },
{ kind: 'resourcepack', label: '리소스팩 약관', showInInstaller: false, showInInstallerRp: true },
{ kind: 'installer-rp', label: '리소스팩 설치기 약관', showInInstaller: false, showInInstallerRp: true }
{ kind: 'map', label: '맵 약관', showInInstaller: true, showInInstallerRp: false, showInFinalResourcepack: false },
{ kind: 'mod', label: '모드 약관', showInInstaller: true, showInInstallerRp: false, showInFinalResourcepack: false },
{ kind: 'installer', label: '설치기 약관', showInInstaller: true, showInInstallerRp: false, showInFinalResourcepack: false },
{ kind: 'resourcepack', label: '리소스팩 약관', showInInstaller: false, showInInstallerRp: true, showInFinalResourcepack: true },
{ kind: 'installer-rp', label: '리소스팩 설치기 약관', showInInstaller: false, showInInstallerRp: true, showInFinalResourcepack: false }
]
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
@@ -411,6 +421,7 @@ export interface TermEntry {
label: string
showInInstaller: boolean
showInInstallerRp: boolean
showInFinalResourcepack: boolean
}
interface TermsMeta {
@@ -502,7 +513,8 @@ async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): P
meta.terms[seed.kind] = {
label: seed.label,
showInInstaller: seed.showInInstaller,
showInInstallerRp: seed.showInInstallerRp
showInInstallerRp: seed.showInInstallerRp,
showInFinalResourcepack: seed.showInFinalResourcepack
}
changed = true
}
@@ -543,10 +555,11 @@ async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): P
terms[seed.kind] = {
label: seed.label,
showInInstaller: seed.showInInstaller,
showInInstallerRp: seed.showInInstallerRp
showInInstallerRp: seed.showInInstallerRp,
showInFinalResourcepack: seed.showInFinalResourcepack
}
}
// 구 스키마의 사용자 정의 약관은 양쪽 인스톨러에 보이도록 기본값으로.
// 구 스키마의 사용자 정의 약관은 양쪽 인스톨러에 보이도록 기본값으로(최종 리소스팩은 기본 off).
for (const [k, label] of Object.entries(oldCustomLabels)) {
if (terms[k]) continue
try {
@@ -554,7 +567,7 @@ async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): P
} catch {
continue
}
terms[k] = { label, showInInstaller: true, showInInstallerRp: true }
terms[k] = { label, showInInstaller: true, showInInstallerRp: true, showInFinalResourcepack: false }
}
await fsp.writeFile(metaPath, `${JSON.stringify({ terms }, null, 2)}\n`, 'utf8')
}
@@ -575,7 +588,8 @@ async function loadTermsMeta(packKey: string): Promise<TermsMeta> {
result.terms[k] = {
label,
showInInstaller: entry.showInInstaller === true,
showInInstallerRp: entry.showInInstallerRp === true
showInInstallerRp: entry.showInInstallerRp === true,
showInFinalResourcepack: entry.showInFinalResourcepack === true
}
}
}
@@ -600,6 +614,7 @@ export interface TermItem {
label: string
showInInstaller: boolean
showInInstallerRp: boolean
showInFinalResourcepack: boolean
}
/**
@@ -633,7 +648,8 @@ export async function listTermsWithLabels(packKey: string): Promise<TermItem[]>
kind: seed.kind,
label: entry.label,
showInInstaller: entry.showInInstaller,
showInInstallerRp: entry.showInInstallerRp
showInInstallerRp: entry.showInInstallerRp,
showInFinalResourcepack: entry.showInFinalResourcepack
})
seen.add(seed.kind)
}
@@ -647,7 +663,8 @@ export async function listTermsWithLabels(packKey: string): Promise<TermItem[]>
kind,
label: entry.label,
showInInstaller: entry.showInInstaller,
showInInstallerRp: entry.showInInstallerRp
showInInstallerRp: entry.showInInstallerRp,
showInFinalResourcepack: entry.showInFinalResourcepack
})
}
return items
@@ -666,7 +683,7 @@ export async function getTermEntry(packKey: string, kind: string): Promise<TermE
export async function setTermVisibility(
packKey: string,
kind: string,
visibility: { showInInstaller: boolean; showInInstallerRp: boolean }
visibility: { showInInstaller: boolean; showInInstallerRp: boolean; showInFinalResourcepack: boolean }
): Promise<void> {
if (!isTermKind(kind)) throw new Error('invalid term kind')
const meta = await loadTermsMeta(packKey)
@@ -674,6 +691,7 @@ export async function setTermVisibility(
if (!entry) throw new Error('term not found')
entry.showInInstaller = !!visibility.showInInstaller
entry.showInInstallerRp = !!visibility.showInInstallerRp
entry.showInFinalResourcepack = !!visibility.showInFinalResourcepack
await saveTermsMeta(packKey, meta)
}
@@ -723,7 +741,8 @@ export async function createTerm(packKey: string, kind: string, label: string):
meta.terms[kind] = {
label: cleanLabel,
showInInstaller: seed ? seed.showInInstaller : true,
showInInstallerRp: seed ? seed.showInInstallerRp : true
showInInstallerRp: seed ? seed.showInInstallerRp : true,
showInFinalResourcepack: seed ? seed.showInFinalResourcepack : false
}
await saveTermsMeta(packKey, meta)
}

View File

@@ -14,8 +14,14 @@ export interface PackDefinition {
platform: PackPlatform
/** /file/mods/<modsFolder>/ 폴더 안의 모든 .jar을 자동 다운로드. */
modsFolder: string
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드(리소스팩 설치기의 베이스). */
resourcepackPath: string
/**
* 간편설치기 마지막에 선택 설치하는 "최종 리소스팩" zip 파일 이름.
* /file/resourcepacks/outputs/<finalResourcepackPath> 에서 받는다.
* `"."` 이면 없음(설치 여부 질문 자체를 건너뜀). 기본값은 `"."`.
*/
finalResourcepackPath: string
/**
* 리소스팩 설치기가 만들어 내는 최종 zip 파일의 이름(확장자 제외).
* 빈 문자열이면 설치기가 `<packKey>_resourcepack` 형식으로 기본 이름을 만든다.