Skip to content
1 change: 1 addition & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@
"nile": "Amazon / Nile log",
"zoom": "Zoom log"
},
"error": "Internal error reading log file",
"instructions": "Join our Discord and look for the \"#-support\" section. Read the pinned \"Read Me First | Frequently Asked Questions\" thread and follow the instructions to share these logs and any relevant information about your problem.",
"instructions_title": "How to report a problem?",
"join-heroic-discord": "Join our Discord",
Expand Down
1 change: 1 addition & 0 deletions public/locales/sv/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@
"log": {
"copy-to-clipboard": "Kopiera loggen till urklipp/klippbord",
"current-log": "Aktuell logg",
"error": "Internt fel vid läsning av loggfil",
"instructions": "Gå med i vår Discord och leta efter kanalen \"#-support\". Läs den fästa \"Read Me First | Frequently Asked Questions\" tråden och följ instruktionerna för att dela dessa loggar och all relevant information om ditt problem.",
"instructions_title": "Hur rapporterar jag problem?",
"last-log": "Senaste logg",
Expand Down
13 changes: 10 additions & 3 deletions src/backend/logger/ipc_handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { addListener, addHandler } from 'backend/ipc'
import { existsSync, readFileSync } from 'graceful-fs'
import { existsSync } from 'graceful-fs'

import { showItemInFolder } from '../utils'

Expand All @@ -10,13 +10,20 @@ import {
deleteUploadedLogFile,
getUploadedLogFiles
} from './uploader'
import { readLastBytes } from 'backend/utils/filesystem/read_last_bytes'
import { decodeUTF8 } from 'backend/utils/strings'

addListener('logInfo', (e, message) => logInfo(message, LogPrefix.Frontend))
addListener('logError', (e, message) => logError(message, LogPrefix.Frontend))

addHandler('getLogContent', (event, appNameOrRunner) => {
addHandler('getLogContent', async (event, appNameOrRunner) => {
const logPath = getLogFilePath(appNameOrRunner)
return existsSync(logPath) ? readFileSync(logPath, 'utf-8') : ''
const MAX_LOG_BYTES = 1024 * 1024 // 1 MB
if (existsSync(logPath)) {
const buffer = await readLastBytes(logPath, MAX_LOG_BYTES)
return decodeUTF8(buffer)
}
return ''
})

addListener('showLogFileInFolder', (e, args) =>
Expand Down
29 changes: 29 additions & 0 deletions src/backend/utils/__tests__/strings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { decodeUTF8 } from '../strings'

jest.mock('backend/logger', () => ({
logError: jest.fn()
}))

describe('decodeUTF8', () => {
it('decodes a simple string', () => {
const buffer = Buffer.from('Hello World')
expect(decodeUTF8(buffer)).toBe('Hello World')
})

it('skips leading continuation bytes', () => {
// '🚀' is 4 bytes: 0xF0 0x9F 0x9A 0x80
// Test broken rocket + 'a'
const buffer = Buffer.from([0x9f, 0x9a, 0x80, 0x61])
expect(decodeUTF8(buffer)).toBe('a')
})

it('does not skip if first byte is not a continuation byte', () => {
const buffer = Buffer.from([0x61, 0x80, 0x80])
expect(decodeUTF8(buffer)).toBe(buffer.toString('utf-8'))
})

it('translates empty buffer to empty string', () => {
const buffer = Buffer.alloc(0)
expect(decodeUTF8(buffer)).toBe('')
})
})
45 changes: 45 additions & 0 deletions src/backend/utils/filesystem/__tests__/read_last_bytes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import fs from 'fs'
import path from 'path'
import os from 'os'
import { readLastBytes } from '../read_last_bytes'
import { logError } from 'backend/logger'

jest.mock('backend/logger', () => ({
logError: jest.fn()
}))

describe('readLastBytes', () => {
const testFile = path.join(os.tmpdir(), `heroic_test_log_${Date.now()}.log`)

afterEach(() => {
if (fs.existsSync(testFile)) {
try {
fs.unlinkSync(testFile)
} catch {
// Ignore if file was already removed
}
}
jest.clearAllMocks()
})

it('reads the whole file if it is smaller than n', async () => {
const content = 'Hello World'
fs.writeFileSync(testFile, content)

const buffer = await readLastBytes(testFile, 100)
expect(buffer.toString()).toBe(content)
})

it('reads only the last n bytes if the file is larger than n', async () => {
const content = '0123456789'
fs.writeFileSync(testFile, content)

const buffer = await readLastBytes(testFile, 5)
expect(buffer.toString()).toBe('56789')
})

it('throws error for non-existent files', async () => {
await expect(readLastBytes('non_existent_file.log', 100)).rejects.toThrow()
expect(logError).toHaveBeenCalled()
})
})
27 changes: 27 additions & 0 deletions src/backend/utils/filesystem/read_last_bytes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { promises as fsp } from 'graceful-fs'
import { logError } from 'backend/logger'

/**
* Reads the last `n` bytes of a file as a Buffer.
* If the file is smaller than `n` bytes, the whole file is read.
*/
export async function readLastBytes(path: string, n: number): Promise<Buffer> {
let fileHandle: fsp.FileHandle | undefined
try {
fileHandle = await fsp.open(path, 'r')
const { size: fileSize } = await fileHandle.stat()
const bytesToRead = Math.min(fileSize, n)
const position = fileSize - bytesToRead

const buffer = Buffer.alloc(bytesToRead)
await fileHandle.read(buffer, 0, bytesToRead, position)
return buffer
} catch (error) {
logError(`Error reading last bytes of ${path}: ${error}`)
throw error
} finally {
if (fileHandle !== undefined) {
await fileHandle.close()
}
}
}
21 changes: 21 additions & 0 deletions src/backend/utils/strings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { logError } from 'backend/logger'

/**
* Decodes a buffer to a UTF-8 string.
* Any leading continuation bytes are skipped to get a clean string start.
*
* @param buffer The buffer to decode
*/
export function decodeUTF8(buffer: Buffer): string {
try {
let skip = 0
// If bit 7 is set and bit 6 is cleared, this is a continuation byte.
while (skip < 4 && skip < buffer.length && (buffer[skip] & 0xc0) === 0x80) {
Comment thread
CommandMC marked this conversation as resolved.
skip++
}
return buffer.subarray(skip).toString('utf-8')
} catch (error) {
logError(`Error decoding buffer as UTF-8: ${error}`)
throw error
}
}
2 changes: 1 addition & 1 deletion src/common/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ interface AsyncIPCFunctions {
getCustomThemes: () => Promise<string[]>
getThemeCSS: (theme: string) => Promise<string>
isNative: (args: { appName: string; runner: Runner }) => boolean
getLogContent: (args: GetLogFileArgs) => string
getLogContent: (args: GetLogFileArgs) => Promise<string>
installWineVersion: (release: WineVersionInfo) => Promise<void>
refreshWineVersionInfo: (fetch?: boolean) => Promise<void>
removeWineVersion: (release: WineVersionInfo) => Promise<void>
Expand Down
35 changes: 25 additions & 10 deletions src/frontend/screens/Settings/sections/LogSettings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useMemo, useState } from 'react'
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { faFolderOpen } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
Expand Down Expand Up @@ -102,17 +102,32 @@ export default function LogSettings() {
zoom.library
])

const isFetching = useRef<boolean>(false)

const getLogContent = () => {
void window.api.getLogContent(showLogOf).then((content: string) => {
if (!content) {
setLogFileContent(t('setting.log.no-file', 'No log file found.'))
if (isFetching.current) return
isFetching.current = true
void window.api
.getLogContent(showLogOf)
.then((content: string) => {
if (!content) {
setLogFileContent(t('setting.log.no-file', 'No log file found'))
setLogFileExist(false)
return
}
setLogFileContent(content)
setLogFileExist(true)
})
.catch(() => {
setLogFileContent(
t('setting.log.error', 'Internal error reading log file')
)
setLogFileExist(false)
return setRefreshing(false)
}
setLogFileContent(content)
setLogFileExist(true)
setRefreshing(false)
})
})
.finally(() => {
isFetching.current = false
setRefreshing(false)
})
}

useEffect(() => {
Expand Down
Loading