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

@@ -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) })
})
}