From 5f458419360c531524a5279e7bd638a4afebe595 Mon Sep 17 00:00:00 2001 From: CAtlas0784 Date: Fri, 30 Oct 2026 23:08:23 +0700 Subject: [PATCH] feat: add comprehensive diagnostic logging subsystem and UI controls --- Doc/G-LAUNCHER-HANDOFF.md | 1 + .../internal/fs-service/fsservice.js | 35 +++ frontend/public/locales/en.json | 1 + frontend/public/locales/ja.json | 1 + frontend/public/locales/ko.json | 1 + frontend/public/locales/th.json | 1 + frontend/public/locales/vi.json | 1 + frontend/public/locales/zh.json | 1 + .../src/components/settingModal/index.tsx | 42 ++- frontend/src/pages/launcher/index.tsx | 52 +++- internal/fs-service/fs.go | 113 +++++++- internal/fs-service/game-selection.go | 3 + internal/git-service/server.go | 2 + main.go | 22 +- pkg/logger/logger.go | 242 ++++++++++++++++++ 15 files changed, 495 insertions(+), 23 deletions(-) create mode 100644 pkg/logger/logger.go diff --git a/Doc/G-LAUNCHER-HANDOFF.md b/Doc/G-LAUNCHER-HANDOFF.md index 10ebe5c..90eaf28 100644 --- a/Doc/G-LAUNCHER-HANDOFF.md +++ b/Doc/G-LAUNCHER-HANDOFF.md @@ -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 | --- diff --git a/frontend/bindings/g-launcher/internal/fs-service/fsservice.js b/frontend/bindings/g-launcher/internal/fs-service/fsservice.js index 61fe225..777e913 100644 --- a/frontend/bindings/g-launcher/internal/fs-service/fsservice.js +++ b/frontend/bindings/g-launcher/internal/fs-service/fsservice.js @@ -39,6 +39,14 @@ export function GetDir(path) { return $Call.ByID(1567262998, path); } +/** + * GetLogFilePath returns the absolute path of launcher.log + * @returns {$CancellablePromise} + */ +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} + */ +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} @@ -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} + */ +export function ReadLogs(maxLines) { + return $Call.ByID(2698372806, maxLines); +} + /** * @param {string} path * @returns {$CancellablePromise} diff --git a/frontend/public/locales/en.json b/frontend/public/locales/en.json index b5df401..fda8425 100644 --- a/frontend/public/locales/en.json +++ b/frontend/public/locales/en.json @@ -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", diff --git a/frontend/public/locales/ja.json b/frontend/public/locales/ja.json index bbe22d3..21a8fda 100644 --- a/frontend/public/locales/ja.json +++ b/frontend/public/locales/ja.json @@ -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秒後に閉じます", diff --git a/frontend/public/locales/ko.json b/frontend/public/locales/ko.json index 467ff8b..46ded20 100644 --- a/frontend/public/locales/ko.json +++ b/frontend/public/locales/ko.json @@ -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초 후 종료", diff --git a/frontend/public/locales/th.json b/frontend/public/locales/th.json index 8641ce8..4f95008 100644 --- a/frontend/public/locales/th.json +++ b/frontend/public/locales/th.json @@ -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 วินาที", diff --git a/frontend/public/locales/vi.json b/frontend/public/locales/vi.json index 5735f4e..b3ec3c2 100644 --- a/frontend/public/locales/vi.json +++ b/frontend/public/locales/vi.json @@ -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", diff --git a/frontend/public/locales/zh.json b/frontend/public/locales/zh.json index aeb5557..8320c0e 100644 --- a/frontend/public/locales/zh.json +++ b/frontend/public/locales/zh.json @@ -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秒后自动关闭", diff --git a/frontend/src/components/settingModal/index.tsx b/frontend/src/components/settingModal/index.tsx index 4079662..7ae2ffa 100644 --- a/frontend/src/components/settingModal/index.tsx +++ b/frontend/src/components/settingModal/index.tsx @@ -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({ + {/* Section 3: Diagnostic Logs */} +
+

+ 📋 + Diagnostic Logs +

+

+ เปิดโฟลเดอร์หรือคัดลอก Log เพื่อส่งให้ทีมพัฒนาช่วยวิเคราะห์ปัญหาการเข้าเกม +

+
+ + +
+
- {/* Section 3: Launcher Version */} + {/* Section 4: Launcher Version */}

{t("setting.version_label")}

diff --git a/frontend/src/pages/launcher/index.tsx b/frontend/src/pages/launcher/index.tsx index e1731e8..1003b8e 100644 --- a/frontend/src/pages/launcher/index.tsx +++ b/frontend/src/pages/launcher/index.tsx @@ -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")} +
  • + +
  • diff --git a/internal/fs-service/fs.go b/internal/fs-service/fs.go index 43dfdbb..e02e0bb 100644 --- a/internal/fs-service/fs.go +++ b/internal/fs-service/fs.go @@ -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) +} diff --git a/internal/fs-service/game-selection.go b/internal/fs-service/game-selection.go index 3b81485..54c1274 100644 --- a/internal/fs-service/game-selection.go +++ b/internal/fs-service/game-selection.go @@ -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 diff --git a/internal/git-service/server.go b/internal/git-service/server.go index 6652994..5c8e454 100644 --- a/internal/git-service/server.go +++ b/internal/git-service/server.go @@ -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 } diff --git a/main.go b/main.go index a6ac121..5b21339 100644 --- a/main.go +++ b/main.go @@ -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) } } diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..2beb152 --- /dev/null +++ b/pkg/logger/logger.go @@ -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 + } + } + }() +}