feat(stage-tamagotchi): add electron main process file logger hook (#1247)

* feat(stage-tamagotchi): add electron main process file logger hook

* chore: code update

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: update comment

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* [autofix.ci] apply automated fixes

* chore: code style update

Co-authored-by: RainbowBird <rbxin2003@outlook.com>

* chore: code style update

Co-authored-by: RainbowBird <rbxin2003@outlook.com>

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: RainbowBird <rbxin2003@outlook.com>
This commit is contained in:
Garfield Lee
2026-03-10 23:33:48 +08:00
committed by GitHub
co-authored by Copilot RainbowBird autofix-ci[bot]
parent 38d41cca84
commit 92e75d86fb
3 changed files with 216 additions and 10 deletions
@@ -0,0 +1,195 @@
/**
* File Logger for Electron Main Process
*
* Sets up file-based logging by creating timestamped log files in the userData directory.
*
* Log file naming: airi-tamagotchi-{timestamp}.log
* - No rotation needed due to unique timestamp per session
* - Unique timestamp per session avoids cross-process log file sharing
* - Easy to identify and debug specific sessions
*
* @example
* ```typescript
* const fileLogger = await setupFileLogger()
* setGlobalFormat(Format.Pretty)
* setGlobalLogLevel(LogLevel.Log)
*
* setGlobalHookPostLog((log, formatted) => {
* if (fileLogger.logFileFd !== null) {
* void fileLogger.appendLog(formatted)
* }
* })
* ```
*/
import { mkdir, open, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { app } from 'electron'
// ============================================================================
// Constants
// ============================================================================
const LOG_FILE_PREFIX = 'airi-tamagotchi'
// ============================================================================
// Public Types
// ============================================================================
/**
* Handle for the file logger, providing access to the log file and append operations.
*/
export interface FileLoggerHandle {
/** Path to the current session's log file, or null if initialization failed */
logFilePath: string | null
/** File descriptor for the current session's log file, or null if initialization failed */
logFileFd: number | null
/**
* Appends a log entry to the file.
* @param content - The formatted log content to append
*/
appendLog: (content: string) => Promise<void>
/**
* Closes the log file and releases resources.
*/
close: () => Promise<void>
}
export const nullFileLoggerHandle: FileLoggerHandle = {
logFilePath: null,
logFileFd: null,
appendLog: async () => {},
close: async () => {},
}
// ============================================================================
// Internal Functions
// ============================================================================
/**
* Extracts a human-readable error message from an unknown error object.
*/
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Generates the log file path for the current session.
* Format: {userData}/logs/airi-tamagotchi-{timestamp}.log
*/
function createLogFilePath(logsDir: string, timestamp: number): string {
return join(logsDir, `${LOG_FILE_PREFIX}-${timestamp}.log`)
}
/**
* Ensures the logs directory exists.
* Returns the logs directory path if successful, null otherwise.
*/
async function ensureLogsDirectory(): Promise<string | null> {
try {
const logsDir = join(app.getPath('userData'), 'logs')
await mkdir(logsDir, { recursive: true })
return logsDir
}
catch (error) {
const message = getErrorMessage(error)
console.error(`[FileLogger] Failed to create logs directory: ${message}`)
return null
}
}
/**
* Checks if the current log file exists and returns its size.
*/
async function getLogFileSize(filePath: string): Promise<number | null> {
try {
const stats = await stat(filePath)
return stats.size
}
catch {
return null
}
}
// ============================================================================
// Public API
// ============================================================================
/**
* Sets up the file logger by creating a timestamped log file.
*
* Returns a {@link FileLoggerHandle} that provides:
* - `logFilePath`: Path to the log file (null if failed)
* - `logFileFd`: File descriptor (null if failed)
* - `appendLog(content)`: Async function to append logs to the file
* - `close()`: Async function to close the file
*
* Note: This function only creates the file. The caller is responsible for
* registering the `setGlobalHookPostLog` hook from @guiiai/logg.
*/
export async function setupFileLogger(): Promise<FileLoggerHandle> {
const timestamp = Date.now()
const logsDir = await ensureLogsDirectory()
if (!logsDir) {
return nullFileLoggerHandle
}
const logFilePath = createLogFilePath(logsDir, timestamp)
try {
const fileHandle = await open(logFilePath, 'a')
const logFileFd = fileHandle.fd
let isFileClosed = false
// Write initialization message
const sessionStartMessage = `[FileLogger] Initialized - logging to: ${logFilePath}\n`
await fileHandle.appendFile(sessionStartMessage)
console.info(`[FileLogger] Session logs: ${logFilePath}`)
async function appendLog(content: string) {
if (isFileClosed) {
return
}
const normalizedContent = content.endsWith('\n') ? content : `${content}\n`
try {
await fileHandle.appendFile(normalizedContent)
}
catch (error) {
const message = getErrorMessage(error)
console.error(`[FileLogger] Failed to write log: ${message}`)
}
}
async function close() {
if (isFileClosed) {
return
}
try {
await fileHandle.close()
isFileClosed = true
console.info('[FileLogger] File closed successfully')
}
catch (error) {
const message = getErrorMessage(error)
console.error(`[FileLogger] Failed to close log file: ${message}`)
}
const size = await getLogFileSize(logFilePath)
const sizeInfo = size !== null ? ` (${(size / 1024).toFixed(2)} KB)` : ''
console.info(`[FileLogger] Session log file: ${logFilePath}${sizeInfo}`)
}
return { logFilePath, logFileFd, appendLog, close }
}
catch (error) {
const message = getErrorMessage(error)
console.error(`[FileLogger] Failed to create log file - logging to console only: ${message}`)
return nullFileLoggerHandle
}
}
+17 -1
View File
@@ -1,3 +1,5 @@
import type { FileLoggerHandle } from './app/file-logger'
import { dirname } from 'node:path'
import { env, platform } from 'node:process'
import { fileURLToPath } from 'node:url'
@@ -5,7 +7,7 @@ import { fileURLToPath } from 'node:url'
import messages from '@proj-airi/i18n/locales'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg'
import { Format, LogLevel, setGlobalFormat, setGlobalHookPostLog, setGlobalLogLevel, useLogg } from '@guiiai/logg'
import { initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main'
import { app, ipcMain } from 'electron'
import { noop } from 'es-toolkit'
@@ -15,6 +17,7 @@ import { isLinux } from 'std-env'
import icon from '../../resources/icon.png?asset'
import { openDebugger, setupDebugger } from './app/debugger'
import { nullFileLoggerHandle, setupFileLogger } from './app/file-logger'
import { createGlobalAppConfig } from './configs/global'
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
import { setElectronMainDirname } from './libs/electron/location'
@@ -78,7 +81,19 @@ electronApp.setAppUserModelId('ai.moeru.airi')
initScreenCaptureForMain()
let fileLogger: FileLoggerHandle = nullFileLoggerHandle
app.whenReady().then(async () => {
// Initialize file logger and register the hook
fileLogger = await setupFileLogger()
// Register the global hook for file logging
setGlobalHookPostLog((_, formatted) => {
if (fileLogger.logFileFd !== null) {
void fileLogger.appendLog(formatted)
}
})
injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig()))
const appConfig = injeca.provide('configs:app', () => createGlobalAppConfig())
@@ -190,4 +205,5 @@ app.on('window-all-closed', () => {
app.on('before-quit', async () => {
emitAppBeforeQuit()
injeca.stop()
await fileLogger.close() // Ensure all logs are flushed
})
+4 -9
View File
@@ -3229,7 +3229,7 @@ importers:
version: 14.1.0(vue@3.5.29(typescript@5.9.3))
'@wxt-dev/module-vue':
specifier: ^1.0.3
version: 1.0.3(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 1.0.3(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
nanoid:
specifier: ^5.1.6
version: 5.1.6
@@ -6222,9 +6222,6 @@ packages:
'@moeru/std@0.1.0-beta.1':
resolution: {integrity: sha512-ImfoMBchZP+F4aos2nFmlcipcgIhT+ZkUUVWB/bBHEqyB/w/nbI/xgA8a1ibJUtG7RcQnQH+SKom0r4/1xCWqA==}
'@moeru/std@0.1.0-beta.15':
resolution: {integrity: sha512-lTi/b2ysilOSE/yo9uY9JIaNOE32kHpemWMxA4ZDIMxWeHZAEXK+jLi8EiXHtdBltjDlBYfNWeWcfH0KKiEJQA==}
'@moeru/std@0.1.0-beta.17':
resolution: {integrity: sha512-GyLDCKHiqXyO60Wn0+Hc7Tfo/GJQA3pV5FZGkzGhvJM7LQPZDB0tksuJV92LuRF1lG8x78B+8UfVFhVf8xXVfg==}
@@ -20173,7 +20170,7 @@ snapshots:
dependencies:
'@antfu/eslint-config': 7.6.1(@typescript-eslint/rule-tester@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@unocss/eslint-plugin@66.6.2(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.29)(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18)
'@masknet/eslint-plugin': 0.4.1(eslint@9.39.3(jiti@2.6.1))
'@moeru/std': 0.1.0-beta.15
'@moeru/std': 0.1.0-beta.17
eslint: 9.39.3(jiti@2.6.1)
eslint-flat-config-utils: 2.1.4
eslint-plugin-de-morgan: 2.0.0(eslint@9.39.3(jiti@2.6.1))
@@ -20220,8 +20217,6 @@ snapshots:
'@moeru/std@0.1.0-beta.1': {}
'@moeru/std@0.1.0-beta.15': {}
'@moeru/std@0.1.0-beta.17': {}
'@mrleebo/prisma-ast@0.13.1':
@@ -24105,9 +24100,9 @@ snapshots:
'@types/filesystem': 0.0.36
'@types/har-format': 1.2.16
'@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@wxt-dev/module-vue@1.0.3(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))(wxt@0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitejs/plugin-vue': 6.0.4(vite@8.0.0-beta.15(@types/node@24.10.14)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))
'@vitejs/plugin-vue': 6.0.4(vite@7.3.1(@types/node@24.10.14)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))
wxt: 0.20.18(@types/node@24.10.14)(canvas@3.2.1)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- vite