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