diff --git a/.gitignore b/.gitignore index 6717be6..51df94b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store TurtleSilicon.* TurtleSilicon.app/Contents/MacOS/turtlesilicon -*.dmg \ No newline at end of file +*.dmg +turtlesilicon \ No newline at end of file diff --git a/FyneApp.toml b/FyneApp.toml index 528f447..1a87ce7 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -3,4 +3,4 @@ Name = "TurtleSilicon" ID = "com.tairasu.turtlesilicon" Version = "1.2.1" - Build = 37 + Build = 47 diff --git a/main.go b/main.go index 35075f2..7d4c114 100644 --- a/main.go +++ b/main.go @@ -10,9 +10,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/widget" ) const appVersion = "1.2.1" @@ -26,29 +23,28 @@ func main() { // Check for updates go func() { prefs, _ := utils.LoadPrefs() - latest, notes, update, err := utils.CheckForUpdate(appVersion) - debug.Printf("DEBUG RAW: latest=%q", latest) - latestVersion := strings.TrimLeft(latest, "v.") - debug.Printf("DEBUG: appVersion=%q, latest=%q, latestVersion=%q, suppressed=%q, update=%v, err=%v\n", - appVersion, latest, latestVersion, prefs.SuppressedUpdateVersion, update, err) - // Always skip popup if versions match - if latestVersion == appVersion { + updateInfo, updateAvailable, err := utils.CheckForUpdateWithAssets(appVersion) + if err != nil { + debug.Printf("Failed to check for updates: %v", err) return } - if err == nil && update && prefs.SuppressedUpdateVersion != latestVersion { - checkbox := widget.NewCheck("Do not show this anymore", func(bool) {}) - content := container.NewVBox( - widget.NewLabel("A new version ("+latestVersion+") is available!"), - widget.NewLabel("Release notes:\n\n"+notes), - checkbox, - ) - dialog.ShowCustomConfirm("Update Available", "OK", "Cancel", content, func(ok bool) { - if checkbox.Checked { - prefs.SuppressedUpdateVersion = latestVersion - utils.SavePrefs(prefs) - } - }, TSWindow) + + if !updateAvailable { + debug.Printf("No updates available") + return } + + latestVersion := strings.TrimPrefix(updateInfo.TagName, "v") + debug.Printf("Update available: current=%s, latest=%s", appVersion, latestVersion) + + // Skip if user has suppressed this version + if prefs.SuppressedUpdateVersion == latestVersion { + debug.Printf("Update suppressed by user: %s", latestVersion) + return + } + + // Show enhanced update dialog + ui.ShowUpdateDialog(updateInfo, appVersion, TSWindow) }() content := ui.CreateUI(TSWindow) diff --git a/pkg/ui/updater.go b/pkg/ui/updater.go new file mode 100644 index 0000000..d7955f2 --- /dev/null +++ b/pkg/ui/updater.go @@ -0,0 +1,187 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "turtlesilicon/pkg/debug" + "turtlesilicon/pkg/utils" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/widget" +) + +// ShowUpdateDialog displays an enhanced update dialog with download and install options +func ShowUpdateDialog(updateInfo *utils.UpdateInfo, currentVersion string, myWindow fyne.Window) { + latestVersion := strings.TrimPrefix(updateInfo.TagName, "v") + + // Find the DMG asset + var dmgAsset *utils.Asset + for _, asset := range updateInfo.Assets { + if strings.HasSuffix(asset.Name, ".dmg") { + dmgAsset = &asset + break + } + } + + if dmgAsset == nil { + dialog.ShowError(fmt.Errorf("no DMG file found in the latest release"), myWindow) + return + } + + // Create content for the update dialog + titleLabel := widget.NewLabel(fmt.Sprintf("Update Available: v%s", latestVersion)) + titleLabel.TextStyle = fyne.TextStyle{Bold: true} + + currentVersionLabel := widget.NewLabel(fmt.Sprintf("Current version: v%s", currentVersion)) + + // Format file size + fileSize := formatFileSize(dmgAsset.Size) + fileSizeLabel := widget.NewLabel(fmt.Sprintf("Download size: %s", fileSize)) + + // Release notes + notesLabel := widget.NewLabel("Release notes:") + notesLabel.TextStyle = fyne.TextStyle{Bold: true} + notesText := widget.NewRichTextFromMarkdown(updateInfo.Body) + notesScroll := container.NewScroll(notesText) + notesScroll.SetMinSize(fyne.NewSize(480, 120)) + + // Progress bar (initially hidden) + progressBar := widget.NewProgressBar() + progressBar.Hide() + + progressLabel := widget.NewLabel("") + progressLabel.Hide() + + // Checkbox for suppressing this version + suppressCheck := widget.NewCheck("Don't show this update again", nil) + + content := container.NewVBox( + titleLabel, + currentVersionLabel, + fileSizeLabel, + widget.NewSeparator(), + notesLabel, + notesScroll, + widget.NewSeparator(), + progressBar, + progressLabel, + suppressCheck, + ) + + // Create custom dialog + d := dialog.NewCustom("New Update Available", "", content, myWindow) + d.Resize(fyne.NewSize(550, 400)) + + // Download and install function + downloadAndInstall := func() { + // Show progress elements + progressBar.Show() + progressLabel.Show() + progressLabel.SetText("Starting download...") + + // Disable dialog closing during download + d.SetButtons([]fyne.CanvasObject{}) + + go func() { + // Download with progress + downloadPath, err := utils.DownloadUpdate(dmgAsset.BrowserDownloadURL, func(downloaded, total int64) { + // Update progress on UI thread + fyne.NewAnimation( + time.Millisecond*50, + func(float32) { + if total > 0 { + progress := float64(downloaded) / float64(total) + progressBar.SetValue(progress) + progressLabel.SetText(fmt.Sprintf("Downloaded: %s / %s (%.1f%%)", + formatFileSize(downloaded), formatFileSize(total), progress*100)) + } + }, + ).Curve = fyne.AnimationLinear + }) + + if err != nil { + progressLabel.SetText(fmt.Sprintf("Download failed: %v", err)) + debug.Printf("Download failed: %v", err) + + // Re-enable close button + d.SetButtons([]fyne.CanvasObject{ + widget.NewButton("Close", func() { d.Hide() }), + }) + return + } + + progressLabel.SetText("Installing update...") + progressBar.SetValue(1.0) + + // Install update + err = utils.InstallUpdate(downloadPath) + if err != nil { + progressLabel.SetText(fmt.Sprintf("Installation failed: %v", err)) + debug.Printf("Installation failed: %v", err) + + // Re-enable close button + d.SetButtons([]fyne.CanvasObject{ + widget.NewButton("Close", func() { d.Hide() }), + }) + return + } + + // Success - show restart dialog + progressLabel.SetText("Update installed successfully!") + + restartDialog := dialog.NewConfirm( + "Update Complete", + "The update has been installed successfully and will require a restart. Would you like to close the application now?", + func(restart bool) { + d.Hide() + if restart { + utils.RestartApp() + fyne.CurrentApp().Quit() + } + }, + myWindow, + ) + restartDialog.Show() + }() + } + + // Set dialog buttons + d.SetButtons([]fyne.CanvasObject{ + widget.NewButton("Download & Install", downloadAndInstall), + widget.NewButton("Later", func() { + if suppressCheck.Checked { + // Save suppressed version + prefs, _ := utils.LoadPrefs() + prefs.SuppressedUpdateVersion = latestVersion + utils.SavePrefs(prefs) + } + d.Hide() + }), + }) + + d.Show() +} + +// formatFileSize formats a file size in bytes to a human-readable string +func formatFileSize(bytes int64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + + switch { + case bytes >= GB: + return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d bytes", bytes) + } +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 2321287..c07b809 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -17,6 +17,14 @@ import ( "fyne.io/fyne/v2/dialog" ) +// min returns the smaller of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + // PathExists checks if a path exists. func PathExists(path string) bool { _, err := os.Stat(path) @@ -140,36 +148,430 @@ func CheckForUpdate(currentVersion string) (latestVersion, releaseNotes string, return latest, data.Body, latest != currentVersion, nil } -// GetBundledResourceSize returns the size of a bundled resource -func GetBundledResourceSize(resourcePath string) (int64, error) { - resource, err := fyne.LoadResourceFromPath(resourcePath) - if err != nil { - return 0, fmt.Errorf("failed to load bundled resource %s: %v", resourcePath, err) - } - return int64(len(resource.Content())), nil +// UpdateInfo contains information about the latest release +type UpdateInfo struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + Assets []Asset `json:"assets"` } -// CompareFileWithBundledResource compares the size of a file with a bundled resource -func CompareFileWithBundledResource(filePath, resourcePath string) bool { +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +// CheckForUpdateWithAssets returns update information including download assets +func CheckForUpdateWithAssets(currentVersion string) (*UpdateInfo, bool, error) { + resp, err := http.Get("https://api.github.com/repos/tairasu/TurtleSilicon/releases/latest") + if err != nil { + return nil, false, err + } + defer resp.Body.Close() + + // Check for HTTP errors + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, false, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Read the response body first to check content + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, false, fmt.Errorf("failed to read response body: %v", err) + } + + // Check if response looks like HTML (rate limiting or other errors) + bodyStr := string(body) + if strings.Contains(bodyStr, "") || strings.Contains(bodyStr, " 0 { + _, writeErr := tempFile.Write(buffer[:n]) + if writeErr != nil { + os.Remove(tempFile.Name()) + return "", fmt.Errorf("failed to write update file: %v", writeErr) + } + downloaded += int64(n) + if progressCallback != nil { + progressCallback(downloaded, totalSize) + } + } + if err == io.EOF { + break + } + if err != nil { + os.Remove(tempFile.Name()) + return "", fmt.Errorf("failed to read update data: %v", err) + } + } + + return tempFile.Name(), nil +} + +// InstallUpdate installs the downloaded update by mounting the DMG and replacing the current app +func InstallUpdate(dmgPath string) error { + // Get current app path + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %v", err) + } + + // Navigate up to find the .app bundle + currentAppPath := execPath + for !strings.HasSuffix(currentAppPath, ".app") && currentAppPath != "/" { + currentAppPath = filepath.Dir(currentAppPath) + } + + if !strings.HasSuffix(currentAppPath, ".app") { + return fmt.Errorf("could not find app bundle path") + } + + // Mount the DMG and parse the mount point from plist output + debug.Printf("Mounting DMG: %s", dmgPath) + mountCmd := exec.Command("hdiutil", "attach", dmgPath, "-nobrowse", "-plist") + mountOutput, err := mountCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to mount DMG: %v, output: %s", err, string(mountOutput)) + } + + debug.Printf("Mount output: %s", string(mountOutput)) + + mountPoint := "" + + // Parse the plist XML output to find mount points + outputStr := string(mountOutput) + + // Look for mount-point entries in the XML + // The plist contains mount-point entries that show where volumes are mounted + lines := strings.Split(outputStr, "\n") + for i, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, "mount-point") && i+1 < len(lines) { + // The next line should contain the mount point path + nextLine := strings.TrimSpace(lines[i+1]) + if strings.HasPrefix(nextLine, "/Volumes/") { + // Extract the path from /Volumes/... + start := strings.Index(nextLine, "") + 8 + end := strings.Index(nextLine, "") + if start >= 8 && end > start { + mountPoint = nextLine[start:end] + debug.Printf("Found mount point in plist: %s", mountPoint) + break + } + } + } + } + + // Fallback: try without -plist flag for simpler output + if mountPoint == "" { + debug.Printf("Plist parsing failed, trying simple mount") + // Unmount first if something was mounted + exec.Command("hdiutil", "detach", dmgPath, "-force").Run() + + // Try mounting without plist + simpleMountCmd := exec.Command("hdiutil", "attach", dmgPath, "-nobrowse") + simpleOutput, simpleErr := simpleMountCmd.CombinedOutput() + if simpleErr != nil { + return fmt.Errorf("failed to mount DMG (simple): %v, output: %s", simpleErr, string(simpleOutput)) + } + + // Parse simple output + simpleLines := strings.Split(string(simpleOutput), "\n") + for _, line := range simpleLines { + line = strings.TrimSpace(line) + if strings.Contains(line, "/Volumes/") { + parts := strings.Fields(line) + for i := len(parts) - 1; i >= 0; i-- { + if strings.HasPrefix(parts[i], "/Volumes/") { + mountPoint = parts[i] + debug.Printf("Found mount point in simple output: %s", mountPoint) + break + } + } + if mountPoint != "" { + break + } + } + } + } + + if mountPoint == "" { + return fmt.Errorf("could not find mount point. Mount output: %s", string(mountOutput)) + } + + debug.Printf("Using mount point: %s", mountPoint) + + defer func() { + // Unmount the DMG + debug.Printf("Unmounting DMG from: %s", mountPoint) + unmountCmd := exec.Command("hdiutil", "detach", mountPoint, "-force") + unmountCmd.Run() + }() + + // Find the app in the mounted DMG - search for any .app bundle + var newAppPath string + + // First, try the exact name + exactPath := filepath.Join(mountPoint, "TurtleSilicon.app") + if PathExists(exactPath) { + newAppPath = exactPath + } else { + // Search for any .app bundle in the mount point + debug.Printf("TurtleSilicon.app not found at exact path, searching for .app bundles") + entries, err := os.ReadDir(mountPoint) + if err != nil { + return fmt.Errorf("failed to read DMG contents: %v", err) + } + + for _, entry := range entries { + if entry.IsDir() && strings.HasSuffix(entry.Name(), ".app") { + candidatePath := filepath.Join(mountPoint, entry.Name()) + debug.Printf("Found .app bundle: %s", candidatePath) + newAppPath = candidatePath + break + } + } + } + + if newAppPath == "" { + return fmt.Errorf("no .app bundle found in DMG at %s", mountPoint) + } + + debug.Printf("Found app to install: %s", newAppPath) + + // Create backup of current app + backupPath := currentAppPath + ".backup" + debug.Printf("Creating backup: %s -> %s", currentAppPath, backupPath) + + // Remove old backup if it exists + if PathExists(backupPath) { + os.RemoveAll(backupPath) + } + + if err := CopyDir(currentAppPath, backupPath); err != nil { + return fmt.Errorf("failed to create backup: %v", err) + } + + // Remove current app + debug.Printf("Removing current app: %s", currentAppPath) + if err := os.RemoveAll(currentAppPath); err != nil { + return fmt.Errorf("failed to remove current app: %v", err) + } + + // Copy new app + debug.Printf("Installing new app: %s -> %s", newAppPath, currentAppPath) + if err := CopyDir(newAppPath, currentAppPath); err != nil { + // Try to restore backup on failure + debug.Printf("Installation failed, restoring backup") + os.RemoveAll(currentAppPath) + CopyDir(backupPath, currentAppPath) + return fmt.Errorf("failed to install new app: %v", err) + } + + // Fix executable permissions for the main binary + executablePath := filepath.Join(currentAppPath, "Contents", "MacOS", "turtlesilicon") + if PathExists(executablePath) { + debug.Printf("Setting executable permissions for: %s", executablePath) + if err := os.Chmod(executablePath, 0755); err != nil { + debug.Printf("Warning: failed to set executable permissions: %v", err) + // Don't fail the entire update for this, but log it + } + } else { + debug.Printf("Warning: executable not found at expected path: %s", executablePath) + } + + // Remove backup on success + os.RemoveAll(backupPath) + + debug.Printf("Update installed successfully") + return nil +} + +// TestDMGMount tests DMG mounting and returns mount point and app path for debugging +func TestDMGMount(dmgPath string) (string, string, error) { + debug.Printf("Testing DMG mount: %s", dmgPath) + + // Mount the DMG with verbose output to better parse mount point + mountCmd := exec.Command("hdiutil", "attach", dmgPath, "-nobrowse", "-plist") + mountOutput, err := mountCmd.CombinedOutput() + if err != nil { + return "", "", fmt.Errorf("failed to mount DMG: %v, output: %s", err, string(mountOutput)) + } + + debug.Printf("Mount output: %s", string(mountOutput)) + + // Parse mount output to get mount point + mountPoint := "" + + // First try: look for /Volumes/ in the output lines + lines := strings.Split(string(mountOutput), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, "/Volumes/") { + parts := strings.Fields(line) + for i := len(parts) - 1; i >= 0; i-- { + if strings.HasPrefix(parts[i], "/Volumes/") { + mountPoint = parts[i] + break + } + } + if mountPoint != "" { + break + } + } + } + + // Second try: use hdiutil info to get mount points if first method failed + if mountPoint == "" { + debug.Printf("First mount point detection failed, trying hdiutil info") + infoCmd := exec.Command("hdiutil", "info", "-plist") + infoOutput, infoErr := infoCmd.CombinedOutput() + if infoErr == nil { + infoLines := strings.Split(string(infoOutput), "\n") + for _, line := range infoLines { + if strings.Contains(line, "/Volumes/") && strings.Contains(line, "TurtleSilicon") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "/Volumes/") { + mountPoint = line + break + } + } + } + } + } + + if mountPoint == "" { + return "", "", fmt.Errorf("could not find mount point. Mount output: %s", string(mountOutput)) + } + + debug.Printf("Using mount point: %s", mountPoint) + + // Find the app in the mounted DMG + var newAppPath string + + // First, try the exact name + exactPath := filepath.Join(mountPoint, "TurtleSilicon.app") + if PathExists(exactPath) { + newAppPath = exactPath + } else { + // Search for any .app bundle in the mount point + debug.Printf("TurtleSilicon.app not found at exact path, searching for .app bundles") + entries, err := os.ReadDir(mountPoint) + if err != nil { + // Unmount before returning error + exec.Command("hdiutil", "detach", mountPoint, "-force").Run() + return "", "", fmt.Errorf("failed to read DMG contents: %v", err) + } + + for _, entry := range entries { + if entry.IsDir() && strings.HasSuffix(entry.Name(), ".app") { + candidatePath := filepath.Join(mountPoint, entry.Name()) + debug.Printf("Found .app bundle: %s", candidatePath) + newAppPath = candidatePath + break + } + } + } + + // Unmount after testing + debug.Printf("Unmounting test DMG from: %s", mountPoint) + exec.Command("hdiutil", "detach", mountPoint, "-force").Run() + + if newAppPath == "" { + return mountPoint, "", fmt.Errorf("no .app bundle found in DMG at %s", mountPoint) + } + + return mountPoint, newAppPath, nil +} + +// CompareFileWithBundledResource compares the size of a file on disk with a bundled resource +func CompareFileWithBundledResource(filePath, resourceName string) bool { + // Check if the file exists first if !PathExists(filePath) { return false } - // Get file size + // Get file info for the existing file fileInfo, err := os.Stat(filePath) if err != nil { - debug.Printf("Failed to get file info for %s: %v", filePath, err) + debug.Printf("Failed to stat file %s: %v", filePath, err) return false } - fileSize := fileInfo.Size() - // Get bundled resource size - resourceSize, err := GetBundledResourceSize(resourcePath) + // Load the bundled resource + resource, err := fyne.LoadResourceFromPath(resourceName) if err != nil { - debug.Printf("Failed to get bundled resource size for %s: %v", resourcePath, err) + debug.Printf("Failed to load bundled resource %s: %v", resourceName, err) return false } - debug.Printf("Comparing file sizes: %s (%d bytes) vs %s (%d bytes)", filePath, fileSize, resourcePath, resourceSize) + // Compare sizes + fileSize := fileInfo.Size() + resourceSize := int64(len(resource.Content())) + + debug.Printf("Comparing file sizes - %s: %d bytes vs bundled %s: %d bytes", + filePath, fileSize, resourceName, resourceSize) + return fileSize == resourceSize } + +// RestartApp restarts the application +func RestartApp() error { + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %v", err) + } + + // Find the .app bundle + appPath := execPath + for !strings.HasSuffix(appPath, ".app") && appPath != "/" { + appPath = filepath.Dir(appPath) + } + + if strings.HasSuffix(appPath, ".app") { + // Launch the app bundle + cmd := exec.Command("open", appPath) + return cmd.Start() + } else { + // Launch the executable directly + cmd := exec.Command(execPath) + return cmd.Start() + } +} diff --git a/turtlesilicon b/turtlesilicon deleted file mode 100755 index cb42c00..0000000 Binary files a/turtlesilicon and /dev/null differ