terms: per-term installer visibility toggles + universal delete (v0.3.4)

- _meta.json: customLabels -> terms.{label,showInInstaller,showInInstallerRp}
- Drop builtin protection; any term kind can be deleted/added/toggled
- New public route /manifest/terms/<pack>/index.json for installer term lists
- Installers fetch terms:list dynamically; skip agreement step if list empty
- Term editor: 2 visibility checkboxes (설치기 / 리소스팩 설치기), multi-select
- Migration from old schema preserves custom labels (default: visible in both)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 10:14:42 +09:00
parent 05dc9d7166
commit 9ba5dc6b7b
14 changed files with 445 additions and 130 deletions

View File

@@ -155,11 +155,11 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
})
// 약관(Markdown) 을 사이트(/manifest/terms/<packKey>/<kind>.md) 에서 받아와 그대로 돌려준다.
// 화이트리스트로 5종 제한. pack 미선택 상태에서는 에러를 돌려준다. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
// v0.3.4~ : 사이트에서 임의 kind 등록 가능 → 하드코딩 5종 화이트리스트 대신 kind 형식만 검증.
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
if (!TERM_KIND_WHITELIST.has(kind)) {
return { ok: false, message: 'unknown term kind' }
if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) {
return { ok: false, message: 'invalid term kind' }
}
if (!state.selectedKey) {
return { ok: false, message: 'pack not selected' }
@@ -173,6 +173,31 @@ ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean;
}
})
// 메인 인스톨러용 약관 목록. /manifest/terms/<packKey>/index.json 을 받아
// showInInstaller=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다.
ipcMain.handle('terms:list', 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.showInInstaller !== 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 }
}
})
ipcMain.handle('packs:select', async (_event, packKey: string) => {
if (!state.packs.has(packKey)) {
throw new Error(t('errors.packNotFound'))