From 099e7494884f4b0b368b6bb6cc1479c9de8cb0a9 Mon Sep 17 00:00:00 2001 From: Matias Daloia Date: Fri, 7 Nov 2025 12:31:49 +0100 Subject: [PATCH 1/7] fix: auto updater logic --- CHANGELOG.md | 14 +- backend/service/update_service_impl.go | 201 +++++++------------------ go.mod | 2 +- main.go | 3 - 4 files changed, 63 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7795143..f860441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,16 +10,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.9.5] 2025-11-07 ### Fixed - Fix macOS postinstall script where it was not properly handling the `--scan-root` argument. -- **macOS**: Complete auto-update implementation - now properly mounts DMG, copies app bundle to /Applications, clears quarantine, and restarts +- **macOS**: Complete auto-update implementation - extracts binary from DMG and uses go-update for atomic replacement - **Windows**: Fix critical file locking bug by integrating go-update library for proper binary replacement -- **Linux**: Implement staging directory approach with atomic binary swap on startup +- **Linux**: Use go-update library for atomic binary replacement with automatic rollback - Add ELF binary verification for Linux updates to prevent corrupted downloads ### Changed -- macOS updates use rsync for atomic installation with fallback to cp -- Windows updates properly handle running executable replacement -- Linux updates use `.next` staging file with atomic swap on next launch -- Improve error handling and logging throughout update process +- **All platforms now use go-update library** for consistent, reliable binary replacement +- macOS updates extract and replace just the binary inside .app bundle (simpler and more reliable) +- Windows and Linux updates also use go-update for atomic operations +- Linux auto-updates now detect webkit version (webkit40/webkit41) based on distro version +- All platforms have automatic rollback on update failure built into go-update +- Significantly simplified update code by using proven library instead of custom approaches ## [0.9.4] 2025-11-06 ### Changed diff --git a/backend/service/update_service_impl.go b/backend/service/update_service_impl.go index a6ba34e..d3d8a43 100644 --- a/backend/service/update_service_impl.go +++ b/backend/service/update_service_impl.go @@ -334,37 +334,49 @@ func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { return fmt.Errorf("no .app bundle found in DMG") } - // Determine installation path - installPath := "/Applications/scanoss-cc.app" - log.Info().Msgf("Installing app to %s...", installPath) - - // Try to use rsync for atomic copy - if _, err := exec.LookPath("rsync"); err == nil { - log.Info().Msg("Using rsync for atomic installation...") - cmd = exec.Command("rsync", "-a", "--delete", appPath+"/", installPath+"/") - if err := cmd.Run(); err != nil { - log.Warn().Err(err).Msg("rsync failed, falling back to cp") - // Fallback to cp if rsync fails - if err := u.copyAppWithCP(appPath, installPath); err != nil { - return err - } - } - } else { - // rsync not available, use cp - log.Info().Msg("rsync not available, using cp...") - if err := u.copyAppWithCP(appPath, installPath); err != nil { - return err - } + // Find the binary inside the new .app bundle + newBinaryPath := filepath.Join(appPath, "Contents", "MacOS", "scanoss-cc") + if _, err := os.Stat(newBinaryPath); err != nil { + return fmt.Errorf("could not find binary in app bundle: %w", err) + } + + log.Info().Msgf("Found new binary: %s", newBinaryPath) + + // Get current executable path (inside our current .app bundle) + currentExe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get current executable: %w", err) } - // Clear macOS quarantine attribute + log.Info().Msgf("Current executable: %s", currentExe) + + // Open the new binary file + newBinary, err := os.Open(newBinaryPath) + if err != nil { + return fmt.Errorf("failed to open new binary: %w", err) + } + defer newBinary.Close() + + // Apply the update using go-update library + // This handles file locking correctly on macOS too + log.Info().Msg("Applying update to binary...") + err = update.Apply(newBinary, update.Options{ + TargetPath: currentExe, + }) + if err != nil { + // Rollback is handled automatically by the library + return fmt.Errorf("failed to apply update: %w", err) + } + + // Clear macOS quarantine attribute on the updated binary log.Info().Msg("Clearing quarantine attributes...") - exec.Command("xattr", "-r", "-d", "com.apple.quarantine", installPath).Run() + exec.Command("xattr", "-d", "com.apple.quarantine", currentExe).Run() - log.Info().Msg("Installation complete, restarting application...") + log.Info().Msg("Update applied successfully, restarting application...") - // Restart the application - cmd = exec.Command("open", installPath) + // Get the path to the .app bundle to restart it + appBundlePath := "/Applications/scanoss-cc.app" + cmd = exec.Command("open", appBundlePath) if err := cmd.Start(); err != nil { log.Warn().Err(err).Msg("failed to restart application") } @@ -374,24 +386,6 @@ func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { return nil } -// copyAppWithCP copies the .app bundle using cp command (fallback method) -func (u *UpdateServiceImpl) copyAppWithCP(sourcePath, destPath string) error { - // Remove existing installation - log.Info().Msgf("Removing old installation at %s...", destPath) - if err := exec.Command("rm", "-rf", destPath).Run(); err != nil { - log.Warn().Err(err).Msg("failed to remove old installation, continuing anyway") - } - - // Copy new app bundle - log.Info().Msgf("Copying new app from %s to %s...", sourcePath, destPath) - cmd := exec.Command("cp", "-R", sourcePath, destPath) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to copy app bundle: %w", err) - } - - return nil -} - func (u *UpdateServiceImpl) applyUpdateWindows(updatePath string) error { // For Windows, the update is a ZIP file containing the new executable // We use go-update library which handles Windows file locking correctly @@ -543,31 +537,27 @@ func (u *UpdateServiceImpl) applyZipUpdate(updatePath string) error { return fmt.Errorf("binary verification failed: %w", err) } - // Make the new binary executable - if err := os.Chmod(newBinaryPath, 0o755); err != nil { - return fmt.Errorf("failed to make binary executable: %w", err) - } - - // Use staging approach: copy to .next file for atomic swap - // This avoids issues with running executables on Linux - nextPath := currentExe + ".next" - log.Info().Msgf("Staging new binary to %s...", nextPath) - - // Copy the new binary to .next location - if err := u.copyFile(newBinaryPath, nextPath); err != nil { - return fmt.Errorf("failed to stage new binary: %w", err) + // Open the new binary file + newBinary, err := os.Open(newBinaryPath) + if err != nil { + return fmt.Errorf("failed to open new binary: %w", err) } + defer newBinary.Close() - // Make sure .next is executable - if err := os.Chmod(nextPath, 0o755); err != nil { - os.Remove(nextPath) - return fmt.Errorf("failed to make staged binary executable: %w", err) + // Apply the update using go-update library + // Linux allows replacing running executables, so this works directly + log.Info().Msg("Applying update to binary...") + err = update.Apply(newBinary, update.Options{ + TargetPath: currentExe, + }) + if err != nil { + // Rollback is handled automatically by the library + return fmt.Errorf("failed to apply update: %w", err) } - log.Info().Msg("Update staged successfully, restarting application...") + log.Info().Msg("Update applied successfully, restarting application...") - // The actual swap will happen on application startup - // For now, just restart to trigger the swap + // Restart the application cmd = exec.Command(currentExe, os.Args[1:]...) if err := cmd.Start(); err != nil { log.Warn().Err(err).Msg("failed to restart application") @@ -611,89 +601,6 @@ func (u *UpdateServiceImpl) verifyLinuxBinary(path string) error { return nil } -// copyFile copies a file from src to dst -func (u *UpdateServiceImpl) copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { - return err - } - defer sourceFile.Close() - - destFile, err := os.Create(dst) - if err != nil { - return err - } - defer destFile.Close() - - if _, err := io.Copy(destFile, sourceFile); err != nil { - return err - } - - return destFile.Sync() -} - -// CheckPendingUpdate checks if there's a pending update and applies it -// This should be called during application startup (Linux only) -func CheckPendingUpdate() error { - if runtime.GOOS != "linux" { - return nil // Only applicable to Linux - } - - currentExe, err := os.Executable() - if err != nil { - return nil // Silently ignore errors during startup check - } - - nextPath := currentExe + ".next" - oldPath := currentExe + ".old" - - // Check if there's a pending update - if _, err := os.Stat(nextPath); os.IsNotExist(err) { - // No pending update, check if we should cleanup old backup - if _, err := os.Stat(oldPath); err == nil { - // Remove old backup from previous successful update - log.Info().Msg("Removing old backup from previous update...") - os.Remove(oldPath) - } - return nil - } - - log.Info().Msg("Pending update detected, performing atomic swap...") - - // Backup current executable - if err := os.Rename(currentExe, oldPath); err != nil { - log.Error().Err(err).Msg("Failed to backup current executable") - return err - } - - // Move .next to current - if err := os.Rename(nextPath, currentExe); err != nil { - // Rollback - log.Error().Err(err).Msg("Failed to swap binaries, rolling back...") - os.Rename(oldPath, currentExe) - return err - } - - // Make sure it's executable - os.Chmod(currentExe, 0o755) - - log.Info().Msg("Update applied successfully, restarting...") - - // Re-exec into the new binary - if err := restartApplication(currentExe); err != nil { - log.Error().Err(err).Msg("Failed to restart after update") - return err - } - - // This code should never be reached as we've re-exec'd - return nil -} - -// restartApplication restarts the application by re-executing it -func restartApplication(exePath string) error { - return exec.Command(exePath, os.Args[1:]...).Start() -} - // GetCurrentVersion returns the current application version func (u *UpdateServiceImpl) GetCurrentVersion() string { return entities.AppVersion diff --git a/go.mod b/go.mod index ece556c..956d7e3 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/go-git/go-git/v5 v5.13.2 github.com/go-playground/validator v9.31.0+incompatible + github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf github.com/rs/zerolog v1.33.0 github.com/scanoss/go-purl-helper v0.2.1 github.com/spf13/cobra v1.8.1 @@ -31,7 +32,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect diff --git a/main.go b/main.go index 48a67c3..6d911cb 100644 --- a/main.go +++ b/main.go @@ -63,9 +63,6 @@ func run() error { validate.RegisterValidation("valid-purl", utils.ValidatePurl) utils.SetValidator(validate) - // Check for pending updates (Linux only) - service.CheckPendingUpdate() - err := cmd.Execute() if err != nil { return fmt.Errorf("error: %v", err) From 64787b119350a3a330a10f9b6324fb31eeb255bb Mon Sep 17 00:00:00 2001 From: Matias Daloia Date: Fri, 7 Nov 2025 12:51:45 +0100 Subject: [PATCH 2/7] fix: restart on same scan root --- backend/service/update_service_impl.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/service/update_service_impl.go b/backend/service/update_service_impl.go index d3d8a43..def3a3c 100644 --- a/backend/service/update_service_impl.go +++ b/backend/service/update_service_impl.go @@ -42,6 +42,7 @@ import ( "github.com/inconshreveable/go-update" "github.com/rs/zerolog/log" "github.com/scanoss/scanoss.cc/backend/entities" + "github.com/scanoss/scanoss.cc/internal/config" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) @@ -302,6 +303,9 @@ func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { // Mount the DMG mountPoint := filepath.Join(os.TempDir(), "scanoss-update-mount") + if err := os.MkdirAll(mountPoint, 0o755); err != nil { + return fmt.Errorf("failed to prepare mount point: %w", err) + } log.Info().Msgf("Mounting DMG to %s...", mountPoint) cmd = exec.Command("hdiutil", "attach", dmgPath, "-nobrowse", "-mountpoint", mountPoint) if err := cmd.Run(); err != nil { @@ -375,10 +379,15 @@ func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { log.Info().Msg("Update applied successfully, restarting application...") // Get the path to the .app bundle to restart it - appBundlePath := "/Applications/scanoss-cc.app" - cmd = exec.Command("open", appBundlePath) - if err := cmd.Start(); err != nil { - log.Warn().Err(err).Msg("failed to restart application") + appBundlePath := filepath.Dir(filepath.Dir(filepath.Dir(currentExe))) + if _, err := os.Stat(appBundlePath); err != nil { + log.Warn().Err(err).Msg("failed to locate app bundle for restart") + } else { + currentScanRoot := config.GetInstance().GetScanRoot() + cmd = exec.Command("open", appBundlePath, "--args", "--scan-root", currentScanRoot) + if err := cmd.Start(); err != nil { + log.Warn().Err(err).Msg("failed to restart application") + } } // Quit the current instance From 51227ff073a0fa6977aa880d186ae69973aed5eb Mon Sep 17 00:00:00 2001 From: Matias Daloia Date: Fri, 7 Nov 2025 13:00:47 +0100 Subject: [PATCH 3/7] fix: handle update rollbacks --- backend/service/update_service_impl.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/service/update_service_impl.go b/backend/service/update_service_impl.go index def3a3c..ace393e 100644 --- a/backend/service/update_service_impl.go +++ b/backend/service/update_service_impl.go @@ -364,11 +364,12 @@ func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { // Apply the update using go-update library // This handles file locking correctly on macOS too log.Info().Msg("Applying update to binary...") - err = update.Apply(newBinary, update.Options{ + if err := update.Apply(newBinary, update.Options{ TargetPath: currentExe, - }) - if err != nil { - // Rollback is handled automatically by the library + }); err != nil { + if rollbackErr := update.RollbackError(err); rollbackErr != nil { + log.Error().Err(rollbackErr).Msg("failed to rollback after macOS update error") + } return fmt.Errorf("failed to apply update: %w", err) } @@ -556,11 +557,12 @@ func (u *UpdateServiceImpl) applyZipUpdate(updatePath string) error { // Apply the update using go-update library // Linux allows replacing running executables, so this works directly log.Info().Msg("Applying update to binary...") - err = update.Apply(newBinary, update.Options{ + if err := update.Apply(newBinary, update.Options{ TargetPath: currentExe, - }) - if err != nil { - // Rollback is handled automatically by the library + }); err != nil { + if rollbackErr := update.RollbackError(err); rollbackErr != nil { + log.Error().Err(rollbackErr).Msg("failed to rollback after Linux update error") + } return fmt.Errorf("failed to apply update: %w", err) } From 766f837c81ef0521fa3f187ec181726d6e7782f1 Mon Sep 17 00:00:00 2001 From: Matias Daloia Date: Fri, 7 Nov 2025 15:56:36 +0100 Subject: [PATCH 4/7] fix: macos update --- backend/service/update_service.go | 6 + backend/service/update_service_impl.go | 298 +++++++++++++++++++++---- cmd/root.go | 37 ++- 3 files changed, 283 insertions(+), 58 deletions(-) diff --git a/backend/service/update_service.go b/backend/service/update_service.go index ec2f7aa..79e9323 100644 --- a/backend/service/update_service.go +++ b/backend/service/update_service.go @@ -45,4 +45,10 @@ type UpdateService interface { // SetContext sets the context for the service SetContext(ctx context.Context) + + // VerifyUpdateSuccess checks if an update completed successfully and cleans up backup + VerifyUpdateSuccess() error + + // CheckForFailedUpdate checks if the previous update failed and performs rollback if needed + CheckForFailedUpdate() error } diff --git a/backend/service/update_service_impl.go b/backend/service/update_service_impl.go index ace393e..df4522d 100644 --- a/backend/service/update_service_impl.go +++ b/backend/service/update_service_impl.go @@ -49,6 +49,106 @@ import ( const ( githubAPIURL = "https://api.github.com/repos/scanoss/scanoss.cc/releases/latest" downloadTimeout = 10 * time.Minute + + // updateHelperScript is the shell script that performs atomic .app bundle replacement + updateHelperScript = `#!/bin/bash +# SCANOSS Update Helper Script +# This script performs atomic .app bundle replacement for macOS updates + +set -e # Exit on error +set -u # Exit on undefined variable + +OLD_APP_PATH="$1" +NEW_APP_PATH="$2" +BACKUP_PATH="$3" +SCAN_ROOT="$4" +OLD_PID="$5" + +LOG_FILE="/tmp/scanoss-update.log" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" +} + +log "Update helper started" +log "Old app: $OLD_APP_PATH" +log "New app: $NEW_APP_PATH" +log "Backup: $BACKUP_PATH" +log "Scan root: $SCAN_ROOT" +log "Old PID: $OLD_PID" + +# Wait for old process to exit (max 30 seconds) +log "Waiting for old process to exit..." +WAIT_COUNT=0 +while kill -0 "$OLD_PID" 2>/dev/null; do + sleep 1 + WAIT_COUNT=$((WAIT_COUNT + 1)) + if [ $WAIT_COUNT -gt 30 ]; then + log "ERROR: Old process did not exit in time" + exit 1 + fi +done +log "Old process exited" + +# Additional grace period for file locks to release +sleep 2 + +# Verify new app signature +log "Verifying new app signature..." +if ! codesign --verify --deep "$NEW_APP_PATH" 2>&1 | tee -a "$LOG_FILE"; then + log "ERROR: New app signature verification failed" + exit 1 +fi +log "Signature verification passed" + +# Create backup of old app +log "Creating backup..." +if [ -d "$BACKUP_PATH" ]; then + rm -rf "$BACKUP_PATH" +fi +if ! mv "$OLD_APP_PATH" "$BACKUP_PATH" 2>&1 | tee -a "$LOG_FILE"; then + log "ERROR: Failed to create backup" + exit 1 +fi +log "Backup created successfully" + +# Copy new app using ditto (preserves extended attributes) +log "Installing new app..." +if ! ditto --extattr "$NEW_APP_PATH" "$OLD_APP_PATH" 2>&1 | tee -a "$LOG_FILE"; then + log "ERROR: Failed to install new app, restoring backup..." + mv "$BACKUP_PATH" "$OLD_APP_PATH" + exit 1 +fi +log "New app installed" + +# Remove quarantine attributes from entire bundle +log "Removing quarantine attributes..." +xattr -dr com.apple.quarantine "$OLD_APP_PATH" 2>&1 | tee -a "$LOG_FILE" || true +log "Quarantine removed" + +# Launch new app +log "Launching new app..." +if [ -n "$SCAN_ROOT" ]; then + open "$OLD_APP_PATH" --args --scan-root "$SCAN_ROOT" --check-update-success 2>&1 | tee -a "$LOG_FILE" || { + log "ERROR: Failed to launch new app, restoring backup..." + rm -rf "$OLD_APP_PATH" + mv "$BACKUP_PATH" "$OLD_APP_PATH" + open "$OLD_APP_PATH" --args --scan-root "$SCAN_ROOT" + exit 1 + } +else + open "$OLD_APP_PATH" --args --check-update-success 2>&1 | tee -a "$LOG_FILE" || { + log "ERROR: Failed to launch new app, restoring backup..." + rm -rf "$OLD_APP_PATH" + mv "$BACKUP_PATH" "$OLD_APP_PATH" + open "$OLD_APP_PATH" + exit 1 + } +fi + +log "Update completed successfully" +exit 0 +` ) type UpdateServiceImpl struct { @@ -264,9 +364,9 @@ func (u *UpdateServiceImpl) ApplyUpdate(updatePath string) error { } func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { - // For macOS, the update is a .zip containing a .dmg file - // We need to extract, mount, copy, and install the .app bundle - log.Info().Msg("Extracting and installing macOS DMG...") + // For macOS, the update is a .zip containing a .dmg file with a signed .app bundle + // We use a helper script to atomically replace the entire .app bundle + log.Info().Msg("Extracting and installing macOS update...") // Extract the ZIP file to a temporary directory extractDir := filepath.Join(os.TempDir(), "scanoss-update-extract") @@ -276,9 +376,10 @@ func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { defer os.RemoveAll(extractDir) // Use unzip command to extract + log.Info().Msg("Extracting ZIP...") cmd := exec.Command("unzip", "-o", updatePath, "-d", extractDir) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to extract ZIP: %w", err) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to extract ZIP: %w (output: %s)", err, string(output)) } // Find the DMG file in the extracted directory @@ -301,6 +402,16 @@ func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { return fmt.Errorf("no DMG file found in update ZIP") } + log.Info().Msgf("Found DMG: %s", dmgPath) + + // Verify DMG signature + log.Info().Msg("Verifying DMG signature...") + cmd = exec.Command("codesign", "--verify", "--deep", dmgPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("DMG signature verification failed: %w (output: %s)", err, string(output)) + } + log.Info().Msg("DMG signature verification passed") + // Mount the DMG mountPoint := filepath.Join(os.TempDir(), "scanoss-update-mount") if err := os.MkdirAll(mountPoint, 0o755); err != nil { @@ -308,13 +419,16 @@ func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { } log.Info().Msgf("Mounting DMG to %s...", mountPoint) cmd = exec.Command("hdiutil", "attach", dmgPath, "-nobrowse", "-mountpoint", mountPoint) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to mount DMG: %w", err) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to mount DMG: %w (output: %s)", err, string(output)) } // Ensure DMG is unmounted on exit defer func() { log.Info().Msg("Unmounting DMG...") - exec.Command("hdiutil", "detach", mountPoint, "-force").Run() + cmd := exec.Command("hdiutil", "detach", mountPoint, "-force") + if output, err := cmd.CombinedOutput(); err != nil { + log.Warn().Err(err).Msgf("Failed to unmount DMG (output: %s)", string(output)) + } os.RemoveAll(mountPoint) }() @@ -338,60 +452,66 @@ func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { return fmt.Errorf("no .app bundle found in DMG") } - // Find the binary inside the new .app bundle - newBinaryPath := filepath.Join(appPath, "Contents", "MacOS", "scanoss-cc") - if _, err := os.Stat(newBinaryPath); err != nil { - return fmt.Errorf("could not find binary in app bundle: %w", err) + log.Info().Msgf("Found .app bundle: %s", appPath) + + // Verify the .app bundle signature + log.Info().Msg("Verifying .app bundle signature...") + cmd = exec.Command("codesign", "--verify", "--deep", appPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf(".app bundle signature verification failed: %w (output: %s)", err, string(output)) } + log.Info().Msg(".app bundle signature verification passed") - log.Info().Msgf("Found new binary: %s", newBinaryPath) + // Copy the entire .app bundle to a staging location + stagingDir := filepath.Join(os.TempDir(), "scanoss-update-staging") + if err := os.MkdirAll(stagingDir, 0o755); err != nil { + return fmt.Errorf("failed to create staging directory: %w", err) + } + defer os.RemoveAll(stagingDir) - // Get current executable path (inside our current .app bundle) - currentExe, err := os.Executable() - if err != nil { - return fmt.Errorf("failed to get current executable: %w", err) + newAppPath := filepath.Join(stagingDir, filepath.Base(appPath)) + log.Info().Msgf("Copying .app bundle to staging location: %s", newAppPath) + cmd = exec.Command("ditto", "--extattr", appPath, newAppPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to copy .app bundle: %w (output: %s)", err, string(output)) } - log.Info().Msgf("Current executable: %s", currentExe) + // Current app bundle path (assuming /Applications installation) + currentAppPath := "/Applications/scanoss-cc.app" + log.Info().Msgf("Current app bundle: %s", currentAppPath) - // Open the new binary file - newBinary, err := os.Open(newBinaryPath) - if err != nil { - return fmt.Errorf("failed to open new binary: %w", err) + // Verify the current app exists + if _, err := os.Stat(currentAppPath); err != nil { + return fmt.Errorf("current app not found at %s: %w", currentAppPath, err) } - defer newBinary.Close() - // Apply the update using go-update library - // This handles file locking correctly on macOS too - log.Info().Msg("Applying update to binary...") - if err := update.Apply(newBinary, update.Options{ - TargetPath: currentExe, - }); err != nil { - if rollbackErr := update.RollbackError(err); rollbackErr != nil { - log.Error().Err(rollbackErr).Msg("failed to rollback after macOS update error") - } - return fmt.Errorf("failed to apply update: %w", err) - } + // Backup path + backupPath := "/Applications/.scanoss-cc.app.backup" - // Clear macOS quarantine attribute on the updated binary - log.Info().Msg("Clearing quarantine attributes...") - exec.Command("xattr", "-d", "com.apple.quarantine", currentExe).Run() + // Get current process PID + pid := os.Getpid() - log.Info().Msg("Update applied successfully, restarting application...") + // Get current scan root + currentScanRoot := config.GetInstance().GetScanRoot() - // Get the path to the .app bundle to restart it - appBundlePath := filepath.Dir(filepath.Dir(filepath.Dir(currentExe))) - if _, err := os.Stat(appBundlePath); err != nil { - log.Warn().Err(err).Msg("failed to locate app bundle for restart") - } else { - currentScanRoot := config.GetInstance().GetScanRoot() - cmd = exec.Command("open", appBundlePath, "--args", "--scan-root", currentScanRoot) - if err := cmd.Start(); err != nil { - log.Warn().Err(err).Msg("failed to restart application") - } + // Write the helper script to a temporary file + helperScriptPath := filepath.Join(os.TempDir(), "scanoss-update-helper.sh") + if err := os.WriteFile(helperScriptPath, []byte(updateHelperScript), 0o755); err != nil { + return fmt.Errorf("failed to write helper script: %w", err) } + defer os.Remove(helperScriptPath) - // Quit the current instance + log.Info().Msg("Launching update helper script...") + + // Launch the helper script in the background + cmd = exec.Command(helperScriptPath, currentAppPath, newAppPath, backupPath, currentScanRoot, fmt.Sprintf("%d", pid)) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to launch helper script: %w", err) + } + + log.Info().Msg("Helper script launched, quitting application...") + + // Quit the current instance - the helper will wait for us to exit wailsruntime.Quit(u.ctx) return nil } @@ -617,6 +737,86 @@ func (u *UpdateServiceImpl) GetCurrentVersion() string { return entities.AppVersion } +// VerifyUpdateSuccess checks if an update completed successfully and cleans up backup +// This should be called on app startup when --check-update-success flag is present +func (u *UpdateServiceImpl) VerifyUpdateSuccess() error { + if runtime.GOOS != "darwin" { + return nil // Only implemented for macOS currently + } + + backupPath := "/Applications/.scanoss-cc.app.backup" + + // Check if backup exists + if _, err := os.Stat(backupPath); err != nil { + if os.IsNotExist(err) { + // No backup exists, nothing to clean up + return nil + } + return fmt.Errorf("failed to check backup status: %w", err) + } + + // Backup exists - update was successful, clean it up + log.Info().Msg("Update completed successfully, removing backup...") + if err := os.RemoveAll(backupPath); err != nil { + log.Warn().Err(err).Msg("Failed to remove backup (non-fatal)") + return nil // Non-fatal error + } + + log.Info().Msg("Update verification complete") + return nil +} + +// CheckForFailedUpdate checks if the previous update failed and performs rollback if needed +// This should be called on app startup before checking for update success +func (u *UpdateServiceImpl) CheckForFailedUpdate() error { + if runtime.GOOS != "darwin" { + return nil // Only implemented for macOS currently + } + + backupPath := "/Applications/.scanoss-cc.app.backup" + currentAppPath := "/Applications/scanoss-cc.app" + + // Check if backup exists + if _, err := os.Stat(backupPath); err != nil { + if os.IsNotExist(err) { + // No backup exists, no failed update + return nil + } + return fmt.Errorf("failed to check backup status: %w", err) + } + + // Backup exists - this means either: + // 1. Update is in progress (helper script is running) + // 2. Update failed and we need to rollback + + // If we're here with a backup and no --check-update-success flag, + // it means the update failed (new version crashed before startup) + + log.Warn().Msg("Detected failed update, attempting rollback...") + + // Remove the current (failed) app + if err := os.RemoveAll(currentAppPath); err != nil { + return fmt.Errorf("failed to remove failed app during rollback: %w", err) + } + + // Restore from backup + if err := os.Rename(backupPath, currentAppPath); err != nil { + return fmt.Errorf("failed to restore backup during rollback: %w", err) + } + + log.Info().Msg("Rollback successful - previous version restored") + + // Restart the app + cmd := exec.Command("open", currentAppPath) + if err := cmd.Start(); err != nil { + log.Error().Err(err).Msg("Failed to restart after rollback") + } + + // Exit this (failed) instance + os.Exit(1) + return nil +} + // Helper function to get the appropriate asset name for the current platform func getAssetNameForPlatform() string { switch runtime.GOOS { diff --git a/cmd/root.go b/cmd/root.go index 6bd42d2..b3aabe6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -29,19 +29,23 @@ import ( "github.com/rs/zerolog/log" "github.com/scanoss/scanoss.cc/backend/entities" + "github.com/scanoss/scanoss.cc/backend/service" "github.com/scanoss/scanoss.cc/internal/config" "github.com/spf13/cobra" ) -var apiKey string -var apiUrl string -var cfgFile string -var debug bool -var inputFile string -var scanossSettingsFilePath string -var scanRoot string -var version bool -var originalWorkDir string +var ( + apiKey string + apiUrl string + cfgFile string + debug bool + inputFile string + scanossSettingsFilePath string + scanRoot string + version bool + originalWorkDir string + checkUpdateSuccess bool +) // This is a workaround to exit the process when the help command is called instead of spinning up the UI func setupHelpCommand(cmd *cobra.Command) { @@ -83,6 +87,7 @@ func init() { rootCmd.Flags().StringVarP(&apiKey, "key", "k", "", "SCANOSS API Key token (optional)") rootCmd.Flags().StringVarP(&apiUrl, "apiUrl", "u", "", fmt.Sprintf("SCANOSS API URL (optional - default: %s)", config.DEFAULT_API_URL)) rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug mode") + rootCmd.Flags().BoolVar(&checkUpdateSuccess, "check-update-success", false, "Internal flag to verify update success (do not use manually)") rootCmd.Root().CompletionOptions.HiddenDefaultCmd = true @@ -90,6 +95,20 @@ func init() { } func initConfig() { + // Check for failed updates first (before initializing config) + // This must happen early in the startup process + updateService := service.NewUpdateService() + if err := updateService.CheckForFailedUpdate(); err != nil { + log.Error().Err(err).Msg("Failed to check for failed update") + } + + if checkUpdateSuccess { + log.Info().Msg("Verifying update success...") + if err := updateService.VerifyUpdateSuccess(); err != nil { + log.Error().Err(err).Msg("Failed to verify update success") + } + } + cfg := config.GetInstance() if err := cfg.InitializeConfig(cfgFile, scanRoot, apiKey, apiUrl, inputFile, scanossSettingsFilePath, originalWorkDir, debug); err != nil { From 0cac6ad41f45accab1b51d871981c79e4bb7c59b Mon Sep 17 00:00:00 2001 From: Matias Daloia Date: Fri, 7 Nov 2025 16:17:32 +0100 Subject: [PATCH 5/7] fix: update version modal padding --- frontend/src/components/UpdateNotification.tsx | 2 +- frontend/wailsjs/go/service/UpdateServiceImpl.d.ts | 4 ++++ frontend/wailsjs/go/service/UpdateServiceImpl.js | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/UpdateNotification.tsx b/frontend/src/components/UpdateNotification.tsx index 5118160..ad1f39f 100644 --- a/frontend/src/components/UpdateNotification.tsx +++ b/frontend/src/components/UpdateNotification.tsx @@ -123,7 +123,7 @@ export default function UpdateNotification() { - + Update Available A new version of SCANOSS Code Compare is available. diff --git a/frontend/wailsjs/go/service/UpdateServiceImpl.d.ts b/frontend/wailsjs/go/service/UpdateServiceImpl.d.ts index 0cb7b08..42a1ab8 100755 --- a/frontend/wailsjs/go/service/UpdateServiceImpl.d.ts +++ b/frontend/wailsjs/go/service/UpdateServiceImpl.d.ts @@ -5,6 +5,8 @@ import {context} from '../models'; export function ApplyUpdate(arg1:string):Promise; +export function CheckForFailedUpdate():Promise; + export function CheckForUpdate():Promise; export function DownloadUpdate(arg1:entities.UpdateInfo):Promise; @@ -12,3 +14,5 @@ export function DownloadUpdate(arg1:entities.UpdateInfo):Promise; export function GetCurrentVersion():Promise; export function SetContext(arg1:context.Context):Promise; + +export function VerifyUpdateSuccess():Promise; diff --git a/frontend/wailsjs/go/service/UpdateServiceImpl.js b/frontend/wailsjs/go/service/UpdateServiceImpl.js index bfd6c13..5ad4960 100755 --- a/frontend/wailsjs/go/service/UpdateServiceImpl.js +++ b/frontend/wailsjs/go/service/UpdateServiceImpl.js @@ -6,6 +6,10 @@ export function ApplyUpdate(arg1) { return window['go']['service']['UpdateServiceImpl']['ApplyUpdate'](arg1); } +export function CheckForFailedUpdate() { + return window['go']['service']['UpdateServiceImpl']['CheckForFailedUpdate'](); +} + export function CheckForUpdate() { return window['go']['service']['UpdateServiceImpl']['CheckForUpdate'](); } @@ -21,3 +25,7 @@ export function GetCurrentVersion() { export function SetContext(arg1) { return window['go']['service']['UpdateServiceImpl']['SetContext'](arg1); } + +export function VerifyUpdateSuccess() { + return window['go']['service']['UpdateServiceImpl']['VerifyUpdateSuccess'](); +} From 9a406ee270491d530fb4148aa1365ce104ab2633 Mon Sep 17 00:00:00 2001 From: Matias Daloia Date: Fri, 7 Nov 2025 16:19:53 +0100 Subject: [PATCH 6/7] fix: preserve --scan-root on restart for windows/linux --- backend/service/update_service_impl.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/service/update_service_impl.go b/backend/service/update_service_impl.go index df4522d..f2041c9 100644 --- a/backend/service/update_service_impl.go +++ b/backend/service/update_service_impl.go @@ -583,7 +583,12 @@ func (u *UpdateServiceImpl) applyUpdateWindows(updatePath string) error { log.Info().Msg("Update applied successfully, restarting application...") // Restart the application - cmd = exec.Command(currentExe, os.Args[1:]...) + currentScanRoot := config.GetInstance().GetScanRoot() + var args []string + if currentScanRoot != "" { + args = []string{"--scan-root", currentScanRoot} + } + cmd = exec.Command(currentExe, args...) if err := cmd.Start(); err != nil { log.Warn().Err(err).Msg("failed to restart application") } @@ -689,7 +694,12 @@ func (u *UpdateServiceImpl) applyZipUpdate(updatePath string) error { log.Info().Msg("Update applied successfully, restarting application...") // Restart the application - cmd = exec.Command(currentExe, os.Args[1:]...) + currentScanRoot := config.GetInstance().GetScanRoot() + var args []string + if currentScanRoot != "" { + args = []string{"--scan-root", currentScanRoot} + } + cmd = exec.Command(currentExe, args...) if err := cmd.Start(); err != nil { log.Warn().Err(err).Msg("failed to restart application") } From aec26142c355905d806434fff7384357d31f4c26 Mon Sep 17 00:00:00 2001 From: Matias Daloia Date: Fri, 7 Nov 2025 18:41:53 +0100 Subject: [PATCH 7/7] chore: completely remove auto-updater --- CHANGELOG.md | 12 - README.md | 8 - backend/service/mocks/mock_UpdateService.go | 274 ------ backend/service/update_service.go | 54 -- backend/service/update_service_impl.go | 909 ------------------ cmd/root.go | 17 - frontend/src/components/StatusBar.tsx | 2 - .../src/components/UpdateNotification.tsx | 177 ---- frontend/wailsjs/go/models.ts | 43 - .../wailsjs/go/service/UpdateServiceImpl.d.ts | 18 - .../wailsjs/go/service/UpdateServiceImpl.js | 31 - go.mod | 2 - go.sum | 4 - main.go | 3 - 14 files changed, 1554 deletions(-) delete mode 100644 backend/service/mocks/mock_UpdateService.go delete mode 100644 backend/service/update_service.go delete mode 100644 backend/service/update_service_impl.go delete mode 100644 frontend/src/components/UpdateNotification.tsx delete mode 100755 frontend/wailsjs/go/service/UpdateServiceImpl.d.ts delete mode 100755 frontend/wailsjs/go/service/UpdateServiceImpl.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f860441..355b23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,18 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.9.5] 2025-11-07 ### Fixed - Fix macOS postinstall script where it was not properly handling the `--scan-root` argument. -- **macOS**: Complete auto-update implementation - extracts binary from DMG and uses go-update for atomic replacement -- **Windows**: Fix critical file locking bug by integrating go-update library for proper binary replacement -- **Linux**: Use go-update library for atomic binary replacement with automatic rollback -- Add ELF binary verification for Linux updates to prevent corrupted downloads - -### Changed -- **All platforms now use go-update library** for consistent, reliable binary replacement -- macOS updates extract and replace just the binary inside .app bundle (simpler and more reliable) -- Windows and Linux updates also use go-update for atomic operations -- Linux auto-updates now detect webkit version (webkit40/webkit41) based on distro version -- All platforms have automatic rollback on update failure built into go-update -- Significantly simplified update code by using proven library instead of custom approaches ## [0.9.4] 2025-11-06 ### Changed diff --git a/README.md b/README.md index c78f4c6..a6340f7 100644 --- a/README.md +++ b/README.md @@ -108,14 +108,6 @@ scanoss-cc --version For complete uninstall instructions for all platforms, see [INSTALLATION.md](INSTALLATION.md#uninstalling). -### Auto-Updates - -SCANOSS Code Compare includes an automatic update system that notifies you when new versions are available: - -- **Status Bar Notification**: When an update is available, a button appears in the status bar at the bottom of the window -- **One-Click Update**: Click the notification to download and install the update -- **Seamless Restart**: After downloading, the app will restart to apply the update - ## Usage ### CLI Parameters diff --git a/backend/service/mocks/mock_UpdateService.go b/backend/service/mocks/mock_UpdateService.go deleted file mode 100644 index 0e6a612..0000000 --- a/backend/service/mocks/mock_UpdateService.go +++ /dev/null @@ -1,274 +0,0 @@ -// Code generated by mockery v2.46.1. DO NOT EDIT. - -package mocks - -import ( - context "context" - - entities "github.com/scanoss/scanoss.cc/backend/entities" - mock "github.com/stretchr/testify/mock" -) - -// MockUpdateService is an autogenerated mock type for the UpdateService type -type MockUpdateService struct { - mock.Mock -} - -type MockUpdateService_Expecter struct { - mock *mock.Mock -} - -func (_m *MockUpdateService) EXPECT() *MockUpdateService_Expecter { - return &MockUpdateService_Expecter{mock: &_m.Mock} -} - -// ApplyUpdate provides a mock function with given fields: updatePath -func (_m *MockUpdateService) ApplyUpdate(updatePath string) error { - ret := _m.Called(updatePath) - - if len(ret) == 0 { - panic("no return value specified for ApplyUpdate") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string) error); ok { - r0 = rf(updatePath) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MockUpdateService_ApplyUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyUpdate' -type MockUpdateService_ApplyUpdate_Call struct { - *mock.Call -} - -// ApplyUpdate is a helper method to define mock.On call -// - updatePath string -func (_e *MockUpdateService_Expecter) ApplyUpdate(updatePath interface{}) *MockUpdateService_ApplyUpdate_Call { - return &MockUpdateService_ApplyUpdate_Call{Call: _e.mock.On("ApplyUpdate", updatePath)} -} - -func (_c *MockUpdateService_ApplyUpdate_Call) Run(run func(updatePath string)) *MockUpdateService_ApplyUpdate_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string)) - }) - return _c -} - -func (_c *MockUpdateService_ApplyUpdate_Call) Return(_a0 error) *MockUpdateService_ApplyUpdate_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockUpdateService_ApplyUpdate_Call) RunAndReturn(run func(string) error) *MockUpdateService_ApplyUpdate_Call { - _c.Call.Return(run) - return _c -} - -// CheckForUpdate provides a mock function with given fields: -func (_m *MockUpdateService) CheckForUpdate() (*entities.UpdateInfo, error) { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for CheckForUpdate") - } - - var r0 *entities.UpdateInfo - var r1 error - if rf, ok := ret.Get(0).(func() (*entities.UpdateInfo, error)); ok { - return rf() - } - if rf, ok := ret.Get(0).(func() *entities.UpdateInfo); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*entities.UpdateInfo) - } - } - - if rf, ok := ret.Get(1).(func() error); ok { - r1 = rf() - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockUpdateService_CheckForUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckForUpdate' -type MockUpdateService_CheckForUpdate_Call struct { - *mock.Call -} - -// CheckForUpdate is a helper method to define mock.On call -func (_e *MockUpdateService_Expecter) CheckForUpdate() *MockUpdateService_CheckForUpdate_Call { - return &MockUpdateService_CheckForUpdate_Call{Call: _e.mock.On("CheckForUpdate")} -} - -func (_c *MockUpdateService_CheckForUpdate_Call) Run(run func()) *MockUpdateService_CheckForUpdate_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockUpdateService_CheckForUpdate_Call) Return(_a0 *entities.UpdateInfo, _a1 error) *MockUpdateService_CheckForUpdate_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockUpdateService_CheckForUpdate_Call) RunAndReturn(run func() (*entities.UpdateInfo, error)) *MockUpdateService_CheckForUpdate_Call { - _c.Call.Return(run) - return _c -} - -// DownloadUpdate provides a mock function with given fields: updateInfo -func (_m *MockUpdateService) DownloadUpdate(updateInfo *entities.UpdateInfo) (string, error) { - ret := _m.Called(updateInfo) - - if len(ret) == 0 { - panic("no return value specified for DownloadUpdate") - } - - var r0 string - var r1 error - if rf, ok := ret.Get(0).(func(*entities.UpdateInfo) (string, error)); ok { - return rf(updateInfo) - } - if rf, ok := ret.Get(0).(func(*entities.UpdateInfo) string); ok { - r0 = rf(updateInfo) - } else { - r0 = ret.Get(0).(string) - } - - if rf, ok := ret.Get(1).(func(*entities.UpdateInfo) error); ok { - r1 = rf(updateInfo) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockUpdateService_DownloadUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DownloadUpdate' -type MockUpdateService_DownloadUpdate_Call struct { - *mock.Call -} - -// DownloadUpdate is a helper method to define mock.On call -// - updateInfo *entities.UpdateInfo -func (_e *MockUpdateService_Expecter) DownloadUpdate(updateInfo interface{}) *MockUpdateService_DownloadUpdate_Call { - return &MockUpdateService_DownloadUpdate_Call{Call: _e.mock.On("DownloadUpdate", updateInfo)} -} - -func (_c *MockUpdateService_DownloadUpdate_Call) Run(run func(updateInfo *entities.UpdateInfo)) *MockUpdateService_DownloadUpdate_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*entities.UpdateInfo)) - }) - return _c -} - -func (_c *MockUpdateService_DownloadUpdate_Call) Return(_a0 string, _a1 error) *MockUpdateService_DownloadUpdate_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockUpdateService_DownloadUpdate_Call) RunAndReturn(run func(*entities.UpdateInfo) (string, error)) *MockUpdateService_DownloadUpdate_Call { - _c.Call.Return(run) - return _c -} - -// GetCurrentVersion provides a mock function with given fields: -func (_m *MockUpdateService) GetCurrentVersion() string { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for GetCurrentVersion") - } - - var r0 string - if rf, ok := ret.Get(0).(func() string); ok { - r0 = rf() - } else { - r0 = ret.Get(0).(string) - } - - return r0 -} - -// MockUpdateService_GetCurrentVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCurrentVersion' -type MockUpdateService_GetCurrentVersion_Call struct { - *mock.Call -} - -// GetCurrentVersion is a helper method to define mock.On call -func (_e *MockUpdateService_Expecter) GetCurrentVersion() *MockUpdateService_GetCurrentVersion_Call { - return &MockUpdateService_GetCurrentVersion_Call{Call: _e.mock.On("GetCurrentVersion")} -} - -func (_c *MockUpdateService_GetCurrentVersion_Call) Run(run func()) *MockUpdateService_GetCurrentVersion_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockUpdateService_GetCurrentVersion_Call) Return(_a0 string) *MockUpdateService_GetCurrentVersion_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockUpdateService_GetCurrentVersion_Call) RunAndReturn(run func() string) *MockUpdateService_GetCurrentVersion_Call { - _c.Call.Return(run) - return _c -} - -// SetContext provides a mock function with given fields: ctx -func (_m *MockUpdateService) SetContext(ctx context.Context) { - _m.Called(ctx) -} - -// MockUpdateService_SetContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetContext' -type MockUpdateService_SetContext_Call struct { - *mock.Call -} - -// SetContext is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockUpdateService_Expecter) SetContext(ctx interface{}) *MockUpdateService_SetContext_Call { - return &MockUpdateService_SetContext_Call{Call: _e.mock.On("SetContext", ctx)} -} - -func (_c *MockUpdateService_SetContext_Call) Run(run func(ctx context.Context)) *MockUpdateService_SetContext_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context)) - }) - return _c -} - -func (_c *MockUpdateService_SetContext_Call) Return() *MockUpdateService_SetContext_Call { - _c.Call.Return() - return _c -} - -func (_c *MockUpdateService_SetContext_Call) RunAndReturn(run func(context.Context)) *MockUpdateService_SetContext_Call { - _c.Call.Return(run) - return _c -} - -// NewMockUpdateService creates a new instance of MockUpdateService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockUpdateService(t interface { - mock.TestingT - Cleanup(func()) -}) *MockUpdateService { - mock := &MockUpdateService{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/backend/service/update_service.go b/backend/service/update_service.go deleted file mode 100644 index 79e9323..0000000 --- a/backend/service/update_service.go +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: MIT -/* - * Copyright (C) 2018-2024 SCANOSS.COM - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package service - -import ( - "context" - - "github.com/scanoss/scanoss.cc/backend/entities" -) - -// UpdateService handles checking for and applying application updates -type UpdateService interface { - // CheckForUpdate checks if a new version is available - CheckForUpdate() (*entities.UpdateInfo, error) - - // DownloadUpdate downloads the new version to a temporary location - DownloadUpdate(updateInfo *entities.UpdateInfo) (string, error) - - // ApplyUpdate applies the downloaded update and restarts the application - ApplyUpdate(updatePath string) error - - // GetCurrentVersion returns the current application version - GetCurrentVersion() string - - // SetContext sets the context for the service - SetContext(ctx context.Context) - - // VerifyUpdateSuccess checks if an update completed successfully and cleans up backup - VerifyUpdateSuccess() error - - // CheckForFailedUpdate checks if the previous update failed and performs rollback if needed - CheckForFailedUpdate() error -} diff --git a/backend/service/update_service_impl.go b/backend/service/update_service_impl.go deleted file mode 100644 index f2041c9..0000000 --- a/backend/service/update_service_impl.go +++ /dev/null @@ -1,909 +0,0 @@ -// SPDX-License-Identifier: MIT -/* - * Copyright (C) 2018-2024 SCANOSS.COM - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "time" - - "github.com/Masterminds/semver/v3" - "github.com/inconshreveable/go-update" - "github.com/rs/zerolog/log" - "github.com/scanoss/scanoss.cc/backend/entities" - "github.com/scanoss/scanoss.cc/internal/config" - wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" -) - -const ( - githubAPIURL = "https://api.github.com/repos/scanoss/scanoss.cc/releases/latest" - downloadTimeout = 10 * time.Minute - - // updateHelperScript is the shell script that performs atomic .app bundle replacement - updateHelperScript = `#!/bin/bash -# SCANOSS Update Helper Script -# This script performs atomic .app bundle replacement for macOS updates - -set -e # Exit on error -set -u # Exit on undefined variable - -OLD_APP_PATH="$1" -NEW_APP_PATH="$2" -BACKUP_PATH="$3" -SCAN_ROOT="$4" -OLD_PID="$5" - -LOG_FILE="/tmp/scanoss-update.log" - -log() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" -} - -log "Update helper started" -log "Old app: $OLD_APP_PATH" -log "New app: $NEW_APP_PATH" -log "Backup: $BACKUP_PATH" -log "Scan root: $SCAN_ROOT" -log "Old PID: $OLD_PID" - -# Wait for old process to exit (max 30 seconds) -log "Waiting for old process to exit..." -WAIT_COUNT=0 -while kill -0 "$OLD_PID" 2>/dev/null; do - sleep 1 - WAIT_COUNT=$((WAIT_COUNT + 1)) - if [ $WAIT_COUNT -gt 30 ]; then - log "ERROR: Old process did not exit in time" - exit 1 - fi -done -log "Old process exited" - -# Additional grace period for file locks to release -sleep 2 - -# Verify new app signature -log "Verifying new app signature..." -if ! codesign --verify --deep "$NEW_APP_PATH" 2>&1 | tee -a "$LOG_FILE"; then - log "ERROR: New app signature verification failed" - exit 1 -fi -log "Signature verification passed" - -# Create backup of old app -log "Creating backup..." -if [ -d "$BACKUP_PATH" ]; then - rm -rf "$BACKUP_PATH" -fi -if ! mv "$OLD_APP_PATH" "$BACKUP_PATH" 2>&1 | tee -a "$LOG_FILE"; then - log "ERROR: Failed to create backup" - exit 1 -fi -log "Backup created successfully" - -# Copy new app using ditto (preserves extended attributes) -log "Installing new app..." -if ! ditto --extattr "$NEW_APP_PATH" "$OLD_APP_PATH" 2>&1 | tee -a "$LOG_FILE"; then - log "ERROR: Failed to install new app, restoring backup..." - mv "$BACKUP_PATH" "$OLD_APP_PATH" - exit 1 -fi -log "New app installed" - -# Remove quarantine attributes from entire bundle -log "Removing quarantine attributes..." -xattr -dr com.apple.quarantine "$OLD_APP_PATH" 2>&1 | tee -a "$LOG_FILE" || true -log "Quarantine removed" - -# Launch new app -log "Launching new app..." -if [ -n "$SCAN_ROOT" ]; then - open "$OLD_APP_PATH" --args --scan-root "$SCAN_ROOT" --check-update-success 2>&1 | tee -a "$LOG_FILE" || { - log "ERROR: Failed to launch new app, restoring backup..." - rm -rf "$OLD_APP_PATH" - mv "$BACKUP_PATH" "$OLD_APP_PATH" - open "$OLD_APP_PATH" --args --scan-root "$SCAN_ROOT" - exit 1 - } -else - open "$OLD_APP_PATH" --args --check-update-success 2>&1 | tee -a "$LOG_FILE" || { - log "ERROR: Failed to launch new app, restoring backup..." - rm -rf "$OLD_APP_PATH" - mv "$BACKUP_PATH" "$OLD_APP_PATH" - open "$OLD_APP_PATH" - exit 1 - } -fi - -log "Update completed successfully" -exit 0 -` -) - -type UpdateServiceImpl struct { - ctx context.Context - httpClient *http.Client - downloadDir string -} - -// NewUpdateService creates a new update service instance -func NewUpdateService() UpdateService { - return &UpdateServiceImpl{ - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - downloadDir: os.TempDir(), - } -} - -type githubRelease struct { - TagName string `json:"tag_name"` - Name string `json:"name"` - Body string `json:"body"` - PublishedAt time.Time `json:"published_at"` - Assets []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - Size int64 `json:"size"` - Digest *string `json:"digest"` // SHA256 digest in format "sha256:hash" - } `json:"assets"` -} - -// SetContext sets the context for the service -func (s *UpdateServiceImpl) SetContext(ctx context.Context) { - s.ctx = ctx -} - -// CheckForUpdate checks if a new version is available on GitHub -func (u *UpdateServiceImpl) CheckForUpdate() (*entities.UpdateInfo, error) { - log.Info().Msg("Checking for updates...") - - currentVersion := u.GetCurrentVersion() - if currentVersion == "" { - return nil, fmt.Errorf("current version is not set") - } - - // Fetch latest release from GitHub - req, err := http.NewRequestWithContext(u.ctx, "GET", githubAPIURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Accept", "application/vnd.github.v3+json") - - resp, err := u.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch release info: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("github API returned status %d", resp.StatusCode) - } - - var release githubRelease - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return nil, fmt.Errorf("failed to decode release info: %w", err) - } - - // Parse versions - latestVersion := strings.TrimPrefix(release.TagName, "v") - currentVersionClean := strings.TrimPrefix(currentVersion, "v") - - log.Info().Msgf("Current version: %s, Latest version: %s", currentVersionClean, latestVersion) - - // Compare versions - isNewer, err := isVersionNewer(currentVersionClean, latestVersion) - if err != nil { - return nil, fmt.Errorf("failed to compare versions: %w", err) - } - - if !isNewer { - log.Info().Msg("No update available") - return &entities.UpdateInfo{ - Version: latestVersion, - Available: false, - }, nil - } - - // Find the appropriate asset for the current platform - assetName := getAssetNameForPlatform() - var downloadURL string - var size int64 - var expectedSHA256 string - - for _, asset := range release.Assets { - if strings.Contains(asset.Name, assetName) { - downloadURL = asset.BrowserDownloadURL - size = asset.Size - - // Extract SHA256 digest from GitHub's automatic checksum - if asset.Digest != nil && *asset.Digest != "" { - // Digest format is "sha256:hash", extract just the hash - digest := *asset.Digest - expectedSHA256, _ = strings.CutPrefix(digest, "sha256:") - log.Info().Msgf("Found SHA256 digest for asset: %s", expectedSHA256) - } else { - log.Warn().Msg("No digest found for release asset - update download will fail verification") - } - break - } - } - - if downloadURL == "" { - return nil, fmt.Errorf("no suitable download found for this platform") - } - - log.Info().Msgf("Update available: %s", latestVersion) - - return &entities.UpdateInfo{ - Version: latestVersion, - DownloadURL: downloadURL, - ReleaseNotes: release.Body, - PublishedAt: release.PublishedAt, - Size: size, - Available: true, - ExpectedSHA256: expectedSHA256, - }, nil -} - -// DownloadUpdate downloads the new version to a temporary location -func (u *UpdateServiceImpl) DownloadUpdate(updateInfo *entities.UpdateInfo) (string, error) { - log.Info().Msgf("Downloading update from: %s", updateInfo.DownloadURL) - - // Create a temporary file - tmpFile, err := os.CreateTemp(u.downloadDir, "scanoss-update-*") - if err != nil { - return "", fmt.Errorf("failed to create temp file: %w", err) - } - defer tmpFile.Close() - - tmpPath := tmpFile.Name() - - // Create request with timeout - ctx, cancel := context.WithTimeout(u.ctx, downloadTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", updateInfo.DownloadURL, nil) - if err != nil { - os.Remove(tmpPath) - return "", fmt.Errorf("failed to create request: %w", err) - } - - // Download the file - resp, err := u.httpClient.Do(req) - if err != nil { - os.Remove(tmpPath) - return "", fmt.Errorf("failed to download update: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - os.Remove(tmpPath) - return "", fmt.Errorf("download failed with status %d", resp.StatusCode) - } - - // Copy with progress tracking - hash := sha256.New() - multiWriter := io.MultiWriter(tmpFile, hash) - - written, err := io.Copy(multiWriter, resp.Body) - if err != nil { - os.Remove(tmpPath) - return "", fmt.Errorf("failed to save update: %w", err) - } - - // Compute actual checksum as hex string - actualChecksum := hex.EncodeToString(hash.Sum(nil)) - log.Info().Msgf("Downloaded %d bytes (SHA256: %s)", written, actualChecksum) - - // Verify checksum - this is mandatory for security - if updateInfo.ExpectedSHA256 == "" { - os.Remove(tmpPath) - return "", fmt.Errorf("no expected checksum available for verification - cannot safely install update") - } - - expectedChecksum := strings.ToLower(strings.TrimSpace(updateInfo.ExpectedSHA256)) - actualChecksumLower := strings.ToLower(actualChecksum) - - if expectedChecksum != actualChecksumLower { - os.Remove(tmpPath) - return "", fmt.Errorf("checksum verification failed: expected %s, got %s", expectedChecksum, actualChecksumLower) - } - - log.Info().Msg("Checksum verification passed successfully") - - return tmpPath, nil -} - -// ApplyUpdate applies the downloaded update and restarts the application -func (u *UpdateServiceImpl) ApplyUpdate(updatePath string) error { - log.Info().Msgf("Applying update from: %s", updatePath) - - switch runtime.GOOS { - case "darwin": - return u.applyUpdateMacOS(updatePath) - case "windows": - return u.applyUpdateWindows(updatePath) - case "linux": - return u.applyUpdateLinux(updatePath) - default: - return fmt.Errorf("unsupported platform: %s", runtime.GOOS) - } -} - -func (u *UpdateServiceImpl) applyUpdateMacOS(updatePath string) error { - // For macOS, the update is a .zip containing a .dmg file with a signed .app bundle - // We use a helper script to atomically replace the entire .app bundle - log.Info().Msg("Extracting and installing macOS update...") - - // Extract the ZIP file to a temporary directory - extractDir := filepath.Join(os.TempDir(), "scanoss-update-extract") - if err := os.MkdirAll(extractDir, 0o755); err != nil { - return fmt.Errorf("failed to create extraction directory: %w", err) - } - defer os.RemoveAll(extractDir) - - // Use unzip command to extract - log.Info().Msg("Extracting ZIP...") - cmd := exec.Command("unzip", "-o", updatePath, "-d", extractDir) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to extract ZIP: %w (output: %s)", err, string(output)) - } - - // Find the DMG file in the extracted directory - var dmgPath string - err := filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".dmg") { - dmgPath = path - return filepath.SkipAll - } - return nil - }) - if err != nil { - return fmt.Errorf("failed to find DMG in ZIP: %w", err) - } - - if dmgPath == "" { - return fmt.Errorf("no DMG file found in update ZIP") - } - - log.Info().Msgf("Found DMG: %s", dmgPath) - - // Verify DMG signature - log.Info().Msg("Verifying DMG signature...") - cmd = exec.Command("codesign", "--verify", "--deep", dmgPath) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("DMG signature verification failed: %w (output: %s)", err, string(output)) - } - log.Info().Msg("DMG signature verification passed") - - // Mount the DMG - mountPoint := filepath.Join(os.TempDir(), "scanoss-update-mount") - if err := os.MkdirAll(mountPoint, 0o755); err != nil { - return fmt.Errorf("failed to prepare mount point: %w", err) - } - log.Info().Msgf("Mounting DMG to %s...", mountPoint) - cmd = exec.Command("hdiutil", "attach", dmgPath, "-nobrowse", "-mountpoint", mountPoint) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to mount DMG: %w (output: %s)", err, string(output)) - } - // Ensure DMG is unmounted on exit - defer func() { - log.Info().Msg("Unmounting DMG...") - cmd := exec.Command("hdiutil", "detach", mountPoint, "-force") - if output, err := cmd.CombinedOutput(); err != nil { - log.Warn().Err(err).Msgf("Failed to unmount DMG (output: %s)", string(output)) - } - os.RemoveAll(mountPoint) - }() - - // Find the .app bundle in the mounted DMG - var appPath string - err = filepath.Walk(mountPoint, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".app") { - appPath = path - return filepath.SkipAll - } - return nil - }) - if err != nil { - return fmt.Errorf("failed to find .app bundle in DMG: %w", err) - } - - if appPath == "" { - return fmt.Errorf("no .app bundle found in DMG") - } - - log.Info().Msgf("Found .app bundle: %s", appPath) - - // Verify the .app bundle signature - log.Info().Msg("Verifying .app bundle signature...") - cmd = exec.Command("codesign", "--verify", "--deep", appPath) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf(".app bundle signature verification failed: %w (output: %s)", err, string(output)) - } - log.Info().Msg(".app bundle signature verification passed") - - // Copy the entire .app bundle to a staging location - stagingDir := filepath.Join(os.TempDir(), "scanoss-update-staging") - if err := os.MkdirAll(stagingDir, 0o755); err != nil { - return fmt.Errorf("failed to create staging directory: %w", err) - } - defer os.RemoveAll(stagingDir) - - newAppPath := filepath.Join(stagingDir, filepath.Base(appPath)) - log.Info().Msgf("Copying .app bundle to staging location: %s", newAppPath) - cmd = exec.Command("ditto", "--extattr", appPath, newAppPath) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to copy .app bundle: %w (output: %s)", err, string(output)) - } - - // Current app bundle path (assuming /Applications installation) - currentAppPath := "/Applications/scanoss-cc.app" - log.Info().Msgf("Current app bundle: %s", currentAppPath) - - // Verify the current app exists - if _, err := os.Stat(currentAppPath); err != nil { - return fmt.Errorf("current app not found at %s: %w", currentAppPath, err) - } - - // Backup path - backupPath := "/Applications/.scanoss-cc.app.backup" - - // Get current process PID - pid := os.Getpid() - - // Get current scan root - currentScanRoot := config.GetInstance().GetScanRoot() - - // Write the helper script to a temporary file - helperScriptPath := filepath.Join(os.TempDir(), "scanoss-update-helper.sh") - if err := os.WriteFile(helperScriptPath, []byte(updateHelperScript), 0o755); err != nil { - return fmt.Errorf("failed to write helper script: %w", err) - } - defer os.Remove(helperScriptPath) - - log.Info().Msg("Launching update helper script...") - - // Launch the helper script in the background - cmd = exec.Command(helperScriptPath, currentAppPath, newAppPath, backupPath, currentScanRoot, fmt.Sprintf("%d", pid)) - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to launch helper script: %w", err) - } - - log.Info().Msg("Helper script launched, quitting application...") - - // Quit the current instance - the helper will wait for us to exit - wailsruntime.Quit(u.ctx) - return nil -} - -func (u *UpdateServiceImpl) applyUpdateWindows(updatePath string) error { - // For Windows, the update is a ZIP file containing the new executable - // We use go-update library which handles Windows file locking correctly - log.Info().Msg("Applying Windows update from ZIP...") - - // Get the current executable path - currentExe, err := os.Executable() - if err != nil { - return fmt.Errorf("failed to get current executable: %w", err) - } - - // Extract the ZIP file to a temporary directory - extractDir := filepath.Join(os.TempDir(), "scanoss-update-extract") - if err := os.MkdirAll(extractDir, 0o755); err != nil { - return fmt.Errorf("failed to create extraction directory: %w", err) - } - defer os.RemoveAll(extractDir) - - // Extract using PowerShell Expand-Archive (available on all modern Windows) - log.Info().Msg("Extracting update ZIP...") - cmd := exec.Command("powershell", "-Command", fmt.Sprintf("Expand-Archive -Path '%s' -DestinationPath '%s' -Force", updatePath, extractDir)) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to extract ZIP: %w", err) - } - - // Find the .exe file in the extracted directory - var newBinaryPath string - err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - // Look for .exe files - if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".exe") { - newBinaryPath = path - return filepath.SkipAll - } - return nil - }) - if err != nil { - return fmt.Errorf("failed to find executable in ZIP: %w", err) - } - - if newBinaryPath == "" { - return fmt.Errorf("no executable found in update ZIP") - } - - log.Info().Msgf("Found new binary: %s", newBinaryPath) - - // Open the new binary file - newBinary, err := os.Open(newBinaryPath) - if err != nil { - return fmt.Errorf("failed to open new binary: %w", err) - } - defer newBinary.Close() - - // Apply the update using go-update - log.Info().Msg("Applying update...") - err = update.Apply(newBinary, update.Options{ - TargetPath: currentExe, - }) - if err != nil { - return fmt.Errorf("failed to apply update: %w", err) - } - - log.Info().Msg("Update applied successfully, restarting application...") - - // Restart the application - currentScanRoot := config.GetInstance().GetScanRoot() - var args []string - if currentScanRoot != "" { - args = []string{"--scan-root", currentScanRoot} - } - cmd = exec.Command(currentExe, args...) - if err := cmd.Start(); err != nil { - log.Warn().Err(err).Msg("failed to restart application") - } - - // Quit the current instance - wailsruntime.Quit(u.ctx) - return nil -} - -func (u *UpdateServiceImpl) applyUpdateLinux(updatePath string) error { - // For Linux, we support .zip and .deb packages - ext := filepath.Ext(updatePath) - - switch ext { - case ".zip": - return u.applyZipUpdate(updatePath) - default: - return fmt.Errorf("unsupported update format: %s", ext) - } -} - -func (u *UpdateServiceImpl) applyZipUpdate(updatePath string) error { - log.Info().Msg("Applying ZIP update...") - - // Get the current executable path - currentExe, err := os.Executable() - if err != nil { - return fmt.Errorf("failed to get current executable: %w", err) - } - - // Extract the ZIP file to a temporary directory - extractDir := filepath.Join(os.TempDir(), "scanoss-update-extract") - if err := os.MkdirAll(extractDir, 0o755); err != nil { - return fmt.Errorf("failed to create extraction directory: %w", err) - } - defer os.RemoveAll(extractDir) - - // Use unzip command to extract - log.Info().Msg("Extracting update ZIP...") - cmd := exec.Command("unzip", "-o", updatePath, "-d", extractDir) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to extract ZIP: %w", err) - } - - // Find the binary in the extracted directory with better detection - var newBinaryPath string - var candidates []string - err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - // Look for executable files that are not directories - if !info.IsDir() && info.Mode()&0o111 != 0 { - // Prefer files with "scanoss" in the name - if strings.Contains(strings.ToLower(info.Name()), "scanoss") { - newBinaryPath = path - return filepath.SkipAll - } - // Otherwise collect as candidates - candidates = append(candidates, path) - } - return nil - }) - if err != nil { - return fmt.Errorf("failed to find binary in ZIP: %w", err) - } - - // If no scanoss binary found, use first candidate - if newBinaryPath == "" && len(candidates) > 0 { - newBinaryPath = candidates[0] - } - - if newBinaryPath == "" { - return fmt.Errorf("no executable found in update ZIP") - } - - log.Info().Msgf("Found new binary: %s", newBinaryPath) - - // Verify the binary is valid - if err := u.verifyLinuxBinary(newBinaryPath); err != nil { - return fmt.Errorf("binary verification failed: %w", err) - } - - // Open the new binary file - newBinary, err := os.Open(newBinaryPath) - if err != nil { - return fmt.Errorf("failed to open new binary: %w", err) - } - defer newBinary.Close() - - // Apply the update using go-update library - // Linux allows replacing running executables, so this works directly - log.Info().Msg("Applying update to binary...") - if err := update.Apply(newBinary, update.Options{ - TargetPath: currentExe, - }); err != nil { - if rollbackErr := update.RollbackError(err); rollbackErr != nil { - log.Error().Err(rollbackErr).Msg("failed to rollback after Linux update error") - } - return fmt.Errorf("failed to apply update: %w", err) - } - - log.Info().Msg("Update applied successfully, restarting application...") - - // Restart the application - currentScanRoot := config.GetInstance().GetScanRoot() - var args []string - if currentScanRoot != "" { - args = []string{"--scan-root", currentScanRoot} - } - cmd = exec.Command(currentExe, args...) - if err := cmd.Start(); err != nil { - log.Warn().Err(err).Msg("failed to restart application") - } - - // Quit the current instance - wailsruntime.Quit(u.ctx) - return nil -} - -// verifyLinuxBinary performs basic verification on a Linux binary -func (u *UpdateServiceImpl) verifyLinuxBinary(path string) error { - // Check file exists - info, err := os.Stat(path) - if err != nil { - return fmt.Errorf("file does not exist: %w", err) - } - - // Check minimum size (should be at least a few MB for a real binary) - if info.Size() < 1024*1024 { - return fmt.Errorf("binary too small: %d bytes", info.Size()) - } - - // Check if it's a valid ELF binary by reading magic bytes - file, err := os.Open(path) - if err != nil { - return fmt.Errorf("cannot open file: %w", err) - } - defer file.Close() - - magic := make([]byte, 4) - if _, err := file.Read(magic); err != nil { - return fmt.Errorf("cannot read file header: %w", err) - } - - // ELF magic: 0x7f 'E' 'L' 'F' - if magic[0] != 0x7f || magic[1] != 'E' || magic[2] != 'L' || magic[3] != 'F' { - return fmt.Errorf("not a valid ELF binary") - } - - return nil -} - -// GetCurrentVersion returns the current application version -func (u *UpdateServiceImpl) GetCurrentVersion() string { - return entities.AppVersion -} - -// VerifyUpdateSuccess checks if an update completed successfully and cleans up backup -// This should be called on app startup when --check-update-success flag is present -func (u *UpdateServiceImpl) VerifyUpdateSuccess() error { - if runtime.GOOS != "darwin" { - return nil // Only implemented for macOS currently - } - - backupPath := "/Applications/.scanoss-cc.app.backup" - - // Check if backup exists - if _, err := os.Stat(backupPath); err != nil { - if os.IsNotExist(err) { - // No backup exists, nothing to clean up - return nil - } - return fmt.Errorf("failed to check backup status: %w", err) - } - - // Backup exists - update was successful, clean it up - log.Info().Msg("Update completed successfully, removing backup...") - if err := os.RemoveAll(backupPath); err != nil { - log.Warn().Err(err).Msg("Failed to remove backup (non-fatal)") - return nil // Non-fatal error - } - - log.Info().Msg("Update verification complete") - return nil -} - -// CheckForFailedUpdate checks if the previous update failed and performs rollback if needed -// This should be called on app startup before checking for update success -func (u *UpdateServiceImpl) CheckForFailedUpdate() error { - if runtime.GOOS != "darwin" { - return nil // Only implemented for macOS currently - } - - backupPath := "/Applications/.scanoss-cc.app.backup" - currentAppPath := "/Applications/scanoss-cc.app" - - // Check if backup exists - if _, err := os.Stat(backupPath); err != nil { - if os.IsNotExist(err) { - // No backup exists, no failed update - return nil - } - return fmt.Errorf("failed to check backup status: %w", err) - } - - // Backup exists - this means either: - // 1. Update is in progress (helper script is running) - // 2. Update failed and we need to rollback - - // If we're here with a backup and no --check-update-success flag, - // it means the update failed (new version crashed before startup) - - log.Warn().Msg("Detected failed update, attempting rollback...") - - // Remove the current (failed) app - if err := os.RemoveAll(currentAppPath); err != nil { - return fmt.Errorf("failed to remove failed app during rollback: %w", err) - } - - // Restore from backup - if err := os.Rename(backupPath, currentAppPath); err != nil { - return fmt.Errorf("failed to restore backup during rollback: %w", err) - } - - log.Info().Msg("Rollback successful - previous version restored") - - // Restart the app - cmd := exec.Command("open", currentAppPath) - if err := cmd.Start(); err != nil { - log.Error().Err(err).Msg("Failed to restart after rollback") - } - - // Exit this (failed) instance - os.Exit(1) - return nil -} - -// Helper function to get the appropriate asset name for the current platform -func getAssetNameForPlatform() string { - switch runtime.GOOS { - case "darwin": - return "-mac.zip" // macOS DMG in a ZIP file - case "windows": - return "-win.zip" // Windows executable in a ZIP file - case "linux": - // Detect webkit version for Linux. The resulting zip file will be named - // -linux-amd64-webkit40.zip or -linux-amd64-webkit41.zip - webkitVersion := detectLinuxWebkitVersion() - return fmt.Sprintf("-linux-amd64-%s.zip", webkitVersion) - default: - return "" - } -} - -// detectLinuxWebkitVersion detects which webkit version to use -// Returns "webkit40" or "webkit41" based on distro version -func detectLinuxWebkitVersion() string { - // Read /etc/os-release to detect distribution - data, err := os.ReadFile("/etc/os-release") - if err != nil { - log.Warn().Err(err).Msg("Could not read /etc/os-release, defaulting to webkit40") - return "webkit40" - } - - content := string(data) - lines := strings.Split(content, "\n") - - var distroID string - var versionID string - - for _, line := range lines { - if token, ok := strings.CutPrefix(line, "ID="); ok { - distroID = strings.Trim(token, "\"") - } - if token, ok := strings.CutPrefix(line, "VERSION_ID="); ok { - versionID = strings.Trim(token, "\"") - } - } - - // Ubuntu >= 24.04 or Debian >= 13 uses webkit41 - switch distroID { - case "ubuntu": - // Parse version like "24.04" - parts := strings.Split(versionID, ".") - if len(parts) >= 1 { - var ver int - if _, err := fmt.Sscanf(parts[0], "%d", &ver); err == nil && ver >= 24 { - return "webkit41" - } - } - case "debian": - // Parse version like "13" or "13.1" - parts := strings.Split(versionID, ".") - if len(parts) >= 1 { - var ver int - if _, err := fmt.Sscanf(parts[0], "%d", &ver); err == nil && ver >= 13 { - return "webkit41" - } - } - } - - // Default to webkit40 for older versions or other distros - return "webkit40" -} - -// isVersionNewer compares two semantic versions -func isVersionNewer(current, latest string) (bool, error) { - currentVersion, err := semver.NewVersion(current) - if err != nil { - return false, fmt.Errorf("failed to parse current version: %w", err) - } - latestVersion, err := semver.NewVersion(latest) - if err != nil { - return false, fmt.Errorf("failed to parse latest version: %w", err) - } - return latestVersion.GreaterThan(currentVersion), nil -} diff --git a/cmd/root.go b/cmd/root.go index b3aabe6..1c20469 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -29,7 +29,6 @@ import ( "github.com/rs/zerolog/log" "github.com/scanoss/scanoss.cc/backend/entities" - "github.com/scanoss/scanoss.cc/backend/service" "github.com/scanoss/scanoss.cc/internal/config" "github.com/spf13/cobra" ) @@ -44,7 +43,6 @@ var ( scanRoot string version bool originalWorkDir string - checkUpdateSuccess bool ) // This is a workaround to exit the process when the help command is called instead of spinning up the UI @@ -87,7 +85,6 @@ func init() { rootCmd.Flags().StringVarP(&apiKey, "key", "k", "", "SCANOSS API Key token (optional)") rootCmd.Flags().StringVarP(&apiUrl, "apiUrl", "u", "", fmt.Sprintf("SCANOSS API URL (optional - default: %s)", config.DEFAULT_API_URL)) rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug mode") - rootCmd.Flags().BoolVar(&checkUpdateSuccess, "check-update-success", false, "Internal flag to verify update success (do not use manually)") rootCmd.Root().CompletionOptions.HiddenDefaultCmd = true @@ -95,20 +92,6 @@ func init() { } func initConfig() { - // Check for failed updates first (before initializing config) - // This must happen early in the startup process - updateService := service.NewUpdateService() - if err := updateService.CheckForFailedUpdate(); err != nil { - log.Error().Err(err).Msg("Failed to check for failed update") - } - - if checkUpdateSuccess { - log.Info().Msg("Verifying update success...") - if err := updateService.VerifyUpdateSuccess(); err != nil { - log.Error().Err(err).Msg("Failed to verify update success") - } - } - cfg := config.GetInstance() if err := cfg.InitializeConfig(cfgFile, scanRoot, apiKey, apiUrl, inputFile, scanossSettingsFilePath, originalWorkDir, debug); err != nil { diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 6ee7096..624521b 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -29,7 +29,6 @@ import AppSettings from './AppSettings'; import SelectResultsFile from './SelectResultsFile'; import SelectScanRoot from './SelectScanRoot'; import SelectSettingsFile from './SelectSettingsFile'; -import UpdateNotification from './UpdateNotification'; export default function StatusBar() { const getInitialConfig = useConfigStore((state) => state.getInitialConfig); @@ -55,7 +54,6 @@ export default function StatusBar() {
-
diff --git a/frontend/src/components/UpdateNotification.tsx b/frontend/src/components/UpdateNotification.tsx deleted file mode 100644 index ad1f39f..0000000 --- a/frontend/src/components/UpdateNotification.tsx +++ /dev/null @@ -1,177 +0,0 @@ -// SPDX-License-Identifier: MIT -/* - * Copyright (C) 2018-2024 SCANOSS.COM - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import { Download, RefreshCw } from 'lucide-react'; -import { useEffect, useState } from 'react'; - -import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; - -import { entities } from '../../wailsjs/go/models'; -import { ApplyUpdate, CheckForUpdate, DownloadUpdate } from '../../wailsjs/go/service/UpdateServiceImpl'; -import { useToast } from './ui/use-toast'; - -export default function UpdateNotification() { - const { toast } = useToast(); - - const [updateInfo, setUpdateInfo] = useState(null); - const [, setChecking] = useState(false); - const [downloading, setDownloading] = useState(false); - const [showDialog, setShowDialog] = useState(false); - const [downloadedPath, setDownloadedPath] = useState(null); - - useEffect(() => { - checkForUpdates(); - }, []); - - const checkForUpdates = async () => { - try { - setChecking(true); - const update = await CheckForUpdate(); - - if (update && update.available) { - setUpdateInfo(update); - - // Show a toast notification about the update - toast({ - title: 'Update Available', - description: `Version ${update.version} is available for download.`, - }); - } - } catch (error) { - console.error('Failed to check for updates:', error); - } finally { - setChecking(false); - } - }; - - const handleDownloadUpdate = async () => { - if (!updateInfo) return; - - try { - setDownloading(true); - - toast({ - title: 'Downloading Update', - description: 'Please wait while we download the update...', - }); - - const path = await DownloadUpdate(updateInfo); - setDownloadedPath(path); - - toast({ - title: 'Download Complete', - description: 'The update is ready to install.', - }); - } catch (error) { - console.error('Failed to download update:', error); - toast({ - variant: 'destructive', - title: 'Download Failed', - description: 'Failed to download the update. Please try again.', - }); - } finally { - setDownloading(false); - } - }; - - const handleInstallUpdate = async () => { - if (!downloadedPath) return; - - try { - await ApplyUpdate(downloadedPath); - // Application will restart automatically - } catch (error) { - console.error('Failed to install update:', error); - toast({ - variant: 'destructive', - title: 'Installation Failed', - description: 'Failed to install the update. Please try installing manually.', - }); - } - }; - - if (!updateInfo?.available) { - return null; - } - - return ( - <> - - - - - - Update Available - A new version of SCANOSS Code Compare is available. - - -
-
-

Version {updateInfo.version}

-

Published: {new Date(updateInfo.published_at).toLocaleDateString()}

-
- - {updateInfo.release_notes && ( -
-

Release Notes

-
-
{updateInfo.release_notes}
-
-
- )} -
- - - - {!downloadedPath ? ( - - ) : ( - - )} - -
-
- - ); -} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 4965cb7..5007295 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -827,49 +827,6 @@ export namespace entities { return a; } } - export class UpdateInfo { - version: string; - download_url: string; - release_notes: string; - // Go type: time - published_at: any; - size: number; - available: boolean; - expected_sha256?: string; - - static createFrom(source: any = {}) { - return new UpdateInfo(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.version = source["version"]; - this.download_url = source["download_url"]; - this.release_notes = source["release_notes"]; - this.published_at = this.convertValues(source["published_at"], null); - this.size = source["size"]; - this.available = source["available"]; - this.expected_sha256 = source["expected_sha256"]; - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice && a.map) { - return (a as any[]).map(elem => this.convertValues(elem, classs)); - } else if ("object" === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } } diff --git a/frontend/wailsjs/go/service/UpdateServiceImpl.d.ts b/frontend/wailsjs/go/service/UpdateServiceImpl.d.ts deleted file mode 100755 index 42a1ab8..0000000 --- a/frontend/wailsjs/go/service/UpdateServiceImpl.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT -import {entities} from '../models'; -import {context} from '../models'; - -export function ApplyUpdate(arg1:string):Promise; - -export function CheckForFailedUpdate():Promise; - -export function CheckForUpdate():Promise; - -export function DownloadUpdate(arg1:entities.UpdateInfo):Promise; - -export function GetCurrentVersion():Promise; - -export function SetContext(arg1:context.Context):Promise; - -export function VerifyUpdateSuccess():Promise; diff --git a/frontend/wailsjs/go/service/UpdateServiceImpl.js b/frontend/wailsjs/go/service/UpdateServiceImpl.js deleted file mode 100755 index 5ad4960..0000000 --- a/frontend/wailsjs/go/service/UpdateServiceImpl.js +++ /dev/null @@ -1,31 +0,0 @@ -// @ts-check -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -export function ApplyUpdate(arg1) { - return window['go']['service']['UpdateServiceImpl']['ApplyUpdate'](arg1); -} - -export function CheckForFailedUpdate() { - return window['go']['service']['UpdateServiceImpl']['CheckForFailedUpdate'](); -} - -export function CheckForUpdate() { - return window['go']['service']['UpdateServiceImpl']['CheckForUpdate'](); -} - -export function DownloadUpdate(arg1) { - return window['go']['service']['UpdateServiceImpl']['DownloadUpdate'](arg1); -} - -export function GetCurrentVersion() { - return window['go']['service']['UpdateServiceImpl']['GetCurrentVersion'](); -} - -export function SetContext(arg1) { - return window['go']['service']['UpdateServiceImpl']['SetContext'](arg1); -} - -export function VerifyUpdateSuccess() { - return window['go']['service']['UpdateServiceImpl']['VerifyUpdateSuccess'](); -} diff --git a/go.mod b/go.mod index 956d7e3..0b60dbe 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,8 @@ toolchain go1.23.2 // replace github.com/wailsapp/wails/v2 v2.9.1 => /home/ubuntu/go/pkg/mod require ( - github.com/Masterminds/semver/v3 v3.4.0 github.com/go-git/go-git/v5 v5.13.2 github.com/go-playground/validator v9.31.0+incompatible - github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf github.com/rs/zerolog v1.33.0 github.com/scanoss/go-purl-helper v0.2.1 github.com/spf13/cobra v1.8.1 diff --git a/go.sum b/go.sum index 2a88710..925ac61 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -35,8 +33,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= -github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= diff --git a/main.go b/main.go index 6d911cb..5151c43 100644 --- a/main.go +++ b/main.go @@ -100,7 +100,6 @@ func run() error { licenseService := service.NewLicenseServiceImpl(licenseRepository, scanossApiService) scanService := service.NewScanServicePythonImpl() treeService := service.NewTreeServiceImpl(resultService, scanossSettingsRepository) - updateService := service.NewUpdateService() // Create application with options err = wails.Run(&options.App{ @@ -114,7 +113,6 @@ func run() error { scanService.SetContext(ctx) resultService.SetContext(ctx) scanossApiService.SetContext(ctx) - updateService.SetContext(ctx) }, OnBeforeClose: func(ctx context.Context) (prevent bool) { return app.BeforeClose(ctx) @@ -129,7 +127,6 @@ func run() error { licenseService, scanService, treeService, - updateService, }, EnumBind: []any{ entities.AllShortcutActions,