mirror of
https://github.com/taigrr/go-selfupdate
synced 2025-01-18 04:33:12 -08:00
456 lines
12 KiB
Go
456 lines
12 KiB
Go
/*
|
|
Package update allows a program to "self-update", replacing its executable file
|
|
with new bytes.
|
|
|
|
Package update provides the facility to create user experiences like auto-updating
|
|
or user-approved updates which manifest as user prompts in commercial applications
|
|
with copy similar to "Restart to being using the new version of X".
|
|
|
|
Updating your program to a new version is as easy as:
|
|
|
|
err := update.FromUrl("http://release.example.com/2.0/myprogram")
|
|
if err != nil {
|
|
fmt.Printf("Update failed: %v", err)
|
|
}
|
|
|
|
The most low-level API is FromStream() which updates the current executable
|
|
with the bytes read from an io.Reader.
|
|
|
|
Additional APIs are provided for common update strategies which include
|
|
updating from a file with FromFile() and updating from the internet with
|
|
FromUrl().
|
|
|
|
Using the more advaced Download.UpdateFromUrl() API gives you the ability
|
|
to resume an interrupted download to enable large updates to complete even
|
|
over intermittent or slow connections. This API also enables more fine-grained
|
|
control over how the update is downloaded from the internet as well as access to
|
|
download progress,
|
|
*/
|
|
package update
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"fmt"
|
|
"bitbucket.org/kardianos/osext"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
)
|
|
|
|
type MeteredReader struct {
|
|
rd io.ReadCloser
|
|
totalSize int64
|
|
progress chan int
|
|
totalRead int64
|
|
ticks int64
|
|
}
|
|
|
|
func (m *MeteredReader) Close() error {
|
|
return m.rd.Close()
|
|
}
|
|
|
|
func (m *MeteredReader) Read(b []byte) (n int, err error) {
|
|
chunkSize := (m.totalSize / 100) + 1
|
|
lenB := int64(len(b))
|
|
|
|
var nChunk int
|
|
for start := int64(0); start < lenB; start += int64(nChunk) {
|
|
end := start + chunkSize
|
|
if end > lenB {
|
|
end = lenB
|
|
}
|
|
|
|
nChunk, err = m.rd.Read(b[start:end])
|
|
|
|
n += nChunk
|
|
m.totalRead += int64(nChunk)
|
|
|
|
if m.totalRead > (m.ticks * chunkSize) {
|
|
m.ticks += 1
|
|
// try to send on channel, but don't block if it's full
|
|
select {
|
|
case m.progress <- int(m.ticks + 1):
|
|
default:
|
|
}
|
|
|
|
// give the progress channel consumer a chance to run
|
|
runtime.Gosched()
|
|
}
|
|
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// We wrap the round tripper when making requests
|
|
// because we need to add headers to the requests we make
|
|
// even when they are requests made after a redirect
|
|
type RoundTripper struct {
|
|
RoundTripFn func(*http.Request) (*http.Response, error)
|
|
}
|
|
|
|
func (rt *RoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
return rt.RoundTripFn(r)
|
|
}
|
|
|
|
// Type Download encapsulates the necessary parameters and state
|
|
// needed to download an update from the internet. Create an instance
|
|
// with the NewDownload() factory function.
|
|
//
|
|
// You may only use a Download once,
|
|
type Download struct {
|
|
// net/http.Client to use when downloading the update.
|
|
// If nil, a default http.Client is used
|
|
HttpClient *http.Client
|
|
|
|
// Path on the file system to dowload the update to
|
|
// If empty, a temporary file is used.
|
|
// After the download begins, this path will be set
|
|
// so that the client can use it to resume aborted
|
|
// downloads
|
|
Path string
|
|
|
|
// Progress returns the percentage of the download
|
|
// completed as an integer between 0 and 100
|
|
Progress chan (int)
|
|
|
|
// HTTP Method to use in the download request. Default is "GET"
|
|
Method string
|
|
|
|
// HTTP URL to issue the download request to
|
|
Url string
|
|
|
|
// Set to true when the server confirms a new version is available
|
|
// even if the updating process encounters an error later on
|
|
Available bool
|
|
}
|
|
|
|
// NewDownload initializes a new Download object
|
|
func NewDownload(url string) *Download {
|
|
return &Download{
|
|
HttpClient: new(http.Client),
|
|
Progress: make(chan int),
|
|
Method: "GET",
|
|
Url: url,
|
|
}
|
|
}
|
|
|
|
func (d *Download) sharedHttp(offset int64) (resp *http.Response, err error) {
|
|
// create the download request
|
|
req, err := http.NewRequest(d.Method, d.Url, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// we have to add headers like this so they get used across redirects
|
|
trans := d.HttpClient.Transport
|
|
if trans == nil {
|
|
trans = http.DefaultTransport
|
|
}
|
|
|
|
d.HttpClient.Transport = &RoundTripper{
|
|
RoundTripFn: func(r *http.Request) (*http.Response, error) {
|
|
// add header for download continuation
|
|
if offset > 0 {
|
|
r.Header.Add("Range", fmt.Sprintf("%d-", offset))
|
|
}
|
|
|
|
// ask for gzipped content so that net/http won't unzip it for us
|
|
// and destroy the content length header we need for progress calculations
|
|
r.Header.Add("Accept-Encoding", "gzip")
|
|
|
|
return trans.RoundTrip(r)
|
|
},
|
|
}
|
|
|
|
// issue the download request
|
|
return d.HttpClient.Do(req)
|
|
}
|
|
|
|
func (d *Download) Check() (available bool, err error) {
|
|
resp, err := d.sharedHttp(0)
|
|
if err != nil {
|
|
return
|
|
}
|
|
resp.Body.Close()
|
|
|
|
switch resp.StatusCode {
|
|
// ok
|
|
case 200, 206:
|
|
available = true
|
|
|
|
// no update available
|
|
case 204:
|
|
available = false
|
|
|
|
// server error
|
|
default:
|
|
err = fmt.Errorf("Non 2XX response when downloading update: %s", resp.Status)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Get() downloads the given url from the internet to a file on disk
|
|
// and then calls FromStream() to update the current program's executable file
|
|
// with the contents of that file.
|
|
//
|
|
// If the update is successful, the downloaded file will be erased from disk.
|
|
// Otherwise, it will remain in d.Path to allow the download to resume later
|
|
// or be skipped entirely.
|
|
//
|
|
// Only HTTP/1.1 servers that implement the Range header support resuming a
|
|
// partially completed download.
|
|
//
|
|
// UpdateFromUrl() uses HTTP status codes to determine what action to take.
|
|
//
|
|
// - The HTTP server should return 200 or 206 for the update to be downloaded.
|
|
//
|
|
// - The HTTP server should return 204 if no update is available at this time.
|
|
//
|
|
// - If the HTTP server returns a 3XX redirect, it will be followed
|
|
// according to d.HttpClient's redirect policy.
|
|
//
|
|
// - Any other HTTP status code will cause UpdateFromUrl to return an error.
|
|
func (d *Download) Get() (err error) {
|
|
var offset int64 = 0
|
|
var fp *os.File
|
|
|
|
// Close the progress channel whenever this function completes
|
|
defer close(d.Progress)
|
|
|
|
// open a file where we will stream the downloaded update to
|
|
// we do this first because if the caller specified a non-empty dlpath
|
|
// we need to determine how large it is in order to resume the download
|
|
if d.Path == "" {
|
|
// no dlpath specified, use a random tempfile
|
|
fp, err = ioutil.TempFile("", "update")
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer fp.Close()
|
|
|
|
// remember the path
|
|
d.Path = fp.Name()
|
|
} else {
|
|
fp, err = os.OpenFile(d.Path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer fp.Close()
|
|
|
|
// determine the file size so we can resume the download, if possible
|
|
var fi os.FileInfo
|
|
fi, err = fp.Stat()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
offset = fi.Size()
|
|
}
|
|
|
|
// start downloading the file
|
|
resp, err := d.sharedHttp(offset)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch resp.StatusCode {
|
|
// ok
|
|
case 200, 206:
|
|
d.Available = true
|
|
|
|
// no update available
|
|
case 204:
|
|
return
|
|
|
|
// server error
|
|
default:
|
|
err = fmt.Errorf("Non 2XX response when downloading update: %s", resp.Status)
|
|
return
|
|
}
|
|
|
|
// Determine how much we have to download
|
|
// net/http sets this to -1 when it is unknown
|
|
clength := resp.ContentLength
|
|
|
|
// Read the content from the response body
|
|
rd := resp.Body
|
|
|
|
// meter the rate at which we download content for
|
|
// progress reporting if we know how much to expect
|
|
if clength > 0 {
|
|
rd = &MeteredReader{rd: rd, totalSize: clength, progress: d.Progress}
|
|
}
|
|
|
|
// Decompress the content if necessary
|
|
if resp.Header.Get("Content-Encoding") == "gzip" {
|
|
rd, err = gzip.NewReader(rd)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Download the update
|
|
_, err = io.Copy(fp, rd)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (d *Download) GetAndUpdate() (err error, errRecover error) {
|
|
// check before we download if this will work
|
|
if err = SanityCheck(); err != nil {
|
|
// keep the contract that d.Progress will close whenever Get() terminates
|
|
close(d.Progress)
|
|
return
|
|
}
|
|
|
|
// download the update
|
|
if err = d.Get(); err != nil || !d.Available {
|
|
return
|
|
}
|
|
|
|
// apply the update
|
|
if err, errRecover = FromFile(d.Path); err != nil || errRecover != nil {
|
|
return
|
|
}
|
|
|
|
// remove the temporary file
|
|
os.Remove(d.Path)
|
|
return
|
|
}
|
|
|
|
// FromUrl downloads the contents of the given url and uses them to update
|
|
// the current program's executable file. It is a convenience function which is equivalent to
|
|
//
|
|
// NewDownload(url).GetAndUpdate()
|
|
//
|
|
// See Download.Get() for more details.
|
|
func FromUrl(url string) (err error, errRecover error) {
|
|
return NewDownload(url).GetAndUpdate()
|
|
}
|
|
|
|
// FromFile reads the contents of the given file and uses them
|
|
// to update the current program's executable file by calling FromStream().
|
|
func FromFile(filepath string) (err error, errRecover error) {
|
|
// open the new binary
|
|
fp, err := os.Open(filepath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer fp.Close()
|
|
|
|
// do the update
|
|
return FromStream(fp)
|
|
}
|
|
|
|
// FromStream reads the contents of the supplied io.Reader newBinary
|
|
// and uses them to update the current program's executable file.
|
|
//
|
|
// FromStream performs the following actions to ensure a cross-platform safe
|
|
// update:
|
|
//
|
|
// - Creates a new file, /path/to/.program-name.new with mode 0755 and copies
|
|
// the contents of newBinary into the file
|
|
//
|
|
// - Renames the current program's executable file from /path/to/program-name
|
|
// to /path/to/.program-name.old
|
|
//
|
|
// - Renames /path/to/.program-name.new to /path/to/program-name
|
|
//
|
|
// - If the rename is successful, it erases /path/to/.program.old. If this operation
|
|
// fails, no error is reported.
|
|
//
|
|
// - If the rename is unsuccessful, it attempts to rename /path/to/.program-name.old
|
|
// back to /path/to/program-name. If this operation fails, the error is not reported
|
|
// in order to not mask the error that caused the rename recovery attempt.
|
|
func FromStream(newBinary io.Reader) (err error, errRecover error) {
|
|
// get the path to the executable
|
|
thisExecPath, err := osext.Executable()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// get the directory the executable exists in
|
|
execDir := filepath.Dir(thisExecPath)
|
|
execName := filepath.Base(thisExecPath)
|
|
|
|
// Copy the contents of of newbinary to a the new executable file
|
|
newExecPath := filepath.Join(execDir, fmt.Sprintf(".%s.new", execName))
|
|
fp, err := os.OpenFile(newExecPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer fp.Close()
|
|
_, err = io.Copy(fp, newBinary)
|
|
|
|
// if we don't call fp.Close(), windows won't let us move the new executable
|
|
// because the file will still be "in use"
|
|
fp.Close()
|
|
|
|
// this is where we'll move the executable to so that we can swap in the updated replacement
|
|
oldExecPath := filepath.Join(execDir, fmt.Sprintf(".%s.old", execName))
|
|
|
|
// delete any existing old exec file - this is necessary on Windows for two reasons:
|
|
// 1. after a successful update, windows can't remove the .old file because the process is still running
|
|
// 2. windows rename operations fail if the destination file already exists
|
|
_ = os.Remove(oldExecPath)
|
|
|
|
// move the existing executable to a new file in the same directory
|
|
err = os.Rename(thisExecPath, oldExecPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// move the new exectuable in to become the new program
|
|
err = os.Rename(newExecPath, thisExecPath)
|
|
|
|
if err != nil {
|
|
// copy unsuccessful
|
|
errRecover = os.Rename(oldExecPath, thisExecPath)
|
|
} else {
|
|
// copy successful, remove the old binary
|
|
_ = os.Remove(oldExecPath)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// SanityCheck() attempts to determine whether an in-place executable update could
|
|
// succeed by performing preliminary checks (to establish valid permissions, etc).
|
|
// This helps avoid downloading updates when we know the update can't be successfully
|
|
// applied later.
|
|
func SanityCheck() (err error) {
|
|
// get the path to the executable
|
|
thisExecPath, err := osext.Executable()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// get the directory the executable exists in
|
|
execDir := filepath.Dir(thisExecPath)
|
|
execName := filepath.Base(thisExecPath)
|
|
|
|
// attempt to open a file in the executable's directory
|
|
newExecPath := filepath.Join(execDir, fmt.Sprintf(".%s.new", execName))
|
|
fp, err := os.OpenFile(newExecPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
|
if err != nil {
|
|
return
|
|
}
|
|
fp.Close()
|
|
|
|
_ = os.Remove(newExecPath)
|
|
return
|
|
}
|