terms: agreement pages + site Notion-style editor + rp cancel fix

- 5종 약관(map/resourcepack/mod/installer/installer-rp) markdown 시드 + manifest/terms/ 노출
- 사이트 /op/agreement 목록 + Notion 스타일 markdown 에디터 (슬래시 명령어, 미리보기)
- 메인 installer: 음악퀴즈 선택 직후 약관 동의 페이지(맵·모드·설치기) 추가
- rp installer: 음악퀴즈 선택 직후 약관 동의 페이지(리소스팩·설치기) 추가
- rp installer 취소 버그 수정: buildResourcepackZip 단계간 + archive.abort() 폴링
- rp installer 취소 UX: 즉시 "취소 중…" 표시, 취소 시 installFailed 알림 생략
- 0.2.6 → 0.3.0 (큰 기능)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 00:55:36 +09:00
parent bc3841147f
commit ffb2048627
26 changed files with 1323 additions and 18 deletions

View File

@@ -251,6 +251,22 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
ipcMain.handle('rp:i18n:dict', () => localeDict)
// ── IPC: 약관 다운로드 ──────────────────────────────
// 사이트가 /manifest/terms/<kind>.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환.
// rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인
// 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다.
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
if (!TERM_KIND_WHITELIST.has(kind)) return { ok: false, message: 'unknown term kind' }
try {
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(kind)}.md`
const buf = await fetchBuffer(url)
return { ok: true, content: buf.toString('utf8') }
} catch (error) {
return { ok: false, message: (error as Error).message }
}
})
// ── IPC: 2단계 설치 ──────────────────────────────────
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
@@ -416,8 +432,11 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
workDir: tempRoot,
outZipPath: resourcepackPath,
baseZipPath,
log: sendLog
log: sendLog,
// build 내부에서도 단계 사이/zip 도중에 폴링해서 취소를 빠르게 반영한다.
cancelChecker: () => state.cancelRequested
})
throwIfCancelled()
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
sendLog(t('log.installComplete', { path: resourcepackPath }))

View File

@@ -29,6 +29,26 @@ export interface BuildResourcepackOptions {
baseZipPath?: string
/** 진단용 로그 콜백 (선택). */
log?: (line: string) => void
/**
* 사용자 취소 신호. true 가 되면 가능한 시점에 build 를 중단한다.
* - 단계 사이 (extract → meta → 음악 복사 → painting 복사 → zip) 폴링.
* - zip 생성 중에도 폴링해서 archive.abort() 로 끊는다.
* 호출자는 후속 처리에서 임시 폴더와 부분 zip 파일을 정리해야 한다.
*/
cancelChecker?: () => boolean
}
/** cancelChecker 가 true 를 반환하면 던지는 에러. main 쪽 에러 처리와 동일한 메시지를 쓰지 않고,
* 명시적인 클래스 마커로 식별하기 쉽게 한다. 메시지는 i18n 의 errors.cancelledByUser 와 1:1. */
class CancelledError extends Error {
constructor() {
super(t('errors.cancelledByUser'))
this.name = 'CancelledError'
}
}
function throwIfCancelled(checker?: () => boolean): void {
if (checker && checker()) throw new CancelledError()
}
/**
@@ -41,6 +61,8 @@ export interface BuildResourcepackOptions {
* assets/musicquiz/textures/painting/cover_NN.png ← paintingDir/cover_NN.png 에서 옮김
*/
export async function buildResourcepackZip(opts: BuildResourcepackOptions): Promise<void> {
const cancel = opts.cancelChecker
throwIfCancelled(cancel)
const root = path.join(opts.workDir, 'resourcepack')
// 베이스가 있건 없건 작업 트리는 항상 처음부터 다시 만든다.
await fs.rm(root, { recursive: true, force: true })
@@ -50,6 +72,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
if (opts.baseZipPath) {
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
await extract(opts.baseZipPath, { dir: root })
throwIfCancelled(cancel)
}
const soundsDir = path.join(root, 'assets', NAMESPACE, 'sounds')
@@ -125,6 +148,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
// 없으면 새로 생성.
}
for (const fname of musicFiles) {
throwIfCancelled(cancel)
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
const stem = path.basename(fname, path.extname(fname)) // "01"
const trackId = `track_${stem}`
@@ -136,36 +160,64 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
}
}
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
throwIfCancelled(cancel)
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀.
const paintingFiles = (await fs.readdir(opts.paintingDir))
.filter((n) => n.toLowerCase().endsWith('.png'))
.sort()
for (const fname of paintingFiles) {
throwIfCancelled(cancel)
await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname))
}
throwIfCancelled(cancel)
// 4) zip 으로 묶기
// 4) zip 으로 묶기. 이 단계가 가장 길어서 별도로 cancel 폴링이 들어간다.
await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true })
await zipDirectory(root, opts.outZipPath)
await zipDirectory(root, opts.outZipPath, cancel)
// zip 빌드가 끝난 직후에도 한 번 더 확인: 마지막 순간 취소가 들어왔을 수 있다.
if (cancel && cancel()) {
// 부분 zip 파일이 디스크에 남아있을 수 있으니 삭제.
await fs.rm(opts.outZipPath, { force: true })
throw new CancelledError()
}
// 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요.
}
function zipDirectory(srcDir: string, outZipPath: string): Promise<void> {
function zipDirectory(srcDir: string, outZipPath: string, cancelChecker?: () => boolean): Promise<void> {
return new Promise((resolve, reject) => {
const output = createWriteStream(outZipPath)
const archive = archiver('zip', { zlib: { level: 9 } })
output.on('close', () => resolve())
output.on('error', reject)
// 취소 폴링: archiver 자체는 abort() 후 'error' 이벤트로 ABORT 코드를 던진다.
// 200ms 간격이면 사용자 체감으로는 즉각적이면서 CPU 부담은 없다.
let interval: NodeJS.Timeout | null = null
let aborted = false
if (cancelChecker) {
interval = setInterval(() => {
if (cancelChecker() && !aborted) {
aborted = true
try { archive.abort() } catch { /* 이미 끝났거나 abort 불가 상태 */ }
}
}, 200)
}
function cleanup() {
if (interval) { clearInterval(interval); interval = null }
}
output.on('close', () => { cleanup(); if (aborted) reject(new CancelledError()); else resolve() })
output.on('error', (err) => { cleanup(); reject(err) })
archive.on('warning', (err: Error & { code?: string }) => {
// ENOENT 정도면 무시, 그 외는 reject.
if (err.code === 'ENOENT') return
reject(err)
cleanup(); reject(err)
})
archive.on('error', (err: Error & { code?: string }) => {
cleanup()
if (err.code === 'ABORT' || aborted) reject(new CancelledError())
else reject(err)
})
archive.on('error', reject)
archive.pipe(output)
archive.directory(srcDir, false)
archive.finalize().catch(reject)
archive.finalize().catch((err) => { cleanup(); reject(err) })
})
}

View File

@@ -12,6 +12,10 @@ const api = {
selectPack: (packKey: string): Promise<void> =>
ipcRenderer.invoke('rp:packs:select', packKey),
/** 약관(Markdown) 다운로드. kind: 'resourcepack' | 'installer-rp'. */
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
ipcRenderer.invoke('rp:terms:get', kind),
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
startInstall: (): Promise<{ resourcepackPath: string }> =>
ipcRenderer.invoke('rp:install:start'),

View File

@@ -154,6 +154,22 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
return results
})
// 약관(Markdown) 을 사이트(/manifest/terms/<kind>.md) 에서 받아와 그대로 돌려준다.
// 화이트리스트로 5종 제한. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
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' }
}
try {
const url = `${state.baseUrl}/manifest/terms/${kind}.md`
const buf = await fetchBuffer(url)
return { ok: true, content: buf.toString('utf8') }
} 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

@@ -11,6 +11,10 @@ const api = {
setSelectedPack: (packKey: string): Promise<void> =>
ipcRenderer.invoke('packs:select', packKey),
// 약관(Markdown) 다운로드
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
ipcRenderer.invoke('terms:get', kind),
// 3-1
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
validateInstallPath: (target: string): Promise<{ ok: boolean; message?: string }> =>

View File

@@ -2,7 +2,10 @@ import express from 'express'
import session from 'express-session'
import path from 'node:path'
import fsp from 'node:fs/promises'
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
import {
manifestRootPath, manifestDirPath, manifestTermsDirPath,
fileDirPath, viewsDirPath, publicDirPath
} from '../shared/paths.js'
import { loadEnv } from '../shared/env.js'
import { t, localeDict } from './i18n.js'
import { indexRouter } from './routes/index.js'
@@ -59,6 +62,21 @@ app.get('/manifest.json', (_req, res) => {
res.sendFile(manifestRootPath)
})
// 설치기에서 약관(markdown) 을 가져갈 수 있도록 화이트리스트 파일명만 허용.
app.get('/manifest/terms/:fileName', (req, res) => {
const fileName = req.params.fileName
// 화이트리스트: map.md, resourcepack.md, mod.md, installer.md, installer-rp.md
if (!/^(map|resourcepack|mod|installer|installer-rp)\.md$/.test(fileName)) {
res.status(404).send('Not Found')
return
}
res.type('text/markdown; charset=utf-8')
res.sendFile(path.join(manifestTermsDirPath, fileName), (err) => {
if (!err || res.headersSent) return
res.status(404).send('Not Found')
})
})
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
// 디렉토리 리스팅, 다른 확장자, 경로 탈출은 차단.
app.get('/manifest/:fileName', (req, res) => {

View File

@@ -6,13 +6,18 @@ import {
listPackKeys,
loadPackDefinition,
loadPackList,
loadTerm,
normalizePackDefinition,
normalizePackList,
readAccounts,
renamePack,
sanitizePackKey,
savePackList
saveTerm,
savePackList,
isTermKind,
TERM_KINDS
} from '../../shared/store.js'
import type { TermKind } from '../../shared/store.js'
import { fetchReleaseVersions } from '../../shared/mojang.js'
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
import { requireAuth } from '../middleware/auth.js'
@@ -295,6 +300,56 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
}
})
// ─── /op/agreement ─────────────────────────────────────────────────────
// 약관(Markdown) 5종 편집기. 사이트에서는 노션 스타일의 슬래시 명령으로
// 마크다운을 작성하고, 인스톨러는 /manifest/terms/<kind>.md 로 받아 표시한다.
const TERM_LABELS: Record<TermKind, string> = {
'map': '맵 약관',
'resourcepack': '리소스팩 약관',
'mod': '모드 약관',
'installer': '설치기 약관',
'installer-rp': '리소스팩 설치기 약관'
}
opRouter.get('/op/agreement', requireAuth, (req, res) => {
const items = TERM_KINDS.map((kind) => ({ kind, label: TERM_LABELS[kind] }))
res.render('op/terms', { userId: req.session.userId, items })
})
opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => {
try {
const kind = pickFirstValue(req.params.kind)
if (!isTermKind(kind)) {
res.status(404).send(t('errors.unknown'))
return
}
const content = await loadTerm(kind)
res.render('op/termsEditor', {
userId: req.session.userId,
kind,
label: TERM_LABELS[kind],
content
})
} catch (error) {
next(error)
}
})
opRouter.post('/op/agreement/:kind', requireAuth, async (req, res, next) => {
try {
const kind = pickFirstValue(req.params.kind)
if (!isTermKind(kind)) {
res.status(404).json({ ok: false, message: t('errors.unknown') })
return
}
const content = typeof req.body?.content === 'string' ? req.body.content : ''
await saveTerm(kind, content)
res.json({ ok: true })
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))

View File

@@ -5,6 +5,7 @@ import os from 'node:os'
export const projectRoot = path.resolve(__dirname, '..', '..')
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
export const manifestDirPath = path.join(projectRoot, 'manifest')
export const manifestTermsDirPath = path.join(manifestDirPath, 'terms')
export const accountFilePath = path.join(projectRoot, 'account.json')
export const fileDirPath = path.join(projectRoot, 'file')
export const fileListDirPath = path.join(fileDirPath, 'list')

View File

@@ -1,7 +1,10 @@
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { manifestRootPath, manifestDirPath, accountFilePath, fileListDirPath } from './paths.js'
import {
manifestRootPath, manifestDirPath, manifestTermsDirPath,
accountFilePath, fileListDirPath
} from './paths.js'
import type {
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
PackList, MusicListEntry, ImageListEntry
@@ -291,6 +294,35 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
await fsp.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
}
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
// 화이트리스트로 5종만 허용한다.
export type TermKind = 'map' | 'resourcepack' | 'mod' | 'installer' | 'installer-rp'
export const TERM_KINDS: readonly TermKind[] = [
'map', 'resourcepack', 'mod', 'installer', 'installer-rp'
]
export function isTermKind(value: unknown): value is TermKind {
return typeof value === 'string' && (TERM_KINDS as readonly string[]).includes(value)
}
export async function loadTerm(kind: TermKind): Promise<string> {
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
try {
return await fsp.readFile(filePath, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
throw error
}
}
export async function saveTerm(kind: TermKind, markdown: string): Promise<void> {
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
}
export async function readAccounts(): Promise<AccountEntry[]> {
try {
const raw = await fsp.readFile(accountFilePath, 'utf8')