From 3558d3a2fc790a4d6d88d071bbce6c4f43130bfa Mon Sep 17 00:00:00 2001 From: Arch Dev Date: Wed, 18 Feb 2026 13:37:54 +0400 Subject: [PATCH 1/6] fix: improve butler debugging and fix Windows stdout handle error - Fix 'write /dev/stdout: The handle is invalid' error on Windows by not writing to os.Stdout/Stderr directly - Add --verbose flag to butler apply command for better output - Add comprehensive debug logging that writes to butler-debug.log on failure - Add TestButler() diagnostic function to help troubleshoot butler issues - Log environment variables and butler version before running commands --- internal/app/util.go | 108 ++++++++++++++++++++++++++++++++++ internal/patch/pwr_patcher.go | 63 +++++++++++++++++--- 2 files changed, 163 insertions(+), 8 deletions(-) diff --git a/internal/app/util.go b/internal/app/util.go index bfa93f4..c2dec83 100644 --- a/internal/app/util.go +++ b/internal/app/util.go @@ -1,10 +1,21 @@ package app import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" "regexp" + "runtime" + "time" + "HyLauncher/internal/env" + "HyLauncher/internal/patch" + "HyLauncher/internal/platform" "HyLauncher/internal/service" "HyLauncher/pkg/hyerrors" + "HyLauncher/pkg/logger" ) func (a *App) GetLogs() (string, error) { @@ -32,3 +43,100 @@ func (a *App) validatePlayerName(name string) error { return nil } + +type ButlerDiagnosticResult struct { + Success bool `json:"success"` + Version string `json:"version,omitempty"` + Error string `json:"error,omitempty"` + Details string `json:"details,omitempty"` + DebugLogPath string `json:"debugLogPath,omitempty"` +} + +func (a *App) TestButler() ButlerDiagnosticResult { + logger.Info("Starting Butler diagnostic test") + + butlerPath, err := patch.GetButlerExec() + if err != nil { + logger.Error("Butler diagnostic: cannot find butler", "error", err) + return ButlerDiagnosticResult{ + Success: false, + Error: fmt.Sprintf("Cannot find butler: %v", err), + } + } + + // Test 1: Check butler version + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + versionCmd := exec.CommandContext(ctx, butlerPath, "--version") + platform.HideConsoleWindow(versionCmd) + versionOutput, versionErr := versionCmd.CombinedOutput() + + versionStr := string(versionOutput) + if versionErr != nil { + logger.Error("Butler diagnostic: version check failed", "error", versionErr, "output", versionStr) + return ButlerDiagnosticResult{ + Success: false, + Error: fmt.Sprintf("Version check failed: %v", versionErr), + Details: versionStr, + } + } + + logger.Info("Butler diagnostic: version check passed", "version", versionStr) + + // Test 2: Check butler can access filesystem + testDir := filepath.Join(env.GetCacheDir(), "butler-test") + _ = os.RemoveAll(testDir) + _ = os.MkdirAll(testDir, 0755) + defer os.RemoveAll(testDir) + + // Create a simple test file + testFile := filepath.Join(testDir, "test.txt") + testContent := []byte("hello world") + if err := os.WriteFile(testFile, testContent, 0644); err != nil { + logger.Error("Butler diagnostic: failed to create test file", "error", err) + return ButlerDiagnosticResult{ + Success: false, + Version: versionStr, + Error: fmt.Sprintf("Failed to create test file: %v", err), + } + } + + // Test 3: Check environment + envInfo := fmt.Sprintf( + "OS: %s\nArch: %s\nGo Version: %s\nButler Path: %s\nTest Dir: %s\nPATH: %s\nTEMP: %s\nTMPDIR: %s", + runtime.GOOS, + runtime.GOARCH, + runtime.Version(), + butlerPath, + testDir, + os.Getenv("PATH"), + os.Getenv("TEMP"), + os.Getenv("TMPDIR"), + ) + + // Write comprehensive debug log + debugLogPath := filepath.Join(env.GetCacheDir(), "butler-diagnostic.log") + debugContent := fmt.Sprintf( + "=== BUTLER DIAGNOSTIC REPORT ===\n"+ + "Time: %s\n\n"+ + "=== ENVIRONMENT ===\n%s\n\n"+ + "=== VERSION OUTPUT ===\n%s\n", + time.Now().Format(time.RFC3339), + envInfo, + versionStr, + ) + + if err := os.WriteFile(debugLogPath, []byte(debugContent), 0644); err != nil { + logger.Warn("Butler diagnostic: failed to write debug log", "error", err) + } + + logger.Info("Butler diagnostic completed successfully", "version", versionStr, "debugLog", debugLogPath) + + return ButlerDiagnosticResult{ + Success: true, + Version: versionStr, + Details: envInfo, + DebugLogPath: debugLogPath, + } +} diff --git a/internal/patch/pwr_patcher.go b/internal/patch/pwr_patcher.go index ef1f08a..5b663af 100644 --- a/internal/patch/pwr_patcher.go +++ b/internal/patch/pwr_patcher.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "os" "os/exec" @@ -115,39 +114,87 @@ func applyPWR(ctx context.Context, pwrFile string, sigFile string, branch string return fmt.Errorf("cannot get butler: %w", err) } + versionCmd := exec.CommandContext(ctx, butlerPath, "--version") + platform.HideConsoleWindow(versionCmd) + versionOutput, versionErr := versionCmd.CombinedOutput() + logger.Info("Butler version check", "output", string(versionOutput), "error", versionErr) + logger.Info("Running butler apply", "pwr", pwrFile, "sig", sigFile, "gameDir", gameDir, "stagingDir", stagingDir) cmd := exec.CommandContext(ctx, butlerPath, "apply", "--staging-dir", stagingDir, "--signature", sigFile, + "--verbose", pwrFile, gameDir, ) platform.HideConsoleWindow(cmd) - // Capture output for logging + logger.Info("Butler environment", + "pwd", gameDir, + "path", os.Getenv("PATH"), + "temp", os.Getenv("TEMP"), + "tmpdir", os.Getenv("TMPDIR")) + var stdoutBuf, stderrBuf bytes.Buffer - cmd.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf) - cmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf if reporter != nil { reporter.Report(progress.StagePatch, 60, "Applying game patch...") } + logger.Info("Starting butler apply command", "args", cmd.Args) + if err := cmd.Run(); err != nil { _ = os.RemoveAll(stagingDir) stdoutStr := stdoutBuf.String() stderrStr := stderrBuf.String() - logger.Error("Butler apply failed", "error", err, "stdout", stdoutStr, "stderr", stderrStr) + + debugLogPath := filepath.Join(env.GetCacheDir(), "butler-debug.log") + debugContent := fmt.Sprintf( + "=== BUTLER DEBUG LOG ===\n"+ + "Time: %s\n"+ + "Command: %v\n"+ + "Working Dir: %s\n"+ + "Game Dir: %s\n"+ + "Staging Dir: %s\n"+ + "PWR File: %s\n"+ + "SIG File: %s\n"+ + "\n=== ENVIRONMENT ===\n"+ + "PATH=%s\n"+ + "TEMP=%s\n"+ + "TMPDIR=%s\n"+ + "\n=== STDOUT ===\n%s\n"+ + "\n=== STDERR ===\n%s\n"+ + "\n=== ERROR ===\n%v\n", + time.Now().Format(time.RFC3339), + cmd.Args, + gameDir, + gameDir, + stagingDir, + pwrFile, + sigFile, + os.Getenv("PATH"), + os.Getenv("TEMP"), + os.Getenv("TMPDIR"), + stdoutStr, + stderrStr, + err, + ) + _ = os.WriteFile(debugLogPath, []byte(debugContent), 0644) + logger.Error("Butler apply failed", "error", err, "stdout", stdoutStr, "stderr", stderrStr, "debugLog", debugLogPath) + if exitErr, ok := err.(*exec.ExitError); ok { - return fmt.Errorf("butler apply failed with exit code %d: stderr=%s", exitErr.ExitCode(), stderrStr) + return fmt.Errorf("butler apply failed with exit code %d: stderr=%s debugLog=%s", exitErr.ExitCode(), stderrStr, debugLogPath) } - return fmt.Errorf("butler apply failed: %w", err) + return fmt.Errorf("butler apply failed: %w (debug log: %s)", err, debugLogPath) } - logger.Info("Butler apply completed", "stdout", stdoutBuf.String()) + stdoutStr := stdoutBuf.String() + logger.Info("Butler apply completed", "stdout", stdoutStr) if cmd.Process != nil { _ = cmd.Process.Kill() From e0933c428e9c8c2888652ed94536de500fff727f Mon Sep 17 00:00:00 2001 From: Arch Dev Date: Wed, 18 Feb 2026 13:44:30 +0400 Subject: [PATCH 2/6] fix: auto-clean and retry when butler signature verification fails When users modify game files (add/remove files), butler's signature verification fails with 'expected X dirs, got Y dirs' error. This fix: - Detects signature verification failures by checking stderr output - Automatically cleans the game directory when signature mismatch is detected - Retries the patch application with a fresh directory - Provides clear error messages if cleanup or retry fails This prevents users from being stuck with a broken installation that requires manual intervention. --- internal/patch/pwr_patcher.go | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/internal/patch/pwr_patcher.go b/internal/patch/pwr_patcher.go index 5b663af..e930ce1 100644 --- a/internal/patch/pwr_patcher.go +++ b/internal/patch/pwr_patcher.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "strconv" + "strings" "time" "HyLauncher/internal/env" @@ -187,6 +188,54 @@ func applyPWR(ctx context.Context, pwrFile string, sigFile string, branch string _ = os.WriteFile(debugLogPath, []byte(debugContent), 0644) logger.Error("Butler apply failed", "error", err, "stdout", stdoutStr, "stderr", stderrStr, "debugLog", debugLogPath) + // Check if it's a signature verification error (user modified files) + if strings.Contains(stderrStr, "expected") && strings.Contains(stderrStr, "got") && strings.Contains(stderrStr, "dirs") { + logger.Warn("Signature verification failed - game files were modified, will clean and retry", "stderr", stderrStr) + + // Clean the game directory and retry once + logger.Info("Cleaning game directory for fresh install", "dir", gameDir) + if cleanErr := os.RemoveAll(gameDir); cleanErr != nil { + logger.Error("Failed to clean game directory", "error", cleanErr) + return fmt.Errorf("signature verification failed and cleanup failed: %w (original error: %v)", cleanErr, err) + } + + // Recreate the directory + if mkdirErr := os.MkdirAll(gameDir, 0755); mkdirErr != nil { + logger.Error("Failed to recreate game directory", "error", mkdirErr) + return fmt.Errorf("signature verification failed and directory recreation failed: %w (original error: %v)", mkdirErr, err) + } + + logger.Info("Game directory cleaned, retrying patch application") + + // Retry the patch application + retryCmd := exec.CommandContext(ctx, butlerPath, + "apply", + "--staging-dir", stagingDir, + "--signature", sigFile, + "--verbose", + pwrFile, + gameDir, + ) + platform.HideConsoleWindow(retryCmd) + + var retryStdoutBuf, retryStderrBuf bytes.Buffer + retryCmd.Stdout = &retryStdoutBuf + retryCmd.Stderr = &retryStderrBuf + + if reporter != nil { + reporter.Report(progress.StagePatch, 60, "Retrying after cleaning modified files...") + } + + if retryErr := retryCmd.Run(); retryErr != nil { + _ = os.RemoveAll(stagingDir) + logger.Error("Butler apply retry failed after cleanup", "error", retryErr, "stdout", retryStdoutBuf.String(), "stderr", retryStderrBuf.String()) + return fmt.Errorf("patch failed even after cleaning modified files: %w (original error: %v)", retryErr, err) + } + + logger.Info("Patch applied successfully after cleaning modified files") + stdoutStr = retryStdoutBuf.String() + } + if exitErr, ok := err.(*exec.ExitError); ok { return fmt.Errorf("butler apply failed with exit code %d: stderr=%s debugLog=%s", exitErr.ExitCode(), stderrStr, debugLogPath) } From 33fa3f2d4a514c79311d9f150fe256d85b6c7eb1 Mon Sep 17 00:00:00 2001 From: Arch Dev Date: Wed, 18 Feb 2026 13:47:26 +0400 Subject: [PATCH 3/6] fix: improve signature error detection to check both stdout and stderr The signature verification error message appears in different places depending on butler version. Now checks both stdout and stderr for signature errors. --- internal/patch/pwr_patcher.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/patch/pwr_patcher.go b/internal/patch/pwr_patcher.go index e930ce1..a10a979 100644 --- a/internal/patch/pwr_patcher.go +++ b/internal/patch/pwr_patcher.go @@ -189,8 +189,15 @@ func applyPWR(ctx context.Context, pwrFile string, sigFile string, branch string logger.Error("Butler apply failed", "error", err, "stdout", stdoutStr, "stderr", stderrStr, "debugLog", debugLogPath) // Check if it's a signature verification error (user modified files) - if strings.Contains(stderrStr, "expected") && strings.Contains(stderrStr, "got") && strings.Contains(stderrStr, "dirs") { - logger.Warn("Signature verification failed - game files were modified, will clean and retry", "stderr", stderrStr) + // The error can appear in stdout or stderr and has various formats + combinedOutput := stdoutStr + stderrStr + isSignatureError := strings.Contains(combinedOutput, "Verifying against signature") && + (strings.Contains(combinedOutput, "expected") || strings.Contains(combinedOutput, "dirs")) + + if isSignatureError { + logger.Warn("Signature verification failed - game files were modified, will clean and retry", + "stdout", stdoutStr, + "stderr", stderrStr) // Clean the game directory and retry once logger.Info("Cleaning game directory for fresh install", "dir", gameDir) From 85fe0a06e289d86ece789be30f93a2006f0ef351 Mon Sep 17 00:00:00 2001 From: Arch Dev Date: Wed, 18 Feb 2026 13:51:05 +0400 Subject: [PATCH 4/6] fix: also clean staging directory when retrying after signature failure Butler keeps resume state in the staging directory. When we clean the game directory, we also need to clean the staging directory to prevent butler from trying to resume from a corrupted state. --- internal/patch/pwr_patcher.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/patch/pwr_patcher.go b/internal/patch/pwr_patcher.go index a10a979..2873c07 100644 --- a/internal/patch/pwr_patcher.go +++ b/internal/patch/pwr_patcher.go @@ -206,12 +206,24 @@ func applyPWR(ctx context.Context, pwrFile string, sigFile string, branch string return fmt.Errorf("signature verification failed and cleanup failed: %w (original error: %v)", cleanErr, err) } - // Recreate the directory + // Also clean the staging directory to remove any resume state + logger.Info("Cleaning staging directory", "dir", stagingDir) + if cleanErr := os.RemoveAll(stagingDir); cleanErr != nil { + logger.Error("Failed to clean staging directory", "error", cleanErr) + return fmt.Errorf("signature verification failed and staging cleanup failed: %w (original error: %v)", cleanErr, err) + } + + // Recreate the directories if mkdirErr := os.MkdirAll(gameDir, 0755); mkdirErr != nil { logger.Error("Failed to recreate game directory", "error", mkdirErr) return fmt.Errorf("signature verification failed and directory recreation failed: %w (original error: %v)", mkdirErr, err) } + if mkdirErr := os.MkdirAll(stagingDir, 0755); mkdirErr != nil { + logger.Error("Failed to recreate staging directory", "error", mkdirErr) + return fmt.Errorf("signature verification failed and staging directory recreation failed: %w (original error: %v)", mkdirErr, err) + } + logger.Info("Game directory cleaned, retrying patch application") // Retry the patch application From 3043d7d11ae585a9f9bcc557149cc0e7187df5ed Mon Sep 17 00:00:00 2001 From: Arch Dev Date: Wed, 18 Feb 2026 13:55:19 +0400 Subject: [PATCH 5/6] refactor: clean up excessive debugging while keeping essential error handling - Remove verbose logging of every file in game directory - Remove butler version check on every patch (not needed) - Remove environment variable logging - Simplify debug log format to essential info only - Keep signature error detection and auto-retry logic - Keep debug log writing for troubleshooting failures --- internal/patch/pwr_patcher.go | 111 ++++++---------------------------- 1 file changed, 18 insertions(+), 93 deletions(-) diff --git a/internal/patch/pwr_patcher.go b/internal/patch/pwr_patcher.go index 2873c07..9cb6ac2 100644 --- a/internal/patch/pwr_patcher.go +++ b/internal/patch/pwr_patcher.go @@ -56,9 +56,6 @@ func DownloadAndApplyPWR(ctx context.Context, branch string, currentVer int, tar } logger.Info("Found patch steps", "count", len(steps), "branch", branch) - for i, step := range steps { - logger.Info(" Step", "index", i, "from", step.From, "to", step.To) - } for i, step := range steps { if targetVer > 0 && step.From >= targetVer { @@ -103,42 +100,21 @@ func applyPWR(ctx context.Context, pwrFile string, sigFile string, branch string _ = os.MkdirAll(gameDir, 0755) _ = os.MkdirAll(stagingDir, 0755) - // Log what files are currently in the game directory - logger.Info("Game directory contents before patch", "dir", gameDir) - entries, _ := os.ReadDir(gameDir) - for _, entry := range entries { - logger.Info(" File", "name", entry.Name(), "isDir", entry.IsDir()) - } - butlerPath, err := GetButlerExec() if err != nil { return fmt.Errorf("cannot get butler: %w", err) } - versionCmd := exec.CommandContext(ctx, butlerPath, "--version") - platform.HideConsoleWindow(versionCmd) - versionOutput, versionErr := versionCmd.CombinedOutput() - logger.Info("Butler version check", "output", string(versionOutput), "error", versionErr) - - logger.Info("Running butler apply", "pwr", pwrFile, "sig", sigFile, "gameDir", gameDir, "stagingDir", stagingDir) - cmd := exec.CommandContext(ctx, butlerPath, "apply", "--staging-dir", stagingDir, "--signature", sigFile, - "--verbose", pwrFile, gameDir, ) platform.HideConsoleWindow(cmd) - logger.Info("Butler environment", - "pwd", gameDir, - "path", os.Getenv("PATH"), - "temp", os.Getenv("TEMP"), - "tmpdir", os.Getenv("TMPDIR")) - var stdoutBuf, stderrBuf bytes.Buffer cmd.Stdout = &stdoutBuf cmd.Stderr = &stderrBuf @@ -147,91 +123,47 @@ func applyPWR(ctx context.Context, pwrFile string, sigFile string, branch string reporter.Report(progress.StagePatch, 60, "Applying game patch...") } - logger.Info("Starting butler apply command", "args", cmd.Args) - if err := cmd.Run(); err != nil { _ = os.RemoveAll(stagingDir) stdoutStr := stdoutBuf.String() stderrStr := stderrBuf.String() - debugLogPath := filepath.Join(env.GetCacheDir(), "butler-debug.log") + // Write debug log for troubleshooting + debugLogPath := filepath.Join(env.GetCacheDir(), fmt.Sprintf("butler-debug-%d.log", time.Now().Unix())) debugContent := fmt.Sprintf( - "=== BUTLER DEBUG LOG ===\n"+ - "Time: %s\n"+ - "Command: %v\n"+ - "Working Dir: %s\n"+ - "Game Dir: %s\n"+ - "Staging Dir: %s\n"+ - "PWR File: %s\n"+ - "SIG File: %s\n"+ - "\n=== ENVIRONMENT ===\n"+ - "PATH=%s\n"+ - "TEMP=%s\n"+ - "TMPDIR=%s\n"+ - "\n=== STDOUT ===\n%s\n"+ - "\n=== STDERR ===\n%s\n"+ - "\n=== ERROR ===\n%v\n", + "Time: %s\nCommand: %v\nGame Dir: %s\n\nSTDOUT:\n%s\n\nSTDERR:\n%s\n\nError: %v\n", time.Now().Format(time.RFC3339), cmd.Args, gameDir, - gameDir, - stagingDir, - pwrFile, - sigFile, - os.Getenv("PATH"), - os.Getenv("TEMP"), - os.Getenv("TMPDIR"), stdoutStr, stderrStr, err, ) _ = os.WriteFile(debugLogPath, []byte(debugContent), 0644) - logger.Error("Butler apply failed", "error", err, "stdout", stdoutStr, "stderr", stderrStr, "debugLog", debugLogPath) // Check if it's a signature verification error (user modified files) - // The error can appear in stdout or stderr and has various formats combinedOutput := stdoutStr + stderrStr isSignatureError := strings.Contains(combinedOutput, "Verifying against signature") && (strings.Contains(combinedOutput, "expected") || strings.Contains(combinedOutput, "dirs")) if isSignatureError { - logger.Warn("Signature verification failed - game files were modified, will clean and retry", - "stdout", stdoutStr, - "stderr", stderrStr) - - // Clean the game directory and retry once - logger.Info("Cleaning game directory for fresh install", "dir", gameDir) - if cleanErr := os.RemoveAll(gameDir); cleanErr != nil { - logger.Error("Failed to clean game directory", "error", cleanErr) - return fmt.Errorf("signature verification failed and cleanup failed: %w (original error: %v)", cleanErr, err) - } + logger.Warn("Signature verification failed - cleaning and retrying", "gameDir", gameDir) - // Also clean the staging directory to remove any resume state - logger.Info("Cleaning staging directory", "dir", stagingDir) - if cleanErr := os.RemoveAll(stagingDir); cleanErr != nil { - logger.Error("Failed to clean staging directory", "error", cleanErr) - return fmt.Errorf("signature verification failed and staging cleanup failed: %w (original error: %v)", cleanErr, err) - } + // Clean both directories to remove any resume state + _ = os.RemoveAll(gameDir) + _ = os.RemoveAll(stagingDir) + _ = os.MkdirAll(gameDir, 0755) + _ = os.MkdirAll(stagingDir, 0755) - // Recreate the directories - if mkdirErr := os.MkdirAll(gameDir, 0755); mkdirErr != nil { - logger.Error("Failed to recreate game directory", "error", mkdirErr) - return fmt.Errorf("signature verification failed and directory recreation failed: %w (original error: %v)", mkdirErr, err) - } - - if mkdirErr := os.MkdirAll(stagingDir, 0755); mkdirErr != nil { - logger.Error("Failed to recreate staging directory", "error", mkdirErr) - return fmt.Errorf("signature verification failed and staging directory recreation failed: %w (original error: %v)", mkdirErr, err) + if reporter != nil { + reporter.Report(progress.StagePatch, 60, "Retrying after cleaning modified files...") } - logger.Info("Game directory cleaned, retrying patch application") - - // Retry the patch application + // Retry retryCmd := exec.CommandContext(ctx, butlerPath, "apply", "--staging-dir", stagingDir, "--signature", sigFile, - "--verbose", pwrFile, gameDir, ) @@ -241,29 +173,22 @@ func applyPWR(ctx context.Context, pwrFile string, sigFile string, branch string retryCmd.Stdout = &retryStdoutBuf retryCmd.Stderr = &retryStderrBuf - if reporter != nil { - reporter.Report(progress.StagePatch, 60, "Retrying after cleaning modified files...") - } - if retryErr := retryCmd.Run(); retryErr != nil { _ = os.RemoveAll(stagingDir) - logger.Error("Butler apply retry failed after cleanup", "error", retryErr, "stdout", retryStdoutBuf.String(), "stderr", retryStderrBuf.String()) - return fmt.Errorf("patch failed even after cleaning modified files: %w (original error: %v)", retryErr, err) + logger.Error("Retry failed after cleanup", "error", retryErr) + return fmt.Errorf("patch failed even after cleanup: %w (original: %v)", retryErr, err) } - logger.Info("Patch applied successfully after cleaning modified files") - stdoutStr = retryStdoutBuf.String() + logger.Info("Patch applied successfully after cleanup") + return nil } if exitErr, ok := err.(*exec.ExitError); ok { - return fmt.Errorf("butler apply failed with exit code %d: stderr=%s debugLog=%s", exitErr.ExitCode(), stderrStr, debugLogPath) + return fmt.Errorf("butler apply failed (exit %d): %s (log: %s)", exitErr.ExitCode(), stderrStr, debugLogPath) } - return fmt.Errorf("butler apply failed: %w (debug log: %s)", err, debugLogPath) + return fmt.Errorf("butler apply failed: %w (log: %s)", err, debugLogPath) } - stdoutStr := stdoutBuf.String() - logger.Info("Butler apply completed", "stdout", stdoutStr) - if cmd.Process != nil { _ = cmd.Process.Kill() _ = cmd.Process.Release() From 845ca817e0278796d606d62cf25496e1ba81085a Mon Sep 17 00:00:00 2001 From: Arch Dev Date: Wed, 18 Feb 2026 13:57:12 +0400 Subject: [PATCH 6/6] refactor: extract functions for cleaner code structure - Extract handleApplyError for error handling logic - Extract isSignatureError for signature detection - Extract retryAfterCleanup for retry logic - Extract cleanup for resource cleanup - Extract writeDebugLog for debug file creation - Remove redundant error logging (errors are returned and logged upstream) - Simplify DownloadAndApplyPWR by removing verbose logging --- internal/patch/pwr_patcher.go | 168 ++++++++++++++++------------------ 1 file changed, 81 insertions(+), 87 deletions(-) diff --git a/internal/patch/pwr_patcher.go b/internal/patch/pwr_patcher.go index 9cb6ac2..51d1921 100644 --- a/internal/patch/pwr_patcher.go +++ b/internal/patch/pwr_patcher.go @@ -42,61 +42,46 @@ type PatchStepsResponse struct { func DownloadAndApplyPWR(ctx context.Context, branch string, currentVer int, targetVer int, versionDir string, reporter *progress.Reporter) error { logger.Info("Starting patch download", "branch", branch, "from", currentVer, "to", targetVer) - var pwrPath string - steps, err := fetchPatchSteps(ctx, branch, currentVer) if err != nil { - logger.Error("Failed to fetch patch steps", "branch", branch, "error", err) return fmt.Errorf("fetch patch steps: %w", err) } if len(steps) == 0 { - logger.Warn("No patch steps available", "branch", branch, "currentVer", currentVer) return fmt.Errorf("no patch steps available") } - logger.Info("Found patch steps", "count", len(steps), "branch", branch) - for i, step := range steps { if targetVer > 0 && step.From >= targetVer { - logger.Info("Reached target version, stopping", "target", targetVer, "current", step.From) break } logger.Info("Downloading patch", "from", step.From, "to", step.To, "progress", fmt.Sprintf("%d/%d", i+1, len(steps))) - if reporter != nil { reporter.Report(progress.StagePatch, 0, fmt.Sprintf("Patching %d → %d (%d/%d)", step.From, step.To, i+1, len(steps))) } pwrPath, sigPath, err := downloadPatchStep(ctx, step, reporter) if err != nil { - logger.Error("Failed to download patch", "from", step.From, "to", step.To, "error", err) return fmt.Errorf("download patch step %d→%d: %w", step.From, step.To, err) } - logger.Info("Applying patch", "from", step.From, "to", step.To) if err := applyPWR(ctx, pwrPath, sigPath, branch, versionDir, reporter); err != nil { _ = os.Remove(pwrPath) _ = os.Remove(sigPath) - logger.Error("Failed to apply patch", "from", step.From, "to", step.To, "error", err) return fmt.Errorf("apply patch %d→%d: %w", step.From, step.To, err) } - - logger.Info("Patch applied successfully", "from", step.From, "to", step.To) } - _ = os.RemoveAll(pwrPath) logger.Info("All patches applied", "totalSteps", len(steps)) - return nil } -func applyPWR(ctx context.Context, pwrFile string, sigFile string, branch string, version string, reporter *progress.Reporter) error { +func applyPWR(ctx context.Context, pwrFile, sigFile, branch, version string, reporter *progress.Reporter) error { gameDir := env.GetGameDir(branch, version) stagingDir := filepath.Join(env.GetCacheDir(), "staging-temp") - _ = os.RemoveAll(stagingDir) + _ = os.RemoveAll(stagingDir) _ = os.MkdirAll(gameDir, 0755) _ = os.MkdirAll(stagingDir, 0755) @@ -112,7 +97,6 @@ func applyPWR(ctx context.Context, pwrFile string, sigFile string, branch string pwrFile, gameDir, ) - platform.HideConsoleWindow(cmd) var stdoutBuf, stderrBuf bytes.Buffer @@ -124,82 +108,93 @@ func applyPWR(ctx context.Context, pwrFile string, sigFile string, branch string } if err := cmd.Run(); err != nil { - _ = os.RemoveAll(stagingDir) - stdoutStr := stdoutBuf.String() - stderrStr := stderrBuf.String() - - // Write debug log for troubleshooting - debugLogPath := filepath.Join(env.GetCacheDir(), fmt.Sprintf("butler-debug-%d.log", time.Now().Unix())) - debugContent := fmt.Sprintf( - "Time: %s\nCommand: %v\nGame Dir: %s\n\nSTDOUT:\n%s\n\nSTDERR:\n%s\n\nError: %v\n", - time.Now().Format(time.RFC3339), - cmd.Args, - gameDir, - stdoutStr, - stderrStr, - err, - ) - _ = os.WriteFile(debugLogPath, []byte(debugContent), 0644) - - // Check if it's a signature verification error (user modified files) - combinedOutput := stdoutStr + stderrStr - isSignatureError := strings.Contains(combinedOutput, "Verifying against signature") && - (strings.Contains(combinedOutput, "expected") || strings.Contains(combinedOutput, "dirs")) - - if isSignatureError { - logger.Warn("Signature verification failed - cleaning and retrying", "gameDir", gameDir) - - // Clean both directories to remove any resume state - _ = os.RemoveAll(gameDir) - _ = os.RemoveAll(stagingDir) - _ = os.MkdirAll(gameDir, 0755) - _ = os.MkdirAll(stagingDir, 0755) + return handleApplyError(ctx, err, cmd, stdoutBuf.String(), stderrBuf.String(), gameDir, stagingDir, butlerPath, sigFile, pwrFile, reporter) + } - if reporter != nil { - reporter.Report(progress.StagePatch, 60, "Retrying after cleaning modified files...") - } + cleanup(cmd, stagingDir, reporter) + return nil +} - // Retry - retryCmd := exec.CommandContext(ctx, butlerPath, - "apply", - "--staging-dir", stagingDir, - "--signature", sigFile, - pwrFile, - gameDir, - ) - platform.HideConsoleWindow(retryCmd) - - var retryStdoutBuf, retryStderrBuf bytes.Buffer - retryCmd.Stdout = &retryStdoutBuf - retryCmd.Stderr = &retryStderrBuf - - if retryErr := retryCmd.Run(); retryErr != nil { - _ = os.RemoveAll(stagingDir) - logger.Error("Retry failed after cleanup", "error", retryErr) - return fmt.Errorf("patch failed even after cleanup: %w (original: %v)", retryErr, err) - } +func handleApplyError(ctx context.Context, err error, cmd *exec.Cmd, stdoutStr, stderrStr, gameDir, stagingDir, butlerPath, sigFile, pwrFile string, reporter *progress.Reporter) error { + _ = os.RemoveAll(stagingDir) - logger.Info("Patch applied successfully after cleanup") - return nil - } + debugLogPath := writeDebugLog(cmd.Args, gameDir, stdoutStr, stderrStr, err) - if exitErr, ok := err.(*exec.ExitError); ok { - return fmt.Errorf("butler apply failed (exit %d): %s (log: %s)", exitErr.ExitCode(), stderrStr, debugLogPath) - } - return fmt.Errorf("butler apply failed: %w (log: %s)", err, debugLogPath) + if isSignatureError(stdoutStr, stderrStr) { + return retryAfterCleanup(ctx, gameDir, stagingDir, butlerPath, sigFile, pwrFile, reporter, err) } + if exitErr, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("butler apply failed (exit %d): %s (log: %s)", exitErr.ExitCode(), stderrStr, debugLogPath) + } + return fmt.Errorf("butler apply failed: %w (log: %s)", err, debugLogPath) +} + +func isSignatureError(stdout, stderr string) bool { + combined := stdout + stderr + return strings.Contains(combined, "Verifying against signature") && + (strings.Contains(combined, "expected") || strings.Contains(combined, "dirs")) +} + +func retryAfterCleanup(ctx context.Context, gameDir, stagingDir, butlerPath, sigFile, pwrFile string, reporter *progress.Reporter, originalErr error) error { + logger.Warn("Signature verification failed - cleaning and retrying", "gameDir", gameDir) + + _ = os.RemoveAll(gameDir) + _ = os.RemoveAll(stagingDir) + _ = os.MkdirAll(gameDir, 0755) + _ = os.MkdirAll(stagingDir, 0755) + + if reporter != nil { + reporter.Report(progress.StagePatch, 60, "Retrying after cleaning modified files...") + } + + retryCmd := exec.CommandContext(ctx, butlerPath, + "apply", + "--staging-dir", stagingDir, + "--signature", sigFile, + pwrFile, + gameDir, + ) + platform.HideConsoleWindow(retryCmd) + + var stdoutBuf, stderrBuf bytes.Buffer + retryCmd.Stdout = &stdoutBuf + retryCmd.Stderr = &stderrBuf + + if err := retryCmd.Run(); err != nil { + _ = os.RemoveAll(stagingDir) + return fmt.Errorf("patch failed even after cleanup: %w (original: %v)", err, originalErr) + } + + logger.Info("Patch applied successfully after cleanup") + cleanup(retryCmd, stagingDir, reporter) + return nil +} + +func cleanup(cmd *exec.Cmd, stagingDir string, reporter *progress.Reporter) { if cmd.Process != nil { _ = cmd.Process.Kill() _ = cmd.Process.Release() } - _ = os.RemoveAll(stagingDir) - if reporter != nil { reporter.Report(progress.StagePatch, 80, "Game patched!") } - return nil +} + +func writeDebugLog(args []string, gameDir, stdout, stderr string, err error) string { + debugLogPath := filepath.Join(env.GetCacheDir(), fmt.Sprintf("butler-debug-%d.log", time.Now().Unix())) + content := fmt.Sprintf( + "Time: %s\nCommand: %v\nGame Dir: %s\n\nSTDOUT:\n%s\n\nSTDERR:\n%s\n\nError: %v\n", + time.Now().Format(time.RFC3339), + args, + gameDir, + stdout, + stderr, + err, + ) + _ = os.WriteFile(debugLogPath, []byte(content), 0644) + return debugLogPath } func fetchPatchSteps(ctx context.Context, branch string, currentVer int) ([]PatchStep, error) { @@ -219,7 +214,6 @@ func fetchPatchSteps(ctx context.Context, branch string, currentVer int) ([]Patc if err != nil { return nil, fmt.Errorf("create request: %w", err) } - req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} @@ -251,13 +245,13 @@ func downloadPatchStep(ctx context.Context, step PatchStep, reporter *progress.R pwrDest := filepath.Join(cacheDir, pwrFileName) sigDest := filepath.Join(cacheDir, sigFileName) - _, pwrErr := os.Stat(pwrDest) - _, sigErr := os.Stat(sigDest) - if pwrErr == nil && sigErr == nil { - if reporter != nil { - reporter.Report(progress.StagePWR, 100, "Patch files cached") + if _, pwrErr := os.Stat(pwrDest); pwrErr == nil { + if _, sigErr := os.Stat(sigDest); sigErr == nil { + if reporter != nil { + reporter.Report(progress.StagePWR, 100, "Patch files cached") + } + return pwrDest, sigDest, nil } - return pwrDest, sigDest, nil } if reporter != nil {