// Package main provides self-hosted update functionality for EMLy. // This file contains methods for checking, downloading, and installing updates // from a corporate network share without relying on third-party services. package main import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/url" "os" "path/filepath" "strconv" "strings" "syscall" "time" "unsafe" "github.com/wailsapp/wails/v2/pkg/runtime" ) // ============================================================================= // Update System Types // ============================================================================= // UpdateManifest represents the version.json file structure on the network share type UpdateManifest struct { StableVersion string `json:"stableVersion"` BetaVersion string `json:"betaVersion"` StableDownload string `json:"stableDownload"` BetaDownload string `json:"betaDownload"` SHA256Checksums map[string]string `json:"sha256Checksums"` ReleaseNotes map[string]string `json:"releaseNotes,omitempty"` } // UpdateStatus represents the current state of the update system type UpdateStatus struct { CurrentVersion string `json:"currentVersion"` AvailableVersion string `json:"availableVersion"` UpdateAvailable bool `json:"updateAvailable"` Checking bool `json:"checking"` Downloading bool `json:"downloading"` DownloadProgress int `json:"downloadProgress"` Ready bool `json:"ready"` InstallerPath string `json:"installerPath"` ErrorMessage string `json:"errorMessage"` ReleaseNotes string `json:"releaseNotes,omitempty"` LastCheckTime string `json:"lastCheckTime"` } // Global update state var updateStatus = UpdateStatus{ CurrentVersion: "", AvailableVersion: "", UpdateAvailable: false, Checking: false, Downloading: false, DownloadProgress: 0, Ready: false, InstallerPath: "", ErrorMessage: "", } // ============================================================================= // Update Check Methods // ============================================================================= // CheckForUpdates checks the configured network share for available updates. // Compares the manifest version with the current GUI version based on release channel. // // Returns: // - UpdateStatus: Current update state including available version // - error: Error if check fails (network, parsing, etc.) func (a *App) CheckForUpdates() (UpdateStatus, error) { // Reset status updateStatus.Checking = true updateStatus.ErrorMessage = "" updateStatus.LastCheckTime = time.Now().Format("2006-01-02 15:04:05") runtime.EventsEmit(a.ctx, "update:status", updateStatus) // Get current version from config config := a.GetConfig() if config == nil { updateStatus.ErrorMessage = "Failed to load configuration" updateStatus.Checking = false return updateStatus, fmt.Errorf("failed to load config") } updateStatus.CurrentVersion = config.EMLy.GUISemver currentChannel := config.EMLy.GUIReleaseChannel // Check if updates are enabled if config.EMLy.UpdateCheckEnabled != "true" { updateStatus.ErrorMessage = "Update checking is disabled" updateStatus.Checking = false return updateStatus, fmt.Errorf("update checking is disabled in config") } // Validate update path updatePath := strings.TrimSpace(config.EMLy.UpdatePath) if updatePath == "" { updateStatus.ErrorMessage = "Update path not configured" updateStatus.Checking = false return updateStatus, fmt.Errorf("UPDATE_PATH is empty in config.ini") } // Load manifest from network share manifest, err := a.loadUpdateManifest(updatePath) if err != nil { updateStatus.ErrorMessage = fmt.Sprintf("Failed to load manifest: %v", err) updateStatus.Checking = false return updateStatus, err } // Determine target version based on release channel var targetVersion string if currentChannel == "beta" { targetVersion = manifest.BetaVersion } else { targetVersion = manifest.StableVersion } updateStatus.AvailableVersion = targetVersion // Compare versions comparison := compareSemanticVersions(updateStatus.CurrentVersion, targetVersion) if comparison < 0 { // New version available updateStatus.UpdateAvailable = true updateStatus.InstallerPath = "" // Reset installer path updateStatus.Ready = false // Get release notes if available if notes, ok := manifest.ReleaseNotes[targetVersion]; ok { updateStatus.ReleaseNotes = notes } log.Printf("Update available: %s -> %s (%s channel)", updateStatus.CurrentVersion, targetVersion, currentChannel) } else { updateStatus.UpdateAvailable = false updateStatus.InstallerPath = "" updateStatus.Ready = false updateStatus.ReleaseNotes = "" log.Printf("Already on latest version: %s (%s channel)", updateStatus.CurrentVersion, currentChannel) } updateStatus.Checking = false return updateStatus, nil } // loadUpdateManifest reads and parses version.json from the network share func (a *App) loadUpdateManifest(updatePath string) (*UpdateManifest, error) { // Resolve path (handle UNC paths, file:// URLs, local paths) manifestPath, err := resolveUpdatePath(updatePath, "version.json") if err != nil { return nil, fmt.Errorf("failed to resolve manifest path: %w", err) } log.Printf("Loading update manifest from: %s", manifestPath) // Read manifest file data, err := os.ReadFile(manifestPath) if err != nil { return nil, fmt.Errorf("failed to read manifest file: %w", err) } // Parse JSON var manifest UpdateManifest if err := json.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("failed to parse manifest JSON: %w", err) } // Validate manifest if manifest.StableVersion == "" || manifest.StableDownload == "" { return nil, fmt.Errorf("invalid manifest: missing stable version or download") } return &manifest, nil } // ============================================================================= // Download Methods // ============================================================================= // DownloadUpdate downloads the installer from the network share to a temporary location. // Verifies SHA256 checksum if provided in the manifest. // // Returns: // - string: Path to the downloaded installer // - error: Error if download or verification fails func (a *App) DownloadUpdate() (string, error) { if !updateStatus.UpdateAvailable { return "", fmt.Errorf("no update available") } updateStatus.Downloading = true updateStatus.DownloadProgress = 0 updateStatus.ErrorMessage = "" runtime.EventsEmit(a.ctx, "update:status", updateStatus) defer func() { updateStatus.Downloading = false runtime.EventsEmit(a.ctx, "update:status", updateStatus) }() // Get config config := a.GetConfig() if config == nil { updateStatus.ErrorMessage = "Failed to load configuration" return "", fmt.Errorf("failed to load config") } updatePath := strings.TrimSpace(config.EMLy.UpdatePath) currentChannel := config.EMLy.GUIReleaseChannel // Reload manifest to get download filename manifest, err := a.loadUpdateManifest(updatePath) if err != nil { updateStatus.ErrorMessage = "Failed to load manifest" return "", err } // Determine download filename var downloadFilename string if currentChannel == "beta" { downloadFilename = manifest.BetaDownload } else { downloadFilename = manifest.StableDownload } // Resolve source path sourcePath, err := resolveUpdatePath(updatePath, downloadFilename) if err != nil { updateStatus.ErrorMessage = "Failed to resolve installer path" return "", fmt.Errorf("failed to resolve installer path: %w", err) } log.Printf("Downloading installer from: %s", sourcePath) // Create temp directory for download tempDir := filepath.Join(os.TempDir(), "emly_update") if err := os.MkdirAll(tempDir, 0755); err != nil { updateStatus.ErrorMessage = "Failed to create temp directory" return "", fmt.Errorf("failed to create temp directory: %w", err) } // Destination path destPath := filepath.Join(tempDir, downloadFilename) // Copy file with progress if err := a.copyFileWithProgress(sourcePath, destPath); err != nil { updateStatus.ErrorMessage = "Download failed" return "", fmt.Errorf("failed to copy installer: %w", err) } // Verify checksum if available if checksum, ok := manifest.SHA256Checksums[downloadFilename]; ok { log.Printf("Verifying checksum for %s", downloadFilename) if err := verifyChecksum(destPath, checksum); err != nil { updateStatus.ErrorMessage = "Checksum verification failed" // Delete corrupted file os.Remove(destPath) return "", fmt.Errorf("checksum verification failed: %w", err) } log.Printf("Checksum verified successfully") } else { log.Printf("Warning: No checksum available for %s", downloadFilename) } updateStatus.InstallerPath = destPath updateStatus.Ready = true updateStatus.DownloadProgress = 100 log.Printf("Update downloaded successfully to: %s", destPath) return destPath, nil } // copyFileWithProgress copies a file and emits progress events func (a *App) copyFileWithProgress(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() // Get file size stat, err := sourceFile.Stat() if err != nil { return err } totalSize := stat.Size() destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() // Copy with progress tracking buffer := make([]byte, 1024*1024) // 1MB buffer var copiedSize int64 = 0 for { n, err := sourceFile.Read(buffer) if n > 0 { if _, writeErr := destFile.Write(buffer[:n]); writeErr != nil { return writeErr } copiedSize += int64(n) // Update progress (avoid too many events) progress := int((copiedSize * 100) / totalSize) if progress != updateStatus.DownloadProgress { updateStatus.DownloadProgress = progress runtime.EventsEmit(a.ctx, "update:status", updateStatus) } } if err == io.EOF { break } if err != nil { return err } } return nil } // ============================================================================= // Install Methods // ============================================================================= // InstallUpdate launches the downloaded installer with elevated privileges // and optionally quits the application. // // Parameters: // - quitAfterLaunch: If true, exits EMLy after launching the installer // // Returns: // - error: Error if installer launch fails func (a *App) InstallUpdate(quitAfterLaunch bool) error { if !updateStatus.Ready || updateStatus.InstallerPath == "" { return fmt.Errorf("no installer ready to install") } installerPath := updateStatus.InstallerPath // Verify installer exists if _, err := os.Stat(installerPath); os.IsNotExist(err) { updateStatus.ErrorMessage = "Installer file not found" updateStatus.Ready = false return fmt.Errorf("installer not found: %s", installerPath) } log.Printf("Launching installer: %s", installerPath) // Launch installer with UAC elevation using ShellExecute if err := shellExecuteAsAdmin(installerPath); err != nil { updateStatus.ErrorMessage = fmt.Sprintf("Failed to launch installer: %v", err) return fmt.Errorf("failed to launch installer: %w", err) } log.Printf("Installer launched successfully") // Quit application if requested if quitAfterLaunch { time.Sleep(500 * time.Millisecond) // Brief delay to ensure installer starts runtime.Quit(a.ctx) } return nil } // shellExecuteAsAdmin launches an executable with UAC elevation on Windows func shellExecuteAsAdmin(exePath string) error { verb := "runas" // Triggers UAC elevation exe := syscall.StringToUTF16Ptr(exePath) verbPtr := syscall.StringToUTF16Ptr(verb) var hwnd uintptr = 0 var operation = verbPtr var file = exe var parameters uintptr = 0 var directory uintptr = 0 var showCmd int32 = 1 // SW_SHOWNORMAL // Load shell32.dll shell32 := syscall.NewLazyDLL("shell32.dll") shellExecute := shell32.NewProc("ShellExecuteW") ret, _, err := shellExecute.Call( hwnd, uintptr(unsafe.Pointer(operation)), uintptr(unsafe.Pointer(file)), parameters, directory, uintptr(showCmd), ) // ShellExecuteW returns a value > 32 on success if ret <= 32 { return fmt.Errorf("ShellExecuteW failed with code %d: %v", ret, err) } return nil } // launchDetachedInstaller launches the installer as a completely detached process // using CreateProcess with DETACHED_PROCESS and CREATE_NEW_PROCESS_GROUP flags. // This allows the installer to continue running and close EMLy without errors. // // Parameters: // - exePath: Full path to the installer executable // - args: Array of command-line arguments to pass to the installer // // Returns: // - error: Error if process creation fails func launchDetachedInstaller(exePath string, args []string) error { // Build command line: executable path + arguments cmdLine := fmt.Sprintf(`"%s"`, exePath) if len(args) > 0 { cmdLine += " " + strings.Join(args, " ") } log.Printf("Launching detached installer: %s", cmdLine) // Convert to UTF16 for Windows API cmdLinePtr := syscall.StringToUTF16Ptr(cmdLine) // Setup process startup info var si syscall.StartupInfo var pi syscall.ProcessInformation si.Cb = uint32(unsafe.Sizeof(si)) si.Flags = syscall.STARTF_USESHOWWINDOW si.ShowWindow = syscall.SW_HIDE // Hide installer window (silent mode) // Process creation flags: // CREATE_NEW_PROCESS_GROUP: Creates process in new process group // DETACHED_PROCESS: Process has no console, completely detached from parent const ( CREATE_NEW_PROCESS_GROUP = 0x00000200 DETACHED_PROCESS = 0x00000008 ) flags := uint32(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) // Create the detached process err := syscall.CreateProcess( nil, // Application name (nil = use command line) cmdLinePtr, // Command line nil, // Process security attributes nil, // Thread security attributes false, // Inherit handles flags, // Creation flags nil, // Environment (nil = inherit) nil, // Current directory (nil = inherit) &si, // Startup info &pi, // Process information (output) ) if err != nil { log.Printf("CreateProcess failed: %v", err) return fmt.Errorf("failed to create detached process: %w", err) } // Close process and thread handles immediately // We don't need to wait for the process - it's fully detached syscall.CloseHandle(pi.Process) syscall.CloseHandle(pi.Thread) log.Printf("Detached installer process launched successfully (PID: %d)", pi.ProcessId) return nil } // InstallUpdateSilent downloads the update (if needed) and launches the installer // in completely silent mode with a detached process. The installer will run with // these arguments: /VERYSILENT /ALLUSERS /SUPPRESSMSGBOXES /NORESTART /FORCEUPGRADE // // This method automatically quits EMLy after launching the installer, allowing the // installer to close the application and complete the upgrade without user interaction. // // Returns: // - error: Error if download or launch fails func (a *App) InstallUpdateSilent() error { log.Println("Starting silent update installation...") // If installer not ready, attempt to download first if !updateStatus.Ready || updateStatus.InstallerPath == "" { log.Println("Installer not ready, downloading update first...") _, err := a.DownloadUpdate() if err != nil { errMsg := fmt.Sprintf("Failed to download update: %v", err) log.Println(errMsg) updateStatus.ErrorMessage = errMsg return fmt.Errorf("download failed: %w", err) } // Wait briefly for download to complete log.Println("Download initiated, waiting for completion...") for i := 0; i < 60; i++ { // Wait up to 60 seconds time.Sleep(1 * time.Second) if updateStatus.Ready { break } if updateStatus.ErrorMessage != "" { return fmt.Errorf("download error: %s", updateStatus.ErrorMessage) } } if !updateStatus.Ready { return fmt.Errorf("download timeout - update not ready after 60 seconds") } } installerPath := updateStatus.InstallerPath // Verify installer exists if _, err := os.Stat(installerPath); os.IsNotExist(err) { updateStatus.ErrorMessage = "Installer file not found" updateStatus.Ready = false log.Printf("Installer not found: %s", installerPath) return fmt.Errorf("installer not found: %s", installerPath) } log.Printf("Installer ready at: %s", installerPath) // Prepare silent installation arguments args := []string{ "/VERYSILENT", // No UI, completely silent "/ALLUSERS", // Install for all users (requires admin) "/SUPPRESSMSGBOXES", // Suppress all message boxes "/NORESTART", // Don't restart system "/FORCEUPGRADE", // Skip upgrade confirmation dialog `/LOG="C:\install.log"`, // Create installation log } log.Printf("Launching installer with args: %v", args) // Launch detached installer if err := launchDetachedInstaller(installerPath, args); err != nil { errMsg := fmt.Sprintf("Failed to launch installer: %v", err) log.Println(errMsg) updateStatus.ErrorMessage = errMsg return fmt.Errorf("failed to launch installer: %w", err) } log.Println("Detached installer launched successfully, quitting EMLy...") // Quit application to allow installer to replace files time.Sleep(500 * time.Millisecond) // Brief delay to ensure installer starts runtime.Quit(a.ctx) return nil } // InstallUpdateSilentFromPath downloads an installer from a custom SMB/network path // and launches it in silent mode with a detached process. Use this when you know the // exact installer path (e.g., \\server\updates\EMLy_Installer.exe) without needing // to check the version.json manifest. // // Parameters: // - smbPath: Full UNC path or local path to the installer (e.g., \\server\share\EMLy.exe) // // Returns: // - error: Error if download or launch fails func (a *App) InstallUpdateSilentFromPath(smbPath string) error { log.Printf("Starting silent installation from custom path: %s", smbPath) // Verify source installer exists and is accessible if _, err := os.Stat(smbPath); os.IsNotExist(err) { errMsg := fmt.Sprintf("Installer not found at: %s", smbPath) log.Println(errMsg) return fmt.Errorf("%s", errMsg) } // Create temporary directory for installer tempDir := os.TempDir() installerFilename := filepath.Base(smbPath) tempInstallerPath := filepath.Join(tempDir, installerFilename) log.Printf("Copying installer to temp location: %s", tempInstallerPath) // Copy installer from SMB path to local temp sourceFile, err := os.Open(smbPath) if err != nil { errMsg := fmt.Sprintf("Failed to open source installer: %v", err) log.Println(errMsg) return fmt.Errorf("failed to open installer: %w", err) } defer sourceFile.Close() destFile, err := os.Create(tempInstallerPath) if err != nil { errMsg := fmt.Sprintf("Failed to create temp installer: %v", err) log.Println(errMsg) return fmt.Errorf("failed to create temp file: %w", err) } defer destFile.Close() // Copy file bytesWritten, err := io.Copy(destFile, sourceFile) if err != nil { errMsg := fmt.Sprintf("Failed to copy installer: %v", err) log.Println(errMsg) return fmt.Errorf("failed to copy installer: %w", err) } log.Printf("Installer copied successfully (%d bytes)", bytesWritten) // Prepare silent installation arguments args := []string{ "/VERYSILENT", // No UI, completely silent "/ALLUSERS", // Install for all users (requires admin) "/SUPPRESSMSGBOXES", // Suppress all message boxes "/NORESTART", // Don't restart system "/FORCEUPGRADE", // Skip upgrade confirmation dialog `/LOG="C:\install.log"`, // Create installation log } log.Printf("Launching installer with args: %v", args) // Launch detached installer if err := launchDetachedInstaller(tempInstallerPath, args); err != nil { errMsg := fmt.Sprintf("Failed to launch installer: %v", err) log.Println(errMsg) return fmt.Errorf("failed to launch installer: %w", err) } log.Println("Detached installer launched successfully, quitting EMLy...") // Quit application to allow installer to replace files time.Sleep(500 * time.Millisecond) // Brief delay to ensure installer starts runtime.Quit(a.ctx) return nil } // ============================================================================= // Status Methods // ============================================================================= // GetUpdateStatus returns the current update system status. // This can be polled by the frontend to update UI state. // // Returns: // - UpdateStatus: Current state of the update system func (a *App) GetUpdateStatus() UpdateStatus { return updateStatus } // ============================================================================= // Utility Functions // ============================================================================= // resolveUpdatePath resolves a network share path or file:// URL to a local path. // Handles UNC paths (\\server\share), file:// URLs, and local paths. func resolveUpdatePath(basePath, filename string) (string, error) { basePath = strings.TrimSpace(basePath) // Handle file:// URL if strings.HasPrefix(strings.ToLower(basePath), "file://") { u, err := url.Parse(basePath) if err != nil { return "", fmt.Errorf("invalid file URL: %w", err) } // Convert file URL to local path basePath = filepath.FromSlash(u.Path) // Handle Windows drive letters (file:///C:/path -> C:/path) if len(basePath) > 0 && basePath[0] == '/' && len(basePath) > 2 && basePath[2] == ':' { basePath = basePath[1:] } } // Join with filename fullPath := filepath.Join(basePath, filename) // Verify path is accessible if _, err := os.Stat(fullPath); err != nil { return "", fmt.Errorf("path not accessible: %w", err) } return fullPath, nil } // compareSemanticVersions compares two semantic version strings. // Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 func compareSemanticVersions(v1, v2 string) int { // Strip beta/alpha suffixes for comparison v1Clean := strings.Split(v1, "-")[0] v2Clean := strings.Split(v2, "-")[0] parts1 := strings.Split(v1Clean, ".") parts2 := strings.Split(v2Clean, ".") // Compare each version component maxLen := len(parts1) if len(parts2) > maxLen { maxLen = len(parts2) } for i := 0; i < maxLen; i++ { var num1, num2 int if i < len(parts1) { num1, _ = strconv.Atoi(parts1[i]) } if i < len(parts2) { num2, _ = strconv.Atoi(parts2[i]) } if num1 < num2 { return -1 } if num1 > num2 { return 1 } } // If base versions are equal, check beta/stable if v1 != v2 { // Version with beta suffix is considered "older" than without if strings.Contains(v1, "-beta") && !strings.Contains(v2, "-beta") { return -1 } if !strings.Contains(v1, "-beta") && strings.Contains(v2, "-beta") { return 1 } } return 0 } // verifyChecksum verifies the SHA256 checksum of a file func verifyChecksum(filePath, expectedChecksum string) error { file, err := os.Open(filePath) if err != nil { return err } defer file.Close() hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { return err } actualChecksum := hex.EncodeToString(hash.Sum(nil)) if !strings.EqualFold(actualChecksum, expectedChecksum) { return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum) } return nil }