feat: add comprehensive diagnostic logging subsystem and UI controls

This commit is contained in:
CAtlas0784 committed 2026-10-30 23:08:23 +07:00
1 parent 517436321b
commit 5f45841936
15 files changed
+495 -23

No files matched your search

+1
View File
@@ -37,6 +37,7 @@
| **Server Selection & GaCy PS** | ✅ | ✅ | ✅ | ✅ | ปุ่มเลือกเซิร์ฟเวอร์ด้านข้าง + ระบบดาวน์โหลด GaCy-GO (ZIP หรือ standalone sdk/server) |
| **High-Res Branding & Icons** | ✅ | ✅ | ✅ | ✅ | ภาพไอคอน 433x433 คมชัด ไร้รอยแตก, รีแบรนด์เป็น G-Launcher และ GaCy ทั้งหมด |
| **GaCy SRTools Integration** | ✅ | ✅ | ✅ | ✅ | เชื่อมโยง URL เครื่องมือ `https://gacy-srtool.vercel.app/` ทั้ง Header และหน้าหลัก |
| **Diagnostic Logging Subsystem** | ✅ | ✅ | ✅ | ✅ | `pkg/logger` บันทึก `%APPDATA%/g-launcher/logs/launcher.log` พร้อมปุ่มเปิดโฟลเดอร์/คัดลอกใน UI |
---
@@ -39,6 +39,14 @@ export function GetDir(path) {
return $Call.ByID(1567262998, path);
}
/**
* GetLogFilePath returns the absolute path of launcher.log
* @returns {$CancellablePromise<string>}
*/
export function GetLogFilePath() {
return $Call.ByID(4150210098);
}
/**
* @returns {$CancellablePromise<[string, string]>}
*/
@@ -54,6 +62,16 @@ export function Join(...paths) {
return $Call.ByID(3036412633, paths);
}
/**
* LogMessage receives log entries from the frontend and appends them to launcher.log
* @param {string} level
* @param {string} message
* @returns {$CancellablePromise<void>}
*/
export function LogMessage(level, message) {
return $Call.ByID(4279191144, level, message);
}
/**
* @param {string} path
* @returns {$CancellablePromise<[boolean, string]>}
@@ -62,6 +80,14 @@ export function OpenFolder(path) {
return $Call.ByID(1601714793, path);
}
/**
* OpenLogsFolder opens the folder containing launcher.log in Windows File Explorer
* @returns {$CancellablePromise<[boolean, string]>}
*/
export function OpenLogsFolder() {
return $Call.ByID(2156513786);
}
/**
* @param {string} filter
* @returns {$CancellablePromise<string>}
@@ -86,6 +112,15 @@ export function ReadConfigFile(gameDir, fileName) {
return $Call.ByID(3834168665, gameDir, fileName);
}
/**
* ReadLogs returns the last maxLines of the log file
* @param {number} maxLines
* @returns {$CancellablePromise<string>}
*/
export function ReadLogs(maxLines) {
return $Call.ByID(2698372806, maxLines);
}
/**
* @param {string} path
* @returns {$CancellablePromise<void>}
+1
View File
@@ -61,6 +61,7 @@
"menu_open_server": "Open server folder",
"menu_open_proxy": "Open proxy folder",
"menu_open_voice": "Open voice folder",
"menu_open_logs": "Open logs folder",
"status_updating_launcher": "Updating launcher",
"status_update_success": "Launcher updated successfully, auto closing after 5s",
"status_update_failed": "Launcher update failed, auto closing after 5s",
+1
View File
@@ -61,6 +61,7 @@
"menu_open_server": "サーバーフォルダを開く",
"menu_open_proxy": "プロキシフォルダを開く",
"menu_open_voice": "ボイスフォルダを開く",
"menu_open_logs": "ログフォルダを開く",
"status_updating_launcher": "ランチャーを更新中",
"status_update_success": "更新完了。5秒後に閉じます",
"status_update_failed": "更新失敗。5秒後に閉じます",
+1
View File
@@ -61,6 +61,7 @@
"menu_open_server": "서버 폴더 열기",
"menu_open_proxy": "프록시 폴더 열기",
"menu_open_voice": "음성 폴더 열기",
"menu_open_logs": "로그 폴더 열기",
"status_updating_launcher": "런처 업데이트 중",
"status_update_success": "업데이트 완료, 5초 후 종료",
"status_update_failed": "업데이트 실패, 5초 후 종료",
+1
View File
@@ -61,6 +61,7 @@
"menu_open_server": "เปิดโฟลเดอร์เซิร์ฟเวอร์",
"menu_open_proxy": "เปิดโฟลเดอร์พร็อกซี",
"menu_open_voice": "เปิดโฟลเดอร์เสียงพากย์",
"menu_open_logs": "เปิดโฟลเดอร์ Log",
"status_updating_launcher": "กำลังอัปเดต Launcher",
"status_update_success": "อัปเดต Launcher สำเร็จ ปิดอัตโนมัติใน 5 วินาที",
"status_update_failed": "อัปเดต Launcher ไม่สำเร็จ ปิดอัตโนมัติใน 5 วินาที",
+1
View File
@@ -61,6 +61,7 @@
"menu_open_server": "Mở thư mục Server",
"menu_open_proxy": "Mở thư mục Proxy",
"menu_open_voice": "Mở thư mục Voice",
"menu_open_logs": "Mở thư mục nhật ký",
"status_updating_launcher": "Đang cập nhật Launcher",
"status_update_success": "Cập nhật thành công, tự động đóng sau 5s",
"status_update_failed": "Cập nhật thất bại, tự động đóng sau 5s",
+1
View File
@@ -61,6 +61,7 @@
"menu_open_server": "打开服务器文件夹",
"menu_open_proxy": "打开代理文件夹",
"menu_open_voice": "打开语音文件夹",
"menu_open_logs": "打开日志文件夹",
"status_updating_launcher": "正在更新启动器",
"status_update_success": "更新成功,5秒后自动关闭",
"status_update_failed": "更新失败,5秒后自动关闭",
+41 -1
View File
@@ -2,6 +2,7 @@ import { CheckUpdateLauncher } from "@/helper"
import useModalStore from "@/stores/modalStore"
import useSettingStore from "@/stores/settingStore"
import useLauncherStore from "@/stores/launcherStore"
import { FSService } from "@bindings/g-launcher/internal/fs-service"
import { toast } from "react-toastify"
import { useTranslation } from "react-i18next"
@@ -91,8 +92,47 @@ export default function SettingModal({
</label>
</div>
{/* Section 3: Diagnostic Logs */}
<div className="p-4 bg-base-200 rounded-xl border border-cyan-500/40 shadow-sm">
<h4 className="font-bold text-lg mb-1 flex items-center gap-2">
<span>📋</span>
<span>Diagnostic Logs</span>
</h4>
<p className="text-xs text-white/70 mb-3">
เปิดโฟลเดอร์หรือคัดลอก Log เพื่อส่งให้ทีมพัฒนาช่วยวิเคราะห์ปัญหาการเข้าเกม
</p>
<div className="flex flex-wrap gap-2">
<button
className="btn btn-sm btn-info text-white shadow"
onClick={async () => {
const [ok, err] = await FSService.OpenLogsFolder()
if (!ok) toast.error("Failed to open logs: " + err)
}}
>
📂 เปิดโฟลเดอร์ Log
</button>
<button
className="btn btn-sm btn-outline btn-secondary"
onClick={async () => {
try {
const [logs, err] = await FSService.ReadLogs(200)
if (err) {
toast.error("Failed to read logs: " + err)
return
}
await navigator.clipboard.writeText(logs)
toast.success("คัดลอก Log 200 บรรทัดล่าสุดเรียบร้อย!")
} catch (e: any) {
toast.error("Cannot copy logs: " + e)
}
}}
>
📋 คัดลอก Log ล่าสุด
</button>
</div>
</div>
{/* Section 3: Launcher Version */}
{/* Section 4: Launcher Version */}
<div className="p-4 bg-base-200 rounded-xl border border-purple-300 shadow-sm">
<h4 className="font-bold text-lg mb-2">{t("setting.version_label")}</h4>
<div className="flex flex-wrap gap-2">
+48 -4
View File
@@ -158,22 +158,29 @@ export default function LauncherPage() {
try {
setIsLoading(true)
const basePath = await FSService.PickFile("exe")
await FSService.LogMessage("INFO", "handlePickFile: selected file=" + basePath)
if (basePath.endsWith("StarRail.exe") || basePath.endsWith("launcher.exe")) {
const normalized = basePath.replace(/\\/g, '/')
const normalized = basePath.replace(/\\/g, "/")
const folderPath = path.dirname(normalized)
const fullPath = `${folderPath}/StarRail_Data/StreamingAssets/DesignData/Windows`
const fullPath = folderPath + "/StarRail_Data/StreamingAssets/DesignData/Windows"
const exists = await FSService.DirExists(fullPath)
await FSService.LogMessage("INFO", "handlePickFile: folderPath=" + folderPath + ", DesignData exists=" + exists)
if (!exists) {
await FSService.LogMessage("WARN", "handlePickFile: DesignData not found at " + fullPath)
toast.error(t("home.error_game_dir"))
} else {
setGamePath(basePath)
setGameDir(folderPath)
await FSService.SaveGameSelection(basePath, folderPath)
toast.success(t("home.game_path_success"))
}
} else {
await FSService.LogMessage("WARN", "handlePickFile: invalid file type: " + basePath)
toast.error(t("home.error_file_type"))
}
} catch (err: any) {
const errStr = err?.stack || err?.message || String(err)
await FSService.LogMessage("ERROR", "handlePickFile error: " + errStr)
toast.error(t("home.toast_pick_folder_error"), err)
} finally {
setIsLoading(false)
@@ -181,21 +188,27 @@ export default function LauncherPage() {
}
const handleStartGame = async () => {
await FSService.LogMessage("INFO", "handleStartGame initiated: selectedServer=" + selectedServer + ", gamePath=" + gamePath + ", serverPath=" + serverPath + ", proxyPath=" + proxyPath)
if (!gamePath) {
await FSService.LogMessage("WARN", "handleStartGame aborted: gamePath is empty")
toast.info("Please select the game executable first")
return
}
if (gameRunning) {
await FSService.LogMessage("WARN", "handleStartGame aborted: game is already marked as running")
return
}
try {
setIsLoading(true)
if (selectedServer === "gacy-ps") {
await FSService.LogMessage("INFO", "handleStartGame: querying GaCy PS server executables...")
const [gameServer, sdkServer] = await GitService.FindServerExecutables()
const activeServer = gameServer || serverPath
await FSService.LogMessage("INFO", "handleStartGame: found gameServer=" + gameServer + ", sdkServer=" + sdkServer + ", activeServer=" + activeServer)
if (!activeServer) {
await FSService.LogMessage("ERROR", "handleStartGame: activeServer missing. Opening download modal.")
toast.error("Please download GaCy PS server files first")
setIsOpenDownloadDataModal(true)
return
@@ -203,12 +216,16 @@ export default function LauncherPage() {
// 1. Start Proxy
const activeProxy = proxyPath || "./proxy/firefly-go-proxy.exe"
await FSService.LogMessage("INFO", "handleStartGame: activeProxy=" + activeProxy + ", proxyRunning=" + proxyRunning)
if (!proxyRunning && !gamePath.endsWith("launcher.exe")) {
await FSService.LogMessage("INFO", "handleStartGame: starting proxy via StartWithConsole(" + activeProxy + ")")
const [resultProxy, error] = await FSService.StartWithConsole(activeProxy)
if (!resultProxy) {
await FSService.LogMessage("ERROR", "handleStartGame: proxy start failed: " + error)
toast.error(t("home.toast_start_proxy_failed") + ": " + error)
return
}
await FSService.LogMessage("INFO", "handleStartGame: proxy started successfully")
setProxyRunning(true)
}
await sleep(500)
@@ -216,39 +233,56 @@ export default function LauncherPage() {
// 2. Start SDK Server if exists
const activeSdk = sdkServer || sdkServerPath
if (activeSdk) {
await FSService.StartWithConsole(activeSdk)
await FSService.LogMessage("INFO", "handleStartGame: starting SDK server via StartWithConsole(" + activeSdk + ")")
const [resultSdk, sdkErr] = await FSService.StartWithConsole(activeSdk)
if (!resultSdk) {
await FSService.LogMessage("WARN", "handleStartGame: SDK server start warning: " + sdkErr)
} else {
await FSService.LogMessage("INFO", "handleStartGame: SDK server started successfully")
}
await sleep(400)
}
// 3. Start Game Server
if (!serverRunning) {
await FSService.LogMessage("INFO", "handleStartGame: starting Game server via StartWithConsole(" + activeServer + ")")
const [resultServer, error] = await FSService.StartWithConsole(activeServer)
if (!resultServer) {
await FSService.LogMessage("ERROR", "handleStartGame: Game server start failed: " + error)
toast.error(t("home.toast_start_server_failed") + ": " + error)
return
}
await FSService.LogMessage("INFO", "handleStartGame: Game server started successfully")
setServerRunning(true)
}
await sleep(1000)
} else {
await FSService.LogMessage("INFO", "handleStartGame: running in Official Server mode (no proxy/PS)")
}
// 4. Start Game
await FSService.LogMessage("INFO", "handleStartGame: starting game binary: " + gamePath)
if (gamePath.endsWith("launcher.exe")) {
const [resultGame, error] = await FSService.StartWithConsole(gamePath)
if (!resultGame) {
await FSService.LogMessage("ERROR", "handleStartGame: StartWithConsole game failed: " + error)
toast.error(t("home.toast_start_game_failed") + ": " + error)
return
}
} else {
const [resultGame, error] = await FSService.StartApp(gamePath)
if (!resultGame) {
await FSService.LogMessage("ERROR", "handleStartGame: StartApp game failed: " + error)
toast.error(t("home.toast_start_game_failed") + ": " + error)
return
}
}
await FSService.LogMessage("INFO", "handleStartGame: Game process started successfully!")
setGameRunning(true)
} catch (err: any) {
const errStr = err?.stack || err?.message || String(err)
await FSService.LogMessage("ERROR", "handleStartGame uncaught exception: " + errStr)
console.log(err)
toast.error(t("home.toast_start_game_error"), err)
} finally {
@@ -256,7 +290,6 @@ export default function LauncherPage() {
}
}
const handlerUpdateData = async () => {
setIsDownloading(true)
if (updateData.launcher.isUpdate) {
@@ -486,6 +519,17 @@ export default function LauncherPage() {
{t("home.menu_open_voice")}
</button>
</li>
<li>
<button onClick={async () => {
const [ok, err] = await FSService.OpenLogsFolder()
if (!ok) {
toast.error("Failed to open logs: " + err)
}
}}
>
📋 {t("home.menu_open_logs") || "Open Logs"}
</button>
</li>
</ul>
</div>
</div>
+100 -13
View File
@@ -2,6 +2,7 @@ package fsService
import (
"fmt"
"g-launcher/pkg/logger"
"g-launcher/pkg/sevenzip"
"os"
"os/exec"
@@ -25,7 +26,13 @@ func (f *FSService) PickFolder() (string, error) {
} else {
dialog.SetTitle("Select a folder")
}
return dialog.PromptForSingleSelection()
res, err := dialog.PromptForSingleSelection()
if err != nil {
logger.Error("PickFolder error: %v", err)
} else {
logger.Info("PickFolder selected: %s", res)
}
return res, err
}
func (f *FSService) PickFile(filter string) (string, error) {
@@ -45,6 +52,7 @@ func (f *FSService) PickFile(filter string) (string, error) {
dialog.AddFilter("Patch files (*.zip;*.7z;*.ziff)", "*.zip;*.7z;*.ziff")
}
if path, err := dialog.PromptForSingleSelection(); err == nil {
logger.Info("PickFile (%s) selected: %s", filter, path)
return path, nil
}
return "", nil
@@ -74,40 +82,72 @@ func (f *FSService) Join(paths ...string) string {
}
func (f *FSService) RemoveFile(path string) error {
logger.Info("RemoveFile: %s", path)
return os.Remove(path)
}
func (f *FSService) StartApp(path string) (bool, string) {
dir := filepath.Dir(path)
cmd := exec.Command(path)
cmd.Dir = dir
err := cmd.Start()
absPath, err := filepath.Abs(path)
if err != nil {
logger.Error("StartApp: failed to resolve absolute path for %s: %v", path, err)
return false, err.Error()
}
if strings.HasSuffix(path, "StarRail.exe") || strings.HasSuffix(path, "ZenlessZoneZeroBeta.exe") {
go func() {
_ = cmd.Wait()
application.Get().Event.Emit("game:exit")
}()
if _, err := os.Stat(absPath); os.IsNotExist(err) {
logger.Error("StartApp: executable file not found: %s", absPath)
return false, "file not found: " + absPath
}
dir := filepath.Dir(absPath)
cmd := exec.Command(absPath)
cmd.Dir = dir
cmd.SysProcAttr = &windows.SysProcAttr{
CreationFlags: windows.CREATE_NEW_CONSOLE | windows.CREATE_BREAKAWAY_FROM_JOB,
NoInheritHandles: true,
}
logger.Info("StartApp: launching process %s in directory %s", absPath, dir)
err = cmd.Start()
if err != nil {
logger.Error("StartApp: failed to start process %s: %v", absPath, err)
return false, err.Error()
}
pid := cmd.Process.Pid
logger.Info("StartApp: process started successfully, PID: %d", pid)
go func() {
waitErr := cmd.Wait()
base := strings.ToLower(filepath.Base(path))
if waitErr != nil {
logger.Warn("StartApp: process PID %d (%s) exited with error: %v", pid, base, waitErr)
} else {
logger.Info("StartApp: process PID %d (%s) exited cleanly", pid, base)
}
if strings.HasSuffix(base, "starrail.exe") || strings.HasSuffix(base, "zenlesszonezerobeta.exe") || strings.HasSuffix(base, "launcher.exe") {
application.Get().Event.Emit("game:exit")
}
}()
return true, ""
}
func (f *FSService) StartWithConsole(path string) (bool, string) {
absPath, err := filepath.Abs(path)
if err != nil {
logger.Error("StartWithConsole: failed to resolve absolute path for %s: %v", path, err)
return false, err.Error()
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
logger.Error("StartWithConsole: file not found: %s", absPath)
return false, "file not found: " + absPath
}
dir := filepath.Dir(absPath)
cmd := exec.Command(absPath)
cmd.Dir = filepath.Dir(absPath)
cmd.Dir = dir
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
@@ -118,14 +158,25 @@ func (f *FSService) StartWithConsole(path string) (bool, string) {
NoInheritHandles: true,
}
logger.Info("StartWithConsole: starting process %s (WorkingDir: %s)", absPath, dir)
err = cmd.Start()
if err != nil {
logger.Error("StartWithConsole: failed to start %s: %v", absPath, err)
return false, err.Error()
}
pid := cmd.Process.Pid
logger.Info("StartWithConsole: process started successfully, PID: %d", pid)
go func() {
_ = cmd.Wait()
waitErr := cmd.Wait()
base := strings.ToLower(filepath.Base(path))
if waitErr != nil {
logger.Warn("StartWithConsole: process PID %d (%s) exited with error: %v", pid, base, waitErr)
} else {
logger.Info("StartWithConsole: process PID %d (%s) exited cleanly", pid, base)
}
if base == "launcher.exe" || base == "starrail.exe" {
application.Get().Event.Emit("game:exit")
} else if base == "firefly-go_win.exe" || base == "gameserver.exe" || base == "gameserver-go.exe" || base == "server.exe" || base == "sdkserver-go.exe" || base == "sdkserver.exe" {
@@ -140,14 +191,17 @@ func (f *FSService) StartWithConsole(path string) (bool, string) {
func (f *FSService) OpenFolder(path string) (bool, string) {
absPath, err := filepath.Abs(path)
if err != nil {
logger.Error("OpenFolder: resolve path failed for %s: %v", path, err)
return false, "failed to resolve absolute path: " + err.Error()
}
if !f.DirExists(absPath) {
logger.Warn("OpenFolder: directory not found: %s", absPath)
return false, "directory not found: " + absPath
}
url := "file:///" + filepath.ToSlash(absPath)
logger.Info("OpenFolder: opening %s", url)
application.Get().Browser.OpenURL(url)
return true, ""
@@ -156,6 +210,7 @@ func (f *FSService) OpenFolder(path string) (bool, string) {
func (f *FSService) FileExistsInZip(archivePath, fileInside string) (bool, string) {
exists, err := sevenzip.IsFileIn7z(archivePath, fileInside)
if err != nil {
logger.Error("FileExistsInZip error: %v", err)
return false, err.Error()
}
return exists, ""
@@ -181,5 +236,37 @@ func (f *FSService) WriteConfigFile(gameDir, fileName, content string) error {
return fmt.Errorf("select the game folder first")
}
target := filepath.Join(gameDir, fileName)
logger.Info("WriteConfigFile: saving %s", target)
return os.WriteFile(target, []byte(content), 0644)
}
// LogMessage receives log entries from the frontend and appends them to launcher.log
func (f *FSService) LogMessage(level string, message string) {
logger.GetInstance().Log(level, "[UI] "+message)
}
// GetLogFilePath returns the absolute path of launcher.log
func (f *FSService) GetLogFilePath() (string, error) {
p := logger.GetLogPath()
if p == "" {
return "", fmt.Errorf("log path not initialized")
}
return p, nil
}
// OpenLogsFolder opens the folder containing launcher.log in Windows File Explorer
func (f *FSService) OpenLogsFolder() (bool, string) {
dir := logger.GetLogDir()
if dir == "" {
return false, "log directory not found"
}
return f.OpenFolder(dir)
}
// ReadLogs returns the last maxLines of the log file
func (f *FSService) ReadLogs(maxLines int) (string, error) {
if maxLines <= 0 {
maxLines = 200
}
return logger.ReadRecentLogs(maxLines)
}
+3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"g-launcher/pkg/logger"
"os"
"path/filepath"
@@ -43,10 +44,12 @@ func (f *FSService) GetSavedGameSelection() (string, string, error) {
return "", "", fmt.Errorf("parse saved game selection: %w", err)
}
logger.Info("GetSavedGameSelection: loaded GamePath=%s, GameDir=%s", selection.GamePath, selection.GameDir)
return selection.GamePath, selection.GameDir, nil
}
func (f *FSService) SaveGameSelection(gamePath, gameDir string) error {
logger.Info("SaveGameSelection: saving GamePath=%s, GameDir=%s", gamePath, gameDir)
path, err := gameSelectionPath()
if err != nil {
return err
+2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"g-launcher/pkg/constant"
"g-launcher/pkg/logger"
"g-launcher/pkg/models"
"io"
"net/http"
@@ -203,5 +204,6 @@ func (g *GitService) FindServerExecutables() (string, string) {
}
}
logger.Info("FindServerExecutables: serverDir=%s, found gameServer=%s, sdkServer=%s", serverDir, gameServer, sdkServer)
return gameServer, sdkServer
}
+17 -5
View File
@@ -11,10 +11,11 @@ import (
languageService "g-launcher/internal/language-service"
"g-launcher/pkg/constant"
"g-launcher/pkg/logger"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
@@ -45,12 +46,23 @@ func extractFile(embedPath, outPath string) error {
}
func main() {
_, err := logger.Init()
if err != nil {
fmt.Printf("failed to initialize logger: %v\n", err)
}
logger.Info("G-Launcher starting up (Version: %s, OS: %s, Arch: %s, Go: %s)", constant.CurrentLauncherVersion, runtime.GOOS, runtime.GOARCH, runtime.Version())
cwd, _ := os.Getwd()
logger.Info("Current Working Directory: %s", cwd)
// Extract required files
for outPath, embedPath := range constant.RequiredFiles {
if !fileExists(outPath.String()) {
err := extractFile(embedPath, outPath.String())
if err != nil {
fmt.Println("can't copy file:", err)
logger.Error("Can't extract file %s: %v", outPath, err)
} else {
logger.Info("Extracted embedded asset: %s", outPath)
}
}
}
@@ -62,8 +74,8 @@ func main() {
base := filepath.Base(exePath)
oldPath := filepath.Join(dir, "."+base+".old")
fmt.Println("Old executable path:", oldPath)
os.Remove(oldPath)
logger.Info("Checking for old executable: %s", oldPath)
_ = os.Remove(oldPath)
}
// Create application
app := application.New(application.Options{
@@ -124,6 +136,6 @@ func main() {
err = app.Run()
if err != nil {
log.Fatal(err)
logger.Error("Application fatal error: %v", err)
}
}
+242
View File
@@ -0,0 +1,242 @@
package logger
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type LogLevel int
const (
DEBUG LogLevel = iota
INFO
WARN
ERROR
)
var levelNames = map[LogLevel]string{
DEBUG: "DEBUG",
INFO: "INFO",
WARN: "WARN",
ERROR: "ERROR",
}
type Logger struct {
mu sync.Mutex
logFile *os.File
logPath string
logDir string
}
var (
defaultLogger *Logger
once sync.Once
)
// Init initializes the centralized logger with file & console outputs.
func Init() (*Logger, error) {
var initErr error
once.Do(func() {
configDir, err := os.UserConfigDir()
if err != nil {
configDir = "."
}
logDir := filepath.Join(configDir, "g-launcher", "logs")
if err := os.MkdirAll(logDir, 0755); err != nil {
initErr = fmt.Errorf("create log dir: %w", err)
return
}
logPath := filepath.Join(logDir, "launcher.log")
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
initErr = fmt.Errorf("open log file: %w", err)
return
}
defaultLogger = &Logger{
logFile: file,
logPath: logPath,
logDir: logDir,
}
defaultLogger.write(INFO, "=== G-Launcher Log Session Started ===")
defaultLogger.write(INFO, fmt.Sprintf("Log Path: %s", logPath))
})
return defaultLogger, initErr
}
// GetInstance returns the default logger, initializing if necessary.
func GetInstance() *Logger {
if defaultLogger == nil {
_, _ = Init()
}
return defaultLogger
}
func (l *Logger) write(level LogLevel, message string) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
lvl := levelNames[level]
line := fmt.Sprintf("[%s] [%-5s] %s\n", timestamp, lvl, message)
// Write to console
fmt.Print(line)
// Write to log file
if l.logFile != nil {
_, _ = l.logFile.WriteString(line)
_ = l.logFile.Sync()
}
}
func (l *Logger) Log(levelStr string, message string) {
lvl := INFO
switch strings.ToUpper(strings.TrimSpace(levelStr)) {
case "DEBUG":
lvl = DEBUG
case "WARN", "WARNING":
lvl = WARN
case "ERROR":
lvl = ERROR
}
l.write(lvl, message)
}
func (l *Logger) Debug(format string, args ...interface{}) {
l.write(DEBUG, fmt.Sprintf(format, args...))
}
func (l *Logger) Info(format string, args ...interface{}) {
l.write(INFO, fmt.Sprintf(format, args...))
}
func (l *Logger) Warn(format string, args ...interface{}) {
l.write(WARN, fmt.Sprintf(format, args...))
}
func (l *Logger) Error(format string, args ...interface{}) {
l.write(ERROR, fmt.Sprintf(format, args...))
}
func (l *Logger) GetLogPath() string {
if l == nil {
return ""
}
return l.logPath
}
func (l *Logger) GetLogDir() string {
if l == nil {
return ""
}
return l.logDir
}
func (l *Logger) ReadRecentLogs(maxLines int) (string, error) {
if l == nil || l.logPath == "" {
return "", fmt.Errorf("logger not initialized")
}
l.mu.Lock()
defer l.mu.Unlock()
data, err := os.ReadFile(l.logPath)
if err != nil {
return "", err
}
lines := strings.Split(string(data), "\n")
if len(lines) <= maxLines {
return string(data), nil
}
recent := lines[len(lines)-maxLines:]
return strings.Join(recent, "\n"), nil
}
func (l *Logger) Close() {
l.mu.Lock()
defer l.mu.Unlock()
if l.logFile != nil {
_ = l.logFile.Close()
l.logFile = nil
}
}
// Global package-level wrappers
func Debug(format string, args ...interface{}) {
GetInstance().Debug(format, args...)
}
func Info(format string, args ...interface{}) {
GetInstance().Info(format, args...)
}
func Warn(format string, args ...interface{}) {
GetInstance().Warn(format, args...)
}
func Error(format string, args ...interface{}) {
GetInstance().Error(format, args...)
}
func GetLogPath() string {
return GetInstance().GetLogPath()
}
func GetLogDir() string {
return GetInstance().GetLogDir()
}
func ReadRecentLogs(maxLines int) (string, error) {
return GetInstance().ReadRecentLogs(maxLines)
}
// CaptureProcessOutput attaches pipes to a command and logs its output in real-time.
func (l *Logger) CaptureProcessOutput(prefix string, stdout, stderr io.ReadCloser) {
go func() {
buf := make([]byte, 1024)
for {
n, err := stdout.Read(buf)
if n > 0 {
msg := strings.TrimSpace(string(buf[:n]))
if msg != "" {
l.Info("[%s:STDOUT] %s", prefix, msg)
}
}
if err != nil {
break
}
}
}()
go func() {
buf := make([]byte, 1024)
for {
n, err := stderr.Read(buf)
if n > 0 {
msg := strings.TrimSpace(string(buf[:n]))
if msg != "" {
l.Warn("[%s:STDERR] %s", prefix, msg)
}
}
if err != nil {
break
}
}
}()
}