1.1.1: fullbak

This commit is contained in:
th committed 2026-04-25 00:40:53 +02:00
1 parent d59201100b
commit 01c13981c4
6 files changed
+443

No files matched your search

+131
View File
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue' import { nextTick, onMounted, ref, watch } from 'vue'
import { import {
PhArchive,
PhArrowsClockwise,
PhFileArrowUp, PhFileArrowUp,
PhFolderOpen, PhFolderOpen,
PhPlus, PhPlus,
@@ -9,9 +11,16 @@ import {
import ruwioLogo from '@/assets/logo.png' import ruwioLogo from '@/assets/logo.png'
import { import {
deleteStoredProject, deleteStoredProject,
listAllProjects,
listRecentProjects, listRecentProjects,
saveProjectToStore,
type StoredProject, type StoredProject,
} from '@/lib/projectStore' } from '@/lib/projectStore'
import {
downloadBackup,
pickBackupFile,
readBackupFile,
} from '@/lib/backupFile'
import { confirmDialog, Modal } from '@/components/ui' import { confirmDialog, Modal } from '@/components/ui'
import { t } from '@/i18n' import { t } from '@/i18n'
@@ -149,6 +158,90 @@ function importFile() {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
const backupBusy = ref(false)
const backupError = ref<string | null>(null)
/** Dump every project in IDB into a single .ruwbak file. No-ops
* with a friendly toast when the store is empty — users expect a
* signal, not a mystery zero-byte file. */
async function backupAll() {
if (backupBusy.value) return
backupBusy.value = true
backupError.value = null
try {
const all = await listAllProjects()
const records = all
.filter((r) => !!r.project)
.map((r) => ({
id: r.id,
name: r.name,
savedAt: r.savedAt,
thumbnail: r.thumbnail,
project: r.project!,
}))
if (!records.length) {
backupError.value = t('welcome.backupEmpty')
return
}
downloadBackup(records)
} catch (err) {
backupError.value =
err instanceof Error ? err.message : String(err)
} finally {
backupBusy.value = false
}
}
/** Pick a .ruwbak file and write every project it contains into
* IDB, preserving ids so the next save overwrites cleanly. The
* existing recents list is NOT cleared — users typically want to
* merge rather than wipe, and we want the opportunity to refuse
* a confusing clobber. */
async function restoreBackup() {
if (backupBusy.value) return
const file = await pickBackupFile()
if (!file) return
backupBusy.value = true
backupError.value = null
try {
const records = await readBackupFile(file)
if (records.length === 0) {
backupError.value = t('welcome.backupEmpty')
return
}
// Check for id collisions so the user knows they'll be
// overwriting existing projects before we start writing.
const existing = await listAllProjects()
const existingIds = new Set(existing.map((r) => r.id))
const overlaps = records.filter((r) => existingIds.has(r.id)).length
if (overlaps > 0) {
const ok = await confirmDialog({
title: t('welcome.restoreConfirmTitle'),
message: t('welcome.restoreConfirmMessage', {
total: records.length,
overlap: overlaps,
}),
confirmLabel: t('welcome.restoreConfirmButton'),
variant: 'danger',
})
if (!ok) return
}
for (const rec of records) {
await saveProjectToStore(rec.project, {
id: rec.id,
name: rec.name,
thumbnail: rec.thumbnail,
})
}
await reload()
} catch (err) {
backupError.value =
err instanceof Error ? err.message : String(err)
} finally {
backupBusy.value = false
}
}
function close() { function close() {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
@@ -219,6 +312,7 @@ watch(
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="backupError" class="backup-error">{{ backupError }}</div>
<template #footer> <template #footer>
<button ref="newBtnRef" class="ftr-btn" @click="startFresh"> <button ref="newBtnRef" class="ftr-btn" @click="startFresh">
<PhPlus :size="13" weight="bold" /> <PhPlus :size="13" weight="bold" />
@@ -228,6 +322,25 @@ watch(
<PhFileArrowUp :size="13" /> <PhFileArrowUp :size="13" />
{{ t('welcome.importFile') }} {{ t('welcome.importFile') }}
</button> </button>
<span class="ftr-sep"></span>
<button
class="ftr-btn"
:disabled="backupBusy"
v-tooltip="t('welcome.backupAllHelp')"
@click="backupAll"
>
<PhArchive :size="13" />
{{ t('welcome.backupAll') }}
</button>
<button
class="ftr-btn"
:disabled="backupBusy"
v-tooltip="t('welcome.restoreBackupHelp')"
@click="restoreBackup"
>
<PhArrowsClockwise :size="13" />
{{ t('welcome.restoreBackup') }}
</button>
</template> </template>
</Modal> </Modal>
</template> </template>
@@ -389,4 +502,22 @@ watch(
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft); box-shadow: 0 0 0 2px var(--accent-soft);
} }
.ftr-btn:disabled {
opacity: 0.5;
cursor: default;
}
.ftr-sep {
flex: 1;
}
.backup-error {
margin: 0 12px 8px;
padding: 8px 10px;
font-size: 11px;
background: var(--danger-soft);
color: var(--danger);
border-radius: var(--radius-sm);
}
</style> </style>
+119
View File
@@ -1943,6 +1943,102 @@ export function registerAllTests(): void {
}) })
}) })
suite('ruwbak — multi-project backup roundtrip', () => {
// A .ruwbak is a zip holding one manifest.json + one packaged
// .ruw per project. Covers the "back up everything in browser
// storage" / "restore a backup" flow exposed from the welcome
// dialog.
test('pack + unpack preserves each project with its metadata', async () => {
const { packBackup, unpackBackup } = await import('@/lib/backupFile')
const a = makeFreshStore()
a.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#000' })
const projectA = JSON.parse(JSON.stringify(a.projectForSave))
const b = makeFreshStore()
b.addAsset({ kind: 'rect', name: 'B', width: 20, height: 20, fill: '#fff' })
const projectB = JSON.parse(JSON.stringify(b.projectForSave))
const bundle = packBackup([
{ id: 'id-a', name: 'Alpha', savedAt: 1, project: projectA },
{ id: 'id-b', name: 'Beta', savedAt: 2, project: projectB },
])
const out = unpackBackup(bundle)
expect(out.length).toBe(2)
expect(out[0].id).toBe('id-a')
expect(out[0].name).toBe('Alpha')
expect(out[0].savedAt).toBe(1)
expect(out[0].project.assets.some((x) => x.name === 'A')).toBe(true)
expect(out[1].id).toBe('id-b')
expect(out[1].project.assets.some((x) => x.name === 'B')).toBe(true)
})
test('backup survives asset data through the inner .ruw bundles', async () => {
const { packBackup, unpackBackup } = await import('@/lib/backupFile')
const s = makeFreshStore()
const imgSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAAC0lEQVQI12NgAAIAAAUAAeImBZsAAAAASUVORK5CYII='
s.addAsset({
kind: 'image', name: 'pixel', src: imgSrc, width: 1, height: 1,
})
const project = JSON.parse(JSON.stringify(s.projectForSave))
const bundle = packBackup([
{ id: 'only', name: 'Sole', savedAt: 0, project },
])
const [rec] = unpackBackup(bundle)
const img = rec.project.assets.find((a) => a.name === 'pixel')! as {
src: string
}
// Image round-trips all the way: through inner .ruw asset
// extraction + rehydration via the outer backup zip.
expect(img.src).toBe(imgSrc)
})
test('unpackBackup rejects non-zip + wrong magic + missing files', async () => {
const { unpackBackup, packBackup } = await import('@/lib/backupFile')
// Non-zip input.
let err: unknown
try { unpackBackup(new Uint8Array([0x7b, 0x7d])) } catch (e) { err = e }
expect((err as Error).message).toContain('ZIP')
// Wrong-magic manifest.
const { zipStore } = await import('@/lib/zipUtil')
const badManifest = zipStore([{
name: 'manifest.json',
data: new TextEncoder().encode(JSON.stringify({ magic: 'nope', version: 1, projects: [] })),
}])
err = null
try { unpackBackup(badManifest) } catch (e) { err = e }
expect((err as Error).message.toLowerCase()).toContain('magic')
// Missing referenced project file.
const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const goodBundle = packBackup([
{ id: 'missing', name: 'Lost', savedAt: 1, project: JSON.parse(JSON.stringify(s.projectForSave)) },
])
// Strip the inner .ruw to simulate corruption.
const { unzipStore } = await import('@/lib/zipUtil')
const entries = unzipStore(goodBundle).filter(
(e) => !e.name.startsWith('projects/'),
)
const corrupt = zipStore(entries)
err = null
try { unpackBackup(corrupt) } catch (e) { err = e }
expect((err as Error).message.toLowerCase()).toContain('missing')
})
test('duplicate ids in the input get unique file paths inside the zip', async () => {
const { packBackup } = await import('@/lib/backupFile')
const { unzipStore } = await import('@/lib/zipUtil')
const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const project = JSON.parse(JSON.stringify(s.projectForSave))
const bundle = packBackup([
{ id: 'dup', name: 'A', savedAt: 1, project },
{ id: 'dup', name: 'B', savedAt: 2, project },
])
const files = unzipStore(bundle).map((e) => e.name).filter((n) => n.startsWith('projects/'))
// Two distinct project files, one suffixed to avoid collision.
expect(new Set(files).size).toBe(2)
})
})
suite('editor state — save / load roundtrip', () => { suite('editor state — save / load roundtrip', () => {
// The store's editor block carries canvas zoom/pan, overlay // The store's editor block carries canvas zoom/pan, overlay
// toggles and timeline display settings. It rides along with the // toggles and timeline display settings. It rides along with the
@@ -2842,6 +2938,29 @@ export function registerAllTests(): void {
const back = await getStoredProject('definitely-missing-id-42') const back = await getStoredProject('definitely-missing-id-42')
expect(back).toBeNull() expect(back).toBeNull()
}) })
test('listAllProjects returns every record — no 8-recents cap', async () => {
// Backup flow needs the full dump, not just the top 8 that
// the welcome dialog shows.
const { listAllProjects } = await import('@/lib/projectStore')
const s = makeFreshStore()
const ids: string[] = []
const prefix = `test-backup-${Date.now()}-`
for (let i = 0; i < 10; i++) ids.push(`${prefix}${i}`)
try {
for (const id of ids) {
await saveProjectToStore(s.project, { id, name: id })
await tick(1)
}
const all = await listAllProjects()
// At least our 10 show up, even though listRecentProjects
// would only return 8.
const seen = ids.filter((id) => all.some((r) => r.id === id))
expect(seen.length).toBe(10)
} finally {
for (const id of ids) await deleteStoredProject(id).catch(() => {})
}
})
}) })
suite('ui — WelcomeDialog', () => { suite('ui — WelcomeDialog', () => {
+8
View File
@@ -58,6 +58,14 @@ export const en = {
message: '"{name}" will be permanently removed from browser storage.', message: '"{name}" will be permanently removed from browser storage.',
confirm: 'Delete', confirm: 'Delete',
}, },
backupAll: 'Backup all…',
backupAllHelp: 'Save every project in browser storage into a single .ruwbak file.',
backupEmpty: 'No saved projects yet — nothing to back up.',
restoreBackup: 'Restore backup…',
restoreBackupHelp: 'Load every project from a .ruwbak file into browser storage.',
restoreConfirmTitle: 'Restore backup?',
restoreConfirmMessage: 'Writing {total} projects — {overlap} of them would overwrite projects already in browser storage with the same id.',
restoreConfirmButton: 'Overwrite and restore',
}, },
sidebar: { sidebar: {
+166
View File
@@ -0,0 +1,166 @@
/**
* Multi-project backup bundle. Packs every StoredProject in IDB into
* one zip with an outer manifest and each project re-serialised as
* its own inner .ruw bundle:
*
* backup.zip
* manifest.json — { magic: 'ruwio-backup', version: 1,
* projects: [{ id, name, savedAt,
* thumbnail?, file: 'projects/<id>.ruw' }] }
* projects/<id>.ruw — normal single-project bundle
* (reusable standalone if the user
* extracts it).
*
* Nested zips cost a bit of overhead but buy a clean boundary: each
* inner .ruw stays a valid round-trippable project that the welcome
* dialog / file picker can import on its own. Extracting the backup
* gives the user a tidy folder of independent projects rather than
* one bucket of tangled assets.
*/
import type { Project } from '@/types/scene'
import { looksLikeZip, unzipStore, zipStore, type ZipEntry } from './zipUtil'
import { packProject, unpackProject } from './ruwFile'
const BACKUP_MAGIC = 'ruwio-backup'
const BACKUP_VERSION = 1
const PROJECTS_DIR = 'projects/'
export interface BackupRecord {
id: string
name: string
savedAt: number
thumbnail?: string
project: Project
}
interface BackupManifestEntry {
id: string
name: string
savedAt: number
thumbnail?: string
file: string
}
interface BackupManifest {
magic: typeof BACKUP_MAGIC
version: number
savedAt: string
projects: BackupManifestEntry[]
}
/** Pack every provided record into a single backup zip. Returns the
* raw bytes; callers download via `downloadBackup`. */
export function packBackup(records: BackupRecord[]): Uint8Array {
const entries: ZipEntry[] = []
const manifest: BackupManifest = {
magic: BACKUP_MAGIC,
version: BACKUP_VERSION,
savedAt: new Date().toISOString(),
projects: [],
}
const used = new Set<string>()
for (const rec of records) {
// Guard against id collisions (shouldn't happen from IDB but we
// accept arbitrary input) — suffix duplicates so zip paths stay
// unique.
let fileId = rec.id
let i = 2
while (used.has(fileId)) {
fileId = `${rec.id}-${i++}`
}
used.add(fileId)
const file = `${PROJECTS_DIR}${fileId}.ruw`
entries.push({ name: file, data: packProject(rec.project) })
manifest.projects.push({
id: rec.id,
name: rec.name,
savedAt: rec.savedAt,
thumbnail: rec.thumbnail,
file,
})
}
entries.unshift({
name: 'manifest.json',
data: new TextEncoder().encode(JSON.stringify(manifest, null, 2)),
})
return zipStore(entries)
}
/** Reverse of `packBackup`. Throws if the file isn't a RUWIO backup
* or an entry referenced by the manifest is missing / corrupt. */
export function unpackBackup(bytes: Uint8Array): BackupRecord[] {
if (!looksLikeZip(bytes)) {
throw new Error('Not a RUWIO backup (missing ZIP signature).')
}
const entries = unzipStore(bytes)
const byName = new Map<string, Uint8Array>()
for (const e of entries) byName.set(e.name, e.data)
const manifestBytes = byName.get('manifest.json')
if (!manifestBytes) {
throw new Error('Backup is missing manifest.json.')
}
let manifest: BackupManifest
try {
manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as BackupManifest
} catch {
throw new Error('Backup manifest is not valid JSON.')
}
if (manifest.magic !== BACKUP_MAGIC) {
throw new Error('Manifest magic does not match — is this a RUWIO backup?')
}
if (!Array.isArray(manifest.projects)) {
throw new Error('Backup manifest is missing the projects list.')
}
const out: BackupRecord[] = []
for (const entry of manifest.projects) {
const file = byName.get(entry.file)
if (!file) {
throw new Error(`Backup is missing project file "${entry.file}".`)
}
const project = unpackProject(file)
out.push({
id: entry.id,
name: entry.name,
savedAt: entry.savedAt,
thumbnail: entry.thumbnail,
project,
})
}
return out
}
export function downloadBackup(
records: BackupRecord[],
filename = `ruwio-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.ruwbak`,
): void {
const bytes = packBackup(records)
const copy = new Uint8Array(bytes.byteLength)
copy.set(bytes)
const blob = new Blob([copy], { type: 'application/zip' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
export async function readBackupFile(file: File): Promise<BackupRecord[]> {
const buf = new Uint8Array(await file.arrayBuffer())
return unpackBackup(buf)
}
/** File-picker tuned for `.ruwbak` plus a zip fallback. */
export function pickBackupFile(): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.ruwbak,application/zip'
input.onchange = () => resolve(input.files?.[0] ?? null)
input.oncancel = () => resolve(null)
input.click()
})
}
+15
View File
@@ -76,6 +76,21 @@ export async function listRecentProjects(): Promise<StoredProject[]> {
} }
} }
/** Full IDB dump — every stored project, newest first, no recents
* cap. Used by the "backup all" flow; the welcome list sticks to
* `listRecentProjects` so the UI stays short. */
export async function listAllProjects(): Promise<StoredProject[]> {
try {
const all = await tx<StoredProject[]>('readonly', (s) => s.getAll() as IDBRequest<StoredProject[]>)
return (all ?? [])
.slice()
.map(migrate)
.sort((a, b) => b.savedAt - a.savedAt)
} catch {
return []
}
}
export interface SaveOptions { export interface SaveOptions {
id?: string id?: string
name?: string name?: string
+4
View File
@@ -4,6 +4,10 @@ import { fileURLToPath, URL } from 'node:url'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: {
port: 5200,
},
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),