feat: Data management
This commit is contained in:
@@ -5,6 +5,7 @@ import { createWindow, getMainWindow } from '../window/create-window';
|
|||||||
const CUSTOM_PROTOCOL = 'toju';
|
const CUSTOM_PROTOCOL = 'toju';
|
||||||
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
||||||
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
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;
|
let pendingDeepLink: string | null = null;
|
||||||
|
|
||||||
@@ -95,6 +96,12 @@ export function initializeDeepLinkHandling(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.on('second-instance', (_event, argv) => {
|
app.on('second-instance', (_event, argv) => {
|
||||||
|
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
|
||||||
|
app.relaunch();
|
||||||
|
app.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
focusMainWindow();
|
focusMainWindow();
|
||||||
|
|
||||||
const deepLink = extractDeepLink(argv);
|
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,
|
readSavedTheme,
|
||||||
writeSavedTheme
|
writeSavedTheme
|
||||||
} from '../theme-library';
|
} from '../theme-library';
|
||||||
|
import {
|
||||||
|
eraseUserData,
|
||||||
|
exportUserData,
|
||||||
|
importUserData,
|
||||||
|
openCurrentDataFolder
|
||||||
|
} from '../data-management';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -335,6 +341,10 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
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('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||||
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||||
|
|||||||
@@ -109,6 +109,24 @@ export interface SavedThemeFileDescriptor {
|
|||||||
path: string;
|
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 {
|
function readLinuxDisplayServer(): string {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -157,6 +175,10 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
|
importUserData: () => Promise<ImportUserDataResult>;
|
||||||
|
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||||
getSavedThemesPath: () => Promise<string>;
|
getSavedThemesPath: () => Promise<string>;
|
||||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
readSavedTheme: (fileName: string) => Promise<string>;
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
@@ -265,6 +287,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
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'),
|
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "2.2MB",
|
"maximumWarning": "2.2MB",
|
||||||
"maximumError": "2.36MB"
|
"maximumError": "2.38MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
@@ -124,6 +124,24 @@ export interface SavedThemeFileDescriptor {
|
|||||||
path: string;
|
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 {
|
export interface ElectronCommand {
|
||||||
type: string;
|
type: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
@@ -165,6 +183,10 @@ export interface ElectronApi {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
|
importUserData: () => Promise<ImportUserDataResult>;
|
||||||
|
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||||
getSavedThemesPath: () => Promise<string>;
|
getSavedThemesPath: () => Promise<string>;
|
||||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
readSavedTheme: (fileName: string) => Promise<string>;
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type SettingsPage =
|
|||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'voice'
|
| 'voice'
|
||||||
| 'updates'
|
| 'updates'
|
||||||
|
| 'data'
|
||||||
| 'debugging'
|
| 'debugging'
|
||||||
| 'server'
|
| 'server'
|
||||||
| 'members'
|
| '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') {
|
@case ('updates') {
|
||||||
Updates
|
Updates
|
||||||
}
|
}
|
||||||
|
@case ('data') {
|
||||||
|
Data
|
||||||
|
}
|
||||||
@case ('debugging') {
|
@case ('debugging') {
|
||||||
Debugging
|
Debugging
|
||||||
}
|
}
|
||||||
@@ -273,6 +276,15 @@
|
|||||||
@case ('updates') {
|
@case ('updates') {
|
||||||
<app-updates-settings />
|
<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') {
|
@case ('debugging') {
|
||||||
<app-debugging-settings />
|
<app-debugging-settings />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { BansSettingsComponent } from './bans-settings/bans-settings.component';
|
|||||||
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
|
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
|
||||||
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
||||||
import { UpdatesSettingsComponent } from './updates-settings/updates-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 { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
||||||
import {
|
import {
|
||||||
ThemeLibraryService,
|
ThemeLibraryService,
|
||||||
@@ -63,6 +64,7 @@ import {
|
|||||||
NotificationsSettingsComponent,
|
NotificationsSettingsComponent,
|
||||||
VoiceSettingsComponent,
|
VoiceSettingsComponent,
|
||||||
UpdatesSettingsComponent,
|
UpdatesSettingsComponent,
|
||||||
|
DataSettingsComponent,
|
||||||
DebuggingSettingsComponent,
|
DebuggingSettingsComponent,
|
||||||
ServerSettingsComponent,
|
ServerSettingsComponent,
|
||||||
MembersSettingsComponent,
|
MembersSettingsComponent,
|
||||||
@@ -120,6 +122,7 @@ export class SettingsModalComponent {
|
|||||||
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
|
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
|
||||||
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
|
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
|
||||||
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
|
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
|
||||||
|
{ id: 'data', label: 'Data', icon: 'lucideDownload' },
|
||||||
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
|
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
|
||||||
];
|
];
|
||||||
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
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 = 23;
|
||||||
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
||||||
|
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
|
||||||
|
|
||||||
function isWaylandSession(env) {
|
function isWaylandSession(env) {
|
||||||
const sessionType = String(env.XDG_SESSION_TYPE || '').trim().toLowerCase();
|
const sessionType = String(env.XDG_SESSION_TYPE || '').trim().toLowerCase();
|
||||||
@@ -36,6 +37,10 @@ function resolveElectronBinary() {
|
|||||||
function buildElectronArgs(argv) {
|
function buildElectronArgs(argv) {
|
||||||
const args = [...argv];
|
const args = [...argv];
|
||||||
|
|
||||||
|
if (isDevelopmentLaunch(process.env) && !args.includes(DEV_RELOAD_EXISTING_ARG)) {
|
||||||
|
args.push(DEV_RELOAD_EXISTING_ARG);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
process.platform === 'linux'
|
process.platform === 'linux'
|
||||||
&& isWaylandSession(process.env)
|
&& isWaylandSession(process.env)
|
||||||
|
|||||||
Reference in New Issue
Block a user