Prerelease Ready?!
Robert Ismo me@robertismo.com
Thu, 25 Jun 2026 14:35:39 -0500
24 files changed,
1458 insertions(+),
8 deletions(-)
jump to
M
Makefile
→
Makefile
@@ -6,8 +6,11 @@ .PHONY: darwin-amd64 darwin-arm64 darwin-universal linux-amd64 linux-arm64 windows-amd64 windows-arm64 windows-386
.PHONY: build macbuild dev clean build: linux-amd64 windows-amd64 -macbuild: darwin-amd64 darwin-arm64 darwin-universal +macbuild: darwin-universal + ./package-darwin.sh release: + cp install.sh public/install.sh + cp build/appicon.png public/appicon.png scp -r public/* machinetele.com:/srv/www/updater.machinetele.com/EMessage/ dev:
M
app.go
→
app.go
@@ -2,17 +2,18 @@ package main
import ( "context" + "time" "github.com/adrg/xdg" + "github.com/sanbornm/go-selfupdate/selfupdate" "gorm.io/driver/sqlite" "gorm.io/gorm" + "gorm.io/gorm/clause" "gorm.io/gorm/logger" - "github.com/sanbornm/go-selfupdate/selfupdate" - "gorm.io/gorm/clause" ) type AppConfig struct { - Title string + Title string Version string }@@ -51,11 +52,29 @@ CmdName: app.GetTitle(),
ForceCheck: true, CheckTime: 1, RandomizeTime: 0, + OnPreFetch: func() { + app.Log("Searching for updates...").Stdout().StatusLine() + }, + OnUpdateAttempt: func() { + app.Log("Found a new version! Attempting to update.").Stdout().StatusLine() + }, OnSuccessfulUpdate: func() { app.Log("Successfully updated to the newest version of EMessage.").Stdout().StatusLine() }, + OnUpdateFailure: func(err error) { + app.Log("Update failed: %v.", err.Error()).Stdout().StatusLine() + }, } - go updater.BackgroundRun() + go updater.Update() + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + if err := updater.Update(); err != nil { + log.Printf("Update failed: %v", err) + } + } + }() app.EstablishMailConnections() return }
A
go-selfupdate/.gitignore
@@ -0,0 +1,20 @@
+# Use glob syntax. +syntax: glob + +*.DS_Store +*.swp +*.swo +*.pyc +*.php~ +*.orig +*~ +*.db +*.log +public/* +go-selfupdate/go-selfupdate +example/example-server +example/hello-updater +example/public/* +example/deployment/* +example/go-selfupdate +*cktime
A
go-selfupdate/LICENSE.md
@@ -0,0 +1,21 @@
+The MIT License (MIT) + +Copyright (c) 2014 Mark Sanborn + +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.
A
go-selfupdate/README.md
@@ -0,0 +1,122 @@
+# go-selfupdate + +[](https://godoc.org/github.com/sanbornm/go-selfupdate/selfupdate) + + +Enable your Golang applications to self update. Inspired by Chrome based on Heroku's [hk](https://github.com/heroku/hk). + +## Features + +* Tested on Mac, Linux, Arm, and Windows +* Creates binary diffs with [bsdiff](http://www.daemonology.net/bsdiff/) allowing small incremental updates +* Falls back to full binary update if diff fails to match SHA + +## QuickStart + +### Install library and update/patch creation utility + +`go install github.com/sanbornm/go-selfupdate/cmd/go-selfupdate@latest` + +### Enable your App to Self Update + +`go get -u github.com/sanbornm/go-selfupdate/...` + + var updater = &selfupdate.Updater{ + CurrentVersion: version, // the current version of your app used to determine if an update is necessary + // these endpoints can be the same if everything is hosted in the same place + ApiURL: "http://updates.yourdomain.com/", // endpoint to get update manifest + BinURL: "http://updates.yourdomain.com/", // endpoint to get full binaries + DiffURL: "http://updates.yourdomain.com/", // endpoint to get binary diff/patches + Dir: "update/", // directory relative to your app to store temporary state files related to go-selfupdate + CmdName: "myapp", // your app's name (must correspond to app name hosting the updates) + // app name allows you to serve updates for multiple apps on the same server/endpoint + } + + // go look for an update when your app starts up + go updater.BackgroundRun() + // your app continues to run... + +### Push Out and Update + + go-selfupdate path-to-your-app the-version + go-selfupdate myapp 1.2 + +By default this will create a folder in your project called *public*. You can then rsync or transfer this to your webserver or S3. To change the output directory use `-o` flag. + +If you are cross compiling you can specify a directory: + + go-selfupdate /tmp/mybinares/ 1.2 + +The directory should contain files with the name, $GOOS-$ARCH. Example: + + windows-386 + darwin-amd64 + linux-arm + +If you are using [goxc](https://github.com/laher/goxc) you can output the files with this naming format by specifying this config: + + "OutPath": "{{.Dest}}{{.PS}}{{.Version}}{{.PS}}{{.Os}}-{{.Arch}}", + +## Update Protocol + +Updates are fetched from an HTTP(s) server. AWS S3 or static hosting can be used. A JSON manifest file is pulled first which points to the wanted version (usually latest) and matching metadata. SHA256 hash is currently the only metadata but new fields may be added here like signatures. `go-selfupdate` isn't aware of any versioning schemes. It doesn't know major/minor versions. It just knows the target version by name and can apply diffs based on current version and version you wish to move to. For example 1.0 to 5.0 or 1.0 to 1.1. You don't even need to use point numbers. You can use hashes, dates, etc for versions. + + GET yourserver.com/appname/linux-amd64.json + + 200 ok + { + "Version": "2", + "Sha256": "..." // base64 + } + + then + + GET patches.yourserver.com/appname/1.1/1.2/linux-amd64 + + 200 ok + [bsdiff data] + + or + + GET fullbins.yourserver.com/appname/1.0/linux-amd64.gz + + 200 ok + [gzipped executable data] + +The only required files are `<appname>/<os>-<arch>.json` and `<appname>/<latest>/<os>-<arch>.gz` everything else is optional. If you wanted to you could skip using go-selfupdate CLI tool and generate these two files manually or with another tool. + +## Config + +Updater Config options: + + type Updater struct { + CurrentVersion string // Currently running version. `dev` is a special version here and will cause the updater to never update. + ApiURL string // Base URL for API requests (JSON files). + CmdName string // Command name is appended to the ApiURL like http://apiurl/CmdName/. This represents one binary. + BinURL string // Base URL for full binary downloads. + DiffURL string // Base URL for diff downloads. + Dir string // Directory to store selfupdate state. + ForceCheck bool // Check for update regardless of cktime timestamp + CheckTime int // Time in hours before next check + RandomizeTime int // Time in hours to randomize with CheckTime + Requester Requester // Optional parameter to override existing HTTP request handler + Info struct { + Version string + Sha256 []byte + } + OnSuccessfulUpdate func() // Optional function to run after an update has successfully taken place + } + +### Restart on update + +It is common for an app to want to restart to apply the update. `go-selfupdate` gives you a hook to do that but leaves it up to you on how and when to restart as it differs for all apps. If you have a service restart application like Docker or systemd you can simply exit and let the upstream app start/restart your application. Just set the `OnSuccessfulUpdate` hook: + + u.OnSuccessfulUpdate = func() { os.Exit(0) } + +Or maybe you have a fancy graceful restart library/func: + + u.OnSuccessfulUpdate = func() { gracefullyRestartMyApp() } + +## State + +go-selfupdate will keep a Go time.Time formatted timestamp in a file named `cktime` in folder specified by `Updater.Dir`. This can be useful for debugging to see when the next update can be applied or allow other applications to manipulate it.
A
go-selfupdate/cmd/go-selfupdate/main.go
@@ -0,0 +1,183 @@
+package main + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "runtime" + + "github.com/kr/binarydist" +) + +var version, genDir string + +type current struct { + Version string + Sha256 []byte +} + +func generateSha256(path string) []byte { + h := sha256.New() + b, err := ioutil.ReadFile(path) + if err != nil { + fmt.Println(err) + } + h.Write(b) + sum := h.Sum(nil) + return sum + //return base64.URLEncoding.EncodeToString(sum) +} + +type gzReader struct { + z, r io.ReadCloser +} + +func (g *gzReader) Read(p []byte) (int, error) { + return g.z.Read(p) +} + +func (g *gzReader) Close() error { + g.z.Close() + return g.r.Close() +} + +func newGzReader(r io.ReadCloser) io.ReadCloser { + var err error + g := new(gzReader) + g.r = r + g.z, err = gzip.NewReader(r) + if err != nil { + panic(err) + } + return g +} + +func createUpdate(path string, platform string) { + c := current{Version: version, Sha256: generateSha256(path)} + + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + fmt.Println("error:", err) + } + err = ioutil.WriteFile(filepath.Join(genDir, platform+".json"), b, 0755) + if err != nil { + panic(err) + } + + os.MkdirAll(filepath.Join(genDir, version), 0755) + + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + f, err := ioutil.ReadFile(path) + if err != nil { + panic(err) + } + w.Write(f) + w.Close() // You must close this first to flush the bytes to the buffer. + err = ioutil.WriteFile(filepath.Join(genDir, version, platform+".gz"), buf.Bytes(), 0755) + + files, err := ioutil.ReadDir(genDir) + if err != nil { + fmt.Println(err) + } + + for _, file := range files { + if file.IsDir() == false { + continue + } + if file.Name() == version { + continue + } + + os.Mkdir(filepath.Join(genDir, file.Name(), version), 0755) + + fName := filepath.Join(genDir, file.Name(), platform+".gz") + old, err := os.Open(fName) + if err != nil { + // Don't have an old release for this os/arch, continue on + continue + } + + fName = filepath.Join(genDir, version, platform+".gz") + newF, err := os.Open(fName) + if err != nil { + fmt.Fprintf(os.Stderr, "Can't open %s: error: %s\n", fName, err) + os.Exit(1) + } + + ar := newGzReader(old) + defer ar.Close() + br := newGzReader(newF) + defer br.Close() + patch := new(bytes.Buffer) + if err := binarydist.Diff(ar, br, patch); err != nil { + panic(err) + } + ioutil.WriteFile(filepath.Join(genDir, file.Name(), version, platform), patch.Bytes(), 0755) + } +} + +func printUsage() { + fmt.Println("") + fmt.Println("Positional arguments:") + fmt.Println("\tSingle platform: go-selfupdate myapp 1.2") + fmt.Println("\tCross platform: go-selfupdate /tmp/mybinares/ 1.2") +} + +func createBuildDir() { + os.MkdirAll(genDir, 0755) +} + +func main() { + outputDirFlag := flag.String("o", "public", "Output directory for writing updates") + + var defaultPlatform string + goos := os.Getenv("GOOS") + goarch := os.Getenv("GOARCH") + if goos != "" && goarch != "" { + defaultPlatform = goos + "-" + goarch + } else { + defaultPlatform = runtime.GOOS + "-" + runtime.GOARCH + } + platformFlag := flag.String("platform", defaultPlatform, + "Target platform in the form OS-ARCH. Defaults to running os/arch or the combination of the environment variables GOOS and GOARCH if both are set.") + + flag.Parse() + if flag.NArg() < 2 { + flag.Usage() + printUsage() + os.Exit(0) + } + + platform := *platformFlag + appPath := flag.Arg(0) + version = flag.Arg(1) + genDir = *outputDirFlag + + createBuildDir() + + // If dir is given create update for each file + fi, err := os.Stat(appPath) + if err != nil { + panic(err) + } + + if fi.IsDir() { + files, err := ioutil.ReadDir(appPath) + if err == nil { + for _, file := range files { + createUpdate(filepath.Join(appPath, file.Name()), file.Name()) + } + os.Exit(0) + } + } + + createUpdate(appPath, platform) +}
A
go-selfupdate/cmd/go-selfupdate/main_test.go
@@ -0,0 +1,6 @@
+package main + +import "testing" + +func TestUpdater(t *testing.T) { +}
A
go-selfupdate/example/run-example.sh
@@ -0,0 +1,75 @@
+#!/bin/bash + +echo "This example will compile the hello-updater application a few times with" +echo "different version strings and demonstrate go-selfupdate's functionality." +echo "If the version is 'dev', no update checking will be performed." +echo + +# build latest/dev local version of go-selfupdate +SELFUPDATE_PATH=../cmd/go-selfupdate/main.go +if [ -f "$SELFUPDATE_PATH" ]; then + go build -o go-selfupdate ../cmd/go-selfupdate +fi + +rm -rf deployment/update deployment/hello* public/hello-updater + +echo "Building example-server" +go build -o example-server src/example-server/main.go + +echo "Running example server" +killall -q example-server +./example-server & + +read -n 1 -p "Press any key to start." ignored; echo + +echo "Building dev version of hello-updater"; echo +go build -ldflags="-X main.version=dev" -o hello-updater src/hello-updater/main.go + +echo "Creating deployment folder and copying hello-updater to it"; echo +mkdir -p deployment/ && cp hello-updater deployment/ + + +echo "Running deployment/hello-updater" +deployment/hello-updater +read -n 1 -p "Press any key to continue." ignored; echo +echo; echo "=========="; echo + +for (( minor=0; minor<=2; minor++ )); do + echo "Building hello-updater with version set to 1.$minor" + go build -ldflags="-X main.version=1.$minor" -o hello-updater src/hello-updater/main.go + + echo "Running ./go-selfupdate to make update available via example-server"; echo + ./go-selfupdate -o public/hello-updater/ hello-updater 1.$minor + + if (( $minor == 0 )); then + echo "Copying version 1.0 to deployment so it can self-update"; echo + cp hello-updater deployment/ + cp hello-updater deployment/hello-updater-1.0 + fi + + echo "Running deployment/hello-updater" + deployment/hello-updater + read -n 1 -p "Press any key to continue." ignored; echo + echo; echo "=========="; echo +done + +echo "Running deployment/hello-updater-1.0 backup copy" +deployment/hello-updater-1.0 +read -n 1 -p "Press any key to continue." ignored; echo +echo; echo "=========="; echo + +echo "Building unknown version of hello-updater"; echo +go build -ldflags="-X main.version=unknown" -o hello-updater src/hello-updater/main.go +echo "Copying unknown version to deployment so it can self-update"; echo +cp hello-updater deployment/ + +echo "Running deployment/hello-updater" +deployment/hello-updater +sleep 5 +echo; echo "Re-running deployment/hello-updater" +deployment/hello-updater +sleep 5 +echo; echo + +echo "Shutting down example-server" +killall example-server
A
go-selfupdate/example/src/example-server/main.go
@@ -0,0 +1,29 @@
+package main + +import ( + "flag" + "log" + "net/http" +) + +var servePath = flag.String("dir", "./public", "path to serve") + +type logHandler struct { + handler http.Handler +} + +func (lh *logHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + log.Printf("(example-server) received request %s\n", r.URL.RequestURI()) + lh.handler.ServeHTTP(rw, r) +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + flag.Parse() + + // Simple static webserver with logging: + log.Printf("Starting HTTP server on :8080 serving path %q Ctrl + C to close and quit", *servePath) + log.Fatal(http.ListenAndServe(":8080", &logHandler{ + handler: http.FileServer(http.Dir(*servePath))}, + )) +}
A
go-selfupdate/example/src/hello-updater/main.go
@@ -0,0 +1,41 @@
+package main + +import ( + "log" + + "github.com/sanbornm/go-selfupdate/selfupdate" +) + +// The purpose of this app is to provide a simple example that just prints +// its version and updates to the latest version from example-server +// on localhost:8080. + +// the app's version. This will be set on build. +var version string + +// go-selfupdate setup and config +var updater = &selfupdate.Updater{ + CurrentVersion: version, // Manually update the const, or set it using `go build -ldflags="-X main.VERSION=<newver>" -o hello-updater src/hello-updater/main.go` + ApiURL: "http://localhost:8080/", // The server hosting `$CmdName/$GOOS-$ARCH.json` which contains the checksum for the binary + BinURL: "http://localhost:8080/", // The server hosting the zip file containing the binary application which is a fallback for the patch method + DiffURL: "http://localhost:8080/", // The server hosting the binary patch diff for incremental updates + Dir: "update/", // The directory created by the app when run which stores the cktime file + CmdName: "hello-updater", // The app name which is appended to the ApiURL to look for an update + ForceCheck: true, // For this example, always check for an update unless the version is "dev" +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + // print the current version + log.Printf("(hello-updater) Hello world! I am currently version: %q", updater.CurrentVersion) + + // try to update + err := updater.BackgroundRun() + if err != nil { + log.Fatalln("Failed to update app:", err) + } + + // print out latest version available + log.Printf("(hello-updater) Latest version available: %q", updater.Info.Version) +}
A
go-selfupdate/go.mod
@@ -0,0 +1,5 @@
+module github.com/sanbornm/go-selfupdate + +go 1.15 + +require github.com/kr/binarydist v0.1.0
A
go-selfupdate/go.sum
@@ -0,0 +1,2 @@
+github.com/kr/binarydist v0.1.0 h1:6kAoLA9FMMnNGSehX0s1PdjbEaACznAv/W219j2uvyo= +github.com/kr/binarydist v0.1.0/go.mod h1:DY7S//GCoz1BCd0B0EVrinCKAZN3pXe+MDaIZbXQVgM=
A
go-selfupdate/selfupdate/hide_noop.go
@@ -0,0 +1,8 @@
+//go:build !windows +// +build !windows + +package selfupdate + +func hideFile(path string) error { + return nil +}
A
go-selfupdate/selfupdate/hide_windows.go
@@ -0,0 +1,19 @@
+package selfupdate + +import ( + "syscall" + "unsafe" +) + +func hideFile(path string) error { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + setFileAttributes := kernel32.NewProc("SetFileAttributesW") + + r1, _, err := setFileAttributes.Call(uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(path))), 2) + + if r1 == 0 { + return err + } else { + return nil + } +}
A
go-selfupdate/selfupdate/requester.go
@@ -0,0 +1,56 @@
+package selfupdate + +import ( + "fmt" + "io" + "net/http" +) + +// Requester interface allows developers to customize the method in which +// requests are made to retrieve the version and binary. +type Requester interface { + Fetch(url string) (io.ReadCloser, error) +} + +// HTTPRequester is the normal requester that is used and does an HTTP +// to the URL location requested to retrieve the specified data. +type HTTPRequester struct{} + +// Fetch will return an HTTP request to the specified url and return +// the body of the result. An error will occur for a non 200 status code. +func (httpRequester *HTTPRequester) Fetch(url string) (io.ReadCloser, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("bad http status from %s: %v", url, resp.Status) + } + + return resp.Body, nil +} + +// mockRequester used for some mock testing to ensure the requester contract +// works as specified. +type mockRequester struct { + currentIndex int + fetches []func(string) (io.ReadCloser, error) +} + +func (mr *mockRequester) handleRequest(requestHandler func(string) (io.ReadCloser, error)) { + if mr.fetches == nil { + mr.fetches = []func(string) (io.ReadCloser, error){} + } + mr.fetches = append(mr.fetches, requestHandler) +} + +func (mr *mockRequester) Fetch(url string) (io.ReadCloser, error) { + if len(mr.fetches) <= mr.currentIndex { + return nil, fmt.Errorf("no for currentIndex %d to mock", mr.currentIndex) + } + current := mr.fetches[mr.currentIndex] + mr.currentIndex++ + + return current(url) +}
A
go-selfupdate/selfupdate/selfupdate.go
@@ -0,0 +1,437 @@
+package selfupdate + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "math/rand" + "net/url" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/kr/binarydist" +) + +const ( + // holds a timestamp which triggers the next update + upcktimePath = "cktime" // path to timestamp file relative to u.Dir + plat = runtime.GOOS + "-" + runtime.GOARCH // ex: linux-amd64 +) + +var ( + ErrHashMismatch = errors.New("new file hash mismatch after patch") + + defaultHTTPRequester = HTTPRequester{} +) + +// Updater is the configuration and runtime data for doing an update. +// +// Note that ApiURL, BinURL and DiffURL should have the same value if all files are available at the same location. +// +// Example: +// +// updater := &selfupdate.Updater{ +// CurrentVersion: version, +// ApiURL: "http://updates.yourdomain.com/", +// BinURL: "http://updates.yourdownmain.com/", +// DiffURL: "http://updates.yourdomain.com/", +// Dir: "update/", +// CmdName: "myapp", // app name +// } +// if updater != nil { +// go updater.BackgroundRun() +// } +type Updater struct { + CurrentVersion string // Currently running version. `dev` is a special version here and will cause the updater to never update. + ApiURL string // Base URL for API requests (JSON files). + CmdName string // Command name is appended to the ApiURL like http://apiurl/CmdName/. This represents one binary. + BinURL string // Base URL for full binary downloads. + DiffURL string // Base URL for diff downloads. + Dir string // Directory to store selfupdate state. + ForceCheck bool // Check for update regardless of cktime timestamp + CheckTime int // Time in hours before next check + RandomizeTime int // Time in hours to randomize with CheckTime + Requester Requester // Optional parameter to override existing HTTP request handler + Info struct { + Version string + Sha256 []byte + } + OnSuccessfulUpdate func() // Optional function to run after an update has successfully taken place + OnPreFetch func() + OnUpdateAttempt func() // Optional: called when an update attempt begins (after info fetch, before patch/full fetch) + OnUpdateFailure func(error) // Optional: called when an update fails with the error +} + +func (u *Updater) getExecRelativeDir(dir string) string { + filename, _ := os.Executable() + path := filepath.Join(filepath.Dir(filename), dir) + return path +} + +func canUpdate() (err error) { + // get the directory the file exists in + path, err := os.Executable() + if err != nil { + return + } + + fileDir := filepath.Dir(path) + fileName := filepath.Base(path) + + // attempt to open a file in the file's directory + newPath := filepath.Join(fileDir, fmt.Sprintf(".%s.new", fileName)) + fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return + } + fp.Close() + + _ = os.Remove(newPath) + return +} + +// BackgroundRun starts the update check and apply cycle. +func (u *Updater) BackgroundRun() error { + if err := os.MkdirAll(u.getExecRelativeDir(u.Dir), 0755); err != nil { + // fail + return err + } + // check to see if we want to check for updates based on version + // and last update time + if u.WantUpdate() { + if err := canUpdate(); err != nil { + // fail + return err + } + + u.SetUpdateTime() + + if err := u.Update(); err != nil { + return err + } + } + return nil +} + +// WantUpdate returns boolean designating if an update is desired. If the app's version +// is `dev` WantUpdate will return false. If u.ForceCheck is true or cktime is after now +// WantUpdate will return true. +func (u *Updater) WantUpdate() bool { + if u.CurrentVersion == "dev" || (!u.ForceCheck && u.NextUpdate().After(time.Now())) { + return false + } + + return true +} + +// NextUpdate returns the next time update should be checked +func (u *Updater) NextUpdate() time.Time { + path := u.getExecRelativeDir(u.Dir + upcktimePath) + nextTime := readTime(path) + + return nextTime +} + +// SetUpdateTime writes the next update time to the state file +func (u *Updater) SetUpdateTime() bool { + path := u.getExecRelativeDir(u.Dir + upcktimePath) + wait := time.Duration(u.CheckTime) * time.Hour + // Add 1 to random time since max is not included + waitrand := time.Duration(rand.Intn(u.RandomizeTime+1)) * time.Hour + + return writeTime(path, time.Now().Add(wait+waitrand)) +} + +// ClearUpdateState writes current time to state file +func (u *Updater) ClearUpdateState() { + path := u.getExecRelativeDir(u.Dir + upcktimePath) + os.Remove(path) +} + +// UpdateAvailable checks if update is available and returns version +func (u *Updater) UpdateAvailable() (string, error) { + path, err := os.Executable() + if err != nil { + return "", err + } + old, err := os.Open(path) + if err != nil { + return "", err + } + defer old.Close() + + err = u.fetchInfo() + if err != nil { + return "", err + } + if u.Info.Version == u.CurrentVersion { + return "", nil + } else { + return u.Info.Version, nil + } +} + +// Update initiates the self update process +func (u *Updater) Update() error { + if u.OnPreFetch != nil { + u.OnPreFetch() + } + path, err := os.Executable() + if err != nil { + if u.OnUpdateFailure != nil { + u.OnUpdateFailure(err) + } + return err + } + + if resolvedPath, err := filepath.EvalSymlinks(path); err == nil { + path = resolvedPath + } + + // go fetch latest updates manifest + err = u.fetchInfo() + if err != nil { + return err + } + + // we are on the latest version, nothing to do + if u.Info.Version == u.CurrentVersion { + return nil + } + + old, err := os.Open(path) + if err != nil { + return err + } + defer old.Close() + + if u.OnUpdateAttempt != nil { + u.OnUpdateAttempt() + } + + bin, err := u.fetchAndVerifyPatch(old) + if err != nil { + if err == ErrHashMismatch { + log.Println("update: hash mismatch from patched binary") + } else { + if u.DiffURL != "" { + log.Println("update: patching binary,", err) + } + } + + // if patch failed grab the full new bin + bin, err = u.fetchAndVerifyFullBin() + if err != nil { + if err == ErrHashMismatch { + log.Println("update: hash mismatch from full binary") + } else { + log.Println("update: fetching full binary,", err) + } + if u.OnUpdateFailure != nil { + u.OnUpdateFailure(err) + } + return err + } + } + + // close the old binary before installing because on windows + // it can't be renamed if a handle to the file is still open + old.Close() + + err, errRecover := fromStream(bytes.NewBuffer(bin)) + if errRecover != nil { + return fmt.Errorf("update and recovery errors: %q %q", err, errRecover) + } + if err != nil { + return err + } + + // update was successful, run func if set + if u.OnSuccessfulUpdate != nil { + u.OnSuccessfulUpdate() + } + + return nil +} + +func fromStream(updateWith io.Reader) (err error, errRecover error) { + updatePath, err := os.Executable() + if err != nil { + return + } + + var newBytes []byte + newBytes, err = ioutil.ReadAll(updateWith) + if err != nil { + return + } + + // get the directory the executable exists in + updateDir := filepath.Dir(updatePath) + filename := filepath.Base(updatePath) + + // Copy the contents of of newbinary to a the new executable file + newPath := filepath.Join(updateDir, fmt.Sprintf(".%s.new", filename)) + fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return + } + defer fp.Close() + _, err = io.Copy(fp, bytes.NewReader(newBytes)) + + // 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 + oldPath := filepath.Join(updateDir, fmt.Sprintf(".%s.old", filename)) + + // 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(oldPath) + + // move the existing executable to a new file in the same directory + err = os.Rename(updatePath, oldPath) + if err != nil { + return + } + + // move the new exectuable in to become the new program + err = os.Rename(newPath, updatePath) + + if err != nil { + // copy unsuccessful + errRecover = os.Rename(oldPath, updatePath) + } else { + // copy successful, remove the old binary + errRemove := os.Remove(oldPath) + + // windows has trouble with removing old binaries, so hide it instead + if errRemove != nil { + _ = hideFile(oldPath) + } + } + + return +} + +// fetchInfo fetches the update JSON manifest at u.ApiURL/appname/platform.json +// and updates u.Info. +func (u *Updater) fetchInfo() error { + r, err := u.fetch(u.ApiURL + url.QueryEscape(u.CmdName) + "/" + url.QueryEscape(plat) + ".json") + if err != nil { + return err + } + defer r.Close() + err = json.NewDecoder(r).Decode(&u.Info) + if err != nil { + return err + } + if len(u.Info.Sha256) != sha256.Size { + return errors.New("bad cmd hash in info") + } + return nil +} + +func (u *Updater) fetchAndVerifyPatch(old io.Reader) ([]byte, error) { + bin, err := u.fetchAndApplyPatch(old) + if err != nil { + return nil, err + } + if !verifySha(bin, u.Info.Sha256) { + return nil, ErrHashMismatch + } + return bin, nil +} + +func (u *Updater) fetchAndApplyPatch(old io.Reader) ([]byte, error) { + r, err := u.fetch(u.DiffURL + url.QueryEscape(u.CmdName) + "/" + url.QueryEscape(u.CurrentVersion) + "/" + url.QueryEscape(u.Info.Version) + "/" + url.QueryEscape(plat)) + if err != nil { + return nil, err + } + defer r.Close() + var buf bytes.Buffer + err = binarydist.Patch(old, &buf, r) + return buf.Bytes(), err +} + +func (u *Updater) fetchAndVerifyFullBin() ([]byte, error) { + bin, err := u.fetchBin() + if err != nil { + return nil, err + } + verified := verifySha(bin, u.Info.Sha256) + if !verified { + return nil, ErrHashMismatch + } + return bin, nil +} + +func (u *Updater) fetchBin() ([]byte, error) { + r, err := u.fetch(u.BinURL + url.QueryEscape(u.CmdName) + "/" + url.QueryEscape(u.Info.Version) + "/" + url.QueryEscape(plat) + ".gz") + if err != nil { + return nil, err + } + defer r.Close() + buf := new(bytes.Buffer) + gz, err := gzip.NewReader(r) + if err != nil { + return nil, err + } + if _, err = io.Copy(buf, gz); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func (u *Updater) fetch(url string) (io.ReadCloser, error) { + if u.Requester == nil { + return defaultHTTPRequester.Fetch(url) + } + + readCloser, err := u.Requester.Fetch(url) + if err != nil { + return nil, err + } + + if readCloser == nil { + return nil, fmt.Errorf("Fetch was expected to return non-nil ReadCloser") + } + + return readCloser, nil +} + +func readTime(path string) time.Time { + p, err := ioutil.ReadFile(path) + if os.IsNotExist(err) { + return time.Time{} + } + if err != nil { + return time.Now().Add(1000 * time.Hour) + } + t, err := time.Parse(time.RFC3339, string(p)) + if err != nil { + return time.Now().Add(1000 * time.Hour) + } + return t +} + +func verifySha(bin []byte, sha []byte) bool { + h := sha256.New() + h.Write(bin) + return bytes.Equal(h.Sum(nil), sha) +} + +func writeTime(path string, t time.Time) bool { + return ioutil.WriteFile(path, []byte(t.Format(time.RFC3339)), 0644) == nil +}
A
go-selfupdate/selfupdate/selfupdate_test.go
@@ -0,0 +1,166 @@
+package selfupdate + +import ( + "bytes" + "io" + "testing" + "time" +) + +func TestUpdaterFetchMustReturnNonNilReaderCloser(t *testing.T) { + mr := &mockRequester{} + mr.handleRequest( + func(url string) (io.ReadCloser, error) { + return nil, nil + }) + updater := createUpdater(mr) + updater.CheckTime = 24 + updater.RandomizeTime = 24 + + err := updater.BackgroundRun() + + if err != nil { + equals(t, "Fetch was expected to return non-nil ReadCloser", err.Error()) + } else { + t.Log("Expected an error") + t.Fail() + } +} + +func TestUpdaterWithEmptyPayloadNoErrorNoUpdate(t *testing.T) { + mr := &mockRequester{} + mr.handleRequest( + func(url string) (io.ReadCloser, error) { + equals(t, "http://updates.yourdomain.com/myapp/linux-amd64.json", url) + return newTestReaderCloser("{}"), nil + }) + updater := createUpdater(mr) + updater.CheckTime = 24 + updater.RandomizeTime = 24 + + err := updater.BackgroundRun() + if err != nil { + t.Errorf("Error occurred: %#v", err) + } +} + +func TestUpdaterCheckTime(t *testing.T) { + mr := &mockRequester{} + mr.handleRequest( + func(url string) (io.ReadCloser, error) { + equals(t, "http://updates.yourdomain.com/myapp/linux-amd64.json", url) + return newTestReaderCloser("{}"), nil + }) + + // Run test with various time + runTestTimeChecks(t, mr, 0, 0, false) + runTestTimeChecks(t, mr, 0, 5, true) + runTestTimeChecks(t, mr, 1, 0, true) + runTestTimeChecks(t, mr, 100, 100, true) +} + +// Helper function to run check time tests +func runTestTimeChecks(t *testing.T, mr *mockRequester, checkTime int, randomizeTime int, expectUpdate bool) { + updater := createUpdater(mr) + updater.ClearUpdateState() + updater.CheckTime = checkTime + updater.RandomizeTime = randomizeTime + + updater.BackgroundRun() + + if updater.WantUpdate() == expectUpdate { + t.Errorf("WantUpdate returned %v; want %v", updater.WantUpdate(), expectUpdate) + } + + maxHrs := time.Duration(updater.CheckTime+updater.RandomizeTime) * time.Hour + maxTime := time.Now().Add(maxHrs) + + if !updater.NextUpdate().Before(maxTime) { + t.Errorf("NextUpdate should less than %s hrs (CheckTime + RandomizeTime) from now; now %s; next update %s", maxHrs, time.Now(), updater.NextUpdate()) + } + + if maxHrs > 0 && !updater.NextUpdate().After(time.Now()) { + t.Errorf("NextUpdate should be after now") + } +} + +func TestUpdaterWithEmptyPayloadNoErrorNoUpdateEscapedPath(t *testing.T) { + mr := &mockRequester{} + mr.handleRequest( + func(url string) (io.ReadCloser, error) { + equals(t, "http://updates.yourdomain.com/myapp%2Bfoo/darwin-amd64.json", url) + return newTestReaderCloser("{}"), nil + }) + updater := createUpdaterWithEscapedCharacters(mr) + + err := updater.BackgroundRun() + if err != nil { + t.Errorf("Error occurred: %#v", err) + } +} + +func TestUpdateAvailable(t *testing.T) { + mr := &mockRequester{} + mr.handleRequest( + func(url string) (io.ReadCloser, error) { + equals(t, "http://updates.yourdomain.com/myapp/linux-amd64.json", url) + return newTestReaderCloser(`{ + "Version": "2023-07-09-66c6c12", + "Sha256": "Q2vvTOW0p69A37StVANN+/ko1ZQDTElomq7fVcex/02=" +}`), nil + }) + updater := createUpdater(mr) + + version, err := updater.UpdateAvailable() + if err != nil { + t.Errorf("Error occurred: %#v", err) + } + equals(t, "2023-07-09-66c6c12", version) +} + +func createUpdater(mr *mockRequester) *Updater { + return &Updater{ + CurrentVersion: "1.2", + ApiURL: "http://updates.yourdomain.com/", + BinURL: "http://updates.yourdownmain.com/", + DiffURL: "http://updates.yourdomain.com/", + Dir: "update/", + CmdName: "myapp", // app name + Requester: mr, + } +} + +func createUpdaterWithEscapedCharacters(mr *mockRequester) *Updater { + return &Updater{ + CurrentVersion: "1.2+foobar", + ApiURL: "http://updates.yourdomain.com/", + BinURL: "http://updates.yourdownmain.com/", + DiffURL: "http://updates.yourdomain.com/", + Dir: "update/", + CmdName: "myapp+foo", // app name + Requester: mr, + } +} + +func equals(t *testing.T, expected, actual interface{}) { + if expected != actual { + t.Logf("Expected: %#v got %#v\n", expected, actual) + t.Fail() + } +} + +type testReadCloser struct { + buffer *bytes.Buffer +} + +func newTestReaderCloser(payload string) io.ReadCloser { + return &testReadCloser{buffer: bytes.NewBufferString(payload)} +} + +func (trc *testReadCloser) Read(p []byte) (n int, err error) { + return trc.buffer.Read(p) +} + +func (trc *testReadCloser) Close() error { + return nil +}
M
go.mod
→
go.mod
@@ -8,12 +8,14 @@ github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.24.0 github.com/google/uuid v1.6.0 - github.com/sanbornm/go-selfupdate v0.0.0-20230714125711-e1c03e3d6ac7 + github.com/sanbornm/go-selfupdate v1.0.0 github.com/wailsapp/wails/v2 v2.12.0 github.com/zalando/go-keyring v0.2.8 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) + +replace github.com/sanbornm/go-selfupdate => ./go-selfupdate require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
M
go.sum
→
go.sum
@@ -67,8 +67,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= -github.com/sanbornm/go-selfupdate v0.0.0-20230714125711-e1c03e3d6ac7 h1:gm8c4AGd1rHqUsWJQHCjJlAg7yEcgzwyHhWbspPk9HI= -github.com/sanbornm/go-selfupdate v0.0.0-20230714125711-e1c03e3d6ac7/go.mod h1:aCbVFjQ5FBm4o5D/JVxc8AtWfGgDYnDXgySPJf91ozU= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
A
install.sh
@@ -0,0 +1,207 @@
+#!/usr/bin/env bash +set -euo pipefail + +# Configuration +APP_NAME="EMessage" +SERVER_URL="https://updater.machinetele.com" + +# Detect platform +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Linux) GOOS="linux" ;; + Darwin) GOOS="darwin" ;; + *) echo "Unsupported OS: $OS"; exit 1 ;; +esac + +case "$ARCH" in + x86_64|amd64) GOARCH="amd64" ;; + aarch64|arm64) GOARCH="arm64" ;; + i386|i686) GOARCH="386" ;; + armv7l) GOARCH="arm" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +PLATFORM="${GOOS}-${GOARCH}" + +echo "Checking for ${APP_NAME} updates for ${PLATFORM}..." +echo "Server: ${SERVER_URL}" + +# Function to cleanup temp files +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup ERR EXIT + +if [[ "$GOOS" == "darwin" ]]; then + # --- macOS: download and install .app bundle from darwin-app --- + MANIFEST_URL="${SERVER_URL}/${APP_NAME}/darwin-app.json" + echo "Looking for macOS bundle manifest at ${MANIFEST_URL}" + + JSON=$(curl -sf "${MANIFEST_URL}") || { + echo "Platform macOS download not available (no install manifest at ${MANIFEST_URL})" + exit 1 + } + + # Parse version and sha256 + if command -v jq &>/dev/null; then + VERSION=$(echo "$JSON" | jq -r '.Version') + SHA256=$(echo "$JSON" | jq -r '.Sha256') + else + VERSION=$(echo "$JSON" | grep -o '"Version":"[^"]*"' | cut -d'"' -f4) + SHA256=$(echo "$JSON" | grep -o '"Sha256":"[^"]*"' | cut -d'"' -f4) + fi + + if [[ -z "$VERSION" || -z "$SHA256" ]]; then + echo "Failed to parse macOS install manifest." + exit 1 + fi + + echo "Latest macOS bundle version: ${VERSION}" + + # Download the darwin-app tarball (uncompressed tar, as built by the build script) + BUNDLE_URL="${SERVER_URL}/${APP_NAME}/darwin-app" + TMP_DIR=$(mktemp -d) + TMP_TAR="${TMP_DIR}/${APP_NAME}.tar" + + echo "Downloading ${BUNDLE_URL}..." + curl -sfL "${BUNDLE_URL}" -o "${TMP_TAR}" || { + echo "Download failed." + exit 1 + } + + # Verify SHA256 + computed=$(sha256sum "${TMP_TAR}" | cut -d' ' -f1) + if [[ "$computed" != "$SHA256" ]]; then + echo "Hash mismatch! Expected $SHA256, got $computed" + exit 1 + fi + + # Determine installation directory + if [[ $EUID -eq 0 ]]; then + APP_INSTALL_DIR="/Applications" + else + APP_INSTALL_DIR="${HOME}/Applications" + mkdir -p "$APP_INSTALL_DIR" + fi + + # Extract the .app bundle (tar, not compressed) + echo "Extracting to ${APP_INSTALL_DIR}..." + tar xf "${TMP_TAR}" -C "$APP_INSTALL_DIR" || { + echo "Extraction failed." + exit 1 + } + + # The archive should contain a .app directory + if [[ ! -d "${APP_INSTALL_DIR}/${APP_NAME}.app" ]]; then + echo "Error: Archive did not contain ${APP_NAME}.app" + ls -la "${APP_INSTALL_DIR}" + exit 1 + fi + + echo "${APP_NAME} ${VERSION} installed to ${APP_INSTALL_DIR}/${APP_NAME}.app" + echo "The auto-updater will handle future updates inside the bundle." + +else + # --- Linux: download binary and create desktop entry (unchanged) --- + MANIFEST_URL="${SERVER_URL}/${APP_NAME}/${PLATFORM}.json" + echo "Checking for Linux binary at ${MANIFEST_URL}" + + JSON=$(curl -sf "${MANIFEST_URL}") || { + echo "Platform ${PLATFORM} is not supported (no manifest found at ${MANIFEST_URL})" + exit 1 + } + + # Parse version and sha256 + if command -v jq &>/dev/null; then + VERSION=$(echo "$JSON" | jq -r '.Version') + SHA256=$(echo "$JSON" | jq -r '.Sha256') + else + VERSION=$(echo "$JSON" | grep -o '"Version":"[^"]*"' | cut -d'"' -f4) + SHA256=$(echo "$JSON" | grep -o '"Sha256":"[^"]*"' | cut -d'"' -f4) + fi + + if [[ -z "$VERSION" || -z "$SHA256" ]]; then + echo "Failed to parse Linux binary manifest." + exit 1 + fi + + echo "Latest Linux version: ${VERSION}" + + # Download binary + BINARY_URL="${SERVER_URL}/${APP_NAME}/${VERSION}/${PLATFORM}.gz" + TMP_DIR=$(mktemp -d) + TMP_GZ="${TMP_DIR}/${APP_NAME}.gz" + + echo "Downloading ${BINARY_URL}..." + curl -sfL "${BINARY_URL}" -o "${TMP_GZ}" || { + echo "Download failed." + exit 1 + } + + # Verify SHA256 + echo "${SHA256} ${TMP_GZ}" | sha256sum -c --quiet 2>/dev/null || { + echo "Hash mismatch! Binary may be corrupted." + exit 1 + } + + # Decompress + gunzip -c "${TMP_GZ}" > "${TMP_DIR}/${APP_NAME}" || { + echo "Decompression failed." + exit 1 + } + chmod +x "${TMP_DIR}/${APP_NAME}" + + # Install binary + if [[ $EUID -eq 0 ]]; then + INSTALL_DIR="/usr/local/bin" + else + # Prefer ~/.local/bin, else ~/bin + if [[ -d "${HOME}/.local/bin" ]]; then + INSTALL_DIR="${HOME}/.local/bin" + elif [[ -d "${HOME}/bin" ]]; then + INSTALL_DIR="${HOME}/bin" + else + INSTALL_DIR="${HOME}/.local/bin" + mkdir -p "$INSTALL_DIR" + fi + fi + + cp "${TMP_DIR}/${APP_NAME}" "${INSTALL_DIR}/${APP_NAME}" + echo "Binary installed to ${INSTALL_DIR}/${APP_NAME}" + + # --- Linux desktop entry with icon --- + ICON_URL="${SERVER_URL}/${APP_NAME}/appicon.png" + ICON_DIR="${HOME}/.local/share/icons/hicolor/256x256/apps" + mkdir -p "$ICON_DIR" + if curl -sfL "${ICON_URL}" -o "${ICON_DIR}/${APP_NAME}.png"; then + echo "App icon downloaded." + else + echo "Warning: Could not download icon (${ICON_URL})." + ICON_DIR="" # will skip icon in desktop file + fi + + DESKTOP_DIR="${HOME}/.local/share/applications" + mkdir -p "$DESKTOP_DIR" + DESKTOP_FILE="${DESKTOP_DIR}/${APP_NAME}.desktop" + + cat > "$DESKTOP_FILE" <<EOF +[Desktop Entry] +Version=1.0 +Type=Application +Name=${APP_NAME} +Comment=Electronic Message client +Exec=${INSTALL_DIR}/${APP_NAME} +Icon=${ICON_DIR:+${ICON_DIR}/${APP_NAME}.png} +Terminal=false +Categories=Network;InstantMessaging; +StartupNotify=true +EOF + + chmod +x "$DESKTOP_FILE" + echo "Desktop entry created: ${DESKTOP_FILE}" +fi + +echo "" +echo "✔ ${APP_NAME} ${VERSION} installed successfully!"
A
logo.xcf
A
package-darwin.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="EMessage" +OUTPUT_DIR="./build/bin/${APP_NAME}" +APP_BUNDLE="./build/bin/${APP_NAME}.app" + +# Ensure output directory exists +mkdir -p "$OUTPUT_DIR" + +# 1. Copy the universal executable to specific architecture names +UNIVERSAL_EXE="${OUTPUT_DIR}/darwin-universal" +if [[ ! -f "$UNIVERSAL_EXE" ]]; then + echo "Error: Universal executable not found at ${UNIVERSAL_EXE}" + exit 1 +fi +cp "$UNIVERSAL_EXE" "${OUTPUT_DIR}/darwin-amd64" +cp "$UNIVERSAL_EXE" "${OUTPUT_DIR}/darwin-arm64" +echo "Created darwin-amd64 and darwin-arm64 from universal executable" + +# 2. Tar the .app bundle into the output directory as 'darwin-app' +if [[ ! -d "$APP_BUNDLE" ]]; then + echo "Error: .app bundle not found at ${APP_BUNDLE}" + exit 1 +fi +TARFILE="${OUTPUT_DIR}/darwin-app" +tar cf "$TARFILE" -C "$(dirname "$APP_BUNDLE")" "$(basename "$APP_BUNDLE")" +echo "Created ${TARFILE}"
M
release.go
→
release.go
@@ -7,6 +7,9 @@
var ( EMessageRemoteServerAddress = "https://emessage.machinetele.com" EMessageVersionHistory = []string{ + "prerelease.4", + "prerelease.3", + "prerelease.2", "prerelease.1", } )