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)
+ })
}