diff --git a/dev.sh b/dev.sh index 4b20e06..c600e9e 100755 --- a/dev.sh +++ b/dev.sh @@ -33,4 +33,4 @@ fi exec npx concurrently --kill-others \ "cd server && npm run dev" \ "$NG_SERVE" \ - "wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron ." + "wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron . --no-sandbox --disable-dev-shm-usage" diff --git a/electron/app/flags.ts b/electron/app/flags.ts new file mode 100644 index 0000000..dc94f70 --- /dev/null +++ b/electron/app/flags.ts @@ -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'); + } +} diff --git a/electron/app/lifecycle.ts b/electron/app/lifecycle.ts new file mode 100644 index 0000000..7d274ec --- /dev/null +++ b/electron/app/lifecycle.ts @@ -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(); + } + }); +} diff --git a/electron/cqrs/commands/handlers/clearAllData.ts b/electron/cqrs/commands/handlers/clearAllData.ts new file mode 100644 index 0000000..93fe179 --- /dev/null +++ b/electron/cqrs/commands/handlers/clearAllData.ts @@ -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 { + 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(); +} diff --git a/electron/cqrs/commands/handlers/clearRoomMessages.ts b/electron/cqrs/commands/handlers/clearRoomMessages.ts new file mode 100644 index 0000000..475f729 --- /dev/null +++ b/electron/cqrs/commands/handlers/clearRoomMessages.ts @@ -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 { + const repo = dataSource.getRepository(MessageEntity); + + await repo.delete({ roomId: command.payload.roomId }); +} diff --git a/electron/cqrs/commands/handlers/deleteAttachmentsForMessage.ts b/electron/cqrs/commands/handlers/deleteAttachmentsForMessage.ts new file mode 100644 index 0000000..3a6a254 --- /dev/null +++ b/electron/cqrs/commands/handlers/deleteAttachmentsForMessage.ts @@ -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 { + const repo = dataSource.getRepository(AttachmentEntity); + + await repo.delete({ messageId: command.payload.messageId }); +} diff --git a/electron/cqrs/commands/handlers/deleteMessage.ts b/electron/cqrs/commands/handlers/deleteMessage.ts new file mode 100644 index 0000000..3188d64 --- /dev/null +++ b/electron/cqrs/commands/handlers/deleteMessage.ts @@ -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 { + const repo = dataSource.getRepository(MessageEntity); + + await repo.delete({ id: command.payload.messageId }); +} diff --git a/electron/cqrs/commands/handlers/deleteRoom.ts b/electron/cqrs/commands/handlers/deleteRoom.ts new file mode 100644 index 0000000..afaee48 --- /dev/null +++ b/electron/cqrs/commands/handlers/deleteRoom.ts @@ -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 { + const { roomId } = command.payload; + await dataSource.getRepository(RoomEntity).delete({ id: roomId }); + await dataSource.getRepository(MessageEntity).delete({ roomId }); +} diff --git a/electron/cqrs/commands/handlers/removeBan.ts b/electron/cqrs/commands/handlers/removeBan.ts new file mode 100644 index 0000000..75299ea --- /dev/null +++ b/electron/cqrs/commands/handlers/removeBan.ts @@ -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 { + const repo = dataSource.getRepository(BanEntity); + + await repo.delete({ oderId: command.payload.oderId }); +} diff --git a/electron/cqrs/commands/handlers/removeReaction.ts b/electron/cqrs/commands/handlers/removeReaction.ts new file mode 100644 index 0000000..56e3376 --- /dev/null +++ b/electron/cqrs/commands/handlers/removeReaction.ts @@ -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 { + const repo = dataSource.getRepository(ReactionEntity); + const { messageId, userId, emoji } = command.payload; + + await repo.delete({ messageId, userId, emoji }); +} diff --git a/electron/cqrs/commands/handlers/saveAttachment.ts b/electron/cqrs/commands/handlers/saveAttachment.ts new file mode 100644 index 0000000..1de3ddb --- /dev/null +++ b/electron/cqrs/commands/handlers/saveAttachment.ts @@ -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 { + 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); +} diff --git a/electron/cqrs/commands/handlers/saveBan.ts b/electron/cqrs/commands/handlers/saveBan.ts new file mode 100644 index 0000000..1860896 --- /dev/null +++ b/electron/cqrs/commands/handlers/saveBan.ts @@ -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 { + 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); +} diff --git a/electron/cqrs/commands/handlers/saveMessage.ts b/electron/cqrs/commands/handlers/saveMessage.ts new file mode 100644 index 0000000..a81bb4f --- /dev/null +++ b/electron/cqrs/commands/handlers/saveMessage.ts @@ -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 { + 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); +} diff --git a/electron/cqrs/commands/handlers/saveReaction.ts b/electron/cqrs/commands/handlers/saveReaction.ts new file mode 100644 index 0000000..b0093dd --- /dev/null +++ b/electron/cqrs/commands/handlers/saveReaction.ts @@ -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 { + 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); +} diff --git a/electron/cqrs/commands/handlers/saveRoom.ts b/electron/cqrs/commands/handlers/saveRoom.ts new file mode 100644 index 0000000..b02b2b7 --- /dev/null +++ b/electron/cqrs/commands/handlers/saveRoom.ts @@ -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 { + 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); +} diff --git a/electron/cqrs/commands/handlers/saveUser.ts b/electron/cqrs/commands/handlers/saveUser.ts new file mode 100644 index 0000000..b9ececc --- /dev/null +++ b/electron/cqrs/commands/handlers/saveUser.ts @@ -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 { + 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); +} diff --git a/electron/cqrs/commands/handlers/setCurrentUserId.ts b/electron/cqrs/commands/handlers/setCurrentUserId.ts new file mode 100644 index 0000000..b114799 --- /dev/null +++ b/electron/cqrs/commands/handlers/setCurrentUserId.ts @@ -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 { + const repo = dataSource.getRepository(MetaEntity); + + await repo.save({ key: 'currentUserId', value: command.payload.userId }); +} diff --git a/electron/cqrs/commands/handlers/updateMessage.ts b/electron/cqrs/commands/handlers/updateMessage.ts new file mode 100644 index 0000000..6b8bbac --- /dev/null +++ b/electron/cqrs/commands/handlers/updateMessage.ts @@ -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 { + 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); +} diff --git a/electron/cqrs/commands/handlers/updateRoom.ts b/electron/cqrs/commands/handlers/updateRoom.ts new file mode 100644 index 0000000..140516b --- /dev/null +++ b/electron/cqrs/commands/handlers/updateRoom.ts @@ -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 { + 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); +} + diff --git a/electron/cqrs/commands/handlers/updateUser.ts b/electron/cqrs/commands/handlers/updateUser.ts new file mode 100644 index 0000000..4b1b8b7 --- /dev/null +++ b/electron/cqrs/commands/handlers/updateUser.ts @@ -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 { + 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); +} + diff --git a/electron/cqrs/commands/handlers/utils/applyUpdates.ts b/electron/cqrs/commands/handlers/utils/applyUpdates.ts new file mode 100644 index 0000000..1beee3c --- /dev/null +++ b/electron/cqrs/commands/handlers/utils/applyUpdates.ts @@ -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 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( + entity: T, + updates: Partial>, + transforms: TransformMap = {} +): void { + const target = entity as unknown as Record; + + for (const [key, value] of Object.entries(updates)) { + if (value === undefined) + continue; + + const transform = transforms[key]; + + target[key] = transform ? transform(value) : (value ?? null); + } +} diff --git a/electron/cqrs/commands/index.ts b/electron/cqrs/commands/index.ts new file mode 100644 index 0000000..9034095 --- /dev/null +++ b/electron/cqrs/commands/index.ts @@ -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 Promise> => ({ + [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) +}); diff --git a/electron/cqrs/mappers.ts b/electron/cqrs/mappers.ts new file mode 100644 index 0000000..7c2270c --- /dev/null +++ b/electron/cqrs/mappers.ts @@ -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 + }; +} diff --git a/electron/cqrs/queries/handlers/getAllAttachments.ts b/electron/cqrs/queries/handlers/getAllAttachments.ts new file mode 100644 index 0000000..c4d5b22 --- /dev/null +++ b/electron/cqrs/queries/handlers/getAllAttachments.ts @@ -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); +} diff --git a/electron/cqrs/queries/handlers/getAllRooms.ts b/electron/cqrs/queries/handlers/getAllRooms.ts new file mode 100644 index 0000000..e0994b7 --- /dev/null +++ b/electron/cqrs/queries/handlers/getAllRooms.ts @@ -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); +} diff --git a/electron/cqrs/queries/handlers/getAttachmentsForMessage.ts b/electron/cqrs/queries/handlers/getAttachmentsForMessage.ts new file mode 100644 index 0000000..a6369c0 --- /dev/null +++ b/electron/cqrs/queries/handlers/getAttachmentsForMessage.ts @@ -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); +} diff --git a/electron/cqrs/queries/handlers/getBansForRoom.ts b/electron/cqrs/queries/handlers/getBansForRoom.ts new file mode 100644 index 0000000..62ea274 --- /dev/null +++ b/electron/cqrs/queries/handlers/getBansForRoom.ts @@ -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); +} diff --git a/electron/cqrs/queries/handlers/getCurrentUser.ts b/electron/cqrs/queries/handlers/getCurrentUser.ts new file mode 100644 index 0000000..6e29748 --- /dev/null +++ b/electron/cqrs/queries/handlers/getCurrentUser.ts @@ -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; +} diff --git a/electron/cqrs/queries/handlers/getMessageById.ts b/electron/cqrs/queries/handlers/getMessageById.ts new file mode 100644 index 0000000..8cc2574 --- /dev/null +++ b/electron/cqrs/queries/handlers/getMessageById.ts @@ -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; +} diff --git a/electron/cqrs/queries/handlers/getMessages.ts b/electron/cqrs/queries/handlers/getMessages.ts new file mode 100644 index 0000000..ee45493 --- /dev/null +++ b/electron/cqrs/queries/handlers/getMessages.ts @@ -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); +} diff --git a/electron/cqrs/queries/handlers/getReactionsForMessage.ts b/electron/cqrs/queries/handlers/getReactionsForMessage.ts new file mode 100644 index 0000000..8d95ef0 --- /dev/null +++ b/electron/cqrs/queries/handlers/getReactionsForMessage.ts @@ -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); +} diff --git a/electron/cqrs/queries/handlers/getRoom.ts b/electron/cqrs/queries/handlers/getRoom.ts new file mode 100644 index 0000000..a89251b --- /dev/null +++ b/electron/cqrs/queries/handlers/getRoom.ts @@ -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; +} diff --git a/electron/cqrs/queries/handlers/getUser.ts b/electron/cqrs/queries/handlers/getUser.ts new file mode 100644 index 0000000..e6e22b9 --- /dev/null +++ b/electron/cqrs/queries/handlers/getUser.ts @@ -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; +} diff --git a/electron/cqrs/queries/handlers/getUsersByRoom.ts b/electron/cqrs/queries/handlers/getUsersByRoom.ts new file mode 100644 index 0000000..6171673 --- /dev/null +++ b/electron/cqrs/queries/handlers/getUsersByRoom.ts @@ -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); +} diff --git a/electron/cqrs/queries/handlers/isUserBanned.ts b/electron/cqrs/queries/handlers/isUserBanned.ts new file mode 100644 index 0000000..840889e --- /dev/null +++ b/electron/cqrs/queries/handlers/isUserBanned.ts @@ -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 { + 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); +} diff --git a/electron/cqrs/queries/index.ts b/electron/cqrs/queries/index.ts new file mode 100644 index 0000000..da631bf --- /dev/null +++ b/electron/cqrs/queries/index.ts @@ -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 Promise> => ({ + [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) +}); diff --git a/electron/cqrs/types.ts b/electron/cqrs/types.ts new file mode 100644 index 0000000..ce17446 --- /dev/null +++ b/electron/cqrs/types.ts @@ -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 } } +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 } } +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 } } +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 } + +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 } +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 } +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 } + +export type Query = + | GetMessagesQuery + | GetMessageByIdQuery + | GetReactionsForMessageQuery + | GetUserQuery + | GetCurrentUserQuery + | GetUsersByRoomQuery + | GetRoomQuery + | GetAllRoomsQuery + | GetBansForRoomQuery + | IsUserBannedQuery + | GetAttachmentsForMessageQuery + | GetAllAttachmentsQuery; diff --git a/electron/data-source.ts b/electron/data-source.ts new file mode 100644 index 0000000..08f3b51 --- /dev/null +++ b/electron/data-source.ts @@ -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 +}); diff --git a/electron/database.js b/electron/database.js deleted file mode 100644 index d365a05..0000000 --- a/electron/database.js +++ /dev/null @@ -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 }; diff --git a/electron/db/database.ts b/electron/db/database.ts new file mode 100644 index 0000000..b905af4 --- /dev/null +++ b/electron/db/database.ts @@ -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 { + 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 { + if (applicationDataSource?.isInitialized) { + try { + await applicationDataSource.destroy(); + console.log('[DB] Connection closed'); + } catch (error) { + console.error('[DB] Error closing connection:', error); + } finally { + applicationDataSource = undefined; + } + } +} diff --git a/electron/entities/AttachmentEntity.ts b/electron/entities/AttachmentEntity.ts new file mode 100644 index 0000000..4dc76bb --- /dev/null +++ b/electron/entities/AttachmentEntity.ts @@ -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; +} diff --git a/electron/entities/BanEntity.ts b/electron/entities/BanEntity.ts new file mode 100644 index 0000000..3d974ad --- /dev/null +++ b/electron/entities/BanEntity.ts @@ -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; +} diff --git a/electron/entities/MessageEntity.ts b/electron/entities/MessageEntity.ts new file mode 100644 index 0000000..3987692 --- /dev/null +++ b/electron/entities/MessageEntity.ts @@ -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; +} diff --git a/electron/entities/MetaEntity.ts b/electron/entities/MetaEntity.ts new file mode 100644 index 0000000..ef53f90 --- /dev/null +++ b/electron/entities/MetaEntity.ts @@ -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; +} diff --git a/electron/entities/ReactionEntity.ts b/electron/entities/ReactionEntity.ts new file mode 100644 index 0000000..3abc901 --- /dev/null +++ b/electron/entities/ReactionEntity.ts @@ -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; +} diff --git a/electron/entities/RoomEntity.ts b/electron/entities/RoomEntity.ts new file mode 100644 index 0000000..0f13717 --- /dev/null +++ b/electron/entities/RoomEntity.ts @@ -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; +} diff --git a/electron/entities/UserEntity.ts b/electron/entities/UserEntity.ts new file mode 100644 index 0000000..291d15d --- /dev/null +++ b/electron/entities/UserEntity.ts @@ -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; +} diff --git a/electron/entities/index.ts b/electron/entities/index.ts new file mode 100644 index 0000000..63dc540 --- /dev/null +++ b/electron/entities/index.ts @@ -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'; diff --git a/electron/ipc/cqrs.ts b/electron/ipc/cqrs.ts new file mode 100644 index 0000000..c9ca527 --- /dev/null +++ b/electron/ipc/cqrs.ts @@ -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 unknown>; + const queryHandlerMap = buildQueryHandlers(dataSource) as Record 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); + }); +} diff --git a/electron/ipc/index.ts b/electron/ipc/index.ts new file mode 100644 index 0000000..8d56f86 --- /dev/null +++ b/electron/ipc/index.ts @@ -0,0 +1,3 @@ +export { setupCqrsHandlers } from './cqrs'; +export { setupSystemHandlers } from './system'; +export { setupWindowControlHandlers } from './window-controls'; diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts new file mode 100644 index 0000000..282e94a --- /dev/null +++ b/electron/ipc/system.ts @@ -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; + }); +} diff --git a/electron/ipc/window-controls.ts b/electron/ipc/window-controls.ts new file mode 100644 index 0000000..8786ca2 --- /dev/null +++ b/electron/ipc/window-controls.ts @@ -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(); + }); +} diff --git a/electron/main.js b/electron/main.js deleted file mode 100644 index bbe13bb..0000000 --- a/electron/main.js +++ /dev/null @@ -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; -}); diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 0000000..fe8ee40 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,6 @@ +import 'reflect-metadata'; +import { configureAppFlags } from './app/flags'; +import { registerAppLifecycle } from './app/lifecycle'; + +configureAppFlags(); +registerAppLifecycle(); diff --git a/electron/migrations/1000000000000-InitialSchema.ts b/electron/migrations/1000000000000-InitialSchema.ts new file mode 100644 index 0000000..f6b6d06 --- /dev/null +++ b/electron/migrations/1000000000000-InitialSchema.ts @@ -0,0 +1,121 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitialSchema1000000000000 implements MigrationInterface { + name = 'InitialSchema1000000000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/electron/preload.js b/electron/preload.js deleted file mode 100644 index 027aa3a..0000000 --- a/electron/preload.js +++ /dev/null @@ -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'), - }, -}); diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 0000000..206c50e --- /dev/null +++ b/electron/preload.ts @@ -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; + getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>; + getAppDataPath: () => Promise; + readFile: (filePath: string) => Promise; + writeFile: (filePath: string, data: string) => Promise; + fileExists: (filePath: string) => Promise; + ensureDir: (dirPath: string) => Promise; + + // CQRS database operations + command: (command: Command) => Promise; + query: (query: Query) => Promise; +} + +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; + } +} diff --git a/electron/settings.ts b/electron/settings.ts new file mode 100644 index 0000000..67e5d3b --- /dev/null +++ b/electron/settings.ts @@ -0,0 +1,4 @@ +export const settings = { + databaseName: 'metoyou.sqlite', + debugMode: false +}; diff --git a/electron/tsconfig.json b/electron/tsconfig.json new file mode 100644 index 0000000..de93b5b --- /dev/null +++ b/electron/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.electron.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./**/*"] +} diff --git a/electron/window/create-window.ts b/electron/window/create-window.ts new file mode 100644 index 0000000..4eee168 --- /dev/null +++ b/electron/window/create-window.ts @@ -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 { + 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); + } + }); +} diff --git a/package.json b/package.json index 1893d18..7f849c4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "metoyou", "version": "1.0.0", "description": "P2P Discord-like chat application", - "main": "electron/main.js", + "main": "dist/electron/main.js", "scripts": { "ng": "ng", "prebuild": "npm run bundle:rnnoise", @@ -10,18 +10,23 @@ "bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js", "start": "ng serve", "build": "ng build", - "build:all": "npm run build && cd server && npm run build", + "build:electron": "tsc -p tsconfig.electron.json", + "build:all": "npm run build && npm run build:electron && cd server && npm run build", "build:prod": "ng build --configuration production --base-href='./'", "watch": "ng build --watch --configuration development", "test": "ng test", "server:build": "cd server && npm run build", "server:start": "cd server && npm start", "server:dev": "cd server && npm run dev", - "electron": "ng build && electron .", - "electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development electron .\"", + "electron": "ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage", + "electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"", "electron:full": "./dev.sh", - "electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron .\"", - "electron:build": "npm run build:prod && electron-builder", + "electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"", + "migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js", + "migration:create": "typeorm migration:create electron/migrations/New", + "migration:run": "typeorm migration:run -d dist/electron/data-source.js", + "migration:revert": "typeorm migration:revert -d dist/electron/data-source.js", + "electron:build": "npm run build:prod && npm run build:electron && electron-builder", "electron:build:win": "npm run build:prod && electron-builder --win", "electron:build:mac": "npm run build:prod && electron-builder --mac", "electron:build:linux": "npm run build:prod && electron-builder --linux", @@ -58,6 +63,7 @@ "clsx": "^2.1.1", "mermaid": "^11.12.3", "ngx-remark": "^0.2.2", + "reflect-metadata": "^0.2.2", "remark": "^15.0.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", @@ -65,6 +71,7 @@ "simple-peer": "^9.11.1", "sql.js": "^1.13.0", "tslib": "^2.3.0", + "typeorm": "^0.3.28", "uuid": "^13.0.0" }, "devDependencies": { @@ -102,7 +109,7 @@ }, "files": [ "dist/client/**/*", - "electron/**/*", + "dist/electron/**/*", "node_modules/**/*", "!node_modules/**/test/**/*", "!node_modules/**/tests/**/*", diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index 51a30a0..7bb0742 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ diff --git a/server/src/index.ts b/server/src/index.ts index 08b5b08..4bc98c4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -19,7 +19,7 @@ const PORT = process.env.PORT || 3001; app.use(cors()); app.use(express.json()); -// In-memory runtime state (WebSocket connections only – not persisted) +// In-memory runtime state (WebSocket connections only - not persisted) interface ConnectedUser { oderId: string; ws: WebSocket; @@ -49,8 +49,10 @@ import { JoinRequest } from './db'; -function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw) - .digest('hex'); } +function hashPassword(pw: string) { + return crypto.createHash('sha256') + .update(pw) + .digest('hex'); } // REST API Routes @@ -476,6 +478,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void { serverId: viewSid, users: viewUsers })); + break; } @@ -514,6 +517,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void { ...message, fromUserId: user.oderId })); + console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`); } else { console.log(`Target user ${message.targetUserId} not found. Connected users:`, diff --git a/src/app/app.ts b/src/app/app.ts index 9cfa5ac..ca607ee 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,4 +1,4 @@ -/* eslint-disable @angular-eslint/component-class-suffix, @typescript-eslint/member-ordering */ +/* eslint-disable @angular-eslint/component-class-suffix */ import { Component, OnInit, @@ -51,16 +51,16 @@ import { styleUrl: './app.scss' }) export class App implements OnInit { + store = inject(Store); + currentRoom = this.store.selectSignal(selectCurrentRoom); + private databaseService = inject(DatabaseService); - private store = inject(Store); private router = inject(Router); private servers = inject(ServerDirectoryService); private timeSync = inject(TimeSyncService); private voiceSession = inject(VoiceSessionService); private externalLinks = inject(ExternalLinkService); - currentRoom = this.store.selectSignal(selectCurrentRoom); - /** Intercept all clicks and open them externally. */ @HostListener('document:click', ['$event']) onGlobalLinkClick(evt: MouseEvent): void { diff --git a/src/app/core/services/electron-database.service.ts b/src/app/core/services/electron-database.service.ts index 5845e87..6b7aa52 100644 --- a/src/app/core/services/electron-database.service.ts +++ b/src/app/core/services/electron-database.service.ts @@ -7,35 +7,40 @@ import { BanEntry } from '../models'; +/** CQRS API exposed by the Electron preload script via `contextBridge`. */ +interface ElectronAPI { + command(command: { type: string; payload: unknown }): Promise; + query(query: { type: string; payload: unknown }): Promise; +} + /** * Database service for the Electron (desktop) runtime. * - * All SQLite queries run in the Electron **main process** - * (`electron/database.js`). This service is a thin IPC client that - * delegates every operation to `window.electronAPI.db.*`. + * The SQLite database is managed by TypeORM in the Electron **main process** + * (`electron/main.ts`). This service is a thin CQRS IPC client that dispatches + * structured command/query objects through the unified `cqrs:command` and + * `cqrs:query` channels exposed by the preload script. + * + * No initialisation IPC call is needed – the database is initialised and + * migrations are run in main.ts before the renderer window is created. */ @Injectable({ providedIn: 'root' }) export class ElectronDatabaseService { - /** Whether {@link initialize} has already been called successfully. */ - private isInitialised = false; - - /** Shorthand accessor for the preload-exposed database API. */ - private get api() { - return (window as any).electronAPI.db; + /** Shorthand accessor for the preload-exposed CQRS API. */ + private get api(): ElectronAPI { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (window as any).electronAPI as ElectronAPI; } - /** Initialise the SQLite database via the main-process IPC bridge. */ - async initialize(): Promise { - if (this.isInitialised) - return; - - await this.api.initialize(); - this.isInitialised = true; - } + /** + * No-op: the database is initialised in the main process before the + * renderer window opens and requires no explicit bootstrap call here. + */ + async initialize(): Promise { /* no-op */ } /** Persist a single chat message. */ saveMessage(message: Message): Promise { - return this.api.saveMessage(message); + return this.api.command({ type: 'save-message', payload: { message } }); } /** @@ -46,141 +51,146 @@ export class ElectronDatabaseService { * @param offset - Number of messages to skip (for pagination). */ getMessages(roomId: string, limit = 100, offset = 0): Promise { - return this.api.getMessages(roomId, limit, offset); + return this.api.query({ type: 'get-messages', payload: { roomId, limit, offset } }); } /** Permanently delete a message by ID. */ deleteMessage(messageId: string): Promise { - return this.api.deleteMessage(messageId); + return this.api.command({ type: 'delete-message', payload: { messageId } }); } /** Apply partial updates to an existing message. */ updateMessage(messageId: string, updates: Partial): Promise { - return this.api.updateMessage(messageId, updates); + return this.api.command({ type: 'update-message', payload: { messageId, updates } }); } /** Retrieve a single message by ID, or `null` if not found. */ getMessageById(messageId: string): Promise { - return this.api.getMessageById(messageId); + return this.api.query({ type: 'get-message-by-id', payload: { messageId } }); } /** Remove every message belonging to a room. */ clearRoomMessages(roomId: string): Promise { - return this.api.clearRoomMessages(roomId); + return this.api.command({ type: 'clear-room-messages', payload: { roomId } }); } - /** Persist a reaction (deduplication is handled server-side). */ + /** Persist a reaction (deduplication is handled main-process side). */ saveReaction(reaction: Reaction): Promise { - return this.api.saveReaction(reaction); + return this.api.command({ type: 'save-reaction', payload: { reaction } }); } /** Remove a specific reaction (user + emoji + message). */ removeReaction(messageId: string, userId: string, emoji: string): Promise { - return this.api.removeReaction(messageId, userId, emoji); + return this.api.command({ type: 'remove-reaction', payload: { messageId, userId, emoji } }); } /** Return all reactions for a given message. */ getReactionsForMessage(messageId: string): Promise { - return this.api.getReactionsForMessage(messageId); + return this.api.query({ type: 'get-reactions-for-message', payload: { messageId } }); } /** Persist a user record. */ saveUser(user: User): Promise { - return this.api.saveUser(user); + return this.api.command({ type: 'save-user', payload: { user } }); } /** Retrieve a user by ID, or `null` if not found. */ getUser(userId: string): Promise { - return this.api.getUser(userId); + return this.api.query({ type: 'get-user', payload: { userId } }); } /** Retrieve the last-authenticated ("current") user, or `null`. */ getCurrentUser(): Promise { - return this.api.getCurrentUser(); + return this.api.query({ type: 'get-current-user', payload: {} }); } /** Store which user ID is considered "current" (logged-in). */ setCurrentUserId(userId: string): Promise { - return this.api.setCurrentUserId(userId); + return this.api.command({ type: 'set-current-user-id', payload: { userId } }); } /** Retrieve users associated with a room. */ getUsersByRoom(roomId: string): Promise { - return this.api.getUsersByRoom(roomId); + return this.api.query({ type: 'get-users-by-room', payload: { roomId } }); } /** Apply partial updates to an existing user. */ updateUser(userId: string, updates: Partial): Promise { - return this.api.updateUser(userId, updates); + return this.api.command({ type: 'update-user', payload: { userId, updates } }); } /** Persist a room record. */ saveRoom(room: Room): Promise { - return this.api.saveRoom(room); + return this.api.command({ type: 'save-room', payload: { room } }); } /** Retrieve a room by ID, or `null` if not found. */ getRoom(roomId: string): Promise { - return this.api.getRoom(roomId); + return this.api.query({ type: 'get-room', payload: { roomId } }); } /** Return every persisted room. */ getAllRooms(): Promise { - return this.api.getAllRooms(); + return this.api.query({ type: 'get-all-rooms', payload: {} }); } - /** Delete a room by ID. */ + /** Delete a room by ID (also removes its messages). */ deleteRoom(roomId: string): Promise { - return this.api.deleteRoom(roomId); + return this.api.command({ type: 'delete-room', payload: { roomId } }); } /** Apply partial updates to an existing room. */ updateRoom(roomId: string, updates: Partial): Promise { - return this.api.updateRoom(roomId, updates); + return this.api.command({ type: 'update-room', payload: { roomId, updates } }); } /** Persist a ban entry. */ saveBan(ban: BanEntry): Promise { - return this.api.saveBan(ban); + return this.api.command({ type: 'save-ban', payload: { ban } }); } /** Remove a ban by the banned user's `oderId`. */ removeBan(oderId: string): Promise { - return this.api.removeBan(oderId); + return this.api.command({ type: 'remove-ban', payload: { oderId } }); } /** Return active bans for a room. */ getBansForRoom(roomId: string): Promise { - return this.api.getBansForRoom(roomId); + return this.api.query({ type: 'get-bans-for-room', payload: { roomId } }); } /** Check whether a user is currently banned from a room. */ isUserBanned(userId: string, roomId: string): Promise { - return this.api.isUserBanned(userId, roomId); + return this.api.query({ type: 'is-user-banned', payload: { userId, roomId } }); } /** Persist attachment metadata. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any saveAttachment(attachment: any): Promise { - return this.api.saveAttachment(attachment); + return this.api.command({ type: 'save-attachment', payload: { attachment } }); } /** Return all attachment records for a message. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any getAttachmentsForMessage(messageId: string): Promise { - return this.api.getAttachmentsForMessage(messageId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this.api.query({ type: 'get-attachments-for-message', payload: { messageId } }); } /** Return every persisted attachment record. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any getAllAttachments(): Promise { - return this.api.getAllAttachments(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this.api.query({ type: 'get-all-attachments', payload: {} }); } /** Delete all attachment records for a message. */ deleteAttachmentsForMessage(messageId: string): Promise { - return this.api.deleteAttachmentsForMessage(messageId); + return this.api.command({ type: 'delete-attachments-for-message', payload: { messageId } }); } /** Wipe every table, removing all persisted data. */ clearAllData(): Promise { - return this.api.clearAllData(); + return this.api.command({ type: 'clear-all-data', payload: {} }); } } diff --git a/src/app/features/admin/admin-panel/admin-panel.component.ts b/src/app/features/admin/admin-panel/admin-panel.component.ts index a141ef3..b2fef30 100644 --- a/src/app/features/admin/admin-panel/admin-panel.component.ts +++ b/src/app/features/admin/admin-panel/admin-panel.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, inject, @@ -67,8 +66,7 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions'; * Only accessible to users with admin privileges. */ export class AdminPanelComponent { - private store = inject(Store); - private webrtc = inject(WebRTCService); + store = inject(Store); currentRoom = this.store.selectSignal(selectCurrentRoom); currentUser = this.store.selectSignal(selectCurrentUser); @@ -95,6 +93,8 @@ export class AdminPanelComponent { adminsManageIcon = false; moderatorsManageIcon = false; + private webrtc = inject(WebRTCService); + constructor() { // Initialize from current room const room = this.currentRoom(); diff --git a/src/app/features/auth/login/login.component.ts b/src/app/features/auth/login/login.component.ts index 1454216..18a3e3b 100644 --- a/src/app/features/auth/login/login.component.ts +++ b/src/app/features/auth/login/login.component.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */ +/* eslint-disable max-statements-per-line */ import { Component, inject, @@ -32,10 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; * Login form allowing existing users to authenticate against a selected server. */ export class LoginComponent { - private auth = inject(AuthService); - private serversSvc = inject(ServerDirectoryService); - private store = inject(Store); - private router = inject(Router); + serversSvc = inject(ServerDirectoryService); servers = this.serversSvc.servers; username = ''; @@ -43,6 +40,10 @@ export class LoginComponent { serverId: string | undefined = this.serversSvc.activeServer()?.id; error = signal(null); + private auth = inject(AuthService); + private store = inject(Store); + private router = inject(Router); + /** TrackBy function for server list rendering. */ trackById(_index: number, item: { id: string }) { return item.id; } diff --git a/src/app/features/auth/register/register.component.ts b/src/app/features/auth/register/register.component.ts index 47924e5..9a93724 100644 --- a/src/app/features/auth/register/register.component.ts +++ b/src/app/features/auth/register/register.component.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */ +/* eslint-disable max-statements-per-line */ import { Component, inject, @@ -32,10 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; * Registration form allowing new users to create an account on a selected server. */ export class RegisterComponent { - private auth = inject(AuthService); - private serversSvc = inject(ServerDirectoryService); - private store = inject(Store); - private router = inject(Router); + serversSvc = inject(ServerDirectoryService); servers = this.serversSvc.servers; username = ''; @@ -44,6 +41,10 @@ export class RegisterComponent { serverId: string | undefined = this.serversSvc.activeServer()?.id; error = signal(null); + private auth = inject(AuthService); + private store = inject(Store); + private router = inject(Router); + /** TrackBy function for server list rendering. */ trackById(_index: number, item: { id: string }) { return item.id; } diff --git a/src/app/features/auth/user-bar/user-bar.component.ts b/src/app/features/auth/user-bar/user-bar.component.ts index 534d6b0..ebe56a0 100644 --- a/src/app/features/auth/user-bar/user-bar.component.ts +++ b/src/app/features/auth/user-bar/user-bar.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; @@ -26,10 +25,11 @@ import { selectCurrentUser } from '../../../store/users/users.selectors'; * Compact user status bar showing the current user with login/register navigation links. */ export class UserBarComponent { - private store = inject(Store); - private router = inject(Router); + store = inject(Store); user = this.store.selectSignal(selectCurrentUser); + private router = inject(Router); + /** Navigate to the specified authentication page. */ goto(path: 'login' | 'register') { this.router.navigate([`/${path}`]); diff --git a/tsconfig.electron.json b/tsconfig.electron.json new file mode 100644 index 0000000..61600e7 --- /dev/null +++ b/tsconfig.electron.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist/electron", + "rootDir": "./electron", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "useDefineForClassFields": false, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["electron/**/*"], + "exclude": ["node_modules", "dist"] +}