diff --git a/electron/app/deep-links.ts b/electron/app/deep-links.ts index e610b39..429baa5 100644 --- a/electron/app/deep-links.ts +++ b/electron/app/deep-links.ts @@ -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); diff --git a/electron/data-archive.ts b/electron/data-archive.ts new file mode 100644 index 0000000..d6406dc --- /dev/null +++ b/electron/data-archive.ts @@ -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 { + 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; +} diff --git a/electron/data-management.ts b/electron/data-management.ts new file mode 100644 index 0000000..ee0b65a --- /dev/null +++ b/electron/data-management.ts @@ -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 { + const error = await shell.openPath(app.getPath('userData')); + + return error.length === 0; +} + +export async function exportUserData(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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('/'); +} diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 4280df3..01f8f4b 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -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)); diff --git a/electron/preload.ts b/electron/preload.ts index 05d7f56..15ec520 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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; + openCurrentDataFolder: () => Promise; + exportUserData: () => Promise; + importUserData: () => Promise; + eraseUserData: () => Promise; getSavedThemesPath: () => Promise; listSavedThemes: () => Promise; readSavedTheme: (fileName: string) => Promise; @@ -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), diff --git a/toju-app/angular.json b/toju-app/angular.json index f4ccea3..652f152 100644 --- a/toju-app/angular.json +++ b/toju-app/angular.json @@ -97,7 +97,7 @@ { "type": "initial", "maximumWarning": "2.2MB", - "maximumError": "2.36MB" + "maximumError": "2.38MB" }, { "type": "anyComponentStyle", diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index aa757aa..c4e9206 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -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; + openCurrentDataFolder: () => Promise; + exportUserData: () => Promise; + importUserData: () => Promise; + eraseUserData: () => Promise; getSavedThemesPath: () => Promise; listSavedThemes: () => Promise; readSavedTheme: (fileName: string) => Promise; diff --git a/toju-app/src/app/core/services/settings-modal.service.ts b/toju-app/src/app/core/services/settings-modal.service.ts index 03349fb..d9b8563 100644 --- a/toju-app/src/app/core/services/settings-modal.service.ts +++ b/toju-app/src/app/core/services/settings-modal.service.ts @@ -7,6 +7,7 @@ export type SettingsPage = | 'notifications' | 'voice' | 'updates' + | 'data' | 'debugging' | 'server' | 'members' diff --git a/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.html b/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.html new file mode 100644 index 0000000..513af71 --- /dev/null +++ b/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.html @@ -0,0 +1,135 @@ +
+
+
+
+
+ +

Local data

+
+

+ Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage. +

+
+ + @if (restartRequired()) { + + } +
+
+ + @if (!isElectron) { +
+

Data management is only available in the packaged Electron desktop app.

+
+ } @else { +
+
+
Current data folder
+

+ {{ dataPath() || 'Resolving data folder...' }} +

+
+ + +
+ +
+
+
+
Export data
+

Create a portable .dat archive that can be imported on another client.

+
+ + +
+ +
+
+
Import all data
+

Restore a .dat archive. Existing local data is moved to a backup folder first.

+
+ + +
+
+ +
+
+
Erase user data
+

Remove local app data from this device and recreate an empty database.

+
+ + +
+ + @if (statusMessage()) { +
+

{{ statusMessage() }}

+
+ } + + @if (errorMessage()) { +
+

{{ errorMessage() }}

+
+ } + } +
diff --git a/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts new file mode 100644 index 0000000..f35aa18 --- /dev/null +++ b/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts @@ -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(null); + readonly busyAction = signal(null); + readonly statusMessage = signal(null); + readonly errorMessage = signal(null); + readonly restartRequired = signal(false); + + constructor() { + void this.loadDataPath(); + } + + async openDataFolder(): Promise { + 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 { + 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 { + 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 { + 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 { + await this.runAction('restart', async () => { + await this.electron.requireApi().relaunchApp(); + }); + } + + private async loadDataPath(): Promise { + 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): Promise { + 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); + } + } +} diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html index f15e59b..c7633e6 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html @@ -144,6 +144,9 @@ @case ('updates') { Updates } + @case ('data') { + Data + } @case ('debugging') { Debugging } @@ -273,6 +276,15 @@ @case ('updates') { } + @case ('data') { + @defer { + + } @loading { +
+

Loading data settings...

+
+ } + } @case ('debugging') { } diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts index e42c03b..eb9ccb9 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts @@ -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 }[] = [ diff --git a/tools/launch-electron.js b/tools/launch-electron.js index b7273d7..c488640 100644 --- a/tools/launch-electron.js +++ b/tools/launch-electron.js @@ -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)