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),

View File

@@ -97,7 +97,7 @@
{
"type": "initial",
"maximumWarning": "2.2MB",
"maximumError": "2.36MB"
"maximumError": "2.38MB"
},
{
"type": "anyComponentStyle",

View File

@@ -124,6 +124,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;
}
export interface ElectronCommand {
type: string;
payload: unknown;
@@ -165,6 +183,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>;

View File

@@ -7,6 +7,7 @@ export type SettingsPage =
| 'notifications'
| 'voice'
| 'updates'
| 'data'
| 'debugging'
| 'server'
| 'members'

View File

@@ -0,0 +1,135 @@
<div class="space-y-6">
<section class="rounded-lg border border-border bg-card/60 p-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="flex items-center gap-2 text-primary">
<ng-icon
name="lucideDatabase"
class="h-5 w-5"
/>
<h4 class="text-base font-semibold text-foreground">Local data</h4>
</div>
<p class="mt-2 text-sm text-muted-foreground">
Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.
</p>
</div>
@if (restartRequired()) {
<button
type="button"
(click)="restartApp()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideRefreshCw"
class="h-4 w-4"
/>
Restart app
</button>
}
</div>
</section>
@if (!isElectron) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Data management is only available in the packaged Electron desktop app.</p>
</section>
} @else {
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Current data folder</h5>
<p class="mt-2 break-all rounded-lg border border-border bg-secondary/20 px-3 py-2 text-sm text-muted-foreground">
{{ dataPath() || 'Resolving data folder...' }}
</p>
</div>
<button
type="button"
(click)="openDataFolder()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideFolderOpen"
class="h-4 w-4"
/>
{{ busyAction() === 'open' ? 'Opening...' : 'Open folder' }}
</button>
</section>
<section class="grid gap-4 md:grid-cols-2">
<div class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Export data</h5>
<p class="mt-1 text-sm text-muted-foreground">Create a portable .dat archive that can be imported on another client.</p>
</div>
<button
type="button"
(click)="exportData()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4"
/>
{{ busyAction() === 'export' ? 'Exporting...' : 'Export data' }}
</button>
</div>
<div class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Import all data</h5>
<p class="mt-1 text-sm text-muted-foreground">Restore a .dat archive. Existing local data is moved to a backup folder first.</p>
</div>
<button
type="button"
(click)="importData()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideUpload"
class="h-4 w-4"
/>
{{ busyAction() === 'import' ? 'Importing...' : 'Import data' }}
</button>
</div>
</section>
<section class="space-y-4 rounded-lg border border-destructive/30 bg-destructive/10 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Erase user data</h5>
<p class="mt-1 text-sm text-muted-foreground">Remove local app data from this device and recreate an empty database.</p>
</div>
<button
type="button"
(click)="eraseData()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideTrash2"
class="h-4 w-4"
/>
{{ busyAction() === 'erase' ? 'Erasing...' : 'Erase user data' }}
</button>
</section>
@if (statusMessage()) {
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
<p class="break-words text-sm text-foreground">{{ statusMessage() }}</p>
</section>
}
@if (errorMessage()) {
<section class="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
</section>
}
}
</div>

View File

@@ -0,0 +1,137 @@
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideDatabase,
lucideDownload,
lucideFolderOpen,
lucideRefreshCw,
lucideTrash2,
lucideUpload
} from '@ng-icons/lucide';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
@Component({
selector: 'app-data-settings',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideDatabase,
lucideDownload,
lucideFolderOpen,
lucideRefreshCw,
lucideTrash2,
lucideUpload
})
],
templateUrl: './data-settings.component.html'
})
export class DataSettingsComponent {
private readonly electron = inject(ElectronBridgeService);
readonly isElectron = this.electron.isAvailable;
readonly dataPath = signal<string | null>(null);
readonly busyAction = signal<DataAction | null>(null);
readonly statusMessage = signal<string | null>(null);
readonly errorMessage = signal<string | null>(null);
readonly restartRequired = signal(false);
constructor() {
void this.loadDataPath();
}
async openDataFolder(): Promise<void> {
await this.runAction('open', async () => {
const opened = await this.electron.requireApi().openCurrentDataFolder();
this.statusMessage.set(opened ? 'Opened the current data folder.' : 'Could not open the data folder.');
});
}
async exportData(): Promise<void> {
await this.runAction('export', async () => {
const result = await this.electron.requireApi().exportUserData();
if (result.cancelled) {
this.statusMessage.set('Export cancelled.');
return;
}
this.statusMessage.set(result.filePath ? `Exported data to ${result.filePath}.` : 'Exported data.');
});
}
async importData(): Promise<void> {
if (!window.confirm('Importing data replaces the current local data. Existing data will be moved to a backup folder first. Continue?')) {
return;
}
await this.runAction('import', async () => {
const result = await this.electron.requireApi().importUserData();
if (result.cancelled) {
this.statusMessage.set('Import cancelled.');
return;
}
this.restartRequired.set(result.restartRequired);
this.statusMessage.set(result.backupPath
? `Imported data. Previous data was backed up to ${result.backupPath}.`
: 'Imported data.');
});
}
async eraseData(): Promise<void> {
if (!window.confirm('Erase all local MetoYou data on this device? This cannot be undone.')) {
return;
}
await this.runAction('erase', async () => {
const result = await this.electron.requireApi().eraseUserData();
this.restartRequired.set(result.restartRequired);
this.statusMessage.set('Local data erased. Restart the app to finish resetting the session.');
await this.loadDataPath();
});
}
async restartApp(): Promise<void> {
await this.runAction('restart', async () => {
await this.electron.requireApi().relaunchApp();
});
}
private async loadDataPath(): Promise<void> {
if (!this.isElectron) {
return;
}
try {
this.dataPath.set(await this.electron.requireApi().getAppDataPath());
} catch {
this.dataPath.set(null);
}
}
private async runAction(action: DataAction, operation: () => Promise<void>): Promise<void> {
this.busyAction.set(action);
this.errorMessage.set(null);
this.statusMessage.set(null);
try {
await operation();
} catch (error) {
this.errorMessage.set(error instanceof Error ? error.message : 'Data operation failed.');
} finally {
this.busyAction.set(null);
}
}
}

View File

@@ -144,6 +144,9 @@
@case ('updates') {
Updates
}
@case ('data') {
Data
}
@case ('debugging') {
Debugging
}
@@ -273,6 +276,15 @@
@case ('updates') {
<app-updates-settings />
}
@case ('data') {
@defer {
<app-data-settings />
} @loading {
<section class="rounded-lg border border-border bg-card/60 p-5">
<p class="text-sm text-muted-foreground">Loading data settings...</p>
</section>
}
}
@case ('debugging') {
<app-debugging-settings />
}

View File

@@ -44,6 +44,7 @@ import { BansSettingsComponent } from './bans-settings/bans-settings.component';
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
import { DataSettingsComponent } from './data-settings/data-settings.component';
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
import {
ThemeLibraryService,
@@ -63,6 +64,7 @@ import {
NotificationsSettingsComponent,
VoiceSettingsComponent,
UpdatesSettingsComponent,
DataSettingsComponent,
DebuggingSettingsComponent,
ServerSettingsComponent,
MembersSettingsComponent,
@@ -120,6 +122,7 @@ export class SettingsModalComponent {
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
{ id: 'data', label: 'Data', icon: 'lucideDownload' },
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
];
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [

View File

@@ -2,6 +2,7 @@ const { spawn } = require('child_process');
const DEV_SINGLE_INSTANCE_EXIT_CODE = 23;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
function isWaylandSession(env) {
const sessionType = String(env.XDG_SESSION_TYPE || '').trim().toLowerCase();
@@ -36,6 +37,10 @@ function resolveElectronBinary() {
function buildElectronArgs(argv) {
const args = [...argv];
if (isDevelopmentLaunch(process.env) && !args.includes(DEV_RELOAD_EXISTING_ARG)) {
args.push(DEV_RELOAD_EXISTING_ARG);
}
if (
process.platform === 'linux'
&& isWaylandSession(process.env)