Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
201 changes: 54 additions & 147 deletions backend/service/update_service_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 0 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading