Refacor electron app and add migrations

This commit is contained in:
2026-03-04 01:38:43 +01:00
parent 4e95ae77c5
commit be91b6dfe8
70 changed files with 1824 additions and 923 deletions

19
electron/app/flags.ts Normal file
View File

@@ -0,0 +1,19 @@
import { app } from 'electron';
export function configureAppFlags(): void {
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
if (process.platform === 'linux') {
app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage');
}
// Suppress Autofill devtools errors
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
// Allow media autoplay without user gesture
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
// Accept self-signed certificates in development (for --ssl dev server)
if (process.env['SSL'] === 'true') {
app.commandLine.appendSwitch('ignore-certificate-errors');
}
}

40
electron/app/lifecycle.ts Normal file
View File

@@ -0,0 +1,40 @@
import { app, BrowserWindow } from 'electron';
import {
initializeDatabase,
destroyDatabase,
getDataSource
} from '../db/database';
import { createWindow } from '../window/create-window';
import {
setupCqrsHandlers,
setupSystemHandlers,
setupWindowControlHandlers
} from '../ipc';
export function registerAppLifecycle(): void {
app.whenReady().then(async () => {
await initializeDatabase();
setupCqrsHandlers();
setupWindowControlHandlers();
setupSystemHandlers();
await createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0)
createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin')
app.quit();
});
app.on('before-quit', async (event) => {
if (getDataSource()?.isInitialized) {
event.preventDefault();
await destroyDatabase();
app.quit();
}
});
}

View File

@@ -0,0 +1,20 @@
import { DataSource } from 'typeorm';
import {
MessageEntity,
UserEntity,
RoomEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
} from '../../../entities';
export async function handleClearAllData(dataSource: DataSource): Promise<void> {
await dataSource.getRepository(MessageEntity).clear();
await dataSource.getRepository(UserEntity).clear();
await dataSource.getRepository(RoomEntity).clear();
await dataSource.getRepository(ReactionEntity).clear();
await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear();
await dataSource.getRepository(MetaEntity).clear();
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { ClearRoomMessagesCommand } from '../../types';
export async function handleClearRoomMessages(command: ClearRoomMessagesCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
await repo.delete({ roomId: command.payload.roomId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { DeleteAttachmentsForMessageCommand } from '../../types';
export async function handleDeleteAttachmentsForMessage(command: DeleteAttachmentsForMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(AttachmentEntity);
await repo.delete({ messageId: command.payload.messageId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { DeleteMessageCommand } from '../../types';
export async function handleDeleteMessage(command: DeleteMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
await repo.delete({ id: command.payload.messageId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { RoomEntity, MessageEntity } from '../../../entities';
import { DeleteRoomCommand } from '../../types';
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId } = command.payload;
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
await dataSource.getRepository(MessageEntity).delete({ roomId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { RemoveBanCommand } from '../../types';
export async function handleRemoveBan(command: RemoveBanCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(BanEntity);
await repo.delete({ oderId: command.payload.oderId });
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { ReactionEntity } from '../../../entities';
import { RemoveReactionCommand } from '../../types';
export async function handleRemoveReaction(command: RemoveReactionCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(ReactionEntity);
const { messageId, userId, emoji } = command.payload;
await repo.delete({ messageId, userId, emoji });
}

View File

@@ -0,0 +1,21 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { SaveAttachmentCommand } from '../../types';
export async function handleSaveAttachment(command: SaveAttachmentCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(AttachmentEntity);
const { attachment } = command.payload;
const entity = repo.create({
id: attachment.id,
messageId: attachment.messageId,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage ? 1 : 0,
uploaderPeerId: attachment.uploaderPeerId ?? null,
filePath: attachment.filePath ?? null,
savedPath: attachment.savedPath ?? null
});
await repo.save(entity);
}

View File

@@ -0,0 +1,20 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { SaveBanCommand } from '../../types';
export async function handleSaveBan(command: SaveBanCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(BanEntity);
const { ban } = command.payload;
const entity = repo.create({
oderId: ban.oderId,
roomId: ban.roomId,
userId: ban.userId ?? null,
bannedBy: ban.bannedBy,
displayName: ban.displayName ?? null,
reason: ban.reason ?? null,
expiresAt: ban.expiresAt ?? null,
timestamp: ban.timestamp
});
await repo.save(entity);
}

View File

@@ -0,0 +1,24 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { SaveMessageCommand } from '../../types';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { message } = command.payload;
const entity = repo.create({
id: message.id,
roomId: message.roomId,
channelId: message.channelId ?? null,
senderId: message.senderId,
senderName: message.senderName,
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt ?? null,
reactions: JSON.stringify(message.reactions ?? []),
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null,
});
await repo.save(entity);
}

View File

@@ -0,0 +1,26 @@
import { DataSource } from 'typeorm';
import { ReactionEntity } from '../../../entities';
import { SaveReactionCommand } from '../../types';
export async function handleSaveReaction(command: SaveReactionCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(ReactionEntity);
const { reaction } = command.payload;
// Deduplicate: skip if same messageId + userId + emoji already exists
const existing = await repo.findOne({
where: { messageId: reaction.messageId, userId: reaction.userId, emoji: reaction.emoji }
});
if (existing)
return;
const entity = repo.create({
id: reaction.id,
messageId: reaction.messageId,
oderId: reaction.oderId ?? null,
userId: reaction.userId ?? null,
emoji: reaction.emoji,
timestamp: reaction.timestamp
});
await repo.save(entity);
}

View File

@@ -0,0 +1,26 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { SaveRoomCommand } from '../../types';
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { room } = command.payload;
const entity = repo.create({
id: room.id,
name: room.name,
description: room.description ?? null,
topic: room.topic ?? null,
hostId: room.hostId,
password: room.password ?? null,
isPrivate: room.isPrivate ? 1 : 0,
createdAt: room.createdAt,
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
channels: room.channels != null ? JSON.stringify(room.channels) : null
});
await repo.save(entity);
}

View File

@@ -0,0 +1,26 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { SaveUserCommand } from '../../types';
export async function handleSaveUser(command: SaveUserCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(UserEntity);
const { user } = command.payload;
const entity = repo.create({
id: user.id,
oderId: user.oderId ?? null,
username: user.username ?? null,
displayName: user.displayName ?? null,
avatarUrl: user.avatarUrl ?? null,
status: user.status ?? null,
role: user.role ?? null,
joinedAt: user.joinedAt ?? null,
peerId: user.peerId ?? null,
isOnline: user.isOnline ? 1 : 0,
isAdmin: user.isAdmin ? 1 : 0,
isRoomOwner: user.isRoomOwner ? 1 : 0,
voiceState: user.voiceState != null ? JSON.stringify(user.voiceState) : null,
screenShareState: user.screenShareState != null ? JSON.stringify(user.screenShareState) : null
});
await repo.save(entity);
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
import { SetCurrentUserIdCommand } from '../../types';
export async function handleSetCurrentUserId(command: SetCurrentUserIdCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MetaEntity);
await repo.save({ key: 'currentUserId', value: command.payload.userId });
}

View File

@@ -0,0 +1,41 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { UpdateMessageCommand } from '../../types';
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { messageId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: messageId } });
if (!existing)
return;
if (updates.channelId !== undefined)
existing.channelId = updates.channelId ?? null;
if (updates.senderId !== undefined)
existing.senderId = updates.senderId;
if (updates.senderName !== undefined)
existing.senderName = updates.senderName;
if (updates.content !== undefined)
existing.content = updates.content;
if (updates.timestamp !== undefined)
existing.timestamp = updates.timestamp;
if (updates.editedAt !== undefined)
existing.editedAt = updates.editedAt ?? null;
if (updates.reactions !== undefined)
existing.reactions = JSON.stringify(updates.reactions ?? []);
if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0;
if (updates.replyToId !== undefined)
existing.replyToId = updates.replyToId ?? null;
await repo.save(existing);
}

View File

@@ -0,0 +1,29 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { UpdateRoomCommand } from '../../types';
import {
applyUpdates,
boolToInt,
jsonOrNull,
TransformMap
} from './utils/applyUpdates';
const ROOM_TRANSFORMS: TransformMap = {
isPrivate: boolToInt,
userCount: (val) => (val ?? 0),
permissions: jsonOrNull,
channels: jsonOrNull
};
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { roomId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: roomId } });
if (!existing)
return;
applyUpdates(existing, updates, ROOM_TRANSFORMS);
await repo.save(existing);
}

View File

@@ -0,0 +1,30 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { UpdateUserCommand } from '../../types';
import {
applyUpdates,
boolToInt,
jsonOrNull,
TransformMap
} from './utils/applyUpdates';
const USER_TRANSFORMS: TransformMap = {
isOnline: boolToInt,
isAdmin: boolToInt,
isRoomOwner: boolToInt,
voiceState: jsonOrNull,
screenShareState: jsonOrNull
};
export async function handleUpdateUser(command: UpdateUserCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(UserEntity);
const { userId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: userId } });
if (!existing)
return;
applyUpdates(existing, updates, USER_TRANSFORMS);
await repo.save(existing);
}

View File

@@ -0,0 +1,32 @@
/** Converts a boolean-like value to SQLite's 0/1 integer representation. */
export const boolToInt = (val: unknown) => (val ? 1 : 0);
/** Serialises an object to a JSON string, or returns null if the value is null/undefined. */
export const jsonOrNull = (val: unknown) => (val != null ? JSON.stringify(val) : null);
/** A map of field names to transform functions that handle special serialisation. */
export type TransformMap = Partial<Record<string, (val: unknown) => unknown>>;
/**
* Applies a partial `updates` object onto an existing entity.
*
* - Fields absent from `updates` (undefined) are skipped entirely.
* - Fields listed in `transforms` are passed through their transform function first.
* - All other fields are written as-is, falling back to null when the value is null/undefined.
*/
export function applyUpdates<T extends object>(
entity: T,
updates: Partial<Record<string, unknown>>,
transforms: TransformMap = {}
): void {
const target = entity as unknown as Record<string, unknown>;
for (const [key, value] of Object.entries(updates)) {
if (value === undefined)
continue;
const transform = transforms[key];
target[key] = transform ? transform(value) : (value ?? null);
}
}

View File

@@ -0,0 +1,59 @@
import { DataSource } from 'typeorm';
import {
CommandType,
CommandTypeKey,
Command,
SaveMessageCommand,
DeleteMessageCommand,
UpdateMessageCommand,
ClearRoomMessagesCommand,
SaveReactionCommand,
RemoveReactionCommand,
SaveUserCommand,
SetCurrentUserIdCommand,
UpdateUserCommand,
SaveRoomCommand,
DeleteRoomCommand,
UpdateRoomCommand,
SaveBanCommand,
RemoveBanCommand,
SaveAttachmentCommand,
DeleteAttachmentsForMessageCommand
} from '../types';
import { handleSaveMessage } from './handlers/saveMessage';
import { handleDeleteMessage } from './handlers/deleteMessage';
import { handleUpdateMessage } from './handlers/updateMessage';
import { handleClearRoomMessages } from './handlers/clearRoomMessages';
import { handleSaveReaction } from './handlers/saveReaction';
import { handleRemoveReaction } from './handlers/removeReaction';
import { handleSaveUser } from './handlers/saveUser';
import { handleSetCurrentUserId } from './handlers/setCurrentUserId';
import { handleUpdateUser } from './handlers/updateUser';
import { handleSaveRoom } from './handlers/saveRoom';
import { handleDeleteRoom } from './handlers/deleteRoom';
import { handleUpdateRoom } from './handlers/updateRoom';
import { handleSaveBan } from './handlers/saveBan';
import { handleRemoveBan } from './handlers/removeBan';
import { handleSaveAttachment } from './handlers/saveAttachment';
import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage';
import { handleClearAllData } from './handlers/clearAllData';
export const buildCommandHandlers = (dataSource: DataSource): Record<CommandTypeKey, (command: Command) => Promise<unknown>> => ({
[CommandType.SaveMessage]: (cmd) => handleSaveMessage(cmd as SaveMessageCommand, dataSource),
[CommandType.DeleteMessage]: (cmd) => handleDeleteMessage(cmd as DeleteMessageCommand, dataSource),
[CommandType.UpdateMessage]: (cmd) => handleUpdateMessage(cmd as UpdateMessageCommand, dataSource),
[CommandType.ClearRoomMessages]: (cmd) => handleClearRoomMessages(cmd as ClearRoomMessagesCommand, dataSource),
[CommandType.SaveReaction]: (cmd) => handleSaveReaction(cmd as SaveReactionCommand, dataSource),
[CommandType.RemoveReaction]: (cmd) => handleRemoveReaction(cmd as RemoveReactionCommand, dataSource),
[CommandType.SaveUser]: (cmd) => handleSaveUser(cmd as SaveUserCommand, dataSource),
[CommandType.SetCurrentUserId]: (cmd) => handleSetCurrentUserId(cmd as SetCurrentUserIdCommand, dataSource),
[CommandType.UpdateUser]: (cmd) => handleUpdateUser(cmd as UpdateUserCommand, dataSource),
[CommandType.SaveRoom]: (cmd) => handleSaveRoom(cmd as SaveRoomCommand, dataSource),
[CommandType.DeleteRoom]: (cmd) => handleDeleteRoom(cmd as DeleteRoomCommand, dataSource),
[CommandType.UpdateRoom]: (cmd) => handleUpdateRoom(cmd as UpdateRoomCommand, dataSource),
[CommandType.SaveBan]: (cmd) => handleSaveBan(cmd as SaveBanCommand, dataSource),
[CommandType.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource),
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
[CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, dataSource),
[CommandType.ClearAllData]: () => handleClearAllData(dataSource)
});

103
electron/cqrs/mappers.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Takes TypeORM entity rows and converts them to plain DTO objects
* matching the Angular-side interfaces.
*/
import { MessageEntity } from '../entities/MessageEntity';
import { UserEntity } from '../entities/UserEntity';
import { RoomEntity } from '../entities/RoomEntity';
import { ReactionEntity } from '../entities/ReactionEntity';
import { BanEntity } from '../entities/BanEntity';
import { AttachmentEntity } from '../entities/AttachmentEntity';
export function rowToMessage(row: MessageEntity) {
return {
id: row.id,
roomId: row.roomId,
channelId: row.channelId ?? undefined,
senderId: row.senderId,
senderName: row.senderName,
content: row.content,
timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined,
reactions: JSON.parse(row.reactions || '[]') as unknown[],
isDeleted: !!row.isDeleted,
replyToId: row.replyToId ?? undefined
};
}
export function rowToUser(row: UserEntity) {
return {
id: row.id,
oderId: row.oderId ?? '',
username: row.username ?? '',
displayName: row.displayName ?? '',
avatarUrl: row.avatarUrl ?? undefined,
status: row.status ?? 'offline',
role: row.role ?? 'member',
joinedAt: row.joinedAt ?? 0,
peerId: row.peerId ?? undefined,
isOnline: !!row.isOnline,
isAdmin: !!row.isAdmin,
isRoomOwner: !!row.isRoomOwner,
voiceState: row.voiceState ? JSON.parse(row.voiceState) : undefined,
screenShareState: row.screenShareState ? JSON.parse(row.screenShareState) : undefined
};
}
export function rowToRoom(row: RoomEntity) {
return {
id: row.id,
name: row.name,
description: row.description ?? undefined,
topic: row.topic ?? undefined,
hostId: row.hostId,
password: row.password ?? undefined,
isPrivate: !!row.isPrivate,
createdAt: row.createdAt,
userCount: row.userCount,
maxUsers: row.maxUsers ?? undefined,
icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
channels: row.channels ? JSON.parse(row.channels) : undefined
};
}
export function rowToReaction(row: ReactionEntity) {
return {
id: row.id,
messageId: row.messageId,
oderId: row.oderId ?? '',
userId: row.userId ?? '',
emoji: row.emoji,
timestamp: row.timestamp
};
}
export function rowToAttachment(row: AttachmentEntity) {
return {
id: row.id,
messageId: row.messageId,
filename: row.filename,
size: row.size,
mime: row.mime,
isImage: !!row.isImage,
uploaderPeerId: row.uploaderPeerId ?? undefined,
filePath: row.filePath ?? undefined,
savedPath: row.savedPath ?? undefined
};
}
export function rowToBan(row: BanEntity) {
return {
oderId: row.oderId,
userId: row.userId ?? '',
roomId: row.roomId,
bannedBy: row.bannedBy,
displayName: row.displayName ?? undefined,
reason: row.reason ?? undefined,
expiresAt: row.expiresAt ?? undefined,
timestamp: row.timestamp
};
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { rowToAttachment } from '../../mappers';
export async function handleGetAllAttachments(dataSource: DataSource) {
const repo = dataSource.getRepository(AttachmentEntity);
const rows = await repo.find();
return rows.map(rowToAttachment);
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { rowToRoom } from '../../mappers';
export async function handleGetAllRooms(dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity);
const rows = await repo.find();
return rows.map(rowToRoom);
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { GetAttachmentsForMessageQuery } from '../../types';
import { rowToAttachment } from '../../mappers';
export async function handleGetAttachmentsForMessage(query: GetAttachmentsForMessageQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(AttachmentEntity);
const rows = await repo.find({ where: { messageId: query.payload.messageId } });
return rows.map(rowToAttachment);
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { GetBansForRoomQuery } from '../../types';
import { rowToBan } from '../../mappers';
export async function handleGetBansForRoom(query: GetBansForRoomQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(BanEntity);
const now = Date.now();
const rows = await repo
.createQueryBuilder('ban')
.where('ban.roomId = :roomId', { roomId: query.payload.roomId })
.andWhere('(ban.expiresAt IS NULL OR ban.expiresAt > :now)', { now })
.getMany();
return rows.map(rowToBan);
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { UserEntity, MetaEntity } from '../../../entities';
import { rowToUser } from '../../mappers';
export async function handleGetCurrentUser(dataSource: DataSource) {
const metaRepo = dataSource.getRepository(MetaEntity);
const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } });
if (!metaRow?.value)
return null;
const userRepo = dataSource.getRepository(UserEntity);
const userRow = await userRepo.findOne({ where: { id: metaRow.value } });
return userRow ? rowToUser(userRow) : null;
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessageByIdQuery } from '../../types';
import { rowToMessage } from '../../mappers';
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const row = await repo.findOne({ where: { id: query.payload.messageId } });
return row ? rowToMessage(row) : null;
}

View File

@@ -0,0 +1,17 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesQuery } from '../../types';
import { rowToMessage } from '../../mappers';
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const { roomId, limit = 100, offset = 0 } = query.payload;
const rows = await repo.find({
where: { roomId },
order: { timestamp: 'ASC' },
take: limit,
skip: offset
});
return rows.map(rowToMessage);
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { ReactionEntity } from '../../../entities';
import { GetReactionsForMessageQuery } from '../../types';
import { rowToReaction } from '../../mappers';
export async function handleGetReactionsForMessage(query: GetReactionsForMessageQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(ReactionEntity);
const rows = await repo.find({ where: { messageId: query.payload.messageId } });
return rows.map(rowToReaction);
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { GetRoomQuery } from '../../types';
import { rowToRoom } from '../../mappers';
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity);
const row = await repo.findOne({ where: { id: query.payload.roomId } });
return row ? rowToRoom(row) : null;
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { GetUserQuery } from '../../types';
import { rowToUser } from '../../mappers';
export async function handleGetUser(query: GetUserQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(UserEntity);
const row = await repo.findOne({ where: { id: query.payload.userId } });
return row ? rowToUser(row) : null;
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { rowToUser } from '../../mappers';
/** Returns all stored users (room filtering not applicable in this schema). */
export async function handleGetUsersByRoom(dataSource: DataSource) {
const repo = dataSource.getRepository(UserEntity);
const rows = await repo.find();
return rows.map(rowToUser);
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { IsUserBannedQuery } from '../../types';
export async function handleIsUserBanned(query: IsUserBannedQuery, dataSource: DataSource): Promise<boolean> {
const repo = dataSource.getRepository(BanEntity);
const now = Date.now();
const { userId, roomId } = query.payload;
const rows = await repo
.createQueryBuilder('ban')
.where('ban.roomId = :roomId', { roomId })
.andWhere('(ban.expiresAt IS NULL OR ban.expiresAt > :now)', { now })
.getMany();
return rows.some((row) => row.oderId === userId);
}

View File

@@ -0,0 +1,41 @@
import { DataSource } from 'typeorm';
import {
QueryType,
QueryTypeKey,
Query,
GetMessagesQuery,
GetMessageByIdQuery,
GetReactionsForMessageQuery,
GetUserQuery,
GetRoomQuery,
GetBansForRoomQuery,
IsUserBannedQuery,
GetAttachmentsForMessageQuery
} from '../types';
import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser';
import { handleGetCurrentUser } from './handlers/getCurrentUser';
import { handleGetUsersByRoom } from './handlers/getUsersByRoom';
import { handleGetRoom } from './handlers/getRoom';
import { handleGetAllRooms } from './handlers/getAllRooms';
import { handleGetBansForRoom } from './handlers/getBansForRoom';
import { handleIsUserBanned } from './handlers/isUserBanned';
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
import { handleGetAllAttachments } from './handlers/getAllAttachments';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
[QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource),
[QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource),
[QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource),
[QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource),
[QueryType.GetBansForRoom]: (query) => handleGetBansForRoom(query as GetBansForRoomQuery, dataSource),
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
[QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, dataSource),
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource)
});

197
electron/cqrs/types.ts Normal file
View File

@@ -0,0 +1,197 @@
/* ------------------------------------------------------------------ */
/* CQRS type definitions for the MetoYou electron main process. */
/* Commands mutate state; queries read state. */
/* ------------------------------------------------------------------ */
// --------------- Command types ---------------
export const CommandType = {
SaveMessage: 'save-message',
DeleteMessage: 'delete-message',
UpdateMessage: 'update-message',
ClearRoomMessages: 'clear-room-messages',
SaveReaction: 'save-reaction',
RemoveReaction: 'remove-reaction',
SaveUser: 'save-user',
SetCurrentUserId: 'set-current-user-id',
UpdateUser: 'update-user',
SaveRoom: 'save-room',
DeleteRoom: 'delete-room',
UpdateRoom: 'update-room',
SaveBan: 'save-ban',
RemoveBan: 'remove-ban',
SaveAttachment: 'save-attachment',
DeleteAttachmentsForMessage: 'delete-attachments-for-message',
ClearAllData: 'clear-all-data'
} as const;
export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
// --------------- Query types ---------------
export const QueryType = {
GetMessages: 'get-messages',
GetMessageById: 'get-message-by-id',
GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user',
GetCurrentUser: 'get-current-user',
GetUsersByRoom: 'get-users-by-room',
GetRoom: 'get-room',
GetAllRooms: 'get-all-rooms',
GetBansForRoom: 'get-bans-for-room',
IsUserBanned: 'is-user-banned',
GetAttachmentsForMessage: 'get-attachments-for-message',
GetAllAttachments: 'get-all-attachments'
} as const;
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
// --------------- Payload interfaces ---------------
export interface MessagePayload {
id: string;
roomId: string;
channelId?: string;
senderId: string;
senderName: string;
content: string;
timestamp: number;
editedAt?: number;
reactions?: ReactionPayload[];
isDeleted?: boolean;
replyToId?: string;
}
export interface ReactionPayload {
id: string;
messageId: string;
oderId: string;
userId: string;
emoji: string;
timestamp: number;
}
export interface UserPayload {
id: string;
oderId?: string;
username?: string;
displayName?: string;
avatarUrl?: string;
status?: string;
role?: string;
joinedAt?: number;
peerId?: string;
isOnline?: boolean;
isAdmin?: boolean;
isRoomOwner?: boolean;
voiceState?: unknown;
screenShareState?: unknown;
}
export interface RoomPayload {
id: string;
name: string;
description?: string;
topic?: string;
hostId: string;
password?: string;
isPrivate?: boolean;
createdAt: number;
userCount?: number;
maxUsers?: number;
icon?: string;
iconUpdatedAt?: number;
permissions?: unknown;
channels?: unknown[];
}
export interface BanPayload {
oderId: string;
roomId: string;
userId?: string;
bannedBy: string;
displayName?: string;
reason?: string;
expiresAt?: number;
timestamp: number;
}
export interface AttachmentPayload {
id: string;
messageId: string;
filename: string;
size: number;
mime: string;
isImage?: boolean;
uploaderPeerId?: string;
filePath?: string;
savedPath?: string;
}
// --------------- Command interfaces ---------------
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
export interface ClearRoomMessagesCommand { type: typeof CommandType.ClearRoomMessages; payload: { roomId: string } }
export interface SaveReactionCommand { type: typeof CommandType.SaveReaction; payload: { reaction: ReactionPayload } }
export interface RemoveReactionCommand { type: typeof CommandType.RemoveReaction; payload: { messageId: string; userId: string; emoji: string } }
export interface SaveUserCommand { type: typeof CommandType.SaveUser; payload: { user: UserPayload } }
export interface SetCurrentUserIdCommand { type: typeof CommandType.SetCurrentUserId; payload: { userId: string } }
export interface UpdateUserCommand { type: typeof CommandType.UpdateUser; payload: { userId: string; updates: Partial<UserPayload> } }
export interface SaveRoomCommand { type: typeof CommandType.SaveRoom; payload: { room: RoomPayload } }
export interface DeleteRoomCommand { type: typeof CommandType.DeleteRoom; payload: { roomId: string } }
export interface UpdateRoomCommand { type: typeof CommandType.UpdateRoom; payload: { roomId: string; updates: Partial<RoomPayload> } }
export interface SaveBanCommand { type: typeof CommandType.SaveBan; payload: { ban: BanPayload } }
export interface RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } }
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } }
export interface ClearAllDataCommand { type: typeof CommandType.ClearAllData; payload: Record<string, never> }
export type Command =
| SaveMessageCommand
| DeleteMessageCommand
| UpdateMessageCommand
| ClearRoomMessagesCommand
| SaveReactionCommand
| RemoveReactionCommand
| SaveUserCommand
| SetCurrentUserIdCommand
| UpdateUserCommand
| SaveRoomCommand
| DeleteRoomCommand
| UpdateRoomCommand
| SaveBanCommand
| RemoveBanCommand
| SaveAttachmentCommand
| DeleteAttachmentsForMessageCommand
| ClearAllDataCommand;
// --------------- Query interfaces ---------------
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> }
export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } }
export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } }
export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> }
export interface GetBansForRoomQuery { type: typeof QueryType.GetBansForRoom; payload: { roomId: string } }
export interface IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } }
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> }
export type Query =
| GetMessagesQuery
| GetMessageByIdQuery
| GetReactionsForMessageQuery
| GetUserQuery
| GetCurrentUserQuery
| GetUsersByRoomQuery
| GetRoomQuery
| GetAllRoomsQuery
| GetBansForRoomQuery
| IsUserBannedQuery
| GetAttachmentsForMessageQuery
| GetAllAttachmentsQuery;

43
electron/data-source.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* TypeORM DataSource used by the CLI migration tools (generate / run / revert).
*
* At **runtime** the DataSource is constructed inside `main.ts` and
* pointed at the user's Electron `userData` directory instead.
*
* For CLI use, the database is stored at the project root (`metoyou.sqlite`)
* so TypeORM can diff entity metadata against the current schema when
* generating new migrations.
*/
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import * as path from 'path';
import * as fs from 'fs';
import { settings } from './settings';
import {
MessageEntity,
UserEntity,
RoomEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
} from './entities';
const projectRootDatabaseFilePath = path.join(__dirname, '..', '..', settings.databaseName);
let databaseFileBuffer: Uint8Array | undefined;
if (fs.existsSync(projectRootDatabaseFilePath)) {
databaseFileBuffer = fs.readFileSync(projectRootDatabaseFilePath);
}
export const AppDataSource = new DataSource({
type: 'sqljs',
database: databaseFileBuffer,
entities: [MessageEntity, UserEntity, RoomEntity, ReactionEntity, BanEntity, AttachmentEntity, MetaEntity],
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
synchronize: false,
logging: false,
autoSave: true,
location: projectRootDatabaseFilePath
});

View File

@@ -1,618 +0,0 @@
/**
* Electron main-process SQLite database module.
*
* All SQL queries live here the renderer communicates exclusively via IPC.
* Uses sql.js (WASM SQLite) loaded in Node.js.
*/
const { ipcMain, app } = require('electron');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
let db = null;
let dbPath = '';
/* ------------------------------------------------------------------ */
/* Migrations */
/* ------------------------------------------------------------------ */
const migrations = [
{
version: 1,
description: 'Initial schema messages, users, rooms, reactions, bans, meta',
up(database) {
database.run(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
roomId TEXT NOT NULL,
channelId TEXT,
senderId TEXT NOT NULL,
senderName TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
editedAt INTEGER,
reactions TEXT NOT NULL DEFAULT '[]',
isDeleted INTEGER NOT NULL DEFAULT 0,
replyToId TEXT
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_messages_roomId ON messages(roomId);');
database.run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
oderId TEXT,
username TEXT,
displayName TEXT,
avatarUrl TEXT,
status TEXT,
role TEXT,
joinedAt INTEGER,
peerId TEXT,
isOnline INTEGER,
isAdmin INTEGER,
isRoomOwner INTEGER,
voiceState TEXT,
screenShareState TEXT
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
topic TEXT,
hostId TEXT NOT NULL,
password TEXT,
isPrivate INTEGER NOT NULL DEFAULT 0,
createdAt INTEGER NOT NULL,
userCount INTEGER NOT NULL DEFAULT 0,
maxUsers INTEGER,
icon TEXT,
iconUpdatedAt INTEGER,
permissions TEXT,
channels TEXT
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS reactions (
id TEXT PRIMARY KEY,
messageId TEXT NOT NULL,
oderId TEXT,
userId TEXT,
emoji TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_reactions_messageId ON reactions(messageId);');
database.run(`
CREATE TABLE IF NOT EXISTS bans (
oderId TEXT NOT NULL,
userId TEXT,
roomId TEXT NOT NULL,
bannedBy TEXT NOT NULL,
displayName TEXT,
reason TEXT,
expiresAt INTEGER,
timestamp INTEGER NOT NULL,
PRIMARY KEY (oderId, roomId)
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_bans_roomId ON bans(roomId);');
database.run(`
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
`);
},
},
{
version: 2,
description: 'Attachments table',
up(database) {
database.run(`
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
messageId TEXT NOT NULL,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
mime TEXT NOT NULL,
isImage INTEGER NOT NULL DEFAULT 0,
uploaderPeerId TEXT,
filePath TEXT,
savedPath TEXT
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_attachments_messageId ON attachments(messageId);');
},
},
];
function runMigrations() {
db.run(`
CREATE TABLE IF NOT EXISTS schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL DEFAULT 0
);
`);
const row = db.exec('SELECT version FROM schema_version WHERE id = 1');
let currentVersion = row.length > 0 ? row[0].values[0][0] : 0;
if (row.length === 0) {
db.run('INSERT INTO schema_version (id, version) VALUES (1, 0)');
}
for (const migration of migrations) {
if (migration.version > currentVersion) {
console.log(`[ElectronDB] Running migration v${migration.version}: ${migration.description}`);
migration.up(db);
currentVersion = migration.version;
db.run('UPDATE schema_version SET version = ? WHERE id = 1', [currentVersion]);
}
}
}
/* ------------------------------------------------------------------ */
/* Persistence */
/* ------------------------------------------------------------------ */
function persist() {
if (!db) return;
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(dbPath, buffer);
}
/* ------------------------------------------------------------------ */
/* Initialisation */
/* ------------------------------------------------------------------ */
async function initDatabase() {
const initSqlJs = require('sql.js');
const SQL = await initSqlJs();
const dbDir = path.join(app.getPath('userData'), 'metoyou');
await fsp.mkdir(dbDir, { recursive: true });
dbPath = path.join(dbDir, 'metoyou.sqlite');
if (fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath);
db = new SQL.Database(fileBuffer);
} else {
db = new SQL.Database();
}
db.run('PRAGMA journal_mode = MEMORY;');
db.run('PRAGMA synchronous = NORMAL;');
runMigrations();
persist();
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/** Run a prepared-statement query and return rows as plain objects. */
function queryAll(sql, params = []) {
const stmt = db.prepare(sql);
stmt.bind(params);
const results = [];
while (stmt.step()) results.push(stmt.getAsObject());
stmt.free();
return results;
}
/** Return a single row as object or null. */
function queryOne(sql, params = []) {
const stmt = db.prepare(sql);
stmt.bind(params);
let result = null;
if (stmt.step()) result = stmt.getAsObject();
stmt.free();
return result;
}
/* ------------------------------------------------------------------ */
/* Row → model mappers */
/* ------------------------------------------------------------------ */
function rowToMessage(r) {
return {
id: String(r.id),
roomId: String(r.roomId),
channelId: r.channelId ? String(r.channelId) : undefined,
senderId: String(r.senderId),
senderName: String(r.senderName),
content: String(r.content),
timestamp: Number(r.timestamp),
editedAt: r.editedAt != null ? Number(r.editedAt) : undefined,
reactions: JSON.parse(String(r.reactions || '[]')),
isDeleted: !!r.isDeleted,
replyToId: r.replyToId ? String(r.replyToId) : undefined,
};
}
function rowToUser(r) {
return {
id: String(r.id),
oderId: String(r.oderId ?? ''),
username: String(r.username ?? ''),
displayName: String(r.displayName ?? ''),
avatarUrl: r.avatarUrl ? String(r.avatarUrl) : undefined,
status: String(r.status ?? 'offline'),
role: String(r.role ?? 'member'),
joinedAt: Number(r.joinedAt ?? 0),
peerId: r.peerId ? String(r.peerId) : undefined,
isOnline: !!r.isOnline,
isAdmin: !!r.isAdmin,
isRoomOwner: !!r.isRoomOwner,
voiceState: r.voiceState ? JSON.parse(String(r.voiceState)) : undefined,
screenShareState: r.screenShareState ? JSON.parse(String(r.screenShareState)) : undefined,
};
}
function rowToRoom(r) {
return {
id: String(r.id),
name: String(r.name),
description: r.description ? String(r.description) : undefined,
topic: r.topic ? String(r.topic) : undefined,
hostId: String(r.hostId),
password: r.password ? String(r.password) : undefined,
isPrivate: !!r.isPrivate,
createdAt: Number(r.createdAt),
userCount: Number(r.userCount),
maxUsers: r.maxUsers != null ? Number(r.maxUsers) : undefined,
icon: r.icon ? String(r.icon) : undefined,
iconUpdatedAt: r.iconUpdatedAt != null ? Number(r.iconUpdatedAt) : undefined,
permissions: r.permissions ? JSON.parse(String(r.permissions)) : undefined,
channels: r.channels ? JSON.parse(String(r.channels)) : undefined,
};
}
function rowToReaction(r) {
return {
id: String(r.id),
messageId: String(r.messageId),
oderId: String(r.oderId ?? ''),
userId: String(r.userId ?? ''),
emoji: String(r.emoji),
timestamp: Number(r.timestamp),
};
}
function rowToAttachment(r) {
return {
id: String(r.id),
messageId: String(r.messageId),
filename: String(r.filename),
size: Number(r.size),
mime: String(r.mime),
isImage: !!r.isImage,
uploaderPeerId: r.uploaderPeerId ? String(r.uploaderPeerId) : undefined,
filePath: r.filePath ? String(r.filePath) : undefined,
savedPath: r.savedPath ? String(r.savedPath) : undefined,
};
}
function rowToBan(r) {
return {
oderId: String(r.oderId),
userId: String(r.userId ?? ''),
roomId: String(r.roomId),
bannedBy: String(r.bannedBy),
displayName: r.displayName ? String(r.displayName) : undefined,
reason: r.reason ? String(r.reason) : undefined,
expiresAt: r.expiresAt != null ? Number(r.expiresAt) : undefined,
timestamp: Number(r.timestamp),
};
}
/* ------------------------------------------------------------------ */
/* IPC handler registration */
/* ------------------------------------------------------------------ */
function registerDatabaseIpc() {
ipcMain.handle('db:initialize', async () => {
await initDatabase();
return true;
});
ipcMain.handle('db:saveMessage', (_e, message) => {
db.run(
`INSERT OR REPLACE INTO messages
(id, roomId, channelId, senderId, senderName, content, timestamp, editedAt, reactions, isDeleted, replyToId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
message.id,
message.roomId,
message.channelId ?? null,
message.senderId,
message.senderName,
message.content,
message.timestamp,
message.editedAt ?? null,
JSON.stringify(message.reactions ?? []),
message.isDeleted ? 1 : 0,
message.replyToId ?? null,
],
);
persist();
});
ipcMain.handle('db:getMessages', (_e, roomId, limit = 100, offset = 0) => {
const rows = queryAll(
'SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC LIMIT ? OFFSET ?',
[roomId, limit, offset],
);
return rows.map(rowToMessage);
});
ipcMain.handle('db:deleteMessage', (_e, messageId) => {
db.run('DELETE FROM messages WHERE id = ?', [messageId]);
persist();
});
ipcMain.handle('db:updateMessage', (_e, messageId, updates) => {
const row = queryOne('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]);
if (!row) return;
const msg = { ...rowToMessage(row), ...updates };
db.run(
`INSERT OR REPLACE INTO messages
(id, roomId, channelId, senderId, senderName, content, timestamp, editedAt, reactions, isDeleted, replyToId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
msg.id, msg.roomId, msg.channelId ?? null, msg.senderId, msg.senderName,
msg.content, msg.timestamp, msg.editedAt ?? null,
JSON.stringify(msg.reactions ?? []), msg.isDeleted ? 1 : 0, msg.replyToId ?? null,
],
);
persist();
});
ipcMain.handle('db:getMessageById', (_e, messageId) => {
const row = queryOne('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]);
return row ? rowToMessage(row) : null;
});
ipcMain.handle('db:clearRoomMessages', (_e, roomId) => {
db.run('DELETE FROM messages WHERE roomId = ?', [roomId]);
persist();
});
ipcMain.handle('db:saveReaction', (_e, reaction) => {
const check = db.exec(
'SELECT 1 FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?',
[reaction.messageId, reaction.userId, reaction.emoji],
);
if (check.length > 0 && check[0].values.length > 0) return;
db.run(
`INSERT OR REPLACE INTO reactions (id, messageId, oderId, userId, emoji, timestamp)
VALUES (?, ?, ?, ?, ?, ?)`,
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp],
);
persist();
});
ipcMain.handle('db:removeReaction', (_e, messageId, userId, emoji) => {
db.run('DELETE FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?', [messageId, userId, emoji]);
persist();
});
ipcMain.handle('db:getReactionsForMessage', (_e, messageId) => {
const rows = queryAll('SELECT * FROM reactions WHERE messageId = ?', [messageId]);
return rows.map(rowToReaction);
});
ipcMain.handle('db:saveUser', (_e, user) => {
db.run(
`INSERT OR REPLACE INTO users
(id, oderId, username, displayName, avatarUrl, status, role, joinedAt,
peerId, isOnline, isAdmin, isRoomOwner, voiceState, screenShareState)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
user.id,
user.oderId ?? null,
user.username ?? null,
user.displayName ?? null,
user.avatarUrl ?? null,
user.status ?? null,
user.role ?? null,
user.joinedAt ?? null,
user.peerId ?? null,
user.isOnline ? 1 : 0,
user.isAdmin ? 1 : 0,
user.isRoomOwner ? 1 : 0,
user.voiceState ? JSON.stringify(user.voiceState) : null,
user.screenShareState ? JSON.stringify(user.screenShareState) : null,
],
);
persist();
});
ipcMain.handle('db:getUser', (_e, userId) => {
const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
return row ? rowToUser(row) : null;
});
ipcMain.handle('db:getCurrentUser', () => {
const rows = db.exec("SELECT value FROM meta WHERE key = 'currentUserId'");
if (rows.length === 0 || rows[0].values.length === 0) return null;
const userId = String(rows[0].values[0][0]);
const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
return row ? rowToUser(row) : null;
});
ipcMain.handle('db:setCurrentUserId', (_e, userId) => {
db.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('currentUserId', ?)", [userId]);
persist();
});
ipcMain.handle('db:getUsersByRoom', (_e, _roomId) => {
const rows = queryAll('SELECT * FROM users');
return rows.map(rowToUser);
});
ipcMain.handle('db:updateUser', (_e, userId, updates) => {
const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
if (!row) return;
const user = { ...rowToUser(row), ...updates };
db.run(
`INSERT OR REPLACE INTO users
(id, oderId, username, displayName, avatarUrl, status, role, joinedAt,
peerId, isOnline, isAdmin, isRoomOwner, voiceState, screenShareState)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
user.id, user.oderId ?? null, user.username ?? null, user.displayName ?? null,
user.avatarUrl ?? null, user.status ?? null, user.role ?? null, user.joinedAt ?? null,
user.peerId ?? null, user.isOnline ? 1 : 0, user.isAdmin ? 1 : 0, user.isRoomOwner ? 1 : 0,
user.voiceState ? JSON.stringify(user.voiceState) : null,
user.screenShareState ? JSON.stringify(user.screenShareState) : null,
],
);
persist();
});
ipcMain.handle('db:saveRoom', (_e, room) => {
db.run(
`INSERT OR REPLACE INTO rooms
(id, name, description, topic, hostId, password, isPrivate, createdAt,
userCount, maxUsers, icon, iconUpdatedAt, permissions, channels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id, room.name, room.description ?? null, room.topic ?? null,
room.hostId, room.password ?? null, room.isPrivate ? 1 : 0, room.createdAt,
room.userCount, room.maxUsers ?? null, room.icon ?? null,
room.iconUpdatedAt ?? null,
room.permissions ? JSON.stringify(room.permissions) : null,
room.channels ? JSON.stringify(room.channels) : null,
],
);
persist();
});
ipcMain.handle('db:getRoom', (_e, roomId) => {
const row = queryOne('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]);
return row ? rowToRoom(row) : null;
});
ipcMain.handle('db:getAllRooms', () => {
const rows = queryAll('SELECT * FROM rooms');
return rows.map(rowToRoom);
});
ipcMain.handle('db:deleteRoom', (_e, roomId) => {
db.run('DELETE FROM rooms WHERE id = ?', [roomId]);
db.run('DELETE FROM messages WHERE roomId = ?', [roomId]);
persist();
});
ipcMain.handle('db:updateRoom', (_e, roomId, updates) => {
const row = queryOne('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]);
if (!row) return;
const room = { ...rowToRoom(row), ...updates };
db.run(
`INSERT OR REPLACE INTO rooms
(id, name, description, topic, hostId, password, isPrivate, createdAt,
userCount, maxUsers, icon, iconUpdatedAt, permissions, channels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id, room.name, room.description ?? null, room.topic ?? null,
room.hostId, room.password ?? null, room.isPrivate ? 1 : 0, room.createdAt,
room.userCount, room.maxUsers ?? null, room.icon ?? null,
room.iconUpdatedAt ?? null,
room.permissions ? JSON.stringify(room.permissions) : null,
room.channels ? JSON.stringify(room.channels) : null,
],
);
persist();
});
ipcMain.handle('db:saveBan', (_e, ban) => {
db.run(
`INSERT OR REPLACE INTO bans
(oderId, userId, roomId, bannedBy, displayName, reason, expiresAt, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
ban.oderId, ban.userId ?? null, ban.roomId, ban.bannedBy,
ban.displayName ?? null, ban.reason ?? null, ban.expiresAt ?? null, ban.timestamp,
],
);
persist();
});
ipcMain.handle('db:removeBan', (_e, oderId) => {
db.run('DELETE FROM bans WHERE oderId = ?', [oderId]);
persist();
});
ipcMain.handle('db:getBansForRoom', (_e, roomId) => {
const now = Date.now();
const rows = queryAll(
'SELECT * FROM bans WHERE roomId = ? AND (expiresAt IS NULL OR expiresAt > ?)',
[roomId, now],
);
return rows.map(rowToBan);
});
ipcMain.handle('db:isUserBanned', (_e, userId, roomId) => {
const now = Date.now();
const rows = queryAll(
'SELECT * FROM bans WHERE roomId = ? AND (expiresAt IS NULL OR expiresAt > ?)',
[roomId, now],
);
return rows.some((r) => String(r.oderId) === userId);
});
ipcMain.handle('db:saveAttachment', (_e, attachment) => {
db.run(
`INSERT OR REPLACE INTO attachments
(id, messageId, filename, size, mime, isImage, uploaderPeerId, filePath, savedPath)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
attachment.id, attachment.messageId, attachment.filename,
attachment.size, attachment.mime, attachment.isImage ? 1 : 0,
attachment.uploaderPeerId ?? null, attachment.filePath ?? null,
attachment.savedPath ?? null,
],
);
persist();
});
ipcMain.handle('db:getAttachmentsForMessage', (_e, messageId) => {
const rows = queryAll('SELECT * FROM attachments WHERE messageId = ?', [messageId]);
return rows.map(rowToAttachment);
});
ipcMain.handle('db:getAllAttachments', () => {
const rows = queryAll('SELECT * FROM attachments');
return rows.map(rowToAttachment);
});
ipcMain.handle('db:deleteAttachmentsForMessage', (_e, messageId) => {
db.run('DELETE FROM attachments WHERE messageId = ?', [messageId]);
persist();
});
ipcMain.handle('db:clearAllData', () => {
db.run('DELETE FROM messages');
db.run('DELETE FROM users');
db.run('DELETE FROM rooms');
db.run('DELETE FROM reactions');
db.run('DELETE FROM bans');
db.run('DELETE FROM attachments');
db.run('DELETE FROM meta');
persist();
});
}
module.exports = { registerDatabaseIpc };

76
electron/db/database.ts Normal file
View File

@@ -0,0 +1,76 @@
import { app } from 'electron';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { DataSource } from 'typeorm';
import {
MessageEntity,
UserEntity,
RoomEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
} from '../entities';
import { settings } from '../settings';
let applicationDataSource: DataSource | undefined;
export function getDataSource(): DataSource | undefined {
return applicationDataSource;
}
export async function initializeDatabase(): Promise<void> {
const userDataPath = app.getPath('userData');
const dbDir = path.join(userDataPath, 'metoyou');
await fsp.mkdir(dbDir, { recursive: true });
const databaseFilePath = path.join(dbDir, settings.databaseName);
let database: Uint8Array | undefined;
if (fs.existsSync(databaseFilePath)) {
database = fs.readFileSync(databaseFilePath);
}
applicationDataSource = new DataSource({
type: 'sqljs',
database,
entities: [MessageEntity, UserEntity, RoomEntity, ReactionEntity, BanEntity, AttachmentEntity, MetaEntity],
migrations: [
path.join(__dirname, '..', 'migrations', '*.js'),
path.join(__dirname, '..', 'migrations', '*.ts')
],
synchronize: false,
logging: false,
autoSave: true,
location: databaseFilePath
});
try {
await applicationDataSource.initialize();
console.log('[DB] Connection initialised at:', databaseFilePath);
try {
await applicationDataSource.runMigrations();
console.log('[DB] Migrations executed');
} catch (migErr) {
console.error('[DB] Migration error:', migErr);
}
} catch (error) {
console.error('[DB] Initialisation error:', error);
}
}
export async function destroyDatabase(): Promise<void> {
if (applicationDataSource?.isInitialized) {
try {
await applicationDataSource.destroy();
console.log('[DB] Connection closed');
} catch (error) {
console.error('[DB] Error closing connection:', error);
} finally {
applicationDataSource = undefined;
}
}
}

View File

@@ -0,0 +1,35 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('attachments')
export class AttachmentEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
messageId!: string;
@Column('text')
filename!: string;
@Column('integer')
size!: number;
@Column('text')
mime!: string;
@Column('integer', { default: 0 })
isImage!: number;
@Column('text', { nullable: true })
uploaderPeerId!: string | null;
@Column('text', { nullable: true })
filePath!: string | null;
@Column('text', { nullable: true })
savedPath!: string | null;
}

View File

@@ -0,0 +1,32 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('bans')
export class BanEntity {
@PrimaryColumn('text')
oderId!: string;
@PrimaryColumn('text')
roomId!: string;
@Column('text', { nullable: true })
userId!: string | null;
@Column('text')
bannedBy!: string;
@Column('text', { nullable: true })
displayName!: string | null;
@Column('text', { nullable: true })
reason!: string | null;
@Column('integer', { nullable: true })
expiresAt!: number | null;
@Column('integer')
timestamp!: number;
}

View File

@@ -0,0 +1,41 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('messages')
export class MessageEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
roomId!: string;
@Column('text', { nullable: true })
channelId!: string | null;
@Column('text')
senderId!: string;
@Column('text')
senderName!: string;
@Column('text')
content!: string;
@Column('integer')
timestamp!: number;
@Column('integer', { nullable: true })
editedAt!: number | null;
@Column('text', { default: '[]' })
reactions!: string;
@Column('integer', { default: 0 })
isDeleted!: number;
@Column('text', { nullable: true })
replyToId!: string | null;
}

View File

@@ -0,0 +1,10 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
@Entity('meta')
export class MetaEntity {
@PrimaryColumn('text')
key!: string;
@Column('text', { nullable: true })
value!: string | null;
}

View File

@@ -0,0 +1,22 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
@Entity('reactions')
export class ReactionEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
messageId!: string;
@Column('text', { nullable: true })
oderId!: string | null;
@Column('text', { nullable: true })
userId!: string | null;
@Column('text')
emoji!: string;
@Column('integer')
timestamp!: number;
}

View File

@@ -0,0 +1,50 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('rooms')
export class RoomEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
name!: string;
@Column('text', { nullable: true })
description!: string | null;
@Column('text', { nullable: true })
topic!: string | null;
@Column('text')
hostId!: string;
@Column('text', { nullable: true })
password!: string | null;
@Column('integer', { default: 0 })
isPrivate!: number;
@Column('integer')
createdAt!: number;
@Column('integer', { default: 0 })
userCount!: number;
@Column('integer', { nullable: true })
maxUsers!: number | null;
@Column('text', { nullable: true })
icon!: string | null;
@Column('integer', { nullable: true })
iconUpdatedAt!: number | null;
@Column('text', { nullable: true })
permissions!: string | null;
@Column('text', { nullable: true })
channels!: string | null;
}

View File

@@ -0,0 +1,50 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryColumn('text')
id!: string;
@Column('text', { nullable: true })
oderId!: string | null;
@Column('text', { nullable: true })
username!: string | null;
@Column('text', { nullable: true })
displayName!: string | null;
@Column('text', { nullable: true })
avatarUrl!: string | null;
@Column('text', { nullable: true })
status!: string | null;
@Column('text', { nullable: true })
role!: string | null;
@Column('integer', { nullable: true })
joinedAt!: number | null;
@Column('text', { nullable: true })
peerId!: string | null;
@Column('integer', { default: 0 })
isOnline!: number;
@Column('integer', { default: 0 })
isAdmin!: number;
@Column('integer', { default: 0 })
isRoomOwner!: number;
@Column('text', { nullable: true })
voiceState!: string | null;
@Column('text', { nullable: true })
screenShareState!: string | null;
}

View File

@@ -0,0 +1,7 @@
export { MessageEntity } from './MessageEntity';
export { UserEntity } from './UserEntity';
export { RoomEntity } from './RoomEntity';
export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity';
export { AttachmentEntity } from './AttachmentEntity';
export { MetaEntity } from './MetaEntity';

39
electron/ipc/cqrs.ts Normal file
View File

@@ -0,0 +1,39 @@
import { ipcMain } from 'electron';
import { buildCommandHandlers } from '../cqrs/commands';
import { buildQueryHandlers } from '../cqrs/queries';
import {
Command,
Query,
CommandTypeKey,
QueryTypeKey
} from '../cqrs/types';
import { getDataSource } from '../db/database';
export function setupCqrsHandlers(): void {
const dataSource = getDataSource();
if (!dataSource) {
throw new Error('DataSource not initialised');
}
const commandHandlerMap = buildCommandHandlers(dataSource) as Record<CommandTypeKey, (command: Command) => unknown>;
const queryHandlerMap = buildQueryHandlers(dataSource) as Record<QueryTypeKey, (query: Query) => unknown>;
ipcMain.handle('cqrs:command', async (_evt, command: Command) => {
const handler = commandHandlerMap[command.type as CommandTypeKey];
if (!handler)
throw new Error(`No command handler for type: ${command.type}`);
return handler(command);
});
ipcMain.handle('cqrs:query', async (_evt, query: Query) => {
const handler = queryHandlerMap[query.type as QueryTypeKey];
if (!handler)
throw new Error(`No query handler for type: ${query.type}`);
return handler(query);
});
}

3
electron/ipc/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { setupCqrsHandlers } from './cqrs';
export { setupSystemHandlers } from './system';
export { setupWindowControlHandlers } from './window-controls';

61
electron/ipc/system.ts Normal file
View File

@@ -0,0 +1,61 @@
import {
app,
desktopCapturer,
ipcMain,
shell
} from 'electron';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
export function setupSystemHandlers(): void {
ipcMain.handle('open-external', async (_event, url: string) => {
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
await shell.openExternal(url);
return true;
}
return false;
});
ipcMain.handle('get-sources', async () => {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 }
});
return sources.map((source) => ({
id: source.id,
name: source.name,
thumbnail: source.thumbnail.toDataURL()
}));
});
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
ipcMain.handle('file-exists', async (_event, filePath: string) => {
try {
await fsp.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
});
ipcMain.handle('read-file', async (_event, filePath: string) => {
const data = await fsp.readFile(filePath);
return data.toString('base64');
});
ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => {
const buffer = Buffer.from(base64Data, 'base64');
await fsp.writeFile(filePath, buffer);
return true;
});
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
await fsp.mkdir(dirPath, { recursive: true });
return true;
});
}

View File

@@ -0,0 +1,21 @@
import { ipcMain } from 'electron';
import { getMainWindow } from '../window/create-window';
export function setupWindowControlHandlers(): void {
ipcMain.on('window-minimize', () => {
getMainWindow()?.minimize();
});
ipcMain.on('window-maximize', () => {
const win = getMainWindow();
if (win?.isMaximized())
win.unmaximize();
else
win?.maximize();
});
ipcMain.on('window-close', () => {
getMainWindow()?.close();
});
}

View File

@@ -1,156 +0,0 @@
const { app, BrowserWindow, ipcMain, desktopCapturer, shell } = require('electron');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { registerDatabaseIpc } = require('./database');
let mainWindow;
// Suppress Autofill devtools errors by disabling related features
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
// Allow media autoplay without user gesture (bypasses Chromium autoplay policy)
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
// Accept self-signed certificates in development (for --ssl dev server)
if (process.env.SSL === 'true') {
app.commandLine.appendSwitch('ignore-certificate-errors');
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
frame: false,
titleBarStyle: 'hidden',
backgroundColor: '#0a0a0f',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
webSecurity: true,
},
});
// In development, load from Angular dev server
if (process.env.NODE_ENV === 'development') {
const devUrl = process.env.SSL === 'true'
? 'https://localhost:4200'
: 'http://localhost:4200';
mainWindow.loadURL(devUrl);
if (process.env.DEBUG_DEVTOOLS === '1') {
mainWindow.webContents.openDevTools();
}
} else {
// In production, load the built Angular app
// The dist folder is at the project root, not in electron folder
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'client', 'browser', 'index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
// Force all external links to open in the system default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (event, url) => {
// Allow navigation to the app itself (dev server or file://)
const currentUrl = mainWindow.webContents.getURL();
const isSameOrigin = new URL(url).origin === new URL(currentUrl).origin;
if (!isSameOrigin) {
event.preventDefault();
shell.openExternal(url);
}
});
}
// Register database IPC handlers before app is ready
registerDatabaseIpc();
// IPC handler for opening URLs in the system default browser
ipcMain.handle('open-external', async (_event, url) => {
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
await shell.openExternal(url);
return true;
}
return false;
});
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// IPC handlers for window controls
ipcMain.on('window-minimize', () => {
mainWindow?.minimize();
});
ipcMain.on('window-maximize', () => {
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow?.maximize();
}
});
ipcMain.on('window-close', () => {
mainWindow?.close();
});
// IPC handler for desktop capturer (screen sharing)
ipcMain.handle('get-sources', async () => {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 },
});
return sources.map((source) => ({
id: source.id,
name: source.name,
thumbnail: source.thumbnail.toDataURL(),
}));
});
// IPC handler for app data path
ipcMain.handle('get-app-data-path', () => {
return app.getPath('userData');
});
// IPC for basic file operations used by renderer
ipcMain.handle('file-exists', async (_event, filePath) => {
try {
await fsp.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
});
ipcMain.handle('read-file', async (_event, filePath) => {
const data = await fsp.readFile(filePath);
return data.toString('base64');
});
ipcMain.handle('write-file', async (_event, filePath, base64Data) => {
const buffer = Buffer.from(base64Data, 'base64');
await fsp.writeFile(filePath, buffer);
return true;
});
ipcMain.handle('ensure-dir', async (_event, dirPath) => {
await fsp.mkdir(dirPath, { recursive: true });
return true;
});

6
electron/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import 'reflect-metadata';
import { configureAppFlags } from './app/flags';
import { registerAppLifecycle } from './app/lifecycle';
configureAppFlags();
registerAppLifecycle();

View File

@@ -0,0 +1,121 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialSchema1000000000000 implements MigrationInterface {
name = 'InitialSchema1000000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "messages" (
"id" TEXT PRIMARY KEY NOT NULL,
"roomId" TEXT NOT NULL,
"channelId" TEXT,
"senderId" TEXT NOT NULL,
"senderName" TEXT NOT NULL,
"content" TEXT NOT NULL,
"timestamp" INTEGER NOT NULL,
"editedAt" INTEGER,
"reactions" TEXT NOT NULL DEFAULT '[]',
"isDeleted" INTEGER NOT NULL DEFAULT 0,
"replyToId" TEXT
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_roomId" ON "messages" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "users" (
"id" TEXT PRIMARY KEY NOT NULL,
"oderId" TEXT,
"username" TEXT,
"displayName" TEXT,
"avatarUrl" TEXT,
"status" TEXT,
"role" TEXT,
"joinedAt" INTEGER,
"peerId" TEXT,
"isOnline" INTEGER NOT NULL DEFAULT 0,
"isAdmin" INTEGER NOT NULL DEFAULT 0,
"isRoomOwner" INTEGER NOT NULL DEFAULT 0,
"voiceState" TEXT,
"screenShareState" TEXT
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "rooms" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"topic" TEXT,
"hostId" TEXT NOT NULL,
"password" TEXT,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"userCount" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER,
"icon" TEXT,
"iconUpdatedAt" INTEGER,
"permissions" TEXT,
"channels" TEXT
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "reactions" (
"id" TEXT PRIMARY KEY NOT NULL,
"messageId" TEXT NOT NULL,
"oderId" TEXT,
"userId" TEXT,
"emoji" TEXT NOT NULL,
"timestamp" INTEGER NOT NULL
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_reactions_messageId" ON "reactions" ("messageId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "bans" (
"oderId" TEXT NOT NULL,
"roomId" TEXT NOT NULL,
"userId" TEXT,
"bannedBy" TEXT NOT NULL,
"displayName" TEXT,
"reason" TEXT,
"expiresAt" INTEGER,
"timestamp" INTEGER NOT NULL,
PRIMARY KEY ("oderId", "roomId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_bans_roomId" ON "bans" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "attachments" (
"id" TEXT PRIMARY KEY NOT NULL,
"messageId" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"mime" TEXT NOT NULL,
"isImage" INTEGER NOT NULL DEFAULT 0,
"uploaderPeerId" TEXT,
"filePath" TEXT,
"savedPath" TEXT
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_attachments_messageId" ON "attachments" ("messageId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "meta" (
"key" TEXT PRIMARY KEY NOT NULL,
"value" TEXT
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "meta"`);
await queryRunner.query(`DROP TABLE IF EXISTS "attachments"`);
await queryRunner.query(`DROP TABLE IF EXISTS "bans"`);
await queryRunner.query(`DROP TABLE IF EXISTS "reactions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "rooms"`);
await queryRunner.query(`DROP TABLE IF EXISTS "users"`);
await queryRunner.query(`DROP TABLE IF EXISTS "messages"`);
}
}

View File

@@ -1,70 +0,0 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// Window controls
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
// Open URL in system default browser
openExternal: (url) => ipcRenderer.invoke('open-external', url),
// Desktop capturer for screen sharing
getSources: () => ipcRenderer.invoke('get-sources'),
// App data path for SQLite storage
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
// File system operations for database persistence
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
db: {
initialize: () => ipcRenderer.invoke('db:initialize'),
// Messages
saveMessage: (message) => ipcRenderer.invoke('db:saveMessage', message),
getMessages: (roomId, limit, offset) => ipcRenderer.invoke('db:getMessages', roomId, limit, offset),
deleteMessage: (messageId) => ipcRenderer.invoke('db:deleteMessage', messageId),
updateMessage: (messageId, updates) => ipcRenderer.invoke('db:updateMessage', messageId, updates),
getMessageById: (messageId) => ipcRenderer.invoke('db:getMessageById', messageId),
clearRoomMessages: (roomId) => ipcRenderer.invoke('db:clearRoomMessages', roomId),
// Reactions
saveReaction: (reaction) => ipcRenderer.invoke('db:saveReaction', reaction),
removeReaction: (messageId, userId, emoji) => ipcRenderer.invoke('db:removeReaction', messageId, userId, emoji),
getReactionsForMessage: (messageId) => ipcRenderer.invoke('db:getReactionsForMessage', messageId),
// Users
saveUser: (user) => ipcRenderer.invoke('db:saveUser', user),
getUser: (userId) => ipcRenderer.invoke('db:getUser', userId),
getCurrentUser: () => ipcRenderer.invoke('db:getCurrentUser'),
setCurrentUserId: (userId) => ipcRenderer.invoke('db:setCurrentUserId', userId),
getUsersByRoom: (roomId) => ipcRenderer.invoke('db:getUsersByRoom', roomId),
updateUser: (userId, updates) => ipcRenderer.invoke('db:updateUser', userId, updates),
// Rooms
saveRoom: (room) => ipcRenderer.invoke('db:saveRoom', room),
getRoom: (roomId) => ipcRenderer.invoke('db:getRoom', roomId),
getAllRooms: () => ipcRenderer.invoke('db:getAllRooms'),
deleteRoom: (roomId) => ipcRenderer.invoke('db:deleteRoom', roomId),
updateRoom: (roomId, updates) => ipcRenderer.invoke('db:updateRoom', roomId, updates),
// Bans
saveBan: (ban) => ipcRenderer.invoke('db:saveBan', ban),
removeBan: (oderId) => ipcRenderer.invoke('db:removeBan', oderId),
getBansForRoom: (roomId) => ipcRenderer.invoke('db:getBansForRoom', roomId),
isUserBanned: (userId, roomId) => ipcRenderer.invoke('db:isUserBanned', userId, roomId),
// Attachments
saveAttachment: (attachment) => ipcRenderer.invoke('db:saveAttachment', attachment),
getAttachmentsForMessage: (messageId) => ipcRenderer.invoke('db:getAttachmentsForMessage', messageId),
getAllAttachments: () => ipcRenderer.invoke('db:getAllAttachments'),
deleteAttachmentsForMessage: (messageId) => ipcRenderer.invoke('db:deleteAttachmentsForMessage', messageId),
// Utilities
clearAllData: () => ipcRenderer.invoke('db:clearAllData'),
},
});

47
electron/preload.ts Normal file
View File

@@ -0,0 +1,47 @@
import { contextBridge, ipcRenderer } from 'electron';
import { Command, Query } from './cqrs/types';
export interface ElectronAPI {
// Window controls
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
// System utilities
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getAppDataPath: () => Promise<string>;
readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
fileExists: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>;
// CQRS database operations
command: <T = unknown>(command: Command) => Promise<T>;
query: <T = unknown>(query: Query) => Promise<T>;
}
const electronAPI: ElectronAPI = {
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
getSources: () => ipcRenderer.invoke('get-sources'),
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
command: (command) => ipcRenderer.invoke('cqrs:command', command),
query: (query) => ipcRenderer.invoke('cqrs:query', query)
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}

4
electron/settings.ts Normal file
View File

@@ -0,0 +1,4 @@
export const settings = {
databaseName: 'metoyou.sqlite',
debugMode: false
};

7
electron/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.electron.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./**/*"]
}

View File

@@ -0,0 +1,59 @@
import { BrowserWindow, shell } from 'electron';
import * as path from 'path';
let mainWindow: BrowserWindow | null = null;
export function getMainWindow(): BrowserWindow | null {
return mainWindow;
}
export async function createWindow(): Promise<void> {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
frame: false,
titleBarStyle: 'hidden',
backgroundColor: '#0a0a0f',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, '..', 'preload.js'),
webSecurity: true
}
});
if (process.env['NODE_ENV'] === 'development') {
const devUrl = process.env['SSL'] === 'true'
? 'https://localhost:4200'
: 'http://localhost:4200';
await mainWindow.loadURL(devUrl);
if (process.env['DEBUG_DEVTOOLS'] === '1') {
mainWindow.webContents.openDevTools();
}
} else {
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (event, url) => {
const currentUrl = mainWindow?.webContents.getURL();
const isSameOrigin = new URL(url).origin === new URL(currentUrl || '').origin;
if (!isSameOrigin) {
event.preventDefault();
shell.openExternal(url);
}
});
}