diff --git a/src/assets/data/en/client_log.ts b/src/assets/data/en/client_log.ts new file mode 100644 index 00000000..013cf09a --- /dev/null +++ b/src/assets/data/en/client_log.ts @@ -0,0 +1,14 @@ +const dict = { + 'channel.system': /^: (?.+)$/, + 'channel.trade': /^\$(?:<(?.+?)> )?(?.+?): (?.+)$/, + 'channel.global': /^#(?:<(?.+?)> )?(?.+?): (?.+)$/, + 'channel.party': /^%(?:<(?.+?)> )?(?.+?): (?.+)$/, + 'channel.guild': /^&(?:<(?.+?)> )?(?.+?): (?.+)$/, + 'channel.whisper.to': /^@To (?.+?): (?.+)$/, + 'channel.whisper.from': /^@From (?:<(?.+?)> )?(?.+?): (?.+)$/, + 'trade.gem': /^level (?\d+) (?\d+)% (?.+)$/ +} + +export default dict + +export type ClientLogDict = typeof dict diff --git a/src/assets/data/index.ts b/src/assets/data/index.ts index 1e7bbe25..a5fca222 100644 --- a/src/assets/data/index.ts +++ b/src/assets/data/index.ts @@ -1,10 +1,12 @@ import type { TranslationDict } from '@/assets/data/en/client_strings' +import type { ClientLogDict } from '@/assets/data/en/client_log' import type { BaseType, DropEntry, Stat, StatMatcher, UniqueItem } from './interfaces' import { Config } from '@/web/Config' export * from './interfaces' export let CLIENT_STRINGS: TranslationDict +export let CLIENTLOG_STRINGS: ClientLogDict export let ITEM_NAME_REF_BY_TRANSLATED: Map export let TRANSLATED_ITEM_NAME_BY_REF: Map @@ -28,6 +30,7 @@ export const ITEM_DROP = new Map() ;(async function initData () { /* eslint-disable no-lone-blocks */ { CLIENT_STRINGS = (require(`./${Config.store.language}/client_strings`).default) + CLIENTLOG_STRINGS = (require(`./${Config.store.language}/client_log`).default) const itemNames: Array<[string, string]> = (require(`./${Config.store.language}/item-names.json`)) TRANSLATED_ITEM_NAME_BY_REF = new Map(itemNames) diff --git a/src/assets/data/ru/client_log.ts b/src/assets/data/ru/client_log.ts new file mode 100644 index 00000000..65ebae61 --- /dev/null +++ b/src/assets/data/ru/client_log.ts @@ -0,0 +1,14 @@ +import type { ClientLogDict } from '../en/client_log' + +const dict: ClientLogDict = { + 'channel.system': /^: (?.+)$/, + 'channel.trade': /^\$(?:<(?.+?)> )?(?.+?): (?.+)$/, + 'channel.global': /^#(?:<(?.+?)> )?(?.+?): (?.+)$/, + 'channel.party': /^%(?:<(?.+?)> )?(?.+?): (?.+)$/, + 'channel.guild': /^&(?:<(?.+?)> )?(?.+?): (?.+)$/, + 'channel.whisper.to': /^@Кому (?.+?): (?.+)$/, + 'channel.whisper.from': /^@От кого (?:<(?.+?)> )?(?.+?): (?.+)$/, + 'trade.gem': /^уровень (?\d+) (?\d+)% (?.+)$/ +} + +export default dict diff --git a/src/background.ts b/src/background.ts index 61644d9b..3877830d 100644 --- a/src/background.ts +++ b/src/background.ts @@ -17,6 +17,7 @@ import { createOverlayWindow } from './main/overlay-window' import { setupAltVisibility } from './main/alt-visibility' import { setupBuiltinBrowser } from './main/builtin-browser' import { createFileProtocol } from './main/app-file-protocol' +import { LogWatcher } from './main/LogWatcher' const isDevelopment = process.env.NODE_ENV !== 'production' if (!app.requestSingleInstanceLock()) { @@ -70,6 +71,7 @@ app.on('ready', async () => { setupCfProtection() setupShortcuts() setupAltVisibility() + LogWatcher.start() }, // fixes(linux): window is black instead of transparent process.platform === 'linux' ? 1000 : 0 diff --git a/src/ipc/ipc-event.ts b/src/ipc/ipc-event.ts index 513882ad..fbdbadc5 100644 --- a/src/ipc/ipc-event.ts +++ b/src/ipc/ipc-event.ts @@ -66,3 +66,8 @@ export const OPEN_WIKI = 'MAIN->OVERLAY::open-wiki' export const IMPORT_FILE = 'OVERLAY->MAIN::import-file' export const TOGGLE_DELVE_GRID = 'MAIN->OVERLAY::delve-grid' + +export const CLIENT_LOG_UPDATE = 'MAIN->OVERLAY::client-log' +export interface IpcClientLog { + lines: string[] +} diff --git a/src/ipc/main-process-bindings.ts b/src/ipc/main-process-bindings.ts index 3999893b..1cda0731 100644 --- a/src/ipc/main-process-bindings.ts +++ b/src/ipc/main-process-bindings.ts @@ -59,6 +59,14 @@ class MainProcessBinding extends EventTarget { electron.ipcRenderer.on(ipcEvent.TOGGLE_DELVE_GRID, (e) => { this.dispatchEvent(new CustomEvent(ipcEvent.TOGGLE_DELVE_GRID)) }) + + electron.ipcRenderer.on(ipcEvent.CLIENT_LOG_UPDATE, (e, data: ipcEvent.IpcClientLog) => { + data.lines.forEach(line => { + this.dispatchEvent(new CustomEvent(ipcEvent.CLIENT_LOG_UPDATE, { + detail: line + })) + }) + }) } } diff --git a/src/main/LogWatcher.ts b/src/main/LogWatcher.ts new file mode 100644 index 00000000..580533f4 --- /dev/null +++ b/src/main/LogWatcher.ts @@ -0,0 +1,90 @@ +import { promises as fs, watchFile, unwatchFile } from 'fs' +import { overlayWindow } from './overlay-window' +import * as ipc from '@/ipc/ipc-event' +import { config } from './config' +import { logger } from './logger' + +const COMMON_PATH = [ + 'C:/Program Files (x86)/Grinding Gear Games/Path of Exile/logs/Client.txt', + 'D:/Program Files (x86)/Grinding Gear Games/Path of Exile/logs/Client.txt', + 'C:/Program Files (x86)/Steam/steamapps/common/Path of Exile/logs/Client.txt', + 'D:/Program Files (x86)/Steam/steamapps/common/Path of Exile/logs/Client.txt' +] + +export class LogWatcher { + static offset = 0 + static filePath?: string + static file?: fs.FileHandle + static isReading = false + static readBuff = Buffer.allocUnsafe(64 * 1024) + + static async start () { + let logFile = config.get('clientLog') + + if (!logFile && process.platform === 'win32') { + for (const filePath of COMMON_PATH) { + try { + await fs.access(filePath) + config.set('clientLog', filePath) + logFile = filePath + break + } catch {} + } + } + + if (logFile) { + try { + await LogWatcher.watch(logFile) + } catch { + logger.error('Failed to watch', { source: 'log-watcher', file: logFile }) + } + } + } + + static async watch (path: string) { + if (this.file) { + unwatchFile(this.filePath!) + await this.file.close() + this.file = undefined + this.isReading = false + } + + watchFile(path, { interval: 450 }, () => { + this.handleFileChange() + }) + this.filePath = path + + this.file = await fs.open(path, 'r') + const stats = await this.file.stat() + this.offset = stats.size + } + + static handleFileChange () { + if (!this.isReading) { + this.isReading = true + this.readToEOF() + } + } + + static async readToEOF () { + if (!this.file) { + this.isReading = false + return + } + + const { bytesRead } = await this.file.read(this.readBuff, 0, this.readBuff.length, this.offset) + + if (bytesRead) { + const str = this.readBuff.toString('utf8', 0, bytesRead) + const lines = str.split('\n').map(line => line.trim()).filter(line => line.length) + overlayWindow!.webContents.send(ipc.CLIENT_LOG_UPDATE, { lines } as ipc.IpcClientLog) + } + + if (bytesRead) { + this.offset += bytesRead + this.readToEOF() + } else { + this.isReading = false + } + } +} diff --git a/src/main/config.ts b/src/main/config.ts index 15347c49..f5d75130 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -6,19 +6,16 @@ import { forbidden, forbiddenCtrl } from '@/ipc/KeyToCode' import { GET_CONFIG, PUSH_CONFIG, CLOSE_SETTINGS_WINDOW } from '@/ipc/ipc-event' import { overlayWindow } from './overlay-window' import { logger } from './logger' +import { LogWatcher } from './LogWatcher' export function setupConfigEvents () { ipcMain.on(GET_CONFIG, (e) => { e.returnValue = config.store }) - ipcMain.on(PUSH_CONFIG, (e, cfg) => { - const old = config.store - Object.setPrototypeOf(old, Object.prototype) - if (!isDeepEq(cfg, old)) { - batchUpdateConfig(cfg, false) - } + ipcMain.on(PUSH_CONFIG, (e, cfg: Config) => { + batchUpdateConfig(cfg, false) }) - ipcMain.on(CLOSE_SETTINGS_WINDOW, (e, cfg) => { + ipcMain.on(CLOSE_SETTINGS_WINDOW, (e, cfg: Config | undefined) => { if (cfg != null) { batchUpdateConfig(cfg, true) } @@ -84,10 +81,17 @@ export const config = (() => { return store })() -export function batchUpdateConfig (upd: Config, push = true) { - config.store = upd - logger.verbose('Saved', { source: 'config', push }) - if (push) { - overlayWindow!.webContents.send(PUSH_CONFIG, upd) +export function batchUpdateConfig (newCfg: Config, push = true) { + const oldCfg = config.store + Object.setPrototypeOf(oldCfg, Object.prototype) + if (!isDeepEq(newCfg, oldCfg)) { + config.store = newCfg + logger.verbose('Saved', { source: 'config', push }) + if (push) { + overlayWindow!.webContents.send(PUSH_CONFIG, newCfg) + } + if (oldCfg.clientLog !== newCfg.clientLog) { + LogWatcher.start() + } } } diff --git a/src/web/client-log/client-log.ts b/src/web/client-log/client-log.ts new file mode 100644 index 00000000..aff24a21 --- /dev/null +++ b/src/web/client-log/client-log.ts @@ -0,0 +1,141 @@ +/* eslint-disable camelcase */ +import { CLIENTLOG_STRINGS as _$, ITEM_NAME_REF_BY_TRANSLATED, TRADE_TAGS } from '@/assets/data' +import { Config } from '@/web/Config' + +const enum MessageChannel { + SYSTEM = 'system', + TRADE = 'trade', + GLOBAL = 'global', + PARTY = 'party', + GUILD = 'guild', + WHISPER_FROM = 'whisper.from', + WHISPER_TO = 'whisper.to' +} + +interface LogEntry { + сhannel: MessageChannel + guildTag?: string + charName?: string + body: string + trade?: { + item: { + name: string + amount?: number + gem?: { level: number, quality: number } + } + price: { + name: string + amount: number + } + tab?: { + name: string + left: number + top: number + } + } +} + +export function handleLine (line: string) { + const text = line.split('] ').slice(1).join('] ') + if (!text) return + + const entry = {} as LogEntry + let match: RegExpMatchArray | null + if ((match = text.match(_$['channel.global']))) { + entry.сhannel = MessageChannel.GLOBAL + } else if ((match = text.match(_$['channel.trade']))) { + entry.сhannel = MessageChannel.TRADE + } else if ((match = text.match(_$['channel.system']))) { + entry.сhannel = MessageChannel.SYSTEM + } else if ((match = text.match(_$['channel.whisper.to']))) { + entry.сhannel = MessageChannel.WHISPER_TO + } else if ((match = text.match(_$['channel.whisper.from']))) { + entry.сhannel = MessageChannel.WHISPER_FROM + } else if ((match = text.match(_$['channel.party']))) { + entry.сhannel = MessageChannel.PARTY + } else if ((match = text.match(_$['channel.guild']))) { + entry.сhannel = MessageChannel.GUILD + } else { + return + } + + entry.guildTag = match.groups!.guild_tag + entry.charName = match.groups!.char_name + entry.body = match.groups!.body + + if (entry.сhannel === MessageChannel.WHISPER_FROM) { + if ((match = entry.body.match(TRADE_WHISPER[Config.store.language]))) { + const [pAmount, pTag] = [ + match.groups!.price.split(' ', 1).toString(), + match.groups!.price.split(' ').slice(1).join(' ') + ] + const gemMatch = match.groups!.item.match(_$['trade.gem']) + if (gemMatch) { + match.groups!.item = gemMatch.groups!.gem_name + } + + entry.trade = { + item: { + name: ITEM_NAME_REF_BY_TRANSLATED.get(match.groups!.item) || + match.groups!.item + }, + price: { + amount: Number(pAmount), + name: TRADE_TAGS.find(pair => pair[1] === pTag)?.[0] || pTag + }, + tab: { + name: match.groups!.tab_name, + top: Number(match.groups!.tab_top), + left: Number(match.groups!.tab_left) + } + } + + if (gemMatch) { + entry.trade.item.gem = { + level: Number(gemMatch.groups!.gem_lvl), + quality: Number(gemMatch.groups!.gem_qual) + } + } + + console.log(entry) + } else if ((match = entry.body.match(TRADE_BULK_WHISPER[Config.store.language]))) { + const [pAmount, pName] = [ + match.groups!.price.split(' ', 1).toString(), + match.groups!.price.split(' ').slice(1).join(' ') + ] + const [iAmount, iName] = [ + match.groups!.item.split(' ', 1).toString(), + match.groups!.item.split(' ').slice(1).join(' ') + ] + + entry.trade = { + item: { + amount: Number(iAmount), + name: ITEM_NAME_REF_BY_TRANSLATED.get(iName) || iName + }, + price: { + amount: Number(pAmount), + name: ITEM_NAME_REF_BY_TRANSLATED.get(pName) || pName + } + } + + console.log(entry) + } + } +} + +const TRADE_WHISPER = { + en: /^Hi, I would like to buy your (?.+) listed for (?.+) in (?.+) \(stash tab "(?.*)"; position: left (?\d+), top (?\d+)\)(?.+)?$/, + ru: /^Здравствуйте, хочу купить у вас (?.+) за (?.+) в лиге (?.+) \(секция "(?.*)"; позиция: (?\d+) столбец, (?\d+) ряд\)(?.+)?$/, + ko: /^안녕하세요, (?.+)\(보관함 탭 "(?.*)", 위치: 왼쪽 (?\d+), 상단 (?\d+)\)에 (?.+)\(으\)로 올려놓은 (?.+)\(을\)를 구매하고 싶습니다(?.+)?$/, + de: /^Hi, ich möchte '(?.+)' zum angebotenen Preis von (?.+) in der (?.+)-Liga kaufen \(Truhenfach "(?.*)"; Position: (?\d+) von links, (?\d+) von oben\)(?.+)?$/, + fr: /^Bonjour, je souhaiterais t'acheter (?.+) pour (?.+) dans la ligue (?.+) \(onglet de réserve "(?.*)" ; (?\d+)e en partant de la gauche, (?\d+)e en partant du haut\)(?.+)?$/, + es: /^Hola, quisiera comprar tu (?.+) listado por (?.+) en (?.+) \(pestaña de alijo "(?.*)"; posición: izquierda(?\d+), arriba (?\d+)\)(?.+)?$/, + pt: /^Olá, eu gostaria de comprar o seu item (?.+) listado por (?.+) na (?.+) \(aba do baú: "(?.*)"; posição: esquerda (?\d+), topo (?\d+)\)(?.+)?$/, + th: /^สวัสดี, เราต้องการจะชื้อของคุณ (?.+) ใน ราคา (?.+) ใน (?.+) \(stash tab "(?.*)"; ตำแหน่ง: ซ้าย (?\d+), บน (?\d+)\)(?.+)?$/ +} + +const TRADE_BULK_WHISPER = { + en: /^Hi, I'd like to buy your (?.+) for my (?.+) in (?.+)\.(?.+)?$/, + ru: /^Здравствуйте, хочу купить у вас (?.+) за (?.+) в лиге (?.+)\.(?.+)?$/ +} diff --git a/src/web/other-services/index.ts b/src/web/other-services/index.ts index 719e8f03..720880b1 100644 --- a/src/web/other-services/index.ts +++ b/src/web/other-services/index.ts @@ -1,9 +1,14 @@ import { MainProcess } from '@/ipc/main-process-bindings' import * as ipc from '@/ipc/ipc-event' import { openWiki } from './wiki' +import { handleLine } from '../client-log/client-log' export function registerOtherServices () { MainProcess.addEventListener(ipc.OPEN_WIKI, (e) => { openWiki((e as CustomEvent).detail) }) + + MainProcess.addEventListener(ipc.CLIENT_LOG_UPDATE, (e) => { + handleLine((e as CustomEvent).detail) + }) }