github.com/pojntfx/hydrapp/hydrapp@v0.0.0-20240516002902-d08759d6ca9f/pkg/update/pages.go (about)

     1  package update
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"io/ioutil"
    10  	"net/http"
    11  	"net/url"
    12  	"os"
    13  	"os/exec"
    14  	"path"
    15  	"path/filepath"
    16  	"runtime"
    17  	"strconv"
    18  	"strings"
    19  	"sync"
    20  	"syscall"
    21  	"time"
    22  
    23  	"github.com/ProtonMail/gopenpgp/v2/crypto"
    24  	"github.com/ncruces/zenity"
    25  	"github.com/pojntfx/hydrapp/hydrapp/pkg/builders"
    26  	"github.com/pojntfx/hydrapp/hydrapp/pkg/config"
    27  	"github.com/pojntfx/hydrapp/hydrapp/pkg/utils"
    28  )
    29  
    30  type BrowserState struct {
    31  	Cmd *exec.Cmd
    32  }
    33  
    34  var (
    35  	ErrNoEscalationMethodFound = errors.New("no escalation method could be found")
    36  
    37  	BranchTimestampRFC3339 = ""
    38  	BranchID               = ""
    39  	PackageType            = ""
    40  )
    41  
    42  type File struct {
    43  	Type string `json:"type"`
    44  	Name string `json:"name"`
    45  	Time string `json:"time"`
    46  }
    47  
    48  type downloadConfiguration struct {
    49  	description string
    50  	url         string
    51  	dst         *os.File
    52  }
    53  
    54  // See https://github.com/pojntfx/bagop/blob/main/main.go#L33
    55  func getBinIdentifier(goOS, goArch string) string {
    56  	if goOS == "windows" {
    57  		return ".exe"
    58  	}
    59  
    60  	if goOS == "js" && goArch == "wasm" {
    61  		return ".wasm"
    62  	}
    63  
    64  	return ""
    65  }
    66  
    67  func Update(
    68  	ctx context.Context,
    69  
    70  	cfg *config.Root,
    71  	state *BrowserState,
    72  	handlePanic func(appName, msg string, err error),
    73  ) {
    74  	if (strings.TrimSpace(BranchTimestampRFC3339) == "" && strings.TrimSpace(BranchID) == "") || os.Getenv(utils.EnvSelfupdate) == "false" {
    75  		return
    76  	}
    77  
    78  	currentBinaryBuildTime, err := time.Parse(time.RFC3339, BranchTimestampRFC3339)
    79  	if err != nil {
    80  		handlePanic(cfg.App.Name, err.Error(), err)
    81  	}
    82  
    83  	baseURL, err := url.Parse(cfg.App.BaseURL)
    84  	if err != nil {
    85  		handlePanic(cfg.App.Name, err.Error(), err)
    86  	}
    87  
    88  	switch PackageType {
    89  	case "dmg":
    90  		baseURL.Path = builders.GetPathForBranch(path.Join(baseURL.Path, cfg.DMG.Path), BranchID, "")
    91  
    92  	case "msi":
    93  		for _, msiCfg := range cfg.MSI {
    94  			if msiCfg.Architecture == runtime.GOARCH {
    95  				baseURL.Path = builders.GetPathForBranch(path.Join(baseURL.Path, msiCfg.Path), BranchID, "")
    96  
    97  				break
    98  			}
    99  		}
   100  
   101  	default:
   102  		baseURL.Path = builders.GetPathForBranch(path.Join(baseURL.Path, cfg.Binaries.Path), BranchID, "")
   103  	}
   104  
   105  	indexURL, err := url.JoinPath(baseURL.String(), "index.json")
   106  	if err != nil {
   107  		handlePanic(cfg.App.Name, err.Error(), err)
   108  	}
   109  
   110  	res, err := http.DefaultClient.Get(indexURL)
   111  	if err != nil {
   112  		handlePanic(cfg.App.Name, err.Error(), err)
   113  	}
   114  
   115  	body, err := ioutil.ReadAll(res.Body)
   116  	if err != nil {
   117  		handlePanic(cfg.App.Name, err.Error(), err)
   118  	}
   119  
   120  	var index []File
   121  	if err := json.Unmarshal(body, &index); err != nil {
   122  		handlePanic(cfg.App.Name, err.Error(), err)
   123  	}
   124  
   125  	updatedBinaryName := ""
   126  	switch PackageType {
   127  	case "dmg":
   128  		updatedBinaryName = builders.GetAppIDForBranch(cfg.App.ID, BranchID) + "." + runtime.GOOS + ".dmg"
   129  
   130  	case "msi":
   131  		updatedBinaryName = builders.GetAppIDForBranch(cfg.App.ID, BranchID) + "." + runtime.GOOS + "-" + utils.GetArchIdentifier(runtime.GOARCH) + ".msi"
   132  
   133  	default:
   134  		updatedBinaryName = builders.GetAppIDForBranch(cfg.App.ID, BranchID) + "." + runtime.GOOS + "-" + utils.GetArchIdentifier(runtime.GOARCH) + getBinIdentifier(runtime.GOOS, runtime.GOARCH)
   135  	}
   136  
   137  	var (
   138  		updatedBinaryURL         = ""
   139  		updatedBinaryReleaseTime time.Time
   140  
   141  		updatedRepoKeyURL = ""
   142  
   143  		updatedSignatureURL = ""
   144  	)
   145  	for _, file := range index {
   146  		if file.Name == updatedBinaryName {
   147  			updatedBinaryReleaseTime, err = time.Parse(time.RFC3339, file.Time)
   148  			if err != nil {
   149  				handlePanic(cfg.App.Name, err.Error(), err)
   150  			}
   151  
   152  			if currentBinaryBuildTime.Before(updatedBinaryReleaseTime) {
   153  				updatedBinaryURL, err = url.JoinPath(baseURL.String(), updatedBinaryName)
   154  				if err != nil {
   155  					handlePanic(cfg.App.Name, err.Error(), err)
   156  				}
   157  
   158  				updatedRepoKeyURL, err = url.JoinPath(baseURL.String(), "repo.asc")
   159  				if err != nil {
   160  					handlePanic(cfg.App.Name, err.Error(), err)
   161  				}
   162  
   163  				updatedSignatureURL, err = url.JoinPath(baseURL.String(), updatedBinaryName+".asc")
   164  				if err != nil {
   165  					handlePanic(cfg.App.Name, err.Error(), err)
   166  				}
   167  			}
   168  
   169  			break
   170  		}
   171  	}
   172  
   173  	if strings.TrimSpace(updatedBinaryURL) == "" {
   174  		return
   175  	}
   176  
   177  	if err := zenity.Question(
   178  		fmt.Sprintf("Do you want to upgrade from version %v to %v now?", currentBinaryBuildTime, updatedBinaryReleaseTime),
   179  		zenity.Title("Update available"),
   180  		zenity.OKLabel("Update now"),
   181  		zenity.CancelLabel("Ask me next time"),
   182  	); err != nil {
   183  		if err == zenity.ErrCanceled {
   184  			return
   185  		}
   186  
   187  		handlePanic(cfg.App.Name, err.Error(), err)
   188  	}
   189  
   190  	updatedBinaryFile, err := os.CreateTemp(os.TempDir(), updatedBinaryName)
   191  	if err != nil {
   192  		handlePanic(cfg.App.Name, err.Error(), err)
   193  	}
   194  	defer os.Remove(updatedBinaryFile.Name())
   195  
   196  	updatedSignatureFile, err := os.CreateTemp(os.TempDir(), updatedBinaryName+".asc")
   197  	if err != nil {
   198  		handlePanic(cfg.App.Name, err.Error(), err)
   199  	}
   200  	defer os.Remove(updatedSignatureFile.Name())
   201  
   202  	updatedRepoKeyFile, err := os.CreateTemp(os.TempDir(), "repo.asc")
   203  	if err != nil {
   204  		handlePanic(cfg.App.Name, err.Error(), err)
   205  	}
   206  	defer os.Remove(updatedRepoKeyFile.Name())
   207  
   208  	downloadConfigurations := []downloadConfiguration{
   209  		{
   210  			description: "Downloading binary",
   211  			url:         updatedBinaryURL,
   212  			dst:         updatedBinaryFile,
   213  		},
   214  		{
   215  			description: "Downloading signature",
   216  			url:         updatedSignatureURL,
   217  			dst:         updatedSignatureFile,
   218  		},
   219  		{
   220  			description: "Downloading repo key",
   221  			url:         updatedRepoKeyURL,
   222  			dst:         updatedRepoKeyFile,
   223  		},
   224  	}
   225  
   226  	for _, downloadConfiguration := range downloadConfigurations {
   227  		res, err := http.Get(downloadConfiguration.url)
   228  		if err != nil {
   229  			handlePanic(cfg.App.Name, err.Error(), err)
   230  		}
   231  		if res.StatusCode != http.StatusOK {
   232  			err := fmt.Errorf("%v", res.Status)
   233  
   234  			handlePanic(cfg.App.Name, err.Error(), err)
   235  		}
   236  
   237  		totalSize, err := strconv.Atoi(res.Header.Get("Content-Length"))
   238  		if err != nil {
   239  			handlePanic(cfg.App.Name, err.Error(), err)
   240  		}
   241  
   242  		dialog, err := zenity.Progress(
   243  			zenity.Title(downloadConfiguration.description),
   244  		)
   245  		if err != nil {
   246  			handlePanic(cfg.App.Name, err.Error(), err)
   247  		}
   248  
   249  		var dialogWg sync.WaitGroup
   250  		dialogWg.Add(1)
   251  		go func() {
   252  			ticker := time.NewTicker(time.Millisecond * 50)
   253  			defer func() {
   254  				defer dialogWg.Done()
   255  
   256  				ticker.Stop()
   257  
   258  				if err := dialog.Complete(); err != nil {
   259  					handlePanic(cfg.App.Name, "could not open download progress dialog", err)
   260  				}
   261  
   262  				if err := dialog.Close(); err != nil {
   263  					handlePanic(cfg.App.Name, "could not close download progress dialog", err)
   264  				}
   265  			}()
   266  
   267  			for {
   268  				select {
   269  				case <-ctx.Done():
   270  
   271  					return
   272  				case <-ticker.C:
   273  					stat, err := downloadConfiguration.dst.Stat()
   274  					if err != nil {
   275  						handlePanic(cfg.App.Name, "could not get info on updated binary", err)
   276  					}
   277  
   278  					downloadedSize := stat.Size()
   279  					if totalSize < 1 {
   280  						downloadedSize = 1
   281  					}
   282  
   283  					percentage := int((float64(downloadedSize) / float64(totalSize)) * 100)
   284  
   285  					if err := dialog.Value(percentage); err != nil {
   286  						handlePanic(cfg.App.Name, "could not set update download progress percentage", err)
   287  					}
   288  
   289  					if err := dialog.Text(fmt.Sprintf("%v%% (%v MB/%v MB)", percentage, downloadedSize/(1024*1024), totalSize/(1024*1024))); err != nil {
   290  						handlePanic(cfg.App.Name, "could not set update download progress description", err)
   291  					}
   292  
   293  					if percentage == 100 {
   294  						return
   295  					}
   296  				}
   297  			}
   298  		}()
   299  
   300  		if _, err := io.Copy(downloadConfiguration.dst, res.Body); err != nil {
   301  			handlePanic(cfg.App.Name, err.Error(), err)
   302  		}
   303  
   304  		dialogWg.Wait()
   305  	}
   306  
   307  	dialog, err := zenity.Progress(
   308  		zenity.Title("Validating update"),
   309  		zenity.Pulsate(),
   310  	)
   311  	if err != nil {
   312  		handlePanic(cfg.App.Name, err.Error(), err)
   313  	}
   314  
   315  	if err := dialog.Text("Reading repo key and signature"); err != nil {
   316  		handlePanic(cfg.App.Name, "could not set update validation progress description", err)
   317  	}
   318  
   319  	if _, err := updatedRepoKeyFile.Seek(0, io.SeekStart); err != nil {
   320  		handlePanic(cfg.App.Name, "could not read repo key", err)
   321  	}
   322  
   323  	updatedRepoKey, err := crypto.NewKeyFromArmoredReader(updatedRepoKeyFile)
   324  	if err != nil {
   325  		handlePanic(cfg.App.Name, "could not parse repo key", err)
   326  	}
   327  
   328  	updatedKeyRing, err := crypto.NewKeyRing(updatedRepoKey)
   329  	if err != nil {
   330  		handlePanic(cfg.App.Name, "could not create key ring", err)
   331  	}
   332  
   333  	if _, err := updatedSignatureFile.Seek(0, io.SeekStart); err != nil {
   334  		handlePanic(cfg.App.Name, "could not read signature", err)
   335  	}
   336  
   337  	rawUpdatedSignature, err := io.ReadAll(updatedSignatureFile)
   338  	if err != nil {
   339  		handlePanic(cfg.App.Name, "could not read signature", err)
   340  	}
   341  
   342  	updatedSignature, err := crypto.NewPGPSignatureFromArmored(string(rawUpdatedSignature))
   343  	if err != nil {
   344  		handlePanic(cfg.App.Name, "could not parse signature", err)
   345  	}
   346  
   347  	if err := dialog.Text("Validating binary with signature and key"); err != nil {
   348  		handlePanic(cfg.App.Name, "could not set update validation progress description", err)
   349  	}
   350  
   351  	if _, err := updatedBinaryFile.Seek(0, io.SeekStart); err != nil {
   352  		handlePanic(cfg.App.Name, "could not read binary", err)
   353  	}
   354  
   355  	if err := updatedKeyRing.VerifyDetachedStream(updatedBinaryFile, updatedSignature, crypto.GetUnixTime()); err != nil {
   356  		handlePanic(cfg.App.Name, "could not validate binary", err)
   357  	}
   358  
   359  	if err := dialog.Complete(); err != nil {
   360  		handlePanic(cfg.App.Name, "could not open validation progress dialog", err)
   361  	}
   362  
   363  	if err := dialog.Close(); err != nil {
   364  		handlePanic(cfg.App.Name, "could not close validation progress dialog", err)
   365  	}
   366  
   367  	oldBinary, err := os.Executable()
   368  	if err != nil {
   369  		handlePanic(cfg.App.Name, err.Error(), err)
   370  	}
   371  
   372  	switch PackageType {
   373  	case "msi":
   374  		stopCmds := fmt.Sprintf(`(Stop-Process -PassThru -Id %v).WaitForExit();`, os.Getpid())
   375  		if state != nil && state.Cmd != nil && state.Cmd.Process != nil {
   376  			stopCmds = fmt.Sprintf(`(Stop-Process -PassThru -Id %v).WaitForExit();`, state.Cmd.Process.Pid) + stopCmds
   377  		}
   378  
   379  		powerShellBinary, err := exec.LookPath("pwsh.exe")
   380  		if err != nil {
   381  			powerShellBinary = "powershell.exe"
   382  		}
   383  
   384  		if output, err := exec.Command(powerShellBinary, `-Command`, fmt.Sprintf(`Start-Process '%v' -Verb RunAs -Wait -ArgumentList "%v; Start-Process msiexec.exe '/i %v'"`, powerShellBinary, stopCmds, updatedBinaryFile.Name())).CombinedOutput(); err != nil {
   385  			err := fmt.Errorf("could not start update installer with output: %s: %v", output, err)
   386  
   387  			handlePanic(cfg.App.Name, err.Error(), err)
   388  		}
   389  
   390  		// We'll never reach this since we kill this process in the elevated shell and start the updated version
   391  		return
   392  
   393  	case "dmg":
   394  		mountpoint, err := os.MkdirTemp(os.TempDir(), "update-mountpoint")
   395  		if err != nil {
   396  			handlePanic(cfg.App.Name, err.Error(), err)
   397  		}
   398  		defer os.RemoveAll(mountpoint)
   399  
   400  		if output, err := exec.Command("hdiutil", "attach", "-mountpoint", mountpoint, updatedBinaryFile.Name()).CombinedOutput(); err != nil {
   401  			err := fmt.Errorf("could not attach DMG with output: %s: %v", output, err)
   402  
   403  			handlePanic(cfg.App.Name, err.Error(), err)
   404  		}
   405  
   406  		appPath, err := filepath.Abs(filepath.Join(oldBinary, "..", ".."))
   407  		if err != nil {
   408  			handlePanic(cfg.App.Name, err.Error(), err)
   409  		}
   410  
   411  		appsPath, err := filepath.Abs(filepath.Join(appPath, ".."))
   412  		if err != nil {
   413  			handlePanic(cfg.App.Name, err.Error(), err)
   414  		}
   415  
   416  		if output, err := exec.Command(
   417  			"osascript",
   418  			"-e",
   419  			fmt.Sprintf(`do shell script "rm -rf '%v'/* && cp -r '%v'/*/ '%v'" with administrator privileges with prompt "Authentication Required: Authentication is needed to apply the update."`, appPath, mountpoint, appsPath),
   420  		).CombinedOutput(); err != nil {
   421  			err := fmt.Errorf("could not replace old app with new app with output: %s: %v", output, err)
   422  
   423  			handlePanic(cfg.App.Name, err.Error(), err)
   424  		}
   425  
   426  		if output, err := exec.Command("hdiutil", "unmount", mountpoint).CombinedOutput(); err != nil {
   427  			err := fmt.Errorf("could not detach DMG with output: %s: %v", output, err)
   428  
   429  			handlePanic(cfg.App.Name, err.Error(), err)
   430  		}
   431  
   432  	default:
   433  		switch runtime.GOOS {
   434  		case "windows":
   435  			stopCmds := fmt.Sprintf(`(Stop-Process -PassThru -Id %v).WaitForExit();`, os.Getpid())
   436  			if state != nil && state.Cmd != nil && state.Cmd.Process != nil {
   437  				stopCmds = fmt.Sprintf(`(Stop-Process -PassThru -Id %v).WaitForExit();`, state.Cmd.Process.Pid) + stopCmds
   438  			}
   439  
   440  			powerShellBinary, err := exec.LookPath("pwsh.exe")
   441  			if err != nil {
   442  				powerShellBinary = "powershell.exe"
   443  			}
   444  
   445  			if output, err := exec.Command(powerShellBinary, `-Command`, fmt.Sprintf(`Start-Process '%v' -Verb RunAs -Wait -ArgumentList "%v; Move-Item -Force '%v' '%v'; Start-Process '%v'"`, powerShellBinary, stopCmds, updatedBinaryFile.Name(), oldBinary, strings.Join(os.Args, " "))).CombinedOutput(); err != nil {
   446  				err := fmt.Errorf("could not install updated binary with output: %s: %v", output, err)
   447  
   448  				handlePanic(cfg.App.Name, err.Error(), err)
   449  			}
   450  
   451  			// We'll never reach this since we kill this process in the elevated shell and start the updated version
   452  			return
   453  
   454  		case "darwin":
   455  			if err := os.Chmod(updatedBinaryFile.Name(), 0755); err != nil {
   456  				handlePanic(cfg.App.Name, err.Error(), err)
   457  			}
   458  
   459  			if output, err := exec.Command(
   460  				"osascript",
   461  				"-e",
   462  				fmt.Sprintf(`do shell script "cp -f '%v' '%v'" with administrator privileges with prompt "Authentication Required: Authentication is needed to apply the update."`, updatedBinaryFile.Name(), oldBinary),
   463  			).CombinedOutput(); err != nil {
   464  				err := fmt.Errorf("could not install updated binary with output: %s: %v", output, err)
   465  
   466  				handlePanic(cfg.App.Name, err.Error(), err)
   467  			}
   468  
   469  		default:
   470  			if err := os.Chmod(updatedBinaryFile.Name(), 0755); err != nil {
   471  				handlePanic(cfg.App.Name, err.Error(), err)
   472  			}
   473  
   474  			// Escalate using Polkit
   475  			if pkexec, err := exec.LookPath("pkexec"); err == nil {
   476  				if output, err := exec.Command(pkexec, "cp", "-f", updatedBinaryFile.Name(), oldBinary).CombinedOutput(); err != nil {
   477  					err := fmt.Errorf("could not install updated binary with output: %s: %v", output, err)
   478  
   479  					handlePanic(cfg.App.Name, err.Error(), err)
   480  				}
   481  			} else {
   482  				// Escalate using using terminal emulator
   483  				xterm, err := exec.LookPath("xterm")
   484  				if err != nil {
   485  					err := fmt.Errorf("%v: %w", ErrNoEscalationMethodFound, err)
   486  
   487  					handlePanic(cfg.App.Name, err.Error(), err)
   488  				}
   489  
   490  				suid, err := exec.LookPath("sudo")
   491  				if err != nil {
   492  					suid, err = exec.LookPath("doas")
   493  					if err != nil {
   494  						err := fmt.Errorf("%v: %w", ErrNoEscalationMethodFound, err)
   495  
   496  						handlePanic(cfg.App.Name, err.Error(), err)
   497  					}
   498  				}
   499  
   500  				if output, err := exec.Command(
   501  					xterm, "-T", "Authentication Required", "-e", fmt.Sprintf(`echo 'Authentication is needed to apply the update.' && %v cp -f '%v' '%v'`, suid, updatedBinaryFile.Name(), oldBinary),
   502  				).CombinedOutput(); err != nil {
   503  					err := fmt.Errorf("could not install updated binary with output: %s: %v", output, err)
   504  
   505  					handlePanic(cfg.App.Name, err.Error(), err)
   506  				}
   507  			}
   508  		}
   509  	}
   510  
   511  	// No need for Windows support since Windows kills & starts the new process earlier with an elevated shell
   512  	if runtime.GOOS != "windows" && state != nil && state.Cmd != nil && state.Cmd.Process != nil {
   513  		// We ignore errors here as the old process might already have finished etc.
   514  		_ = state.Cmd.Process.Signal(syscall.SIGTERM)
   515  	}
   516  
   517  	if err := utils.ForkExec(
   518  		oldBinary,
   519  		os.Args,
   520  	); err != nil {
   521  		handlePanic(cfg.App.Name, err.Error(), err)
   522  	}
   523  
   524  	os.Exit(0)
   525  }