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:
@@ -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 }))
|
||||
|
||||
@@ -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) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user