3cab735754
Updated various components to utilize parseAppDecimal and formatAppDecimal for consistent decimal parsing and formatting. This change enhances the handling of numeric inputs across the application, ensuring better accuracy and user experience in forms and displays.
651 lines
17 KiB
TypeScript
651 lines
17 KiB
TypeScript
import { formatAppDecimal } from '../utils/numberFormat.js'
|
|
import { db } from './db.js'
|
|
import { getActiveMasterKey } from './auth.js'
|
|
import {
|
|
decryptJson,
|
|
encryptBuffer,
|
|
decryptBuffer
|
|
} from './crypto.js'
|
|
import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
|
|
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
|
|
import { syncLogbook } from './sync.js'
|
|
import type { SyncQueueItem } from './db.js'
|
|
import { getAppVersion } from './pwaVersion.js'
|
|
import { dexieFieldsFromEncBytes, encBytesFromDexieFields } from './logbookBackup/encBlob.js'
|
|
import {
|
|
BACKUP_FORMAT,
|
|
BACKUP_VERSION,
|
|
type BackupManifestCounts,
|
|
type BackupManifestV2,
|
|
type LogbookMetaJson
|
|
} from './logbookBackup/manifest.js'
|
|
import {
|
|
buildArchiveFromCollected,
|
|
collectLogbookBackupData,
|
|
type BackupExportProgress
|
|
} from './logbookBackup/collector.js'
|
|
import {
|
|
isZipArchive,
|
|
readBinaryFile,
|
|
readManifestFromArchive,
|
|
readTextFile,
|
|
unzipArchive
|
|
} from './logbookBackup/zipArchive.js'
|
|
|
|
export { BACKUP_FORMAT, BACKUP_VERSION }
|
|
export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 }
|
|
|
|
export interface LogbookBackupPreview {
|
|
title: string
|
|
exportedAt: string
|
|
sourceLogbookId: string
|
|
counts: BackupManifestCounts
|
|
totalUncompressedBytes: number
|
|
}
|
|
|
|
export interface ParsedLogbookBackup {
|
|
manifest: BackupManifestV2
|
|
files: Record<string, Uint8Array>
|
|
}
|
|
|
|
export interface ExportLogbookBackupOptions {
|
|
onProgress?: (progress: BackupExportProgress) => void
|
|
}
|
|
|
|
const BACKUP_PASSPHRASE_SALT = 'KapteinsDaagbokBackupFileSalt_v1'
|
|
|
|
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
|
|
const encoder = new TextEncoder()
|
|
const passphraseBytes = encoder.encode(passphrase.trim())
|
|
const saltBytes = encoder.encode(BACKUP_PASSPHRASE_SALT)
|
|
|
|
const baseKey = await window.crypto.subtle.importKey(
|
|
'raw',
|
|
passphraseBytes,
|
|
{ name: 'PBKDF2' },
|
|
false,
|
|
['deriveKey']
|
|
)
|
|
|
|
return window.crypto.subtle.deriveKey(
|
|
{
|
|
name: 'PBKDF2',
|
|
salt: saltBytes,
|
|
iterations: 100_000,
|
|
hash: 'SHA-256'
|
|
},
|
|
baseKey,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt', 'decrypt']
|
|
)
|
|
}
|
|
|
|
async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
|
|
const key = await deriveBackupPassphraseKey(passphrase)
|
|
return encryptBuffer(logbookKey, key)
|
|
}
|
|
|
|
async function unwrapLogbookKeyFromEnc(
|
|
keyEnc: Uint8Array,
|
|
passphrase: string
|
|
): Promise<ArrayBuffer> {
|
|
try {
|
|
const fields = dexieFieldsFromEncBytes(keyEnc)
|
|
const cryptoKey = await deriveBackupPassphraseKey(passphrase)
|
|
return decryptBuffer(fields.encryptedData, fields.iv, fields.tag, cryptoKey)
|
|
} catch {
|
|
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
|
}
|
|
}
|
|
|
|
function encryptedPayloadData(
|
|
encryptedData: string,
|
|
iv: string,
|
|
tag: string,
|
|
extra?: Record<string, string>
|
|
): string {
|
|
return JSON.stringify({
|
|
ciphertext: encryptedData,
|
|
iv,
|
|
tag,
|
|
...extra
|
|
})
|
|
}
|
|
|
|
async function queueRestoredLogbookForSync(
|
|
logbookId: string,
|
|
encryptedTitle: string,
|
|
logbookKey: ArrayBuffer,
|
|
manifest: BackupManifestV2,
|
|
files: Record<string, Uint8Array>
|
|
): Promise<void> {
|
|
const masterKey = getActiveMasterKey()
|
|
if (!masterKey) throw new Error('Master key not found')
|
|
|
|
const aesMasterKey = await window.crypto.subtle.importKey(
|
|
'raw',
|
|
masterKey,
|
|
{ name: 'AES-GCM' },
|
|
false,
|
|
['encrypt']
|
|
)
|
|
const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey)
|
|
const now = new Date().toISOString()
|
|
|
|
const items: Omit<SyncQueueItem, 'id'>[] = [
|
|
{
|
|
action: 'create',
|
|
type: 'logbook',
|
|
payloadId: logbookId,
|
|
logbookId,
|
|
data: JSON.stringify({
|
|
encryptedTitle,
|
|
encryptedKey: encryptedKey.ciphertext,
|
|
iv: encryptedKey.iv,
|
|
tag: encryptedKey.tag
|
|
}),
|
|
updatedAt: now
|
|
}
|
|
]
|
|
|
|
const readFields = (path: string | null) => {
|
|
if (!path) return null
|
|
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
|
|
}
|
|
|
|
const yacht = readFields(manifest.files.yacht)
|
|
if (yacht) {
|
|
items.push({
|
|
action: 'update',
|
|
type: 'yacht',
|
|
payloadId: logbookId,
|
|
logbookId,
|
|
data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag),
|
|
updatedAt: now
|
|
})
|
|
}
|
|
|
|
const deviation = readFields(manifest.files.deviation)
|
|
if (deviation) {
|
|
items.push({
|
|
action: 'update',
|
|
type: 'deviation',
|
|
payloadId: logbookId,
|
|
logbookId,
|
|
data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag),
|
|
updatedAt: now
|
|
})
|
|
}
|
|
|
|
const logbookCrew = readFields(manifest.files.logbookCrewSelection)
|
|
if (logbookCrew) {
|
|
items.push({
|
|
action: 'update',
|
|
type: 'logbookCrew',
|
|
payloadId: logbookId,
|
|
logbookId,
|
|
data: encryptedPayloadData(logbookCrew.encryptedData, logbookCrew.iv, logbookCrew.tag),
|
|
updatedAt: now
|
|
})
|
|
}
|
|
|
|
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
|
|
if (logbookVessel) {
|
|
items.push({
|
|
action: 'update',
|
|
type: 'logbookVessel',
|
|
payloadId: logbookId,
|
|
logbookId,
|
|
data: encryptedPayloadData(
|
|
logbookVessel.encryptedData,
|
|
logbookVessel.iv,
|
|
logbookVessel.tag
|
|
),
|
|
updatedAt: now
|
|
})
|
|
}
|
|
|
|
for (const crew of manifest.files.crews) {
|
|
const f = readFields(crew.path)
|
|
items.push({
|
|
action: 'create',
|
|
type: 'crew',
|
|
payloadId: crew.payloadId,
|
|
logbookId,
|
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
|
updatedAt: crew.updatedAt
|
|
})
|
|
}
|
|
|
|
for (const entry of manifest.files.entries) {
|
|
const f = readFields(entry.path)
|
|
items.push({
|
|
action: 'create',
|
|
type: 'entry',
|
|
payloadId: entry.payloadId,
|
|
logbookId,
|
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
|
updatedAt: entry.updatedAt
|
|
})
|
|
}
|
|
|
|
for (const photo of manifest.files.photos) {
|
|
const f = readFields(photo.path)
|
|
items.push({
|
|
action: 'create',
|
|
type: 'photo',
|
|
payloadId: photo.payloadId,
|
|
logbookId,
|
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
|
|
entryId: photo.entryId
|
|
}),
|
|
updatedAt: photo.updatedAt
|
|
})
|
|
}
|
|
|
|
for (const voice of manifest.files.voiceMemos) {
|
|
const f = readFields(voice.path)
|
|
items.push({
|
|
action: 'create',
|
|
type: 'voiceMemo',
|
|
payloadId: voice.payloadId,
|
|
logbookId,
|
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
|
|
entryId: voice.entryId
|
|
}),
|
|
updatedAt: voice.updatedAt
|
|
})
|
|
}
|
|
|
|
for (const track of manifest.files.gpsTracks) {
|
|
const f = readFields(track.path)
|
|
items.push({
|
|
action: 'create',
|
|
type: 'gpsTrack',
|
|
payloadId: track.entryId,
|
|
logbookId,
|
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
|
updatedAt: track.updatedAt
|
|
})
|
|
}
|
|
|
|
await db.syncQueue.bulkPut(items)
|
|
}
|
|
|
|
async function writeBackupToDexie(
|
|
logbookId: string,
|
|
logbookMeta: LogbookMetaJson,
|
|
logbookKey: ArrayBuffer,
|
|
manifest: BackupManifestV2,
|
|
files: Record<string, Uint8Array>
|
|
): Promise<void> {
|
|
await db.logbooks.put({
|
|
id: logbookId,
|
|
encryptedTitle: logbookMeta.encryptedTitle,
|
|
updatedAt: logbookMeta.updatedAt,
|
|
isSynced: 0,
|
|
isShared: 0,
|
|
isDemo: logbookMeta.isDemo ? 1 : 0
|
|
})
|
|
|
|
await saveLogbookKey(logbookId, logbookKey)
|
|
|
|
const readFields = (path: string | null) => {
|
|
if (!path) return null
|
|
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
|
|
}
|
|
|
|
const yacht = readFields(manifest.files.yacht)
|
|
if (yacht) {
|
|
await db.yachts.put({
|
|
logbookId,
|
|
encryptedData: yacht.encryptedData,
|
|
iv: yacht.iv,
|
|
tag: yacht.tag,
|
|
updatedAt: logbookMeta.updatedAt
|
|
})
|
|
}
|
|
|
|
const deviation = readFields(manifest.files.deviation)
|
|
if (deviation) {
|
|
await db.deviations.put({
|
|
logbookId,
|
|
encryptedData: deviation.encryptedData,
|
|
iv: deviation.iv,
|
|
tag: deviation.tag,
|
|
updatedAt: logbookMeta.updatedAt
|
|
})
|
|
}
|
|
|
|
const logbookCrew = readFields(manifest.files.logbookCrewSelection)
|
|
if (logbookCrew) {
|
|
await db.logbookCrewSelections.put({
|
|
logbookId,
|
|
encryptedData: logbookCrew.encryptedData,
|
|
iv: logbookCrew.iv,
|
|
tag: logbookCrew.tag,
|
|
updatedAt: logbookMeta.updatedAt
|
|
})
|
|
}
|
|
|
|
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
|
|
if (logbookVessel) {
|
|
await db.logbookVesselSelections.put({
|
|
logbookId,
|
|
encryptedData: logbookVessel.encryptedData,
|
|
iv: logbookVessel.iv,
|
|
tag: logbookVessel.tag,
|
|
updatedAt: logbookMeta.updatedAt
|
|
})
|
|
}
|
|
|
|
if (manifest.files.crews.length > 0) {
|
|
await db.crews.bulkPut(
|
|
manifest.files.crews.map((c) => {
|
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path))
|
|
return {
|
|
payloadId: c.payloadId,
|
|
logbookId,
|
|
encryptedData: f.encryptedData,
|
|
iv: f.iv,
|
|
tag: f.tag,
|
|
updatedAt: c.updatedAt
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
if (manifest.files.entries.length > 0) {
|
|
await db.entries.bulkPut(
|
|
manifest.files.entries.map((e) => {
|
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path))
|
|
return {
|
|
payloadId: e.payloadId,
|
|
logbookId,
|
|
encryptedData: f.encryptedData,
|
|
iv: f.iv,
|
|
tag: f.tag,
|
|
updatedAt: e.updatedAt
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
if (manifest.files.photos.length > 0) {
|
|
await db.photos.bulkPut(
|
|
manifest.files.photos.map((p) => {
|
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path))
|
|
return {
|
|
payloadId: p.payloadId,
|
|
entryId: p.entryId,
|
|
logbookId,
|
|
encryptedData: f.encryptedData,
|
|
iv: f.iv,
|
|
tag: f.tag,
|
|
caption: '',
|
|
updatedAt: p.updatedAt
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
if (manifest.files.voiceMemos.length > 0) {
|
|
await db.voiceMemos.bulkPut(
|
|
manifest.files.voiceMemos.map((v) => {
|
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path))
|
|
return {
|
|
payloadId: v.payloadId,
|
|
entryId: v.entryId,
|
|
logbookId,
|
|
encryptedData: f.encryptedData,
|
|
iv: f.iv,
|
|
tag: f.tag,
|
|
updatedAt: v.updatedAt
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
if (manifest.files.gpsTracks.length > 0) {
|
|
await db.gpsTracks.bulkPut(
|
|
manifest.files.gpsTracks.map((t) => {
|
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, t.path))
|
|
return {
|
|
entryId: t.entryId,
|
|
logbookId,
|
|
encryptedData: f.encryptedData,
|
|
iv: f.iv,
|
|
tag: f.tag,
|
|
updatedAt: t.updatedAt
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
if (manifest.files.nmeaArchives.length > 0) {
|
|
await db.nmeaArchives.bulkPut(
|
|
manifest.files.nmeaArchives.map((n) => {
|
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, n.path))
|
|
return {
|
|
entryId: n.entryId,
|
|
logbookId,
|
|
encryptedData: f.encryptedData,
|
|
iv: f.iv,
|
|
tag: f.tag,
|
|
updatedAt: n.updatedAt
|
|
}
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
function remapParsedBackup(
|
|
parsed: ParsedLogbookBackup,
|
|
newLogbookId: string
|
|
): ParsedLogbookBackup {
|
|
const logbookMeta = JSON.parse(readTextFile(parsed.files, parsed.manifest.files.logbook)) as LogbookMetaJson
|
|
logbookMeta.id = newLogbookId
|
|
const newFiles = { ...parsed.files }
|
|
newFiles[parsed.manifest.files.logbook] = new TextEncoder().encode(JSON.stringify(logbookMeta))
|
|
return {
|
|
manifest: { ...parsed.manifest, logbookId: newLogbookId },
|
|
files: newFiles
|
|
}
|
|
}
|
|
|
|
export async function exportLogbookBackup(
|
|
logbookId: string,
|
|
passphrase: string,
|
|
options: ExportLogbookBackupOptions = {}
|
|
): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> {
|
|
if (!passphrase.trim() || passphrase.length < 8) {
|
|
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
|
|
}
|
|
|
|
const logbook = await db.logbooks.get(logbookId)
|
|
if (!logbook || logbook.isShared === 1) {
|
|
throw new Error('BACKUP_NOT_OWNER')
|
|
}
|
|
|
|
if (navigator.onLine) {
|
|
await syncLogbook(logbookId).catch((err) => {
|
|
console.warn('Pre-backup sync failed, exporting local data:', err)
|
|
})
|
|
}
|
|
|
|
options.onProgress?.({ phase: 'collect', current: 0, total: 1, bytesPacked: 0 })
|
|
const collected = await collectLogbookBackupData(logbookId)
|
|
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
|
|
const wrapped = await wrapLogbookKey(logbookKey, passphrase)
|
|
const keyEnc = encBytesFromDexieFields({
|
|
encryptedData: wrapped.ciphertext,
|
|
iv: wrapped.iv,
|
|
tag: wrapped.tag
|
|
})
|
|
|
|
const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, {
|
|
exportedAt: new Date().toISOString(),
|
|
appVersion: getAppVersion(),
|
|
onProgress: options.onProgress
|
|
})
|
|
|
|
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
|
|
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
|
const datePart = new Date().toISOString().slice(0, 10)
|
|
const filename = `${safeTitle}-${datePart}.daagbok`
|
|
const blob = new Blob([zipBytes.slice()], { type: 'application/zip' })
|
|
|
|
return { blob, filename, manifest }
|
|
}
|
|
|
|
function detectLegacyJsonV1(text: string): boolean {
|
|
const trimmed = text.trimStart()
|
|
if (!trimmed.startsWith('{')) return false
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as { format?: string; version?: number }
|
|
return parsed.format === BACKUP_FORMAT && parsed.version === 1
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function parseLogbookBackupFile(file: File): Promise<ParsedLogbookBackup> {
|
|
const buffer = await file.arrayBuffer()
|
|
const bytes = new Uint8Array(buffer)
|
|
|
|
if (!isZipArchive(bytes)) {
|
|
const text = new TextDecoder().decode(bytes)
|
|
if (detectLegacyJsonV1(text)) {
|
|
throw new Error('BACKUP_VERSION_UNSUPPORTED')
|
|
}
|
|
throw new Error('BACKUP_INVALID_ARCHIVE')
|
|
}
|
|
|
|
const files = unzipArchive(bytes)
|
|
const manifest = readManifestFromArchive(files)
|
|
return { manifest, files }
|
|
}
|
|
|
|
export async function previewLogbookBackup(
|
|
backup: ParsedLogbookBackup,
|
|
passphrase: string
|
|
): Promise<LogbookBackupPreview> {
|
|
const logbookKey = await unwrapLogbookKeyFromEnc(
|
|
readBinaryFile(backup.files, backup.manifest.files.key),
|
|
passphrase
|
|
)
|
|
const logbookMeta = JSON.parse(
|
|
readTextFile(backup.files, backup.manifest.files.logbook)
|
|
) as LogbookMetaJson
|
|
const parsed = JSON.parse(logbookMeta.encryptedTitle)
|
|
let title: string
|
|
try {
|
|
title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
|
|
} catch {
|
|
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
|
}
|
|
|
|
return {
|
|
title,
|
|
exportedAt: backup.manifest.exportedAt,
|
|
sourceLogbookId: backup.manifest.logbookId,
|
|
counts: backup.manifest.counts,
|
|
totalUncompressedBytes: backup.manifest.totalUncompressedBytes
|
|
}
|
|
}
|
|
|
|
export interface RestoreLogbookOptions {
|
|
overwrite?: boolean
|
|
assignNewId?: boolean
|
|
}
|
|
|
|
export async function restoreLogbookBackup(
|
|
backup: ParsedLogbookBackup,
|
|
passphrase: string,
|
|
options: RestoreLogbookOptions = {}
|
|
): Promise<{ logbookId: string; title: string }> {
|
|
if (!getActiveMasterKey()) {
|
|
throw new Error('BACKUP_NOT_AUTHENTICATED')
|
|
}
|
|
|
|
const logbookKey = await unwrapLogbookKeyFromEnc(
|
|
readBinaryFile(backup.files, backup.manifest.files.key),
|
|
passphrase
|
|
)
|
|
const logbookMeta = JSON.parse(
|
|
readTextFile(backup.files, backup.manifest.files.logbook)
|
|
) as LogbookMetaJson
|
|
const parsedTitle = JSON.parse(logbookMeta.encryptedTitle)
|
|
let title: string
|
|
try {
|
|
title = await decryptJson(parsedTitle.ciphertext, parsedTitle.iv, parsedTitle.tag, logbookKey)
|
|
} catch {
|
|
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
|
}
|
|
|
|
let targetId = backup.manifest.logbookId
|
|
const existing = await db.logbooks.get(targetId)
|
|
|
|
if (existing && !options.overwrite && !options.assignNewId) {
|
|
throw new Error('BACKUP_ID_CONFLICT')
|
|
}
|
|
|
|
if (existing && options.overwrite) {
|
|
await deleteLocalLogbookCache(targetId)
|
|
}
|
|
|
|
let prepared = backup
|
|
if (options.assignNewId || (existing && !options.overwrite)) {
|
|
targetId = crypto.randomUUID()
|
|
prepared = remapParsedBackup(backup, targetId)
|
|
}
|
|
|
|
const finalMeta = JSON.parse(
|
|
readTextFile(prepared.files, prepared.manifest.files.logbook)
|
|
) as LogbookMetaJson
|
|
|
|
await writeBackupToDexie(
|
|
targetId,
|
|
finalMeta,
|
|
logbookKey,
|
|
prepared.manifest,
|
|
prepared.files
|
|
)
|
|
await queueRestoredLogbookForSync(
|
|
targetId,
|
|
finalMeta.encryptedTitle,
|
|
logbookKey,
|
|
prepared.manifest,
|
|
prepared.files
|
|
)
|
|
|
|
if (navigator.onLine) {
|
|
await syncLogbook(targetId).catch((err) => {
|
|
console.warn('Post-restore sync failed, data saved locally:', err)
|
|
})
|
|
}
|
|
|
|
return { logbookId: targetId, title }
|
|
}
|
|
|
|
export function downloadBackupBlob(blob: Blob, filename: string): void {
|
|
const url = URL.createObjectURL(blob)
|
|
const anchor = document.createElement('a')
|
|
anchor.href = url
|
|
anchor.download = filename
|
|
anchor.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
/** Human-readable size for UI warnings. */
|
|
export function formatBackupBytes(bytes: number): string {
|
|
const fmt = (n: number) => formatAppDecimal(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB`
|
|
return `${fmt(bytes / (1024 * 1024))} MB`
|
|
}
|
|
|
|
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
|
|
export const BACKUP_SIZE_CONFIRM_BYTES = 150_000_000
|