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 }