integrated an auto-updater
This commit is contained in:
		
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,4 +1,5 @@ | ||||
| .DS_Store | ||||
| TurtleSilicon.* | ||||
| TurtleSilicon.app/Contents/MacOS/turtlesilicon | ||||
| *.dmg | ||||
| *.dmg | ||||
| turtlesilicon | ||||
| @@ -3,4 +3,4 @@ | ||||
|   Name = "TurtleSilicon" | ||||
|   ID = "com.tairasu.turtlesilicon" | ||||
|   Version = "1.2.1" | ||||
|   Build = 37 | ||||
|   Build = 47 | ||||
|   | ||||
							
								
								
									
										42
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										42
									
								
								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) | ||||
|   | ||||
							
								
								
									
										187
									
								
								pkg/ui/updater.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								pkg/ui/updater.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
| @@ -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, "<!DOCTYPE html>") || strings.Contains(bodyStr, "<html") { | ||||
| 		return nil, false, fmt.Errorf("GitHub API returned HTML instead of JSON (possible rate limiting): %s", bodyStr[:min(200, len(bodyStr))]) | ||||
| 	} | ||||
|  | ||||
| 	var updateInfo UpdateInfo | ||||
| 	if err := json.Unmarshal(body, &updateInfo); err != nil { | ||||
| 		return nil, false, fmt.Errorf("failed to parse JSON response: %v. Response: %s", err, bodyStr[:min(200, len(bodyStr))]) | ||||
| 	} | ||||
|  | ||||
| 	latest := strings.TrimPrefix(updateInfo.TagName, "v") | ||||
| 	updateAvailable := latest != currentVersion | ||||
|  | ||||
| 	return &updateInfo, updateAvailable, nil | ||||
| } | ||||
|  | ||||
| // DownloadUpdate downloads the latest release and returns the path to the downloaded file | ||||
| func DownloadUpdate(downloadURL string, progressCallback func(downloaded, total int64)) (string, error) { | ||||
| 	// Create temporary file | ||||
| 	tempFile, err := os.CreateTemp("", "TurtleSilicon-update-*.dmg") | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("failed to create temp file: %v", err) | ||||
| 	} | ||||
| 	defer tempFile.Close() | ||||
|  | ||||
| 	// Download the file | ||||
| 	resp, err := http.Get(downloadURL) | ||||
| 	if err != nil { | ||||
| 		os.Remove(tempFile.Name()) | ||||
| 		return "", fmt.Errorf("failed to download update: %v", err) | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	totalSize := resp.ContentLength | ||||
| 	var downloaded int64 | ||||
|  | ||||
| 	// Copy with progress tracking | ||||
| 	buffer := make([]byte, 32*1024) // 32KB buffer | ||||
| 	for { | ||||
| 		n, err := resp.Body.Read(buffer) | ||||
| 		if n > 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, "<key>mount-point</key>") && i+1 < len(lines) { | ||||
| 			// The next line should contain the mount point path | ||||
| 			nextLine := strings.TrimSpace(lines[i+1]) | ||||
| 			if strings.HasPrefix(nextLine, "<string>/Volumes/") { | ||||
| 				// Extract the path from <string>/Volumes/...</string> | ||||
| 				start := strings.Index(nextLine, "<string>") + 8 | ||||
| 				end := strings.Index(nextLine, "</string>") | ||||
| 				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() | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								turtlesilicon
									
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								turtlesilicon
									
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							
		Reference in New Issue
	
	Block a user
	 aomizu
					aomizu