feat: Data management

This commit is contained in:
2026-04-27 03:29:41 +02:00
parent 1b91eacb5b
commit 3858beb28e
13 changed files with 845 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import { createWindow, getMainWindow } from '../window/create-window';
const CUSTOM_PROTOCOL = 'toju';
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
let pendingDeepLink: string | null = null;
@@ -95,6 +96,12 @@ export function initializeDeepLinkHandling(): boolean {
}
app.on('second-instance', (_event, argv) => {
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
app.relaunch();
app.exit(0);
return;
}
focusMainWindow();
const deepLink = extractDeepLink(argv);

229
electron/data-archive.ts Normal file
View File

@@ -0,0 +1,229 @@
import * as fsp from 'fs/promises';
import * as path from 'path';
export interface ZipArchiveEntry {
data: Buffer;
path: string;
}
interface CentralDirectoryEntry {
compressedSize: number;
crc: number;
data: Buffer;
localHeaderOffset: number;
name: Buffer;
uncompressedSize: number;
}
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
const ZIP_UTF8_FLAG = 0x0800;
const ZIP_STORE_METHOD = 0;
const ZIP_VERSION = 20;
const MAX_UINT32 = 0xffffffff;
const crcTable = buildCrcTable();
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
const localParts: Buffer[] = [];
const centralEntries: CentralDirectoryEntry[] = [];
let offset = 0;
for (const entry of entries) {
const normalizedPath = normalizeZipPath(entry.path);
const name = Buffer.from(normalizedPath, 'utf8');
const data = entry.data;
if (name.length > 0xffff || data.length > MAX_UINT32 || offset > MAX_UINT32) {
throw new Error('Data archive is too large for the portable ZIP format.');
}
const crc = crc32(data);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(ZIP_LOCAL_FILE_HEADER_SIGNATURE, 0);
localHeader.writeUInt16LE(ZIP_VERSION, 4);
localHeader.writeUInt16LE(ZIP_UTF8_FLAG, 6);
localHeader.writeUInt16LE(ZIP_STORE_METHOD, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(data.length, 18);
localHeader.writeUInt32LE(data.length, 22);
localHeader.writeUInt16LE(name.length, 26);
localHeader.writeUInt16LE(0, 28);
localParts.push(localHeader, name, data);
centralEntries.push({
compressedSize: data.length,
crc,
data,
localHeaderOffset: offset,
name,
uncompressedSize: data.length
});
offset += localHeader.length + name.length + data.length;
}
const centralDirectoryOffset = offset;
const centralParts = centralEntries.map((entry) => {
const header = Buffer.alloc(46);
header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_SIGNATURE, 0);
header.writeUInt16LE(ZIP_VERSION, 4);
header.writeUInt16LE(ZIP_VERSION, 6);
header.writeUInt16LE(ZIP_UTF8_FLAG, 8);
header.writeUInt16LE(ZIP_STORE_METHOD, 10);
header.writeUInt16LE(0, 12);
header.writeUInt16LE(0, 14);
header.writeUInt32LE(entry.crc, 16);
header.writeUInt32LE(entry.compressedSize, 20);
header.writeUInt32LE(entry.uncompressedSize, 24);
header.writeUInt16LE(entry.name.length, 28);
header.writeUInt16LE(0, 30);
header.writeUInt16LE(0, 32);
header.writeUInt16LE(0, 34);
header.writeUInt16LE(0, 36);
header.writeUInt32LE(0, 38);
header.writeUInt32LE(entry.localHeaderOffset, 42);
offset += header.length + entry.name.length;
return Buffer.concat([header, entry.name]);
});
const centralDirectorySize = offset - centralDirectoryOffset;
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
throw new Error('Data archive is too large for the portable ZIP format.');
}
const end = Buffer.alloc(22);
end.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(centralEntries.length, 8);
end.writeUInt16LE(centralEntries.length, 10);
end.writeUInt32LE(centralDirectorySize, 12);
end.writeUInt32LE(centralDirectoryOffset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, ...centralParts, end]);
}
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
const endOffset = findEndOfCentralDirectory(data);
if (endOffset < 0) {
throw new Error('The selected file is not a supported data archive.');
}
const entryCount = data.readUInt16LE(endOffset + 10);
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
const entries: ZipArchiveEntry[] = [];
let offset = centralDirectoryOffset;
for (let index = 0; index < entryCount; index += 1) {
if (data.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
throw new Error('The data archive directory is invalid.');
}
const method = data.readUInt16LE(offset + 10);
const compressedSize = data.readUInt32LE(offset + 20);
const uncompressedSize = data.readUInt32LE(offset + 24);
const nameLength = data.readUInt16LE(offset + 28);
const extraLength = data.readUInt16LE(offset + 30);
const commentLength = data.readUInt16LE(offset + 32);
const localHeaderOffset = data.readUInt32LE(offset + 42);
const entryPath = normalizeZipPath(data.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
if (method !== ZIP_STORE_METHOD || compressedSize !== uncompressedSize) {
throw new Error('Compressed data archives are not supported by this build.');
}
if (data.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
throw new Error('The data archive contains an invalid file entry.');
}
const localNameLength = data.readUInt16LE(localHeaderOffset + 26);
const localExtraLength = data.readUInt16LE(localHeaderOffset + 28);
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength;
entries.push({
data: Buffer.from(data.subarray(dataOffset, dataOffset + compressedSize)),
path: entryPath
});
offset += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
export async function extractZipEntries(entries: ZipArchiveEntry[], destinationPath: string): Promise<void> {
const destinationRoot = path.resolve(destinationPath);
for (const entry of entries) {
const targetPath = path.resolve(destinationRoot, entry.path);
if (!targetPath.startsWith(destinationRoot + path.sep) && targetPath !== destinationRoot) {
throw new Error('The data archive contains an unsafe path.');
}
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.writeFile(targetPath, entry.data);
}
}
function findEndOfCentralDirectory(data: Buffer): number {
const minimumOffset = Math.max(0, data.length - 0xffff - 22);
for (let offset = data.length - 22; offset >= minimumOffset; offset -= 1) {
if (data.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
return offset;
}
}
return -1;
}
function normalizeZipPath(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized || normalized.split('/').some((part) => part === '..' || part === '')) {
throw new Error('The data archive contains an unsafe path.');
}
return normalized;
}
function buildCrcTable(): number[] {
const table: number[] = [];
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) !== 0
? 0xedb88320 ^ (value >>> 1)
: value >>> 1;
}
table[index] = value >>> 0;
}
return table;
}
function crc32(data: Buffer): number {
let crc = 0xffffffff;
for (const byte of data) {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}

257
electron/data-management.ts Normal file
View File

@@ -0,0 +1,257 @@
import {
app,
dialog,
shell
} from 'electron';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { destroyDatabase, initializeDatabase } from './db/database';
import {
createZipArchive,
extractZipEntries,
readZipArchive,
type ZipArchiveEntry
} from './data-archive';
export interface ExportUserDataResult {
cancelled: boolean;
exported: boolean;
filePath?: string;
}
export interface ImportUserDataResult {
backupPath?: string;
cancelled: boolean;
imported: boolean;
restartRequired: boolean;
}
export interface EraseUserDataResult {
erased: boolean;
restartRequired: boolean;
}
const ARCHIVE_MANIFEST_PATH = 'metoyou-data-manifest.json';
const ARCHIVE_DATA_PREFIX = 'data/';
const BACKUP_DIRECTORY_NAME = 'metoyou-data-backups';
export async function openCurrentDataFolder(): Promise<boolean> {
const error = await shell.openPath(app.getPath('userData'));
return error.length === 0;
}
export async function exportUserData(): Promise<ExportUserDataResult> {
const dataPath = app.getPath('userData');
const defaultFileName = `metoyou-data-${new Date().toISOString().slice(0, 10)}.dat`;
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: path.join(app.getPath('documents'), defaultFileName),
filters: [
{ extensions: ['dat'], name: 'MetoYou data archive' }
],
title: 'Export MetoYou data'
});
if (canceled || !filePath) {
return { cancelled: true, exported: false };
}
const entries: ZipArchiveEntry[] = [
{
data: Buffer.from(JSON.stringify({
appVersion: app.getVersion(),
exportedAt: new Date().toISOString(),
format: 'metoyou-user-data',
version: 1
}, null, 2)),
path: ARCHIVE_MANIFEST_PATH
}
];
for (const file of await collectDataFiles(dataPath)) {
const relativePath = toArchivePath(path.relative(dataPath, file));
entries.push({
data: await fsp.readFile(file),
path: `${ARCHIVE_DATA_PREFIX}${relativePath}`
});
}
await fsp.writeFile(ensureDatExtension(filePath), createZipArchive(entries));
return {
cancelled: false,
exported: true,
filePath: ensureDatExtension(filePath)
};
}
export async function importUserData(): Promise<ImportUserDataResult> {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [
{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }
],
properties: ['openFile'],
title: 'Import MetoYou data'
});
if (canceled || filePaths.length === 0) {
return {
cancelled: true,
imported: false,
restartRequired: false
};
}
const archiveEntries = readZipArchive(await fsp.readFile(filePaths[0]));
validateArchiveManifest(archiveEntries);
const importRoot = path.join(app.getPath('temp'), `metoyou-import-${Date.now()}`);
const importDataPath = path.join(importRoot, 'data');
try {
await extractZipEntries(
archiveEntries
.filter((entry) => entry.path.startsWith(ARCHIVE_DATA_PREFIX))
.map((entry) => ({
data: entry.data,
path: entry.path.slice(ARCHIVE_DATA_PREFIX.length)
})),
importDataPath
);
await destroyDatabase();
const backupPath = await moveCurrentDataAside();
await copyDirectory(importDataPath, app.getPath('userData'));
await initializeDatabase();
return {
backupPath,
cancelled: false,
imported: true,
restartRequired: true
};
} catch (error) {
await initializeDatabase().catch(() => {});
throw error;
} finally {
await fsp.rm(importRoot, { force: true, recursive: true }).catch(() => {});
}
}
export async function eraseUserData(): Promise<EraseUserDataResult> {
const dataPath = app.getPath('userData');
await destroyDatabase();
for (const entry of await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => [])) {
await fsp.rm(path.join(dataPath, entry.name), { force: true, recursive: true });
}
await fsp.mkdir(dataPath, { recursive: true });
await initializeDatabase();
return {
erased: true,
restartRequired: true
};
}
async function collectDataFiles(directoryPath: string): Promise<string[]> {
const files: string[] = [];
const entries = await fsp.readdir(directoryPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.name === BACKUP_DIRECTORY_NAME) {
continue;
}
const entryPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
files.push(...await collectDataFiles(entryPath));
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
}
async function moveCurrentDataAside(): Promise<string | undefined> {
const dataPath = app.getPath('userData');
const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME);
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);
const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []);
await fsp.mkdir(backupPath, { recursive: true });
let movedAny = false;
for (const entry of entries) {
if (entry.name === BACKUP_DIRECTORY_NAME) {
continue;
}
const sourcePath = path.join(dataPath, entry.name);
const targetPath = path.join(backupPath, entry.name);
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.rename(sourcePath, targetPath).catch(async () => {
await copyPath(sourcePath, targetPath);
await fsp.rm(sourcePath, { force: true, recursive: true });
});
movedAny = true;
}
return movedAny ? backupPath : undefined;
}
async function copyDirectory(sourcePath: string, targetPath: string): Promise<void> {
await fsp.mkdir(targetPath, { recursive: true });
for (const entry of await fsp.readdir(sourcePath, { withFileTypes: true }).catch(() => [])) {
await copyPath(path.join(sourcePath, entry.name), path.join(targetPath, entry.name));
}
}
async function copyPath(sourcePath: string, targetPath: string): Promise<void> {
const stats = await fsp.stat(sourcePath);
if (stats.isDirectory()) {
await copyDirectory(sourcePath, targetPath);
return;
}
if (stats.isFile()) {
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.copyFile(sourcePath, targetPath);
}
}
function validateArchiveManifest(entries: ZipArchiveEntry[]): void {
const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH);
if (!manifest) {
throw new Error('The selected file is missing a MetoYou data manifest.');
}
const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number };
if (parsed.format !== 'metoyou-user-data' || parsed.version !== 1) {
throw new Error('The selected file uses an unsupported data archive format.');
}
}
function ensureDatExtension(filePath: string): string {
return path.extname(filePath).toLowerCase() === '.dat'
? filePath
: `${filePath}.dat`;
}
function toArchivePath(filePath: string): string {
return filePath.split(path.sep).join('/');
}

View File

@@ -49,6 +49,12 @@ import {
readSavedTheme,
writeSavedTheme
} from '../theme-library';
import {
eraseUserData,
exportUserData,
importUserData,
openCurrentDataFolder
} from '../data-management';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [
@@ -335,6 +341,10 @@ export function setupSystemHandlers(): void {
});
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
ipcMain.handle('export-user-data', async () => await exportUserData());
ipcMain.handle('import-user-data', async () => await importUserData());
ipcMain.handle('erase-user-data', async () => await eraseUserData());
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));

View File

@@ -109,6 +109,24 @@ export interface SavedThemeFileDescriptor {
path: string;
}
export interface ExportUserDataResult {
cancelled: boolean;
exported: boolean;
filePath?: string;
}
export interface ImportUserDataResult {
backupPath?: string;
cancelled: boolean;
imported: boolean;
restartRequired: boolean;
}
export interface EraseUserDataResult {
erased: boolean;
restartRequired: boolean;
}
function readLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
@@ -157,6 +175,10 @@ export interface ElectronAPI {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;
importUserData: () => Promise<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>;
getSavedThemesPath: () => Promise<string>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>;
@@ -265,6 +287,10 @@ const electronAPI: ElectronAPI = {
};
},
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
exportUserData: () => ipcRenderer.invoke('export-user-data'),
importUserData: () => ipcRenderer.invoke('import-user-data'),
eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),