562 lines
16 KiB
Go
562 lines
16 KiB
Go
package utils
|
|
|
|
import (
|
|
"encoding/json"
|
|
"epochsilicon/pkg/log"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/Masterminds/semver/v3"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"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)
|
|
return err == nil
|
|
}
|
|
|
|
// DirExists checks if a path exists and is a directory.
|
|
func DirExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.IsDir()
|
|
}
|
|
|
|
// CopyFile copies a single file from src to dst.
|
|
func CopyFile(src, dst string) error {
|
|
sourceFileStat, err := os.Stat(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !sourceFileStat.Mode().IsRegular() {
|
|
return fmt.Errorf("%s is not a regular file", src)
|
|
}
|
|
|
|
source, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer source.Close()
|
|
|
|
destination, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer destination.Close()
|
|
_, err = io.Copy(destination, source)
|
|
return err
|
|
}
|
|
|
|
// CopyDir copies a directory recursively from src to dst.
|
|
func CopyDir(src string, dst string) error {
|
|
srcInfo, err := os.Stat(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = os.MkdirAll(dst, srcInfo.Mode())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dir, err := os.ReadDir(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, entry := range dir {
|
|
srcPath := filepath.Join(src, entry.Name())
|
|
dstPath := filepath.Join(dst, entry.Name())
|
|
|
|
if entry.IsDir() {
|
|
err = CopyDir(srcPath, dstPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
err = CopyFile(srcPath, dstPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RunOsascript runs an AppleScript command using osascript.
|
|
func RunOsascript(scriptString string, myWindow fyne.Window) bool {
|
|
log.Debugf("Executing AppleScript: %s", scriptString)
|
|
cmd := exec.Command("osascript", "-e", scriptString)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
errMsg := fmt.Sprintf("AppleScript failed: %v\nOutput: %s", err, string(output))
|
|
dialog.ShowError(errors.New(errMsg), myWindow)
|
|
log.Debug(errMsg)
|
|
return false
|
|
}
|
|
log.Debugf("osascript output: %s", string(output))
|
|
return true
|
|
}
|
|
|
|
// EscapeStringForAppleScript escapes a string for AppleScript.
|
|
func EscapeStringForAppleScript(s string) string {
|
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
|
s = strings.ReplaceAll(s, "\"", "\\\"")
|
|
return s
|
|
}
|
|
|
|
// QuotePathForShell quotes paths for shell commands.
|
|
func QuotePathForShell(path string) string {
|
|
return fmt.Sprintf(`"%s"`, path)
|
|
}
|
|
|
|
// UpdateInfo contains information about the latest release
|
|
type UpdateInfo struct {
|
|
TagName string `json:"tag_name"`
|
|
Body string `json:"body"`
|
|
Assets []Asset `json:"assets"`
|
|
}
|
|
|
|
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 *semver.Version) (*UpdateInfo, bool, error) {
|
|
resp, err := http.Get("https://git.burkey.co/api/v1/repos/eburk/epochsilicon/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, err := semver.NewVersion(updateInfo.TagName)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failed to parse semver: %v", err)
|
|
}
|
|
updateAvailable := latest.GreaterThan(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("", "EpochSilicon-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
|
|
log.Debugf("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))
|
|
}
|
|
|
|
log.Debugf("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]
|
|
log.Debugf("Found mount point in plist: %s", mountPoint)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: try without -plist flag for simpler output
|
|
if mountPoint == "" {
|
|
log.Debugf("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]
|
|
log.Debugf("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))
|
|
}
|
|
|
|
log.Debugf("Using mount point: %s", mountPoint)
|
|
|
|
defer func() {
|
|
// Unmount the DMG
|
|
log.Debugf("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, "EpochSilicon.app")
|
|
if PathExists(exactPath) {
|
|
newAppPath = exactPath
|
|
} else {
|
|
// Search for any .app bundle in the mount point
|
|
log.Debugf("EpochSilicon.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())
|
|
log.Debugf("Found .app bundle: %s", candidatePath)
|
|
newAppPath = candidatePath
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if newAppPath == "" {
|
|
return fmt.Errorf("no .app bundle found in DMG at %s", mountPoint)
|
|
}
|
|
|
|
log.Debugf("Found app to install: %s", newAppPath)
|
|
|
|
// Create backup of current app
|
|
backupPath := currentAppPath + ".backup"
|
|
log.Debugf("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
|
|
log.Debugf("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
|
|
log.Debugf("Installing new app: %s -> %s", newAppPath, currentAppPath)
|
|
if err := CopyDir(newAppPath, currentAppPath); err != nil {
|
|
// Try to restore backup on failure
|
|
log.Debugf("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", "epochsilicon")
|
|
if PathExists(executablePath) {
|
|
log.Debugf("Setting executable permissions for: %s", executablePath)
|
|
if err := os.Chmod(executablePath, 0755); err != nil {
|
|
log.Debugf("Warning: failed to set executable permissions: %v", err)
|
|
// Don't fail the entire update for this, but log it
|
|
}
|
|
} else {
|
|
log.Debugf("Warning: executable not found at expected path: %s", executablePath)
|
|
}
|
|
|
|
// Remove backup on success
|
|
os.RemoveAll(backupPath)
|
|
|
|
log.Debugf("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) {
|
|
log.Debugf("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))
|
|
}
|
|
|
|
log.Debugf("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 == "" {
|
|
log.Debugf("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, "EpochSilicon") {
|
|
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))
|
|
}
|
|
|
|
log.Debugf("Using mount point: %s", mountPoint)
|
|
|
|
// Find the app in the mounted DMG
|
|
var newAppPath string
|
|
|
|
// First, try the exact name
|
|
exactPath := filepath.Join(mountPoint, "EpochSilicon.app")
|
|
if PathExists(exactPath) {
|
|
newAppPath = exactPath
|
|
} else {
|
|
// Search for any .app bundle in the mount point
|
|
log.Debugf("EpochSilicon.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())
|
|
log.Debugf("Found .app bundle: %s", candidatePath)
|
|
newAppPath = candidatePath
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unmount after testing
|
|
log.Debugf("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 info for the existing file
|
|
fileInfo, err := os.Stat(filePath)
|
|
if err != nil {
|
|
log.Debugf("Failed to stat file %s: %v", filePath, err)
|
|
return false
|
|
}
|
|
|
|
// Load the bundled resource
|
|
resource, err := fyne.LoadResourceFromPath(resourceName)
|
|
if err != nil {
|
|
log.Debugf("Failed to load bundled resource %s: %v", resourceName, err)
|
|
return false
|
|
}
|
|
|
|
// Compare sizes
|
|
fileSize := fileInfo.Size()
|
|
resourceSize := int64(len(resource.Content()))
|
|
|
|
log.Debugf("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()
|
|
}
|
|
}
|