feat: Data management
This commit is contained in:
@@ -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
229
electron/data-archive.ts
Normal 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
257
electron/data-management.ts
Normal 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('/');
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.36MB"
|
||||
"maximumError": "2.38MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SettingsPage =
|
||||
| 'notifications'
|
||||
| 'voice'
|
||||
| 'updates'
|
||||
| 'data'
|
||||
| 'debugging'
|
||||
| 'server'
|
||||
| 'members'
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user