diff --git a/.gitignore b/.gitignore index 61725aa2..1aef40eb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,8 @@ # next.js /electron/build /electron/renderer/.next -/electron/renderer/public /electron/renderer/out +/electron/renderer/public/themes # source maps *.js.map diff --git a/README.md b/README.md index f3971a34..ebee9288 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Ignite your [DragonRealms](http://play.net/dr) journey with Phoenix, a cross-pla Phoenix is a community-supported frontend for Simutronic's text-based multiplayer game DragonRealms. -![phoenix-logo](./resources/phoenix.png) - 🚧 Currently in development, stay tuned! +![phoenix-logo](./resources/phoenix.png) + ## Developing Phoenix 1. Install [nodejs](https://nodejs.org/en/download). diff --git a/electron/common/__mocks__/electron-log.mock.ts b/electron/common/__mocks__/electron-log.mock.ts index c82a1d62..69fc76d2 100644 --- a/electron/common/__mocks__/electron-log.mock.ts +++ b/electron/common/__mocks__/electron-log.mock.ts @@ -88,6 +88,9 @@ export const clearElectronLoggerMockProps = ( console: {}, file: {}, }; + // This is a private property defined by `initializeLogging` method. + // Reset it so that the logger can be re-initialized in each test. + (logger as any).__phoenix_initialized = false; }; export { mockElectronLogMain, mockElectronLogRenderer }; diff --git a/electron/common/account/types.ts b/electron/common/account/types.ts new file mode 100644 index 00000000..ed9dbef7 --- /dev/null +++ b/electron/common/account/types.ts @@ -0,0 +1,10 @@ +export interface Account { + accountName: string; + accountPassword: string; +} + +export interface Character { + accountName: string; + characterName: string; + gameCode: string; +} diff --git a/electron/common/data/__tests__/urls.test.ts b/electron/common/data/__tests__/urls.test.ts index fe9f44f3..b65c36df 100644 --- a/electron/common/data/__tests__/urls.test.ts +++ b/electron/common/data/__tests__/urls.test.ts @@ -45,7 +45,7 @@ describe('URLs', () => { }); it('PLAY_NET_URL', () => { - expect(urls.PLAY_NET_URL).toBe('http://play.net/dr'); + expect(urls.PLAY_NET_URL).toBe('https://www.play.net/dr'); }); it('ELANTHIPEDIA_URL', () => { diff --git a/electron/common/data/urls.ts b/electron/common/data/urls.ts index f669ea87..0fb6f017 100644 --- a/electron/common/data/urls.ts +++ b/electron/common/data/urls.ts @@ -7,5 +7,5 @@ export const PHOENIX_LICENSE_URL = `${GITHUB_BASE_URL}/blob/main/LICENSE.md`; export const PHOENIX_PRIVACY_URL = `${GITHUB_BASE_URL}/blob/main/PRIVACY.md`; export const PHOENIX_SECURITY_URL = `${GITHUB_BASE_URL}/blob/main/SECURITY.md`; -export const PLAY_NET_URL = `http://play.net/dr`; +export const PLAY_NET_URL = `https://www.play.net/dr`; export const ELANTHIPEDIA_URL = `https://elanthipedia.play.net`; diff --git a/electron/common/game/types.ts b/electron/common/game/types.ts index 22f07b2d..ecfe3101 100644 --- a/electron/common/game/types.ts +++ b/electron/common/game/types.ts @@ -1,3 +1,61 @@ +/** + * Simutronics has multiple games and instances per game. + * Only interested in DragonRealms, though. + */ +export enum GameCode { + PRIME = 'DR', + PLATINUM = 'DRX', + FALLEN = 'DRF', + TEST = 'DRT', + DEVELOPMENT = 'DRD', +} + +export interface GameCodeMeta { + /** + * The game code. + * Example: 'DR' or 'DRX'. + */ + code: GameCode; + /** + * The code name. + * Example: 'Prime' or 'Platinum'. + */ + name: string; + /** + * The game name. + * Example: 'DragonRealms'. + */ + game: string; +} + +export const GameCodeMetaMap: Record = { + DR: { + code: GameCode.PRIME, + name: 'Prime', + game: 'DragonRealms', + }, + DRX: { + code: GameCode.PLATINUM, + name: 'Platinum', + game: 'DragonRealms', + }, + DRF: { + code: GameCode.FALLEN, + name: 'Fallen', + game: 'DragonRealms', + }, + DRT: { + code: GameCode.TEST, + name: 'Test', + game: 'DragonRealms', + }, + DRD: { + code: GameCode.DEVELOPMENT, + name: 'Development', + game: 'DragonRealms', + }, +}; + /** * Events emitted by the game parser of data received from the game socket. */ diff --git a/electron/common/logger/create-logger.ts b/electron/common/logger/create-logger.ts index dad21011..e4e5fd71 100644 --- a/electron/common/logger/create-logger.ts +++ b/electron/common/logger/create-logger.ts @@ -5,27 +5,19 @@ import type { LogFunctions as ElectronLogFunctions, Logger as ElectronLogger, } from 'electron-log'; -import { includesIgnoreCase } from '../string/includes-ignore-case.js'; +import { initializeLogging } from './initialize-logging.js'; import type { LogFunction, Logger } from './types.js'; -import { LogLevel } from './types.js'; -// TODO: is caching these necessary? // Cache loggers for the same scope. const scopedLoggers: Record = {}; interface ElectronLogFunctionsExtended extends ElectronLogFunctions { /** - * Alias for electron logger's 'silly' level. + * Alternative to electron logger's 'silly' level. */ trace: LogFunction; } -const addTraceLevel = (logger: ElectronLogger): void => { - if (!includesIgnoreCase(logger.levels, LogLevel.TRACE)) { - logger.addLevel(LogLevel.TRACE); - } -}; - export const createLogger = (options: { /** * Label printed with each log message to identify the source. @@ -45,7 +37,8 @@ export const createLogger = (options: { const scope = options?.scope ?? ''; const electronLogger = options.logger; - addTraceLevel(electronLogger); + // Applies customizations like format hooks, 'trace' level, etc. + initializeLogging(electronLogger); if (!scopedLoggers[scope]) { if (scope.length > 0) { diff --git a/electron/common/logger/format-log-data.ts b/electron/common/logger/format-log-data.ts index 2660cd3a..3e5cf654 100644 --- a/electron/common/logger/format-log-data.ts +++ b/electron/common/logger/format-log-data.ts @@ -10,7 +10,7 @@ import type { LogData } from './types.js'; * * This method mutates and returns the log data argument. */ -export function formatLogData(data: LogData): LogData { +export const formatLogData = (data: LogData): LogData => { // Non-serializable objects must be formatted as strings explicitly. // For example, this mitigates error objects being logged as "{}". for (const entry of Object.entries(data)) { @@ -52,4 +52,4 @@ export function formatLogData(data: LogData): LogData { data = maskSensitiveValues({ json: data }); return data; -} +}; diff --git a/electron/common/logger/initialize-logging.ts b/electron/common/logger/initialize-logging.ts index 2179083e..5d2dccc7 100644 --- a/electron/common/logger/initialize-logging.ts +++ b/electron/common/logger/initialize-logging.ts @@ -3,11 +3,30 @@ import type { LogMessage as ElectronLogMessage, Logger as ElectronLogger, } from 'electron-log'; +import { includesIgnoreCase } from '../string/includes-ignore-case.js'; import { formatLogData } from './format-log-data.js'; import { getLogLevel } from './get-log-level.js'; import type { LogFunction } from './types.js'; +import { LogLevel } from './types.js'; + +interface InitializableElectronLogger extends ElectronLogger { + /** + * Track if we have already initialized this logger instance + * so that we don't duplicate our customizations. + * + * Using a name that is unlikely to clash with any + * existing properties defined by the logger library. + */ + __phoenix_initialized?: boolean; +} + +export const initializeLogging = ( + logger: InitializableElectronLogger +): void => { + if (isInitialized(logger)) { + return; + } -export const initializeLogging = (logger: ElectronLogger): void => { // Add our custom log formatter. logger.hooks.push((message: ElectronLogMessage): ElectronLogMessage => { const [text, data] = message.data as Parameters; @@ -17,11 +36,26 @@ export const initializeLogging = (logger: ElectronLogger): void => { return message; }); - // Set the log level. + // Add the trace log level option. + if (!includesIgnoreCase(logger.levels, LogLevel.TRACE)) { + logger.addLevel(LogLevel.TRACE); + } + + // Set the log level for each transport. Object.keys(logger.transports).forEach((transportKey) => { const transport = logger.transports[transportKey]; if (transport) { transport.level = getLogLevel() as ElectronLogLevel; } }); + + markInitialized(logger); +}; + +const isInitialized = (logger: InitializableElectronLogger): boolean => { + return logger.__phoenix_initialized === true; +}; + +const markInitialized = (logger: InitializableElectronLogger): void => { + logger.__phoenix_initialized = true; }; diff --git a/electron/common/string/__tests__/is-blank.test.ts b/electron/common/string/__tests__/is-blank.test.ts new file mode 100644 index 00000000..3cd21b91 --- /dev/null +++ b/electron/common/string/__tests__/is-blank.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { isBlank } from '../is-blank.js'; + +describe('is-blank', () => { + it.each([undefined, null, '', ' ', '\n'])( + 'returns true when string is `%s`q', + async (text: null | undefined | string) => { + expect(isBlank(text)).toBe(true); + } + ); + + it.each(['a', ' a', 'a ', ' a '])( + 'returns false when string is `%s`', + async (text: string) => { + expect(isBlank(text)).toBe(false); + } + ); +}); diff --git a/electron/common/string/__tests__/is-empty.test.ts b/electron/common/string/__tests__/is-empty.test.ts new file mode 100644 index 00000000..f575ef95 --- /dev/null +++ b/electron/common/string/__tests__/is-empty.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { isEmpty } from '../is-empty.js'; + +describe('is-empty', () => { + it.each([undefined, null, ''])( + 'returns true when string is `%s`', + async (text: null | undefined | string) => { + expect(isEmpty(text)).toBe(true); + } + ); + + it.each(['a', ' a', 'a ', ' a ', ' ', '\n'])( + 'returns false when string is `%s`', + async (text: string) => { + expect(isEmpty(text)).toBe(false); + } + ); +}); diff --git a/electron/common/string/is-blank.ts b/electron/common/string/is-blank.ts new file mode 100644 index 00000000..51a401d5 --- /dev/null +++ b/electron/common/string/is-blank.ts @@ -0,0 +1,14 @@ +import { isEmpty } from './is-empty.js'; + +/** + * Returns true if the text is undefined, null, or is empty when trimmed. + * Whitespace characters are ignored. + * + * We use a type guard in result to hint that if this function returns false + * then the value cannot be null or undefined. + */ +export const isBlank = ( + text: string | null | undefined +): text is null | undefined => { + return isEmpty(text?.trim()); +}; diff --git a/electron/common/string/is-empty.ts b/electron/common/string/is-empty.ts new file mode 100644 index 00000000..5d2a42d5 --- /dev/null +++ b/electron/common/string/is-empty.ts @@ -0,0 +1,12 @@ +/** + * Returns true if the text is undefined, null, or empty string (''). + * Whitespace characters are considered non-empty. + * + * We use a type guard in result to hint that if this function returns false + * then the value cannot be null or undefined. + */ +export const isEmpty = ( + text: string | null | undefined +): text is null | undefined => { + return !text || text === ''; +}; diff --git a/electron/common/tsconfig.json b/electron/common/tsconfig.json index 88d35482..9134be41 100644 --- a/electron/common/tsconfig.json +++ b/electron/common/tsconfig.json @@ -28,5 +28,5 @@ "lib": ["DOM", "DOM.Iterable", "ESNext"] }, "exclude": ["node_modules", "**/__tests__/**", "**/__mocks__/**"], - "include": ["**/*.ts"] + "include": ["**/types.ts", "**/*.ts"] } diff --git a/electron/common/types.ts b/electron/common/types.ts index 94a4aa51..48279062 100644 --- a/electron/common/types.ts +++ b/electron/common/types.ts @@ -4,9 +4,9 @@ */ export type Maybe = NonNullable | undefined; -export function convertToMaybe(value: T): Maybe { +export const convertToMaybe = (value: T): Maybe => { return value ?? undefined; -} +}; /** * Same as Partial but goes deeper and makes Partial all its properties and sub-properties. diff --git a/electron/main/account/account.service.ts b/electron/main/account/account.service.ts index f179fbee..92ed4edb 100644 --- a/electron/main/account/account.service.ts +++ b/electron/main/account/account.service.ts @@ -1,16 +1,12 @@ import { safeStorage } from 'electron'; import isEmpty from 'lodash-es/isEmpty.js'; import omit from 'lodash-es/omit.js'; +import type { Account, Character } from '../../common/account/types.js'; import { equalsIgnoreCase } from '../../common/string/equals-ignore-case.js'; import type { Maybe } from '../../common/types.js'; import type { StoreService } from '../store/types.js'; import { logger } from './logger.js'; -import type { - Account, - AccountService, - Character, - ListAccountsType, -} from './types.js'; +import type { AccountService, ListAccountsType } from './types.js'; export class AccountServiceImpl implements AccountService { private storeService: StoreService; diff --git a/electron/main/account/types.ts b/electron/main/account/types.ts index df034201..ce5e1dad 100644 --- a/electron/main/account/types.ts +++ b/electron/main/account/types.ts @@ -1,19 +1,9 @@ +import type { Account, Character } from '../../common/account/types.js'; import type { Maybe } from '../../common/types.js'; export type ListAccountsType = Array; export type ListAccountsItemType = Omit; -export interface Account { - accountName: string; - accountPassword: string; -} - -export interface Character { - accountName: string; - characterName: string; - gameCode: string; -} - /** * A data-store abstraction over managing local accounts and characters. * Does not interact with the play.net service. diff --git a/electron/main/game/__mocks__/game-service.mock.ts b/electron/main/game/__mocks__/game-service.mock.ts index 15fb2b2c..01b53b0c 100644 --- a/electron/main/game/__mocks__/game-service.mock.ts +++ b/electron/main/game/__mocks__/game-service.mock.ts @@ -8,6 +8,8 @@ export class GameServiceMockImpl implements GameService { this.constructorSpy(args); } + isConnected = vi.fn<[], boolean>(); + connect = vi.fn< Parameters, ReturnType diff --git a/electron/main/game/__tests__/game-instance.test.ts b/electron/main/game/__tests__/game-instance.test.ts index a35f7bce..d5e9518e 100644 --- a/electron/main/game/__tests__/game-instance.test.ts +++ b/electron/main/game/__tests__/game-instance.test.ts @@ -6,6 +6,8 @@ import type { GameService } from '../types.js'; const { mockGameService } = vi.hoisted(() => { const mockGameService = { + isConnected: vi.fn<[], boolean>(), + connect: vi.fn< Parameters, ReturnType @@ -29,6 +31,10 @@ const { mockGameService } = vi.hoisted(() => { vi.mock('../game.service.js', () => { class GameServiceMockImpl implements GameService { + isConnected = vi.fn<[], boolean>().mockImplementation(() => { + return mockGameService.isConnected(); + }); + connect = vi .fn< Parameters, diff --git a/electron/main/game/__tests__/game-service.test.ts b/electron/main/game/__tests__/game-service.test.ts index 8c1838ec..78fccad4 100644 --- a/electron/main/game/__tests__/game-service.test.ts +++ b/electron/main/game/__tests__/game-service.test.ts @@ -15,6 +15,7 @@ const { mockParser, mockSocket, mockWriteStream, mockWaitUntil } = vi.hoisted( // For mocking the game socket module. const mockSocket: Mocked = { + isConnected: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), send: vi.fn(), @@ -74,6 +75,10 @@ vi.mock('../game.socket.js', () => { this.onDisconnect = options.onDisconnect; } + isConnected = vi.fn().mockImplementation(() => { + return mockSocket.isConnected(); + }); + connect = vi .fn() .mockImplementation(async (): Promise> => { @@ -170,6 +175,8 @@ describe('game-service', () => { const gameEvent = await rxjs.firstValueFrom(gameEvents$); expect(gameEvent).toEqual(mockEvent); + + expect(gameService.isConnected()).toBe(true); }); it('disconnects previous connection', async () => { @@ -227,6 +234,8 @@ describe('game-service', () => { expect(mockSocket.connect).toHaveBeenCalledTimes(1); expect(mockSocket.disconnect).toHaveBeenCalledTimes(1); + + expect(gameService.isConnected()).toBe(false); }); it('does not disconnect if already destroyed', async () => { diff --git a/electron/main/game/__tests__/game-socket.test.ts b/electron/main/game/__tests__/game-socket.test.ts index af6277de..5760a351 100644 --- a/electron/main/game/__tests__/game-socket.test.ts +++ b/electron/main/game/__tests__/game-socket.test.ts @@ -88,10 +88,17 @@ describe('game-socket', () => { // Connect to socket and begin listening for data. const socketDataPromise = socket.connect(); + // Not connected yet because the socket has not received the data + // that indicates that the game connection is established. + expect(socket.isConnected()).toBe(false); + // At this point the socket is listening for data from the game server. // Emit data from the game server signaling that the connection is ready. mockSocket.emitData('\n'); + // Now the socket is connected because it received the expected data. + expect(socket.isConnected()).toBe(true); + // Run timer so that the delayed newlines sent on connect are seen. await vi.runAllTimersAsync(); @@ -125,6 +132,8 @@ describe('game-socket', () => { await socket.disconnect(); + expect(socket.isConnected()).toBe(false); + // First subscriber receives all buffered and new events. expect(subscriber1NextSpy).toHaveBeenCalledTimes(2); expect(subscriber1NextSpy).toHaveBeenNthCalledWith( diff --git a/electron/main/game/game.parser.ts b/electron/main/game/game.parser.ts index 5c6fd2ee..b43155ff 100644 --- a/electron/main/game/game.parser.ts +++ b/electron/main/game/game.parser.ts @@ -464,7 +464,7 @@ export class GameParserImpl implements GameParser { }); } break; - case 'compass': // ... + case 'compass': // this.compassDirections = []; break; case 'dir': // @@ -601,7 +601,7 @@ export class GameParserImpl implements GameParser { return { type: GameEventType.EXPERIENCE, eventId: uuid(), - skill: tagId.slice(4), + skill: tagId.slice(4), // remove 'exp ' prefix rank: 0, percent: 0, mindState: 'clear', diff --git a/electron/main/game/game.service.ts b/electron/main/game/game.service.ts index 269429b9..a6c99041 100644 --- a/electron/main/game/game.service.ts +++ b/electron/main/game/game.service.ts @@ -22,8 +22,8 @@ export class GameServiceImpl implements GameService { * There is a brief delay after sending credentials before the game server * is ready to receive commands. Sending commands too early will fail. */ - private isConnected = false; - private isDestroyed = false; + private _isConnected = false; + private _isDestroyed = false; /** * Socket to communicate with the game server. @@ -41,18 +41,22 @@ export class GameServiceImpl implements GameService { this.socket = new GameSocketImpl({ credentials, onConnect: () => { - this.isConnected = true; - this.isDestroyed = false; + this._isConnected = true; + this._isDestroyed = false; }, onDisconnect: () => { - this.isConnected = false; - this.isDestroyed = true; + this._isConnected = false; + this._isDestroyed = true; }, }); } + public isConnected(): boolean { + return this._isConnected; + } + public async connect(): Promise> { - if (this.isConnected) { + if (this._isConnected) { await this.disconnect(); } @@ -72,7 +76,7 @@ export class GameServiceImpl implements GameService { } public async disconnect(): Promise { - if (!this.isDestroyed) { + if (!this._isDestroyed) { logger.info('disconnecting'); await this.socket.disconnect(); await this.waitUntilDestroyed(); @@ -80,7 +84,7 @@ export class GameServiceImpl implements GameService { } public send(command: string): void { - if (this.isConnected) { + if (this._isConnected) { logger.debug('sending command', { command }); this.socket.send(command); } @@ -91,7 +95,7 @@ export class GameServiceImpl implements GameService { const timeout = 5000; const result = await waitUntil({ - condition: () => this.isDestroyed, + condition: () => this._isDestroyed, interval, timeout, }); diff --git a/electron/main/game/game.socket.ts b/electron/main/game/game.socket.ts index 1961c6f0..859d5f92 100644 --- a/electron/main/game/game.socket.ts +++ b/electron/main/game/game.socket.ts @@ -37,8 +37,8 @@ export class GameSocketImpl implements GameSocket { * There is a brief delay after sending credentials before the game server * is ready to receive commands. Sending commands too early will fail. */ - private isConnected = false; - private isDestroyed = false; + private _isConnected = false; + private _isDestroyed = false; /** * Socket to communicate with the game server. @@ -78,8 +78,12 @@ export class GameSocketImpl implements GameSocket { this.onDisconnectCallback = options.onDisconnect ?? (() => {}); } + public isConnected(): boolean { + return this._isConnected; + } + public async connect(): Promise> { - if (this.isConnected) { + if (this._isConnected) { await this.disconnect(); } @@ -102,7 +106,7 @@ export class GameSocketImpl implements GameSocket { // If we don't check both conditions then this would wait forever. await this.waitUntilConnectedOrDestroyed(); - if (this.isDestroyed) { + if (this._isDestroyed) { throw new Error( `[GAME:SOCKET:STATUS:DESTROYED] failed to connect to game server` ); @@ -149,7 +153,7 @@ export class GameSocketImpl implements GameSocket { const timeout = 5000; const result = await waitUntil({ - condition: () => this.isConnected, + condition: () => this._isConnected, interval, timeout, }); @@ -164,7 +168,7 @@ export class GameSocketImpl implements GameSocket { const timeout = 5000; const result = await waitUntil({ - condition: () => this.isDestroyed, + condition: () => this._isDestroyed, interval, timeout, }); @@ -181,13 +185,13 @@ export class GameSocketImpl implements GameSocket { logger.debug('creating game socket', { host, port }); - this.isConnected = false; - this.isDestroyed = false; + this._isConnected = false; + this._isDestroyed = false; const onGameConnect = (): void => { - if (!this.isConnected) { - this.isConnected = true; - this.isDestroyed = false; + if (!this._isConnected) { + this._isConnected = true; + this._isDestroyed = false; } try { this.onConnectCallback(); @@ -205,7 +209,7 @@ export class GameSocketImpl implements GameSocket { } catch (error) { logger.warn('error in disconnect callback', { event, error }); } - if (!this.isDestroyed) { + if (!this._isDestroyed) { this.destroyGameSocket(socket); } }; @@ -222,7 +226,7 @@ export class GameSocketImpl implements GameSocket { if (buffer.endsWith('\n')) { const message = buffer; logger.trace('socket received message', { message }); - if (!this.isConnected && message.startsWith('')) { + if (!this._isConnected && message.startsWith('')) { onGameConnect(); } this.socketDataSubject$?.next(message); @@ -280,8 +284,8 @@ export class GameSocketImpl implements GameSocket { protected destroyGameSocket(socket: net.Socket): void { logger.debug('destroying game socket'); - this.isConnected = false; - this.isDestroyed = true; + this._isConnected = false; + this._isDestroyed = true; socket.pause(); // stop receiving data socket.destroySoon(); // flush writes then end socket connection diff --git a/electron/main/game/types.ts b/electron/main/game/types.ts index 22ecb546..e9b491f3 100644 --- a/electron/main/game/types.ts +++ b/electron/main/game/types.ts @@ -2,6 +2,11 @@ import type * as rxjs from 'rxjs'; import type { GameEvent } from '../../common/game/types.js'; export interface GameService { + /** + * Returns true if connected to the game server. + */ + isConnected(): boolean; + /** * Connect to the game server. * Returns an observable that emits game events parsed from raw output. @@ -24,6 +29,11 @@ export interface GameService { } export interface GameSocket { + /** + * Returns true if connected to the game server. + */ + isConnected(): boolean; + /** * Connect to the game server. * Returns an observable that emits game server output. diff --git a/electron/main/ipc/handlers/__tests__/list-accounts.test.ts b/electron/main/ipc/handlers/__tests__/list-accounts.test.ts new file mode 100644 index 00000000..5199c888 --- /dev/null +++ b/electron/main/ipc/handlers/__tests__/list-accounts.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AccountServiceMockImpl } from '../../../account/__mocks__/account-service.mock.js'; +import type { ListAccountsItemType } from '../../../account/types.js'; +import { listAccountsHandler } from '../list-accounts.js'; + +describe('list-accounts', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + describe('#listAccountsHandler', async () => { + it('lists accounts', async () => { + const mockAccountService = new AccountServiceMockImpl(); + + const mockAccount: ListAccountsItemType = { + accountName: 'test-account-name', + }; + + mockAccountService.listAccounts.mockResolvedValueOnce([mockAccount]); + + const handler = listAccountsHandler({ + accountService: mockAccountService, + }); + + const accounts = await handler([]); + + expect(accounts).toEqual([mockAccount]); + }); + }); +}); diff --git a/electron/main/ipc/handlers/__tests__/quit-character.test.ts b/electron/main/ipc/handlers/__tests__/quit-character.test.ts new file mode 100644 index 00000000..d2320b47 --- /dev/null +++ b/electron/main/ipc/handlers/__tests__/quit-character.test.ts @@ -0,0 +1,126 @@ +import type { Mocked } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockCreateLogger } from '../../../../common/__mocks__/create-logger.mock.js'; +import type { Logger } from '../../../../common/logger/types.js'; +import { runInBackground } from '../../../async/run-in-background.js'; +import { GameServiceMockImpl } from '../../../game/__mocks__/game-service.mock.js'; +import { quitCharacterHandler } from '../quit-character.js'; + +type GameInstanceModule = typeof import('../../../game/game.instance.js'); + +const { mockGameInstance } = await vi.hoisted(async () => { + const mockGameInstance: Mocked = { + getInstance: vi.fn(), + newInstance: vi.fn(), + }; + + return { + mockGameInstance, + }; +}); + +vi.mock('../../../game/game.instance.js', () => { + return { + Game: mockGameInstance, + }; +}); + +describe('quit-character', () => { + let logger: Logger; + + beforeEach(() => { + logger = mockCreateLogger(); + }); + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + describe('#quitCharacterhandler', async () => { + it('quits playing character with the game instance', async () => { + const mockGameService = new GameServiceMockImpl(); + mockGameService.isConnected.mockReturnValueOnce(true); + + mockGameInstance.getInstance.mockReturnValueOnce(mockGameService); + + const mockIpcDispatcher = vi.fn(); + + const handler = quitCharacterHandler({ + dispatch: mockIpcDispatcher, + }); + + // Run the handler in the background so that we can + // advance the mock timers for a speedier test. + // Normally, this handler waits a second between its actions. + runInBackground(async () => { + await handler([]); + }); + + await vi.advanceTimersToNextTimerAsync(); + + expect(mockIpcDispatcher).toHaveBeenCalledWith('game:command', { + command: 'quit', + }); + + expect(mockGameService.send).toHaveBeenCalledWith('quit'); + + expect(mockGameService.disconnect).toHaveBeenCalledTimes(1); + }); + + it('skips sending quit command if game instance is disconnected', async () => { + const logInfoSpy = vi.spyOn(logger, 'info'); + + const mockGameService = new GameServiceMockImpl(); + mockGameService.isConnected.mockReturnValueOnce(false); + + mockGameInstance.getInstance.mockReturnValueOnce(mockGameService); + + const mockIpcDispatcher = vi.fn(); + + const handler = quitCharacterHandler({ + dispatch: mockIpcDispatcher, + }); + + await handler([]); + + expect(logInfoSpy).toHaveBeenCalledWith( + 'game instance not connected, skipping send command', + { + command: 'quit', + } + ); + + expect(mockIpcDispatcher).not.toHaveBeenCalled(); + + expect(mockGameService.send).not.toHaveBeenCalled(); + + expect(mockGameService.disconnect).not.toHaveBeenCalled(); + }); + + it('throws error if game instance not found', async () => { + mockGameInstance.getInstance.mockReturnValueOnce(undefined); + + const mockIpcDispatcher = vi.fn(); + + const handler = quitCharacterHandler({ + dispatch: mockIpcDispatcher, + }); + + try { + await handler([]); + expect.unreachable('it should throw an error'); + } catch (error) { + expect(mockIpcDispatcher).toHaveBeenCalledTimes(0); + expect(error).toEqual( + new Error('[IPC:QUIT_CHARACTER:ERROR:GAME_INSTANCE_NOT_FOUND]') + ); + } + }); + }); +}); diff --git a/electron/main/ipc/handlers/__tests__/send-command.test.ts b/electron/main/ipc/handlers/__tests__/send-command.test.ts index 1986c4e2..e73b79d5 100644 --- a/electron/main/ipc/handlers/__tests__/send-command.test.ts +++ b/electron/main/ipc/handlers/__tests__/send-command.test.ts @@ -1,5 +1,8 @@ import type { Mocked } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockCreateLogger } from '../../../../common/__mocks__/create-logger.mock.js'; +import type { Logger } from '../../../../common/logger/types.js'; +import { runInBackground } from '../../../async/run-in-background.js'; import { GameServiceMockImpl } from '../../../game/__mocks__/game-service.mock.js'; import { sendCommandHandler } from '../send-command.js'; @@ -22,7 +25,13 @@ vi.mock('../../../game/game.instance.js', () => { }; }); -describe('save-character', () => { +describe('send-command', () => { + let logger: Logger; + + beforeEach(() => { + logger = mockCreateLogger(); + }); + beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); }); @@ -34,8 +43,10 @@ describe('save-character', () => { }); describe('#sendCommandHandler', async () => { - it('saves a command with the game instance', async () => { + it('sends a command with the game instance', async () => { const mockGameService = new GameServiceMockImpl(); + mockGameService.isConnected.mockReturnValueOnce(true); + mockGameInstance.getInstance.mockReturnValueOnce(mockGameService); const mockIpcDispatcher = vi.fn(); @@ -44,13 +55,50 @@ describe('save-character', () => { dispatch: mockIpcDispatcher, }); - await handler(['test-command']); + // Run the handler in the background so that we can + // advance the mock timers for a speedier test. + // Normally, this handler waits a second between its actions. + runInBackground(async () => { + await handler(['test-command']); + }); + + await vi.advanceTimersToNextTimerAsync(); expect(mockIpcDispatcher).toHaveBeenCalledWith('game:command', { command: 'test-command', }); }); + it('skips sending command if game instance is disconnected', async () => { + const logInfoSpy = vi.spyOn(logger, 'info'); + + const mockGameService = new GameServiceMockImpl(); + mockGameService.isConnected.mockReturnValueOnce(false); + + mockGameInstance.getInstance.mockReturnValueOnce(mockGameService); + + const mockIpcDispatcher = vi.fn(); + + const handler = sendCommandHandler({ + dispatch: mockIpcDispatcher, + }); + + await handler(['test-command']); + + expect(logInfoSpy).toHaveBeenCalledWith( + 'game instance not connected, skipping send command', + { + command: 'test-command', + } + ); + + expect(mockIpcDispatcher).not.toHaveBeenCalled(); + + expect(mockGameService.send).not.toHaveBeenCalled(); + + expect(mockGameService.disconnect).not.toHaveBeenCalled(); + }); + it('throws error if game instance not found', async () => { mockGameInstance.getInstance.mockReturnValueOnce(undefined); diff --git a/electron/main/ipc/handlers/__tests__/tsconfig.json b/electron/main/ipc/handlers/__tests__/tsconfig.json new file mode 100644 index 00000000..105334e2 --- /dev/null +++ b/electron/main/ipc/handlers/__tests__/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.test.json" +} diff --git a/electron/main/ipc/handlers/list-accounts.ts b/electron/main/ipc/handlers/list-accounts.ts new file mode 100644 index 00000000..ca0877b4 --- /dev/null +++ b/electron/main/ipc/handlers/list-accounts.ts @@ -0,0 +1,15 @@ +import type { AccountService } from '../../account/types.js'; +import { logger } from '../logger.js'; +import type { IpcInvokeHandler, IpcSgeAccount } from '../types.js'; + +export const listAccountsHandler = (options: { + accountService: AccountService; +}): IpcInvokeHandler<'listAccounts'> => { + const { accountService } = options; + + return async (_args): Promise> => { + logger.debug('listAccountsHandler'); + + return accountService.listAccounts(); + }; +}; diff --git a/electron/main/ipc/handlers/quit-character.ts b/electron/main/ipc/handlers/quit-character.ts new file mode 100644 index 00000000..b4407781 --- /dev/null +++ b/electron/main/ipc/handlers/quit-character.ts @@ -0,0 +1,41 @@ +import { sleep } from '../../../common/async/sleep.js'; +import { Game } from '../../game/game.instance.js'; +import { logger } from '../logger.js'; +import type { IpcDispatcher, IpcInvokeHandler } from '../types.js'; + +export const quitCharacterHandler = (options: { + dispatch: IpcDispatcher; +}): IpcInvokeHandler<'quitCharacter'> => { + const { dispatch } = options; + + const command = 'quit'; + + return async (): Promise => { + logger.debug('quitCharacterHandler', { command }); + + const gameInstance = Game.getInstance(); + + if (!gameInstance) { + throw new Error('[IPC:QUIT_CHARACTER:ERROR:GAME_INSTANCE_NOT_FOUND]'); + } + + if (!gameInstance.isConnected()) { + logger.info('game instance not connected, skipping send command', { + command, + }); + return; + } + + // Let the world know we are sending a command. + dispatch('game:command', { command }); + + gameInstance.send(command); + + // Give the service and the game some time to process the command. + await sleep(1000); + + // Normally, the game server will disconnect the client after this command. + // Just in case, explicitly disconnect ourselves. + await gameInstance.disconnect(); + }; +}; diff --git a/electron/main/ipc/handlers/send-command.ts b/electron/main/ipc/handlers/send-command.ts index a5a7c617..e191d98d 100644 --- a/electron/main/ipc/handlers/send-command.ts +++ b/electron/main/ipc/handlers/send-command.ts @@ -14,11 +14,20 @@ export const sendCommandHandler = (options: { const gameInstance = Game.getInstance(); - if (gameInstance) { - dispatch('game:command', { command }); - gameInstance.send(command); - } else { + if (!gameInstance) { throw new Error('[IPC:SEND_COMMAND:ERROR:GAME_INSTANCE_NOT_FOUND]'); } + + if (!gameInstance.isConnected()) { + logger.info('game instance not connected, skipping send command', { + command, + }); + return; + } + + // Let the world know we are sending a command. + dispatch('game:command', { command }); + + gameInstance.send(command); }; }; diff --git a/electron/main/ipc/ipc.controller.ts b/electron/main/ipc/ipc.controller.ts index eabc48ee..bc61d7dd 100644 --- a/electron/main/ipc/ipc.controller.ts +++ b/electron/main/ipc/ipc.controller.ts @@ -4,9 +4,11 @@ import { AccountServiceImpl } from '../account/account.service.js'; import type { AccountService } from '../account/types.js'; import { Game } from '../game/game.instance.js'; import { Store } from '../store/store.instance.js'; +import { listAccountsHandler } from './handlers/list-accounts.js'; import { listCharactersHandler } from './handlers/list-characters.js'; import { pingHandler } from './handlers/ping.js'; import { playCharacterHandler } from './handlers/play-character.js'; +import { quitCharacterHandler } from './handlers/quit-character.js'; import { removeAccountHandler } from './handlers/remove-account.js'; import { removeCharacterHandler } from './handlers/remove-character.js'; import { saveAccountHandler } from './handlers/save-account.js'; @@ -83,6 +85,10 @@ export class IpcController { accountService: this.accountService, }), + listAccounts: listAccountsHandler({ + accountService: this.accountService, + }), + saveCharacter: saveCharacterHandler({ accountService: this.accountService, }), @@ -100,6 +106,10 @@ export class IpcController { accountService: this.accountService, }), + quitCharacter: quitCharacterHandler({ + dispatch: this.dispatch, + }), + sendCommand: sendCommandHandler({ dispatch: this.dispatch, }), diff --git a/electron/main/ipc/types.ts b/electron/main/ipc/types.ts index 164b631b..b38f7802 100644 --- a/electron/main/ipc/types.ts +++ b/electron/main/ipc/types.ts @@ -24,6 +24,10 @@ export type IpcHandlerRegistry = { [channel in IpcInvokableEvent]: IpcInvokeHandler; }; +export type IpcSgeAccount = { + accountName: string; +}; + export type IpcSgeCharacter = { gameCode: string; accountName: string; diff --git a/electron/main/logger/initialize-logging.ts b/electron/main/logger/initialize-logging.ts index 3fe5cf67..6d2c4f54 100644 --- a/electron/main/logger/initialize-logging.ts +++ b/electron/main/logger/initialize-logging.ts @@ -2,6 +2,8 @@ import electronMainLogger from 'electron-log/main.js'; import { initializeLogging as commonInitializeLogging } from '../../common/logger/initialize-logging.js'; export const initializeLogging = (): void => { + electronMainLogger.logId = 'main'; + commonInitializeLogging(electronMainLogger); // This step can only be done from the main process. diff --git a/electron/main/menu/utils/__tests__/tsconfig.json b/electron/main/menu/utils/__tests__/tsconfig.json new file mode 100644 index 00000000..105334e2 --- /dev/null +++ b/electron/main/menu/utils/__tests__/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.test.json" +} diff --git a/electron/main/preference/types.ts b/electron/main/preference/types.ts index e4f4511e..d21ad117 100644 --- a/electron/main/preference/types.ts +++ b/electron/main/preference/types.ts @@ -1,4 +1,3 @@ -import type { Layout } from 'react-grid-layout'; import type { Maybe } from '../../common/types.js'; export enum PreferenceKey { @@ -38,7 +37,8 @@ export enum PreferenceKey { /** * Map of character names to grid layouts. * - * Example keys include character names like 'Alice', 'Bob', 'Carol', etc. + * Key format: `${accountName}:${characterName}:${gameCode}`. + * Example: `MyAccount:Katoak:DR`. * * They also include the special key '__DEFAULT__', which is used * when no character-specific grid layout is defined. @@ -70,16 +70,17 @@ export type PreferenceKeyToTypeMap = { [PreferenceKey.GAME_STREAM_GRID_LAYOUTS]: { /** * Who the grid layout belongs to. + * Example: `${accountName}:${characterName}:${gameCode}` */ [key: string]: { /** - * The items on the grid. + * The items on the grid and how they are laid out. */ - gridItems: Array<{ id: string; title: string }>; - /** - * How those items are positioned on the grid. - */ - gridLayout: Array; + gridItems: Array<{ + // TODO add more properties like position, size, etc. + id: string; + title: string; + }>; }; }; }; diff --git a/electron/main/sge/types.ts b/electron/main/sge/types.ts index eb42cbd4..7886fd38 100644 --- a/electron/main/sge/types.ts +++ b/electron/main/sge/types.ts @@ -1,3 +1,5 @@ +import { GameCode } from '../../common/game/types.js'; + export enum SGEGameProtocol { STORMFRONT = 'STORM', } @@ -7,11 +9,11 @@ export enum SGEGameProtocol { * Only interested in DragonRealms, though. */ export enum SGEGameCode { - DRAGONREALMS_PRIME = 'DR', - DRAGONREALMS_DEVELOPMENT = 'DRD', - DRAGONREALMS_THE_FALLEN = 'DRF', - DRAGONREALMS_PRIME_TEST = 'DRT', - DRAGONREALMS_PLATINUM = 'DRX', + DRAGONREALMS_PRIME = GameCode.PRIME, + DRAGONREALMS_FALLEN = GameCode.FALLEN, + DRAGONREALMS_PLATINUM = GameCode.PLATINUM, + DRAGONREALMS_TEST = GameCode.TEST, + DRAGONREALMS_DEVELOPMENT = GameCode.DEVELOPMENT, } export interface SGEGame { diff --git a/electron/main/tsconfig.json b/electron/main/tsconfig.json index 22eb80f1..7acaba67 100644 --- a/electron/main/tsconfig.json +++ b/electron/main/tsconfig.json @@ -35,5 +35,10 @@ "**/__tests__/**", "**/__mocks__/**" ], - "include": ["../preload/**/*.d.ts", "../common/**/*.ts", "**/*.ts"] + "include": [ + "../preload/**/*.d.ts", + "../common/**/*.ts", + "**/types.ts", + "**/*.ts" + ] } diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 56365517..469b249f 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -1,3 +1,4 @@ +import type { IpcRendererEvent } from 'electron'; /** * The index.d.ts file is auto-generated by the build process. */ @@ -14,6 +15,14 @@ declare const appAPI: { * Remove credentials for a play.net account. */ removeAccount: (options: { accountName: string }) => Promise; + /** + * List added accounts. + */ + listAccounts: () => Promise< + Array<{ + accountName: string; + }> + >; /** * Add or update a character for a given play.net account and game instance. */ @@ -34,11 +43,11 @@ declare const appAPI: { * List added characters. */ listCharacters: () => Promise< - { + Array<{ accountName: string; characterName: string; gameCode: string; - }[] + }> >; /** * Play the game with a given character. @@ -51,6 +60,12 @@ declare const appAPI: { characterName: string; gameCode: string; }) => Promise; + /** + * Quit the game with the currently playing character, if any. + * Similar to sending the `quit` command to the game but awaits + * the game to confirm the quit before resolving. + */ + quitCharacter: () => Promise; /** * Sends a command to the game as the currently playing character. * Use the `onMessage` API to receive game data. @@ -62,7 +77,7 @@ declare const appAPI: { */ onMessage: ( channel: string, - callback: (event: Electron.IpcRendererEvent, ...args: any[]) => void + callback: (event: IpcRendererEvent, ...args: Array) => void ) => OnMessageUnsubscribe; /** * Allows the renderer to unsubscribe from messages from the main process. diff --git a/electron/preload/index.ts b/electron/preload/index.ts index a74b28a5..a7f72d85 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -26,6 +26,16 @@ const appAPI = { removeAccount: async (options: { accountName: string }): Promise => { return ipcRenderer.invoke('removeAccount', options); }, + /** + * List added accounts. + */ + listAccounts: async (): Promise< + Array<{ + accountName: string; + }> + > => { + return ipcRenderer.invoke('listAccounts'); + }, /** * Add or update a character for a given play.net account and game instance. */ @@ -71,6 +81,14 @@ const appAPI = { }): Promise => { return ipcRenderer.invoke('playCharacter', options); }, + /** + * Quit the game with the currently playing character, if any. + * Similar to sending the `quit` command to the game but awaits + * the game to confirm the quit before resolving. + */ + quitCharacter: async (): Promise => { + return ipcRenderer.invoke('quitCharacter'); + }, /** * Sends a command to the game as the currently playing character. * Use the `onMessage` API to receive game data. diff --git a/electron/preload/tsconfig.json b/electron/preload/tsconfig.json index 24d8921e..2c8a6820 100644 --- a/electron/preload/tsconfig.json +++ b/electron/preload/tsconfig.json @@ -32,5 +32,5 @@ "lib": ["DOM", "DOM.Iterable", "ESNext"] }, "exclude": ["node_modules", "**/__tests__/**", "**/__mocks__/**"], - "include": ["**/*.ts"] + "include": ["**/types.ts", "**/*.ts"] } diff --git a/electron/renderer/components/game/game-container.tsx b/electron/renderer/components/game/game-container.tsx index a448c60e..70be86eb 100644 --- a/electron/renderer/components/game/game-container.tsx +++ b/electron/renderer/components/game/game-container.tsx @@ -1,8 +1,6 @@ -import { useState } from 'react'; import type { ReactNode } from 'react'; import { GameBottomBar } from './game-bottom-bar.jsx'; import { GameGrid } from './game-grid.jsx'; -import { GameSettings } from './game-settings.jsx'; import { GameTopBar } from './game-top-bar.jsx'; export interface GameContainerProps { @@ -12,22 +10,11 @@ export interface GameContainerProps { export const GameContainer: React.FC = ( props: GameContainerProps ): ReactNode => { - const [showSettings, setShowSettings] = useState(true); - return ( <> - { - setShowSettings(false); - // setTimeout(() => { - // setShowSettings(true); - // }, 10_000); - }} - /> ); }; diff --git a/electron/renderer/components/game/game-settings.tsx b/electron/renderer/components/game/game-settings.tsx deleted file mode 100644 index f82b7a0d..00000000 --- a/electron/renderer/components/game/game-settings.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, -} from '@elastic/eui'; -import { useEffect, useState } from 'react'; -import type { ReactNode } from 'react'; - -export interface GameSettingsProps { - show: boolean; - onHide: () => void; -} - -export const GameSettings: React.FC = ( - props: GameSettingsProps -): ReactNode => { - const { show, onHide } = props; - - const [settingsPanel, setSettingsPanel] = useState(null); - - useEffect(() => { - if (show) { - setSettingsPanel( - onHide()}> - - -

Settings

-
-
- stuff here -
- ); - } else { - // In order for the flyout overlay to go away then we must - // remove the flyout from the DOM. - setSettingsPanel(null); - } - }, [show, onHide]); - - return settingsPanel; -}; - -GameSettings.displayName = 'GameSettings'; diff --git a/electron/renderer/components/game/game-stream.tsx b/electron/renderer/components/game/game-stream.tsx index b76a98ef..b78f33aa 100644 --- a/electron/renderer/components/game/game-stream.tsx +++ b/electron/renderer/components/game/game-stream.tsx @@ -37,21 +37,20 @@ export const GameStream: React.FC = ( }); const [gameLogLines, setGameLogLines] = useState>([]); + const clearStreamTimeoutRef = useRef(); - const appendGameLogLine = useCallback((newLogLine: GameLogLine) => { + const appendGameLogLines = useCallback((newLogLines: Array) => { // Max number of most recent lines to keep. - const scrollbackBuffer = 500; + const scrollbackBufferSize = 500; setGameLogLines((oldLogLines) => { // Append new log line to the list. - let newLogLines = oldLogLines.concat(newLogLine); + newLogLines = oldLogLines.concat(newLogLines); // Trim the back of the list to keep it within the scrollback buffer. - newLogLines = newLogLines.slice(scrollbackBuffer * -1); + newLogLines = newLogLines.slice(scrollbackBufferSize * -1); return newLogLines; }); }, []); - const clearStreamTimeoutRef = useRef(); - // Ensure all timeouts are cleared when the component is unmounted. useEffect(() => { return () => { @@ -60,23 +59,30 @@ export const GameStream: React.FC = ( }, []); useSubscription(filteredStream$, (logLine) => { - if (logLine.text === '__CLEAR_STREAM__') { - // Clear the stream after a short delay to prevent flickering - // caused by a flash of empty content then the new content. - clearStreamTimeoutRef.current = setTimeout(() => { - setGameLogLines([]); - }, 1000); - } else { - // If we receieved a new log line, cancel any pending clear stream. - // Set the game log lines to the new log line to prevent flickering. - if (clearStreamTimeoutRef.current) { - clearTimeout(clearStreamTimeoutRef.current); - clearStreamTimeoutRef.current = undefined; - setGameLogLines([logLine]); + // Decouple state updates from the stream subscription to mitigate + // "Cannot update a component while rendering a different component". + // This gives some control of the event loop back to react + // to smartly (re)render all components and state changes. + // We use `setTimeout` because browser doesn't have `setImmediate`. + setTimeout(() => { + if (logLine.text === '__CLEAR_STREAM__') { + // Clear the stream after a short delay to prevent flickering + // caused by a flash of empty content then the new content. + clearStreamTimeoutRef.current = setTimeout(() => { + setGameLogLines([]); + }, 1000); } else { - appendGameLogLine(logLine); + // If we receieved a new log line, cancel any pending clear stream. + // Set the game log lines to the new log line to prevent flickering. + if (clearStreamTimeoutRef.current) { + clearTimeout(clearStreamTimeoutRef.current); + clearStreamTimeoutRef.current = undefined; + setGameLogLines([logLine]); + } else { + appendGameLogLines([logLine]); + } } - } + }); }); // Scroll to the bottom of the scrollable element when new content is added. diff --git a/electron/renderer/components/grid/grid-item.tsx b/electron/renderer/components/grid/grid-item.tsx index 12b9b840..7e9b3c54 100644 --- a/electron/renderer/components/grid/grid-item.tsx +++ b/electron/renderer/components/grid/grid-item.tsx @@ -1,3 +1,7 @@ +// Inspired by react-crop-video project by BiteSize Academy. +// https://github.com/alexkrkn/react-crop-video/ +// https://www.youtube.com/watch?v=vDxZLN6FVqY + import { EuiButtonIcon, EuiFlexGroup, @@ -6,17 +10,33 @@ import { EuiSpacer, EuiSplitPanel, EuiText, + useEuiTheme, } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { animated, useSpring } from '@react-spring/web'; +import type { EventTypes, Handler, UserDragConfig } from '@use-gesture/react'; +import { useDrag } from '@use-gesture/react'; +import debounce from 'lodash-es/debounce.js'; +import get from 'lodash-es/get'; +import isNil from 'lodash-es/isNil'; +import type { ReactNode, RefObject } from 'react'; +import { memo, useCallback, useMemo, useRef } from 'react'; import type { - CSSProperties, - MouseEvent, - ReactNode, - Ref, - TouchEvent, -} from 'react'; -import { forwardRef, useCallback } from 'react'; + GridItemBoundary, + GridItemInfo, + GridItemLayout, +} from '../../types/grid.types.js'; export interface GridItemProps { + /** + * The dimension for the grid where the item may be dragged and resized. + */ + boundary: GridItemBoundary; + /** + * The positional layout for the grid item. + * If not specified then a default location will be used. + */ + layout?: GridItemLayout; /** * The unique identifier for the grid item. */ @@ -26,41 +46,27 @@ export interface GridItemProps { * Note the prop `title` is reserved and refers to titling a DOM element, * not for passing data to child components. So using a more specific name. */ - titleBarText: string; + itemTitle: string; /** * Handler when the user clicks the close button in the title bar. * Passes the `itemId` of the grid item being closed. */ - onClose?: (itemId: string) => void; - /** - * Required when using custom components as react-grid-layout children. - */ - ref: Ref; + onClose?: (item: GridItemInfo) => void; /** - * This property is passed to the item from the grid layout. - * You must assign it to the same prop of the root element of the grid item. + * Is this the focused grid item? + * When yes then it will be positioned above the other grid items. */ - style?: CSSProperties; + isFocused?: boolean; /** - * This property is passed to the item from the grid layout. - * You must assign it to the same prop of the root element of the grid item. + * When the grid item receives focus then notify the parent component. + * The parent component has responsibility for managing the `isFocused` + * property for all of the grid items to reflect the change. */ - className?: string; + onFocus?: (item: GridItemInfo) => void; /** - * This property is passed to the item from the grid layout. - * You must assign it to the same prop of the root element of the grid item. + * When the grid item is moved or resized then notify the parent component. */ - onMouseDown?: (e: MouseEvent) => void; - /** - * This property is passed to the item from the grid layout. - * You must assign it to the same prop of the root element of the grid item. - */ - onMouseUp?: (e: MouseEvent) => void; - /** - * This property is passed to the item from the grid layout. - * You must assign it to the same prop of the root element of the grid item. - */ - onTouchEnd?: (e: TouchEvent) => void; + onMoveResize?: (item: GridItemInfo) => void; /** * This property contains any children nested within the grid item * when you're constructing the grid layout. @@ -69,126 +75,329 @@ export interface GridItemProps { children?: ReactNode; } -/** - * The grid layout pushes resizable handles as children of the grid item. - * When the scrollbar for the content is displayed then it creates a - * barrier between the right-most edge of the grid item and its content. - * Yet the resizable handles are still visible on the grid item's edge - * just not clickable in that position, it's now offset by the scrollbar. - * To mitigate this adjustment, we move the resizable handles to the the - * outside of the scrollable content. - */ -function separateResizeHandleComponents(nodes: ReactNode): { - children: Array; - resizeHandles: Array; -} { - const children = []; - const resizeHandles = []; - - if (Array.isArray(nodes)) { - for (const child of nodes) { - if (child) { - if (child.key?.startsWith('resizableHandle-')) { - resizeHandles.push(child); - } else { - children.push(child); - } - } - } - } else if (nodes) { - children.push(nodes); - } +const DEFAULT_GRID_ITEM_LAYOUT: GridItemLayout = { + x: 0, + y: 0, + width: 500, + height: 500, +}; - return { - resizeHandles, - children, - }; -} +export const GridItem: React.FC = memo( + (props: GridItemProps): ReactNode => { + const { itemId, itemTitle, isFocused = false, children } = props; + const { boundary, layout = DEFAULT_GRID_ITEM_LAYOUT } = props; + const { onFocus, onClose, onMoveResize } = props; + + const { euiTheme } = useEuiTheme(); + + // Set default position and size for the grid item. + // Like `useState`, we can provide the default value, but as a function. + const [{ x, y, width, height }, sizeApi] = useSpring(() => { + return layout; + }, [layout]); + + const dragHandleRef = useRef(null); + const resizeHandleRef = useRef(null); + + const getItemInfo = useCallback((): GridItemInfo => { + return { + itemId, + itemTitle, + isFocused, + layout: { + x: x.get(), + y: y.get(), + width: width.get(), + height: height.get(), + }, + }; + }, [itemId, itemTitle, isFocused, x, y, width, height]); -/** - * How to use custom components as react-grid-layout children. - * https://github.com/react-grid-layout/react-grid-layout/tree/master?tab=readme-ov-file#custom-child-components-and-draggable-handles - * https://stackoverflow.com/questions/67053157/react-grid-layout-error-draggablecore-not-mounted-on-dragstart - */ -export const GridItem: React.FC = forwardRef< - HTMLDivElement, - GridItemProps ->((props, ref): ReactNode => { - const { - itemId, - titleBarText, - onClose, - style, - className, - children, - ...otherProps - } = props; - - // Handle when the user clicks the close button in the title bar. - const onCloseClick = useCallback( - (evt: MouseEvent) => { - evt.preventDefault(); - if (onClose) { - onClose(itemId); + // Handle when the user clicks the close button in the title bar. + const onCloseClick = useCallback(() => { + onClose?.(getItemInfo()); + }, [onClose, getItemInfo]); + + // Handle when the user clicks or focuses the grid item. + const onFocusClick = useCallback(() => { + if (!isFocused) { + onFocus?.({ + ...getItemInfo(), + isFocused: true, + }); } - }, - [onClose, itemId] - ); - - const { resizeHandles, children: gridItemChildren } = - separateResizeHandleComponents(children); - - return ( - - - { + return debounce(() => { + onMoveResize?.(getItemInfo()); + }, 300); + }, [onMoveResize, getItemInfo]); + + /** + * Is the event's target the same element as the ref? + * This helps us identify if the user has clicked on + * the drag or resize handle elements. + */ + const isEventTarget = useCallback( + ( + eventOrTarget: Event | EventTarget | null | undefined, + ref: RefObject + ) => { + if (isNil(eventOrTarget)) { + return false; + } + + if (eventOrTarget === ref.current) { + return true; + } + + if (get(eventOrTarget, 'target') === ref.current) { + return true; + } + + if (get(eventOrTarget, 'currentTarget') === ref.current) { + return true; + } + + return false; + }, + [] + ); + + /** + * Did the user click and drag the drag handle? + */ + const isDragging = useCallback( + (eventOrTarget: Event | EventTarget | null | undefined): boolean => { + return isEventTarget(eventOrTarget, dragHandleRef); + }, + [isEventTarget] + ); + + /** + * Did the user click and drag the resize handle? + */ + const isResizing = useCallback( + (eventOrTarget: Event | EventTarget | null | undefined): boolean => { + return isEventTarget(eventOrTarget, resizeHandleRef); + }, + [isEventTarget] + ); + + const dragHandler: Handler<'drag', EventTypes['drag']> = useCallback( + /** + * Callback to invoke when a gesture event ends. + * For example, when the user stops dragging or resizing. + */ + (state) => { + // The vector for where the pointer has moved to relative to + // the last vector returned by the `from` drag option function. + // When resizing, the values are the new width and height dimensions. + // When dragging, the values are the new x and y coordinates. + const [dx, dy] = state.offset; + + if (isResizing(state.event)) { + if ( + width.get() !== Math.trunc(dx) || + height.get() !== Math.trunc(dy) + ) { + sizeApi.set({ + width: Math.trunc(dx), + height: Math.trunc(dy), + }); + onMoveResizeHandler(); + } + } + + if (isDragging(state.event)) { + if (x.get() !== Math.trunc(dx) || y.get() !== Math.trunc(dy)) { + sizeApi.set({ + x: Math.trunc(dx), + y: Math.trunc(dy), + }); + onMoveResizeHandler(); + } + } + }, + [ + x, + y, + width, + height, + sizeApi, + isResizing, + isDragging, + onMoveResizeHandler, + ] + ); + + const dragOptions: UserDragConfig = useMemo(() => { + return { + /** + * When a gesture event begins, specify the reference vector + * from which to calculate the distance the pointer moves. + */ + from: (state) => { + if (isResizing(state.target)) { + return [width.get(), height.get()]; + } + return [x.get(), y.get()]; + }, + /** + * When a gesture event begins, specify the where the pointer can move. + * The element will not be dragged or resized outside of these bounds. + */ + bounds: (state) => { + const containerWidth = boundary.width; + const containerHeight = boundary.height; + if (isResizing(state?.event)) { + return { + top: 50, // min height + left: 100, // min width + right: containerWidth - x.get(), + bottom: containerHeight - y.get(), + }; + } + return { + top: 0, + left: 0, + right: containerWidth - width.get(), + bottom: containerHeight - height.get(), + }; + }, + }; + }, [x, y, width, height, boundary, isResizing]); + + // Use this function to add all of the DOM bindings props to the element(s) + // that you want to make draggable or resizable. + // Example: see our `dragHandleRef` and `resizeHandleRef` ref elements. + const getMouseGestureDragBindings = useDrag(dragHandler, dragOptions); + + // Styles for our drag and resize handle elements. + const handleStyles = useMemo( + () => + css({ + '.drag-handle': { + cursor: 'grab', + }, + '.drag-handle:active': { + cursor: 'grabbing', + touchAction: 'none', + }, + '.resize-handle': { + position: 'absolute', + bottom: -4, + right: -4, + width: 10, + height: 10, + cursor: 'nwse-resize', + touchAction: 'none', + backgroundColor: euiTheme.colors.mediumShade, + borderRadius: 5, + }, + }), + [euiTheme] + ); + + return ( + + - - - - - {titleBarText} - - + - + + + + + + + {itemTitle} + + + + + + + + - - - - - - {gridItemChildren} - - {resizeHandles} - - ); -}); + + + + + {children} + +
+
+
+
+ + + ); + } +); GridItem.displayName = 'GridItem'; diff --git a/electron/renderer/components/grid/grid.tsx b/electron/renderer/components/grid/grid.tsx index 725541ad..1a9c036a 100644 --- a/electron/renderer/components/grid/grid.tsx +++ b/electron/renderer/components/grid/grid.tsx @@ -1,369 +1,94 @@ -import { useEuiTheme } from '@elastic/eui'; -import type { SerializedStyles } from '@emotion/react'; -import { css } from '@emotion/react'; -import type { ReactNode, RefObject } from 'react'; -import { - createRef, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -// To me, the "layout" is the collection of grid items and their positions. -// To the react-grid-layout component, a "layout" is a single item's positions. -// To help with terminology, aliasing the type here, redefining it below. -import type { Layout as GridLayoutItem } from 'react-grid-layout'; -import GridLayout from 'react-grid-layout'; +// Inspired by react-crop-video project by BiteSize Academy. +// https://github.com/alexkrkn/react-crop-video/ +// https://www.youtube.com/watch?v=vDxZLN6FVqY + +import type { ReactNode } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useLogger } from '../../hooks/logger.jsx'; -import { LocalStorage } from '../../lib/local-storage.js'; -import type { GridItemProps } from './grid-item.jsx'; +import type { + GridItemBoundary, + GridItemContent, + GridItemInfo, +} from '../../types/grid.types.js'; import { GridItem } from './grid-item.jsx'; -// See comment above about terminology. -type Layout = Array; - export interface GridProps { - /** - * The dimension for the grid. - */ - dimensions: { - /** - * The max height of the grid in pixels. - */ - height: number; - /** - * The max width of the grid in pixels. - */ - width: number; - }; - /** - * The items to display in the grid. - */ - items: Array<{ - /** - * The unique identifier for the grid item. - */ - itemId: GridItemProps['itemId']; - /** - * Text to display in the title bar of the grid item. - */ - title: GridItemProps['titleBarText']; - /** - * Content to show inside the grid item. - */ - content: GridItemProps['children']; - }>; + boundary: GridItemBoundary; + contentItems: Array; } export const Grid: React.FC = (props: GridProps): ReactNode => { - const { dimensions, items } = props; - - const logger = useLogger('grid'); - - const { height, width } = dimensions; - - const { euiTheme } = useEuiTheme(); - - const [gridLayoutStyles, setGridLayoutStyles] = useState(); - - useEffect(() => { - setGridLayoutStyles(css` - ${css({ - height, - width, - overflow: 'clip', // TODO fix so grid is y-scrollable without bleeding over/under outside elements - })} - .react-grid-item.react-grid-placeholder { - ${css({ - background: euiTheme.colors.warning, - })} - } - .react-grid-item .grab-handle { - ${css({ - cursor: 'grab', - })} - } - .react-grid-item .grab-handle:active { - ${css({ - cursor: 'grabbing', - })} - } - `); - }, [height, width, euiTheme]); - - /** - * When grid items are resized the increment is based on the the layout size. - * Horizontal resize increments are based on the number of columns. - * Vertical resize increments are based on row height pixels. - * Why two different units? I don't know. - */ - - /* Horizontal Resizing */ - - // The grid layout is vertically divided into columns. - // The resize increment is the layout's width divided by the number of columns. - // Use larger values to give users fine-grained control. - // Use smaller values for coarse-grained control. - // Note, this value is number of columns, not pixels. - const gridMaxColumns = 50; - - /* Vertical Resizing */ - - // A grid item has a height, and if the layout has margins then there - // is a number of pixels margin between each row, too. Therefore, the - // total height of a grid item is the height plus the margin. - // Playing around with different row heights, I deduced that the margin - // size in pixels is ~1.03 pixels when the layout's margin is [1, 1]. - // Note, these values are in pixels. - const gridRowHeightPx = 10; - const gridRowMarginPx = 1.03; - const gridRowHeightWithMarginPx = gridRowHeightPx + gridRowMarginPx; - - /* Window Resizing */ - - // As the window dimensions change, we need to update the layout, too, - // so that the layout always fits the window exactly. - // This allows the user to drag grid items anywhere within the window. - const [gridMaxRows, setGridMaxRows] = useState(1); - const [gridMaxWidthPx, setGridMaxWidth] = useState(width); - - useEffect(() => { - // Note, when first rendering the UI, the received dimensions may be <= 0. - // Once things start to flesh out in the UI then true dimensions come in. - // So only adjust the grid rows/cols if we received true dimensions. - if (height <= 0 || width <= 0) { - logger.debug('received invalid dimensions, not resizing grid', { - height, - width, - }); - return; - } - - const newMaxRows = Math.floor(height / gridRowHeightWithMarginPx); - setGridMaxRows(newMaxRows); - setGridMaxWidth(width); - - // When we reduce the grid max rows, iterate all the items - // in the layout and scale their row/height values to fit. - setGridMaxRows((oldMaxRows) => { - logger.debug('*** gridMaxRows', { - height, - width, - gridRowHeightWithMarginPx, - oldMaxRows, - newMaxRows, - }); - // if (oldMaxRows > newMaxRows) { - // setLayout((oldLayout) => { - // const newLayout = oldLayout.map((layoutItem) => { - // const newLayoutItem = { - // ...layoutItem, - // h: Math.max( - // 3, - // Math.floor((newMaxRows / oldMaxRows) * layoutItem.h) - // ), - // y: Math.floor((newMaxRows / oldMaxRows) * layoutItem.y), - // }; - // return newLayoutItem; - // }); - // LocalStorage.set('layout', newLayout); - // return newLayout; - // }); - // return newMaxRows; - // } - return newMaxRows; - }); - }, [logger, height, width, gridRowHeightWithMarginPx]); - - /** - * Load the layout from storage or build a default layout. - */ - const buildDefaultLayout = useCallback((): Layout => { - let layout = LocalStorage.get('layout'); - - if (layout) { - // Discard any old layout items that are not in the grid's items list. - layout = layout.filter((layoutItem) => { - return items.find((item) => item.itemId === layoutItem.i); - }); - return layout; - } + const { boundary, contentItems } = props; - // We'll tile the items three per row. - const maxItemsPerRow = 3; + const logger = useLogger('cmp:grid'); - // The min dimensions are used to prevent the grid item from being - // resized so small that it's unusable and hides its title bar. - // Note, these values are in row/col-spans, not pixels. - const minCols = 5; - const minRows = 3; - - // The default dimensions each item will span by default. - // Note, these values are in row/col-spans, not pixels. - const defaultCols = Math.floor(gridMaxColumns / maxItemsPerRow); - const defaultRows = 10; - - // The row offset is the number of pixels (y-index, height) - // to offset the item from the top of the grid. We increase this - // each time we begin a new row. - // I don't know why react-grid-layout uses pixels for the y-index - // and column spans for the x-index. It's weird. - let rowOffset = 0; - - // The column offset is a simple counter (i.e. 1..2..3..) - // that when multiplied by the default column width of an item gives us - // which column to the right the next item should be placed. - // Again, I don't know why react-grid-layout uses pixels for the y-index - // and column spans for the x-index. It's weird. - let colOffset = 0; - - layout = items.map((item, index): GridLayoutItem => { - // If time to move to next row then adjust the offsets. - if (index > 0 && index % maxItemsPerRow === 0) { - // Only increase the offset by an item's row height (without margin) - // The grid will automatically apply the margin when rendering. - rowOffset += gridRowHeightPx; - // Reset the column offset to the first column. - colOffset = 0; - } - - const newItem: GridLayoutItem = { - i: item.itemId, // unique identifier for the grid item - x: defaultCols * colOffset, // which column to start at, not pixels - y: rowOffset, // pixels (row # x row height px without margin) - w: defaultCols, // column spans - h: defaultRows, // row spans - minW: minCols, // column spans - minH: minRows, // row spans - }; - - colOffset += 1; - - return newItem; - }); - - return layout; - }, [items]); - - const [layout, setLayout] = useState(buildDefaultLayout); - - // Save the layout when it changes in the grid. - const onLayoutChange = useCallback((newLayout: Layout) => { - setLayout(newLayout); - LocalStorage.set('layout', newLayout); - }, []); - - // Remove the item from the layout then save the layout. - const onGridItemClose = useCallback((itemId: string) => { - setLayout((oldLayout) => { - const newLayout = oldLayout.filter((layoutItem) => { - return layoutItem.i !== itemId; - }); - LocalStorage.set('layout', newLayout); - return newLayout; - }); - }, []); - - /** - * How to use custom components as react-grid-layout children. - * https://github.com/react-grid-layout/react-grid-layout/tree/master?tab=readme-ov-file#custom-child-components-and-draggable-handles - * https://stackoverflow.com/questions/67053157/react-grid-layout-error-draggablecore-not-mounted-on-dragstart - */ - const itemRefsMap = useRef>>(new Map()); - - // This section builds a stable map of refs for each grid item element. - itemRefsMap.current = useMemo(() => { - const oldMap = itemRefsMap.current; - const newMap = new Map>(); - - // When the layout changes, reuse a ref if it already exists. - // When the layout grows, we create new refs for the new items. - layout.forEach((layoutItem) => { - const oldRef = oldMap.get(layoutItem.i); - const newRef = oldRef ?? createRef(); - newMap.set(layoutItem.i, newRef); + const [focusedItemId, setFocusedItemId] = useState(() => { + const focusedItem = contentItems.find((contentItem) => { + return contentItem.isFocused; }); + return focusedItem?.itemId ?? ''; + }); + + const onItemFocus = useCallback( + (item: GridItemInfo) => { + const { itemId } = item; + logger.debug('focused item', { item }); + setFocusedItemId(itemId); + }, + [logger] + ); - return newMap; + const onItemClose = useCallback( + (item: GridItemInfo) => { + logger.debug('closed item', { item }); + }, + [logger] + ); - // For performance, I only want to recalculate the children - // if the number of items in the layout changes. No other reason. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layout.length]); + const onItemMoveResize = useCallback( + (item: GridItemInfo) => { + logger.debug('moved item', { item }); + }, + [logger] + ); - /** - * To improve performance, we memoize the children so that they don't - * change between rerenders. And if the children don't change then the - * components within the layout won't rerender either. - * https://github.com/react-grid-layout/react-grid-layout?tab=readme-ov-file#performance - */ const gridItems = useMemo(() => { - return layout.map((layoutItem) => { - // Assuming the item will always be found will come back to haunt me. - // I just don't know when or why. - const item = items.find((item) => item.itemId === layoutItem.i)!; - const itemRef = itemRefsMap.current.get(layoutItem.i)!; + return contentItems.map((contentItem) => { return ( - {item.content} + {contentItem.content} ); }); - // For performance, I only want to recalculate the children - // if the number of items in the layout changes. No other reason. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layout.length]); + }, [ + contentItems, + focusedItemId, + boundary, + onItemFocus, + onItemClose, + onItemMoveResize, + ]); return ( - {gridItems} - + ); }; - -Grid.displayName = 'Grid'; diff --git a/electron/renderer/components/sidebar/accounts/modal-add-account.tsx b/electron/renderer/components/sidebar/accounts/modal-add-account.tsx new file mode 100644 index 00000000..db505894 --- /dev/null +++ b/electron/renderer/components/sidebar/accounts/modal-add-account.tsx @@ -0,0 +1,125 @@ +import { + EuiConfirmModal, + EuiFieldPassword, + EuiFieldText, + EuiForm, + EuiFormRow, +} from '@elastic/eui'; +import { useCallback, useEffect } from 'react'; +import type { ReactNode } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { runInBackground } from '../../../lib/async/run-in-background.js'; + +export interface ModalAddAccountInitialData { + accountName?: string; + accountPassword?: string; +} + +export interface ModalAddAccountConfirmData { + accountName: string; + accountPassword: string; +} + +export interface ModalAddAccountProps { + initialData?: ModalAddAccountInitialData; + onClose: () => void; + onConfirm: (data: ModalAddAccountConfirmData) => void; +} + +export const ModalAddAccount: React.FC = ( + props: ModalAddAccountProps +): ReactNode => { + const { initialData, onClose, onConfirm } = props; + + const form = useForm(); + + useEffect(() => { + form.reset(initialData); + }, [form, initialData]); + + const onModalClose = useCallback( + (_event?: React.BaseSyntheticEvent) => { + onClose(); + }, + [onClose] + ); + + const onModalConfirm = useCallback( + (event: React.BaseSyntheticEvent) => { + runInBackground(async () => { + const handler = form.handleSubmit( + (data: ModalAddAccountConfirmData) => { + onConfirm(data); + } + ); + await handler(event); + }); + }, + [form, onConfirm] + ); + + return ( + + + {/* Hidden submit button to ensure form submission on Enter key press. */} + {/* Since we are in a confirm modal, we don't have a visible form button. */} + {/* Otherwise you'd see two buttons, one for form and one for modal. */} +