From ac893a1c19112cf210b5d4f2e43ed3fa9abc42c5 Mon Sep 17 00:00:00 2001 From: aomizu Date: Sun, 8 Jun 2025 21:21:33 +0900 Subject: [PATCH] added option key remap --- main_test.go | 10 - pkg/ui/components.go | 184 +++++++++++++ pkg/ui/popup.go | 6 +- pkg/ui/status.go | 5 + pkg/ui/variables.go | 13 + pkg/utils/prefs.go | 1 + pkg/utils/wine_registry.go | 440 ++++++++++++++++++++++++++++++++ winerosetta/libSiliconPatch.dll | Bin 91136 -> 90624 bytes 8 files changed, 647 insertions(+), 12 deletions(-) delete mode 100644 main_test.go create mode 100644 pkg/utils/wine_registry.go diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 64e753e..0000000 --- a/main_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -import ( - "testing" -) - -func TestMain(t *testing.T) { - // Basic test to ensure the main package can be imported and tested - t.Log("TurtleSilicon main package test passed") -} diff --git a/pkg/ui/components.go b/pkg/ui/components.go index d3f0a8f..77517fa 100644 --- a/pkg/ui/components.go +++ b/pkg/ui/components.go @@ -3,6 +3,8 @@ package ui import ( "log" "net/url" + "strings" + "time" "turtlesilicon/pkg/launcher" "turtlesilicon/pkg/patching" @@ -45,6 +47,9 @@ func createOptionsComponents() { vanillaTweaksCheckbox.SetChecked(prefs.EnableVanillaTweaks) launcher.EnableVanillaTweaks = prefs.EnableVanillaTweaks + // Create Wine registry Option-as-Alt buttons and status + createWineRegistryComponents() + // Load environment variables from preferences if prefs.EnvironmentVariables != "" { launcher.CustomEnvVars = prefs.EnvironmentVariables @@ -158,3 +163,182 @@ func createBottomBar(myWindow fyne.Window) fyne.CanvasObject { return container.NewPadded(bottomContainer) } + +// createWineRegistryComponents creates Wine registry Option-as-Alt buttons and status +func createWineRegistryComponents() { + // Create status label to show current state + optionAsAltStatusLabel = widget.NewRichText() + + // Create enable button + enableOptionAsAltButton = widget.NewButton("Enable", func() { + enableOptionAsAltButton.Disable() + disableOptionAsAltButton.Disable() + + // Show loading state in status label + fyne.Do(func() { + optionAsAltStatusLabel.ParseMarkdown("**Remap Option key as Alt key:** Enabling...") + startPulsingEffect() + }) + + // Run in goroutine to avoid blocking UI + go func() { + if err := utils.SetOptionAsAltEnabled(true); err != nil { + log.Printf("Failed to enable Option-as-Alt mapping: %v", err) + // Update UI on main thread + fyne.Do(func() { + stopPulsingEffect() + optionAsAltStatusLabel.ParseMarkdown("**Remap Option key as Alt key:** Enable Failed") + }) + time.Sleep(2 * time.Second) // Show error briefly + } else { + log.Printf("Successfully enabled Option-as-Alt mapping") + // Update preferences + prefs, _ := utils.LoadPrefs() + prefs.RemapOptionAsAlt = true + utils.SavePrefs(prefs) + } + + // Update UI on main thread + fyne.Do(func() { + stopPulsingEffect() + updateWineRegistryStatusWithMethod(true) // Use Wine command for accurate check after modifications + }) + }() + }) + + // Create disable button + disableOptionAsAltButton = widget.NewButton("Disable", func() { + enableOptionAsAltButton.Disable() + disableOptionAsAltButton.Disable() + + // Show loading state in status label + fyne.Do(func() { + optionAsAltStatusLabel.ParseMarkdown("**Remap Option key as Alt key:** Disabling...") + startPulsingEffect() + }) + + // Run in goroutine to avoid blocking UI + go func() { + if err := utils.SetOptionAsAltEnabled(false); err != nil { + log.Printf("Failed to disable Option-as-Alt mapping: %v", err) + // Update UI on main thread + fyne.Do(func() { + stopPulsingEffect() + optionAsAltStatusLabel.ParseMarkdown("**Remap Option key as Alt key:** Disable Failed") + }) + time.Sleep(2 * time.Second) // Show error briefly + } else { + log.Printf("Successfully disabled Option-as-Alt mapping") + // Update preferences + prefs, _ := utils.LoadPrefs() + prefs.RemapOptionAsAlt = false + utils.SavePrefs(prefs) + } + + // Update UI on main thread + fyne.Do(func() { + stopPulsingEffect() + updateWineRegistryStatusWithMethod(true) // Use Wine command for accurate check after modifications + }) + }() + }) + + // Style the buttons similar to other action buttons + enableOptionAsAltButton.Importance = widget.MediumImportance + disableOptionAsAltButton.Importance = widget.MediumImportance + + // Initialize status and button states + updateWineRegistryStatus() +} + +// updateWineRegistryStatus updates the Wine registry status label and button states +func updateWineRegistryStatus() { + updateWineRegistryStatusWithMethod(false) +} + +// updateWineRegistryStatusWithMethod updates status with choice of checking method +func updateWineRegistryStatusWithMethod(useWineCommand bool) { + if useWineCommand { + // Use Wine command for accurate check after modifications + currentWineRegistryEnabled = utils.CheckOptionAsAltEnabled() + } else { + // Use fast file-based check for regular status updates + currentWineRegistryEnabled = utils.CheckOptionAsAltEnabledFast() + } + + // Update UI with simple white text + if currentWineRegistryEnabled { + optionAsAltStatusLabel.ParseMarkdown("**Remap Option key as Alt key:** Enabled") + } else { + optionAsAltStatusLabel.ParseMarkdown("**Remap Option key as Alt key:** Disabled") + } + + // Update button states based on current status + if currentWineRegistryEnabled { + // If enabled, only show disable button as clickable + enableOptionAsAltButton.Disable() + disableOptionAsAltButton.Enable() + } else { + // If disabled, only show enable button as clickable + enableOptionAsAltButton.Enable() + disableOptionAsAltButton.Disable() + } +} + +// startPulsingEffect starts a pulsing animation for the status label during loading +func startPulsingEffect() { + if pulsingActive { + return // Already pulsing + } + + pulsingActive = true + pulsingTicker = time.NewTicker(500 * time.Millisecond) + + go func() { + dots := "" + + for pulsingActive { + select { + case <-pulsingTicker.C: + if pulsingActive { + // Cycle through different dot patterns for visual effect + switch len(dots) { + case 0: + dots = "." + case 1: + dots = ".." + case 2: + dots = "..." + default: + dots = "" + } + + // Update the label with pulsing dots + fyne.Do(func() { + if pulsingActive && optionAsAltStatusLabel != nil { + // Use the dots directly in the status text + if strings.Contains(optionAsAltStatusLabel.String(), "Enabling") { + optionAsAltStatusLabel.ParseMarkdown("**Remap Option key as Alt key:** Enabling" + dots) + } else if strings.Contains(optionAsAltStatusLabel.String(), "Disabling") { + optionAsAltStatusLabel.ParseMarkdown("**Remap Option key as Alt key:** Disabling" + dots) + } + } + }) + } + } + } + }() +} + +// stopPulsingEffect stops the pulsing animation +func stopPulsingEffect() { + if !pulsingActive { + return + } + + pulsingActive = false + if pulsingTicker != nil { + pulsingTicker.Stop() + pulsingTicker = nil + } +} diff --git a/pkg/ui/popup.go b/pkg/ui/popup.go index 18f7e2e..513bb95 100644 --- a/pkg/ui/popup.go +++ b/pkg/ui/popup.go @@ -21,6 +21,8 @@ func showOptionsPopup() { metalHudCheckbox, showTerminalCheckbox, vanillaTweaksCheckbox, + widget.NewSeparator(), + container.NewBorder(nil, nil, nil, container.NewHBox(enableOptionAsAltButton, disableOptionAsAltButton), optionAsAltStatusLabel), ) envVarsTitle := widget.NewLabel("Environment Variables") @@ -55,8 +57,8 @@ func showOptionsPopup() { // Get the window size and calculate 2/3 size windowSize := currentWindow.Content().Size() - popupWidth := windowSize.Width * 2 / 3 - popupHeight := windowSize.Height * 2 / 3 + popupWidth := windowSize.Width * 5 / 6 + popupHeight := windowSize.Height * 5 / 6 // Create a modal popup popup := widget.NewModalPopUp(popupContent, currentWindow.Canvas()) diff --git a/pkg/ui/status.go b/pkg/ui/status.go index 6dd6e16..8741443 100644 --- a/pkg/ui/status.go +++ b/pkg/ui/status.go @@ -25,6 +25,11 @@ func UpdateAllStatuses() { updateTurtleWoWStatus() updatePlayButtonState() updateServiceStatus() + + // Update Wine registry status if components are initialized + if optionAsAltStatusLabel != nil { + updateWineRegistryStatus() + } } // updateCrossoverStatus updates CrossOver path and patch status diff --git a/pkg/ui/variables.go b/pkg/ui/variables.go index f22c469..01c987d 100644 --- a/pkg/ui/variables.go +++ b/pkg/ui/variables.go @@ -1,6 +1,8 @@ package ui import ( + "time" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/widget" ) @@ -30,9 +32,20 @@ var ( showTerminalCheckbox *widget.Check vanillaTweaksCheckbox *widget.Check + // Wine registry buttons and status + enableOptionAsAltButton *widget.Button + disableOptionAsAltButton *widget.Button + optionAsAltStatusLabel *widget.RichText + // Environment variables entry envVarsEntry *widget.Entry // Window reference for popup functionality currentWindow fyne.Window + + // State variables + currentWineRegistryEnabled bool + + // Pulsing effect variables (pulsingActive is in status.go) + pulsingTicker *time.Ticker ) diff --git a/pkg/utils/prefs.go b/pkg/utils/prefs.go index 63f0da3..5bd0f4b 100644 --- a/pkg/utils/prefs.go +++ b/pkg/utils/prefs.go @@ -14,6 +14,7 @@ type UserPrefs struct { SaveSudoPassword bool `json:"save_sudo_password"` ShowTerminalNormally bool `json:"show_terminal_normally"` EnableVanillaTweaks bool `json:"enable_vanilla_tweaks"` + RemapOptionAsAlt bool `json:"remap_option_as_alt"` } func getPrefsPath() (string, error) { diff --git a/pkg/utils/wine_registry.go b/pkg/utils/wine_registry.go new file mode 100644 index 0000000..0943489 --- /dev/null +++ b/pkg/utils/wine_registry.go @@ -0,0 +1,440 @@ +package utils + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + wineRegistrySection = "[Software\\Wine\\Mac Driver]" + leftOptionKey = "\"LeftOptionIsAlt\"=\"Y\"" + rightOptionKey = "\"RightOptionIsAlt\"=\"Y\"" + wineLoaderPath = "/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/CrossOver-Hosted Application/wineloader2" + registryKeyPath = "HKEY_CURRENT_USER\\Software\\Wine\\Mac Driver" +) + +// GetWineUserRegPath returns the path to the Wine user.reg file +func GetWineUserRegPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %v", err) + } + return filepath.Join(homeDir, ".wine", "user.reg"), nil +} + +// queryRegistryValue queries a specific registry value using Wine's reg command +func queryRegistryValue(winePrefix, valueName string) bool { + cmd := exec.Command(wineLoaderPath, "reg", "query", registryKeyPath, "/v", valueName) + cmd.Env = append(os.Environ(), fmt.Sprintf("WINEPREFIX=%s", winePrefix)) + + output, err := cmd.Output() + if err != nil { + // Value doesn't exist or other error + return false + } + + // Check if the output contains the value set to "Y" + outputStr := string(output) + return strings.Contains(outputStr, valueName) && strings.Contains(outputStr, "Y") +} + +func setRegistryValuesOptimized(winePrefix string, enabled bool) error { + if enabled { + return addBothRegistryValues(winePrefix) + } else { + return deleteBothRegistryValues(winePrefix) + } +} + +func addBothRegistryValues(winePrefix string) error { + batchContent := fmt.Sprintf(`@echo off +reg add "%s" /v "LeftOptionIsAlt" /t REG_SZ /d "Y" /f +reg add "%s" /v "RightOptionIsAlt" /t REG_SZ /d "Y" /f +`, registryKeyPath, registryKeyPath) + + // Create temporary batch file + tempDir := os.TempDir() + batchFile := filepath.Join(tempDir, "wine_registry_add.bat") + + if err := os.WriteFile(batchFile, []byte(batchContent), 0644); err != nil { + return fmt.Errorf("failed to create batch file: %v", err) + } + defer os.Remove(batchFile) + + // Run the batch file with Wine + cmd := exec.Command(wineLoaderPath, "cmd", "/c", batchFile) + cmd.Env = append(os.Environ(), fmt.Sprintf("WINEPREFIX=%s", winePrefix)) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("batch registry add failed: %v, output: %s", err, string(output)) + } + + log.Printf("Successfully enabled Option-as-Alt mapping in Wine registry (optimized)") + return nil +} + +func deleteBothRegistryValues(winePrefix string) error { + batchContent := fmt.Sprintf(`@echo off +reg delete "%s" /v "LeftOptionIsAlt" /f 2>nul +reg delete "%s" /v "RightOptionIsAlt" /f 2>nul +`, registryKeyPath, registryKeyPath) + + // Create temporary batch file + tempDir := os.TempDir() + batchFile := filepath.Join(tempDir, "wine_registry_delete.bat") + + if err := os.WriteFile(batchFile, []byte(batchContent), 0644); err != nil { + return fmt.Errorf("failed to create batch file: %v", err) + } + defer os.Remove(batchFile) // Clean up + + // Run the batch file with Wine + cmd := exec.Command(wineLoaderPath, "cmd", "/c", batchFile) + cmd.Env = append(os.Environ(), fmt.Sprintf("WINEPREFIX=%s", winePrefix)) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("batch registry delete failed: %v, output: %s", err, string(output)) + } + + log.Printf("Successfully disabled Option-as-Alt mapping in Wine registry (optimized)") + return nil +} + +// CheckOptionAsAltEnabled checks if Option keys are remapped as Alt keys in Wine registry +func CheckOptionAsAltEnabled() bool { + homeDir, err := os.UserHomeDir() + if err != nil { + log.Printf("Failed to get user home directory: %v", err) + return false + } + + winePrefix := filepath.Join(homeDir, ".wine") + + // Check if CrossOver wine loader exists + if !PathExists(wineLoaderPath) { + log.Printf("CrossOver wine loader not found at: %s", wineLoaderPath) + return false + } + + // Query both registry values + leftEnabled := queryRegistryValue(winePrefix, "LeftOptionIsAlt") + rightEnabled := queryRegistryValue(winePrefix, "RightOptionIsAlt") + + return leftEnabled && rightEnabled +} + +// CheckOptionAsAltEnabledFast checks status by reading user.reg file directly +func CheckOptionAsAltEnabledFast() bool { + regPath, err := GetWineUserRegPath() + if err != nil { + log.Printf("Failed to get Wine registry path: %v", err) + return false + } + + if !PathExists(regPath) { + log.Printf("Wine user.reg file not found at: %s", regPath) + return false + } + + content, err := os.ReadFile(regPath) + if err != nil { + log.Printf("Failed to read Wine registry file: %v", err) + return false + } + + contentStr := string(content) + + // Check for the Mac Driver section in different possible formats + macDriverSectionFound := strings.Contains(contentStr, wineRegistrySection) || + strings.Contains(contentStr, "[SoftwareWineMac Driver]") + + if macDriverSectionFound { + // Look for the registry values in the proper format or any format + leftOptionFound := strings.Contains(contentStr, leftOptionKey) || + strings.Contains(contentStr, "\"LeftOptionIsAlt\"=\"Y\"") + rightOptionFound := strings.Contains(contentStr, rightOptionKey) || + strings.Contains(contentStr, "\"RightOptionIsAlt\"=\"Y\"") + + return leftOptionFound && rightOptionFound + } + + return false +} + +// SetOptionAsAltEnabled enables or disables Option key remapping in Wine registry +func SetOptionAsAltEnabled(enabled bool) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %v", err) + } + + winePrefix := filepath.Join(homeDir, ".wine") + + // Check if CrossOver wine loader exists + if !PathExists(wineLoaderPath) { + return fmt.Errorf("CrossOver wine loader not found at: %s", wineLoaderPath) + } + + // Ensure the .wine directory exists + if err := os.MkdirAll(winePrefix, 0755); err != nil { + return fmt.Errorf("failed to create .wine directory: %v", err) + } + + if enabled { + return setRegistryValuesOptimized(winePrefix, true) + } else { + err := setRegistryValuesOptimized(winePrefix, false) + if err != nil { + log.Printf("Wine registry disable failed: %v", err) + } + + err2 := setRegistryValuesFast(false) + if err2 != nil { + log.Printf("File-based cleanup failed: %v", err2) + } + + if err != nil { + return err + } + return err2 + } +} + +// setRegistryValuesFast directly modifies the user.reg file (much faster) +func setRegistryValuesFast(enabled bool) error { + regPath, err := GetWineUserRegPath() + if err != nil { + return fmt.Errorf("failed to get Wine registry path: %v", err) + } + + // Ensure the .wine directory exists + wineDir := filepath.Dir(regPath) + if err := os.MkdirAll(wineDir, 0755); err != nil { + return fmt.Errorf("failed to create .wine directory: %v", err) + } + + var content string + var lines []string + + // Read existing content if file exists + if PathExists(regPath) { + contentBytes, err := os.ReadFile(regPath) + if err != nil { + return fmt.Errorf("failed to read existing registry file: %v", err) + } + content = string(contentBytes) + lines = strings.Split(content, "\n") + } else { + // Create basic registry structure if file doesn't exist + content = "WINE REGISTRY Version 2\n;; All keys relative to \\User\n\n" + lines = strings.Split(content, "\n") + } + + if enabled { + return addOptionAsAltSettingsFast(regPath, lines) + } else { + return removeOptionAsAltSettingsFast(regPath, lines) + } +} + +// addOptionAsAltSettingsFast adds the Option-as-Alt registry settings directly to the file +func addOptionAsAltSettingsFast(regPath string, lines []string) error { + var newLines []string + sectionFound := false + sectionIndex := -1 + leftOptionFound := false + rightOptionFound := false + + // Find the Mac Driver section + for i, line := range lines { + if strings.TrimSpace(line) == wineRegistrySection { + sectionFound = true + sectionIndex = i + break + } + } + + if !sectionFound { + // Add the section at the end + newLines = append(lines, "") + newLines = append(newLines, wineRegistrySection) + newLines = append(newLines, "#time=1dbd859c084de18") + newLines = append(newLines, leftOptionKey) + newLines = append(newLines, rightOptionKey) + } else { + // Section exists, check if keys are already present + newLines = make([]string, len(lines)) + copy(newLines, lines) + + // Look for existing keys in the section + for i := sectionIndex + 1; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + if strings.HasPrefix(line, "[") && line != wineRegistrySection { + // Found start of another section, stop looking + break + } + if strings.Contains(line, "LeftOptionIsAlt") { + leftOptionFound = true + if !strings.Contains(line, "\"Y\"") { + newLines[i] = leftOptionKey + } + } + if strings.Contains(line, "RightOptionIsAlt") { + rightOptionFound = true + if !strings.Contains(line, "\"Y\"") { + newLines[i] = rightOptionKey + } + } + } + + // Add missing keys + if !leftOptionFound || !rightOptionFound { + insertIndex := sectionIndex + 1 + + // Add timestamp if it doesn't exist + timestampExists := false + for i := sectionIndex + 1; i < len(newLines); i++ { + if strings.HasPrefix(strings.TrimSpace(newLines[i]), "#time=") { + timestampExists = true + break + } + if strings.HasPrefix(strings.TrimSpace(newLines[i]), "[") && newLines[i] != wineRegistrySection { + break + } + } + + if !timestampExists { + timestampLine := "#time=1dbd859c084de18" + newLines = insertLine(newLines, insertIndex, timestampLine) + insertIndex++ + } + + if !leftOptionFound { + newLines = insertLine(newLines, insertIndex, leftOptionKey) + insertIndex++ + } + if !rightOptionFound { + newLines = insertLine(newLines, insertIndex, rightOptionKey) + } + } + } + + // Write the updated content + newContent := strings.Join(newLines, "\n") + if err := os.WriteFile(regPath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write registry file: %v", err) + } + + log.Printf("Successfully enabled Option-as-Alt mapping in Wine registry (fast method)") + return nil +} + +// removeOptionAsAltSettingsFast removes the Option-as-Alt registry settings directly from the file +func removeOptionAsAltSettingsFast(regPath string, lines []string) error { + if !PathExists(regPath) { + // File doesn't exist, nothing to remove + log.Printf("Successfully disabled Option-as-Alt mapping in Wine registry (no file to modify)") + return nil + } + + var newLines []string + + // Remove lines that contain our option key settings from any section + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Skip lines that contain our option key settings + if strings.Contains(trimmedLine, "LeftOptionIsAlt") || strings.Contains(trimmedLine, "RightOptionIsAlt") { + continue + } + + newLines = append(newLines, line) + } + + // Check if any Mac Driver sections are now empty and remove them + newLines = removeEmptyMacDriverSections(newLines) + + // Write the updated content + newContent := strings.Join(newLines, "\n") + if err := os.WriteFile(regPath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write registry file: %v", err) + } + + log.Printf("Successfully disabled Option-as-Alt mapping in Wine registry (fast method)") + return nil +} + +// removeEmptyMacDriverSections removes empty Mac Driver sections from the registry +func removeEmptyMacDriverSections(lines []string) []string { + var finalLines []string + i := 0 + + for i < len(lines) { + line := strings.TrimSpace(lines[i]) + + // Check if this is a Mac Driver section + if line == wineRegistrySection || line == "[SoftwareWineMac Driver]" { + // Check if the section is empty (only contains timestamp or nothing) + sectionStart := i + sectionEnd := i + 1 + sectionEmpty := true + + // Find the end of this section + for sectionEnd < len(lines) { + nextLine := strings.TrimSpace(lines[sectionEnd]) + if nextLine == "" { + sectionEnd++ + continue + } + if strings.HasPrefix(nextLine, "[") { + // Start of new section + break + } + if !strings.HasPrefix(nextLine, "#time=") { + // Found non-timestamp content in section + sectionEmpty = false + } + sectionEnd++ + } + + if sectionEmpty { + // Skip the entire empty section + i = sectionEnd + continue + } else { + // Keep the section + for j := sectionStart; j < sectionEnd; j++ { + finalLines = append(finalLines, lines[j]) + } + i = sectionEnd + continue + } + } + + finalLines = append(finalLines, lines[i]) + i++ + } + + return finalLines +} + +// insertLine inserts a line at the specified index +func insertLine(lines []string, index int, newLine string) []string { + if index >= len(lines) { + return append(lines, newLine) + } + + lines = append(lines, "") + copy(lines[index+1:], lines[index:]) + lines[index] = newLine + return lines +} diff --git a/winerosetta/libSiliconPatch.dll b/winerosetta/libSiliconPatch.dll index 1b980e4107d22a0ffdfb3d6bf41e3aee45beed1b..3f9ae850410ec9421aee6b4909395ad5cd1ecb02 100644 GIT binary patch delta 5945 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 R_>(;RtZ)lZNFHPj{|g&|9I5~S delta 6121 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