diff --git a/Jenkinsfile b/Jenkinsfile
index dc916d2a1639..a3e0dc96a8b2 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -196,6 +196,8 @@ helpers.rootLinuxNode(env, {
"GPG=/usr/bin/gpg.distrib",
]) {
if (hasGoChanges || hasJenkinsfileChanges) {
+ // install the updater test binary
+ sh "go install github.com/keybase/client/go/updater/test"
testGo("test_linux_go_", packagesToTest, hasKBFSChanges)
}
}},
@@ -458,6 +460,7 @@ def testGoBuilds(prefix, packagesToTest, hasKBFSChanges) {
sh 'go install github.com/golangci/golangci-lint/cmd/golangci-lint'
}
}
+ //
// TODO re-enable for kbfs.
// if (hasKBFSChanges) {
diff --git a/go/buildtools/tools.go b/go/buildtools/tools.go
index 1c379cebcc24..98834b9f7b98 100644
--- a/go/buildtools/tools.go
+++ b/go/buildtools/tools.go
@@ -6,7 +6,7 @@ package tools
import (
_ "github.com/golang/mock/mockgen"
_ "github.com/golangci/golangci-lint/cmd/golangci-lint"
- _ "github.com/keybase/release"
+ _ "github.com/keybase/client/go/release"
_ "golang.org/x/lint/golint"
_ "golang.org/x/mobile/cmd/gobind"
_ "golang.org/x/mobile/cmd/gomobile"
diff --git a/go/chat/giphy/search.go b/go/chat/giphy/search.go
index f5b5ba3f4a6d..e4ab145c5d66 100644
--- a/go/chat/giphy/search.go
+++ b/go/chat/giphy/search.go
@@ -173,10 +173,14 @@ func Asset(mctx libkb.MetaContext, sourceURL string) (res io.ReadCloser, length
}
req.Header.Add("Accept", "image/*")
req.Host = MediaHost
- resp, err := ctxhttp.Do(mctx.Ctx(), WebClient(mctx), req)
+ resp, err := ctxhttp.Do(mctx.Ctx(), AssetClient(mctx), req)
if err != nil {
return nil, 0, err
}
+
+ if resp.StatusCode != 200 {
+ return nil, 0, fmt.Errorf("Status %s", resp.Status)
+ }
return resp.Body, resp.ContentLength, nil
}
diff --git a/go/chat/uithreadloader.go b/go/chat/uithreadloader.go
index 5686c39ec81d..822794fcb8c3 100644
--- a/go/chat/uithreadloader.go
+++ b/go/chat/uithreadloader.go
@@ -317,7 +317,7 @@ func (t *UIThreadLoader) setUIStatus(ctx context.Context, chatUI libkb.ChatUI,
case <-ctx.Done():
t.Debug(ctx, "setUIStatus: context canceled")
default:
- if err := chatUI.ChatThreadStatus(ctx, status); err != nil {
+ if err := chatUI.ChatThreadStatus(context.Background(), status); err != nil {
t.Debug(ctx, "setUIStatus: failed to send: %s", err)
}
displayed = true
@@ -661,6 +661,8 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI,
}
// wait until we are online before attempting the full pull, otherwise we just waste an attempt
if fullErr = t.waitForOnline(ctx); fullErr != nil {
+ t.Debug(ctx, "LoadNonblock: waitForOnline error: %s", fullErr)
+ setDisplayedStatus(cancelUIStatus)
return
}
customRi := t.makeRi(ctx, uid, convID, knownRemotes)
@@ -806,7 +808,11 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI,
t.Debug(ctx, "LoadNonblock: failed to set status: %s", err)
}
}
+ t.Debug(ctx, "LoadNonblock: clear complete")
+ } else {
+ t.Debug(ctx, "LoadNonblock: no status displayed, not clearing")
}
+
cancel()
return fullErr
}
diff --git a/go/chat/unfurl/packager.go b/go/chat/unfurl/packager.go
index 428cecc25b9b..a54a6d5000a5 100644
--- a/go/chat/unfurl/packager.go
+++ b/go/chat/unfurl/packager.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
+ "net/http"
"strings"
"github.com/keybase/client/go/avatars"
@@ -57,10 +58,20 @@ func (p *Packager) assetFilename(url string) string {
}
func (p *Packager) assetBodyAndLength(ctx context.Context, url string) (body io.ReadCloser, size int64, err error) {
- resp, err := libkb.ProxyHTTPGet(p.G().ExternalG(), p.G().Env, url, "UnfurlPackager")
+ client := libkb.ProxyHTTPClient(p.G().ExternalG(), p.G().Env, "UnfurlPackager")
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, 0, err
+ }
+ req.Header.Add("User-Agent", libkb.UserAgent)
+
+ resp, err := client.Do(req)
if err != nil {
return body, size, err
}
+ if resp.StatusCode != 200 {
+ return nil, 0, fmt.Errorf("Status %s", resp.Status)
+ }
return resp.Body, resp.ContentLength, nil
}
diff --git a/go/client/cmd_ctl_watchdog.go b/go/client/cmd_ctl_watchdog.go
index 59f2a9774d53..28de000a1a7e 100644
--- a/go/client/cmd_ctl_watchdog.go
+++ b/go/client/cmd_ctl_watchdog.go
@@ -15,7 +15,7 @@ import (
"github.com/keybase/client/go/install"
"github.com/keybase/client/go/libcmdline"
"github.com/keybase/client/go/libkb"
- "github.com/keybase/go-updater/watchdog"
+ "github.com/keybase/client/go/updater/watchdog"
)
// CmdWatchdog defines watchdog command
diff --git a/go/go.mod b/go/go.mod
index d59b7b53df9c..c83216cee472 100644
--- a/go/go.mod
+++ b/go/go.mod
@@ -42,16 +42,14 @@ require (
github.com/keybase/go-merkle-tree v0.0.0-20221220225120-009ea00ffb15
github.com/keybase/go-porterstemmer v1.0.2-0.20181016185745-521f1ed5c3f7
github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19
- github.com/keybase/go-triplesec v0.0.0-20221220225315-06ddee08f3c2
- github.com/keybase/go-triplesec-insecure v0.0.0-20221220225342-ddc3aa12adec
- github.com/keybase/go-updater v0.0.0-20221221194633-9e97736a0b42
+ github.com/keybase/go-triplesec v0.0.0-20231213205702-981541df982e
+ github.com/keybase/go-triplesec-insecure v0.0.0-20231213205953-ffb6212a205e
github.com/keybase/go-winio v0.4.12-0.20180913221037-b1d96ab97b58
github.com/keybase/golang-ico v0.0.0-20181117022008-819cbeb217c9
github.com/keybase/gomounts v0.0.0-20180302000443-349507f4d353
github.com/keybase/keybase-test-vectors v1.0.12-0.20200309162119-ea1e58fecd5d
- github.com/keybase/pipeliner v0.0.0-20211118220306-ca1be321c9e5
- github.com/keybase/release v0.0.0-20221220220653-50771d921175
- github.com/keybase/saltpack v0.0.0-20221220231257-f6cce11cfd0f
+ github.com/keybase/pipeliner v0.0.0-20231213214924-f648db4bba63
+ github.com/keybase/saltpack v0.0.0-20231213211625-726bb684c617
github.com/keybase/stellarnet v0.0.0-20200311180805-6c05850f9050
github.com/kr/text v0.2.0
github.com/kyokomi/emoji v2.2.2+incompatible
@@ -62,25 +60,25 @@ require (
github.com/pkg/xattr v0.2.2
github.com/qrtz/nativemessaging v0.0.0-20161221035708-f4769a80e040
github.com/rcrowley/go-metrics v0.0.0-20161128210544-1f30fe9094a5
- github.com/sergi/go-diff v1.2.0
+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/shirou/gopsutil v2.18.13-0.20181231150826-db425313bfa8+incompatible
github.com/stathat/go v1.0.0
// NOTE: if stellar/go is updated, consider removing the `replace` directive
// for goautoneg at the bottom of this go.mod
github.com/stellar/go v0.0.0-20221209134558-b4ba6f8e67f2
- github.com/stretchr/testify v1.8.1
+ github.com/stretchr/testify v1.8.4
github.com/syndtr/goleveldb v1.0.0
github.com/urfave/cli v1.22.1
github.com/vividcortex/ewma v1.1.2-0.20170804035156-43880d236f69
go.uber.org/zap v1.17.0
- golang.org/x/crypto v0.4.0
+ golang.org/x/crypto v0.16.0
golang.org/x/image v0.0.0-20190802002840-cff245a6509b
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616
golang.org/x/mobile v0.0.0-20221110043201-43a038452099
- golang.org/x/net v0.4.0
- golang.org/x/sync v0.1.0
- golang.org/x/sys v0.3.0
- golang.org/x/text v0.5.0
+ golang.org/x/net v0.19.0
+ golang.org/x/sync v0.5.0
+ golang.org/x/sys v0.15.0
+ golang.org/x/text v0.14.0
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1
gopkg.in/src-d/go-billy.v4 v4.3.2
gopkg.in/src-d/go-git.v4 v4.13.1
@@ -89,7 +87,13 @@ require (
stathat.com/c/ramcache v1.0.0
)
-require github.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a
+require (
+ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
+ github.com/aws/aws-sdk-go v1.49.13
+ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
+ github.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a
+ gopkg.in/alecthomas/kingpin.v2 v2.2.6
+)
require (
4d63.com/gochecknoglobals v0.1.0 // indirect
@@ -104,8 +108,7 @@ require (
github.com/RoaringBitmap/roaring v0.4.22-0.20191112221735-4d53b29a8f7d // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 // indirect
- github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
- github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
+ github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/andybalholm/cascadia v0.0.0-20150730174459-3ad29d1ad1c4 // indirect
@@ -116,7 +119,6 @@ require (
github.com/asaskevich/govalidator v0.0.0-20180319081651-7d2e70ef918f // indirect
github.com/ashanbrown/forbidigo v1.3.0 // indirect
github.com/ashanbrown/makezero v1.1.1 // indirect
- github.com/aws/aws-sdk-go v1.44.164 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bkielbasa/cyclop v1.2.0 // indirect
github.com/blevesearch/blevex v0.0.0-20190916190636-152f0fe5c040 // indirect
@@ -300,11 +302,10 @@ require (
go4.org v0.0.0-20161118210015-09d86de304dc // indirect
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect
golang.org/x/exp/typeparams v0.0.0-20220827204233-334a2380cb91 // indirect
- golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
- golang.org/x/tools v0.1.12 // indirect
+ golang.org/x/mod v0.10.0 // indirect
+ golang.org/x/tools v0.8.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.0 // indirect
- gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 // indirect
gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 // indirect
diff --git a/go/go.sum b/go/go.sum
index af234fe33053..c6b9dbaafee1 100644
--- a/go/go.sum
+++ b/go/go.sum
@@ -79,8 +79,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
-github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc=
-github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
+github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs=
+github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw=
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
@@ -105,8 +105,8 @@ github.com/ashanbrown/forbidigo v1.3.0/go.mod h1:vVW7PEdqEFqapJe95xHkTfB1+XvZXBF
github.com/ashanbrown/makezero v1.1.1 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5FcB28s=
github.com/ashanbrown/makezero v1.1.1/go.mod h1:i1bJLCRSCHOcOa9Y6MyF2FTfMZMFdHvxKHxgO5Z1axI=
github.com/aws/aws-sdk-go v1.6.11-0.20170104181648-8649d278323e/go.mod h1:ZRmQr0FajVIyZ4ZzBYKG5P3ZqPz9IHG41ZoMu1ADI3k=
-github.com/aws/aws-sdk-go v1.44.164 h1:qDj0RutF2Ut0HZYyUJxFdReLxpYrjupsu2JmDIgCvX8=
-github.com/aws/aws-sdk-go v1.44.164/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
+github.com/aws/aws-sdk-go v1.49.13 h1:f4mGztsgnx2dR9r8FQYa9YW/RsKb+N7bgef4UGrOW1Y=
+github.com/aws/aws-sdk-go v1.49.13/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -463,6 +463,8 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8
github.com/julz/importas v0.1.0 h1:F78HnrsjY3cR7j0etXy5+TU1Zuy7Xt08X/1aJnH5xXY=
github.com/julz/importas v0.1.0/go.mod h1:oSFU2R4XK/P7kNBrnL/FEQlDGN1/6WoxXEjSSXO0DV0=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
+github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
+github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
github.com/kevinburke/ssh_config v0.0.0-20180830205328-81db2a75821e h1:RgQk53JHp/Cjunrr1WlsXSZpqXn+uREuHvUVcK82CV8=
@@ -503,12 +505,10 @@ github.com/keybase/go-porterstemmer v1.0.2-0.20181016185745-521f1ed5c3f7 h1:gYjy
github.com/keybase/go-porterstemmer v1.0.2-0.20181016185745-521f1ed5c3f7/go.mod h1:4ZEXiGFpvjOEuVLyAcOYrHKJ4rHvn1fFsTaiQ3gnHnM=
github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19 h1:WjT3fLi9n8YWh/Ih8Q1LHAPsTqGddPcHqscN+PJ3i68=
github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19/go.mod h1:hY+WOq6m2FpbvyrI93sMaypsttvaIL5nhVR92dTMUcQ=
-github.com/keybase/go-triplesec v0.0.0-20221220225315-06ddee08f3c2 h1:bnQkrsJdUFvDelyjIouqcdArv9HFk3gCmCrNu1zKVFc=
-github.com/keybase/go-triplesec v0.0.0-20221220225315-06ddee08f3c2/go.mod h1:OS+cAHuE4nh0MGMiBZGSBJyUdzBa1BOF5dsAlXX8JjY=
-github.com/keybase/go-triplesec-insecure v0.0.0-20221220225342-ddc3aa12adec h1:2XVtAaCXMp2wjbMdlK5fWjzqwdK52E5Iksy16Ro/isY=
-github.com/keybase/go-triplesec-insecure v0.0.0-20221220225342-ddc3aa12adec/go.mod h1:oYiRzsVHN0GPOGRMGveJhWyyuOM2H4WHEJdUlvySQo8=
-github.com/keybase/go-updater v0.0.0-20221221194633-9e97736a0b42 h1:Lix6VfHewZcmtVSc9Dp1CsDXjNigKjP+7p0j5MhR5xQ=
-github.com/keybase/go-updater v0.0.0-20221221194633-9e97736a0b42/go.mod h1:ne1BruxmXYg/q6pZcNm4Lk5mNMaLZoVFzkUkXILMNzE=
+github.com/keybase/go-triplesec v0.0.0-20231213205702-981541df982e h1:gq30zfpBFPOp84ZrUFrJ/0o3T8d39kqdmS6f51wURYo=
+github.com/keybase/go-triplesec v0.0.0-20231213205702-981541df982e/go.mod h1:qgaIwulBeySrYGk5RF9eMxzchKMVnI8BPBwqK5O5j8c=
+github.com/keybase/go-triplesec-insecure v0.0.0-20231213205953-ffb6212a205e h1:PU+eVHu7gjLjBqHSBV7DMlZCtnq0TUtRrNpFVc+EyNg=
+github.com/keybase/go-triplesec-insecure v0.0.0-20231213205953-ffb6212a205e/go.mod h1:FZedR+t+xUayFbjFxL+yqtOsbJZR2kkd69T8Io9mAPY=
github.com/keybase/go-winio v0.4.12-0.20180913221037-b1d96ab97b58 h1:W3c3cQc7Fe2LWwa9gz1qyOBfJwuMNcnVIoHP9joEUzI=
github.com/keybase/go-winio v0.4.12-0.20180913221037-b1d96ab97b58/go.mod h1:Rcswqyeiwun4CF+RpzSNllKs3nO8Es5HZIhl+8YCm94=
github.com/keybase/golang-ico v0.0.0-20181117022008-819cbeb217c9 h1:O4kEXd3yzbpHCRqwjbsBNKlanp52zXjKP6nFh9VSGy0=
@@ -521,12 +521,10 @@ github.com/keybase/keybase-test-vectors v1.0.12-0.20200309162119-ea1e58fecd5d h1
github.com/keybase/keybase-test-vectors v1.0.12-0.20200309162119-ea1e58fecd5d/go.mod h1:X9vCtvYYyA79MN7GvnlYKjIUkxlo5e0uZ+dYL5kTBqg=
github.com/keybase/msgpackzip v0.0.0-20221220225959-4abf538d2b9c h1:PRG2AXSelSy7MiDI+PwJR2QSqI1N3OybRUutsMiHtpo=
github.com/keybase/msgpackzip v0.0.0-20221220225959-4abf538d2b9c/go.mod h1:DkylHDco/FLr1+GM6wg0GF4E3CCKov54MSYojKYAbS0=
-github.com/keybase/pipeliner v0.0.0-20211118220306-ca1be321c9e5 h1:uOr2TADf5Cx7+PQwiCJIa48GavS1t6nXsWy959mzSew=
-github.com/keybase/pipeliner v0.0.0-20211118220306-ca1be321c9e5/go.mod h1:zIf2LD88KIut7wqLV0IIOfUeXwYyvXDl41rsrkBzNYE=
-github.com/keybase/release v0.0.0-20221220220653-50771d921175 h1:NmV1FvKs0zlOoZ28W7P/0gFb7CkworBjpetsEb+NC8k=
-github.com/keybase/release v0.0.0-20221220220653-50771d921175/go.mod h1:9fiJDQ5WXJrOnDjFB3rLncCLczLU6RwgaDyiQrwkXTw=
-github.com/keybase/saltpack v0.0.0-20221220231257-f6cce11cfd0f h1:q6W4z1Qbv9UfOPCpX/ymoiNVbT/+PuCVtOWHu2O5Pss=
-github.com/keybase/saltpack v0.0.0-20221220231257-f6cce11cfd0f/go.mod h1:8hM5WwVH+oXJVaxqscISOuOjPHV20Htnl56CBLAPzMY=
+github.com/keybase/pipeliner v0.0.0-20231213214924-f648db4bba63 h1:zwjxaJp3XkHxS5jBFbQjcy2yHiUecymXB3MXPf0U7tg=
+github.com/keybase/pipeliner v0.0.0-20231213214924-f648db4bba63/go.mod h1:3ui+OKQTLV41wZxubSXA4oWfqg1/YSrpJ4Zs86z26OM=
+github.com/keybase/saltpack v0.0.0-20231213211625-726bb684c617 h1:z0BITnIaKvnqlZuK0BroCaZ0rMLIwFJN1/lttLG3xvw=
+github.com/keybase/saltpack v0.0.0-20231213211625-726bb684c617/go.mod h1:sslrL/EiYuXAYxsh0dUHhkWtFypUfWEz4pkES+5QWvQ=
github.com/keybase/stellar-org v0.0.0-20191010205648-0fc3bfe3dfa7 h1:wOqDICze0HpgV4PfV6m4fWvHV5XZteSgFHrT6KVDOkg=
github.com/keybase/stellar-org v0.0.0-20191010205648-0fc3bfe3dfa7/go.mod h1:U4zGmzxsNw5Kr0Z8f0clhHVFOIfi80iVh8MBp7y8pY0=
github.com/keybase/stellarnet v0.0.0-20200311180805-6c05850f9050 h1:S1Zlz3H0Jg6qZKKfURnXgv/tOvV6ZLWoFF1ajLiHj78=
@@ -769,8 +767,8 @@ github.com/securego/gosec/v2 v2.13.1/go.mod h1:EO1sImBMBWFjOTFzMWfTRrZW6M15gm60l
github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 h1:S4OC0+OBKz6mJnzuHioeEat74PuQ4Sgvbf8eus695sc=
github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2/go.mod h1:8zLRYR5npGjaOXgPSKat5+oOh+UHd8OdbS18iqX9F6Y=
github.com/sergi/go-diff v0.0.0-20161205080420-83532ca1c1ca/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
-github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
-github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c h1:W65qqJCIOVP4jpqPQ0YvHYKwcMEMVWIzWC5iNQQfBTU=
github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAxekIXwN8qQyfc5gl2NlkB3CQlkizAbOkeBs=
github.com/shirou/gopsutil v2.18.13-0.20181231150826-db425313bfa8+incompatible h1:IIjAFawxeB0nqQwSV3mWI02t5DgAAOFtgRKrjpEnXyU=
@@ -852,8 +850,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/strib/gomounts v0.0.0-20180215003523-d9ea4eaa52ca h1:wVJqmi/uGy9ZBcMltRJykdA+HDMHhd+rYDuCH9Z9zKg=
github.com/strib/gomounts v0.0.0-20180215003523-d9ea4eaa52ca/go.mod h1:kyoAB93nwIsDbBftMJ8L2vIIGTdNORUr9hwjX83liiA=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
@@ -958,8 +956,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
-golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
+golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
+golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1006,8 +1004,9 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
+golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1053,9 +1052,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
-golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
-golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
+golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
+golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1078,8 +1076,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
+golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1149,13 +1147,11 @@ golang.org/x/sys v0.0.0-20220702020025-31831981b65f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
-golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
+golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1164,9 +1160,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
-golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1254,8 +1249,9 @@ golang.org/x/tools v0.1.9-0.20211228192929-ee1ca4ffc4da/go.mod h1:nABZi5QlRsZVlz
golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4=
-golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
+golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/go/install/terminate_nonmobile.go b/go/install/terminate_nonmobile.go
index 5378ebeeb39c..562870d2d16c 100644
--- a/go/install/terminate_nonmobile.go
+++ b/go/install/terminate_nonmobile.go
@@ -7,7 +7,7 @@ import (
"time"
"github.com/keybase/client/go/logger"
- "github.com/keybase/go-updater/process"
+ "github.com/keybase/client/go/updater/process"
)
// TerminateApp will stop the Keybase (UI) app
diff --git a/go/kbfs/libgit/autogit_node_wrappers_test.go b/go/kbfs/libgit/autogit_node_wrappers_test.go
index 3c69545d1a8e..1bf2cecb2204 100644
--- a/go/kbfs/libgit/autogit_node_wrappers_test.go
+++ b/go/kbfs/libgit/autogit_node_wrappers_test.go
@@ -296,7 +296,7 @@ func TestAutogitCommitFile(t *testing.T) {
email1 := "user1@keyba.se"
time1 := time.Now()
hash1 := addFileToWorktreeWithInfo(
- t, repo, worktreeFS, "foo", "hello", msg1, user1, email1, time1)
+ t, repo, worktreeFS, "foo", "hello\n\nworld\n", msg1, user1, email1, time1)
commitWorktree(ctx, t, config, h, worktreeFS)
t.Log("Check the first commit -- no diff")
@@ -319,7 +319,7 @@ func TestAutogitCommitFile(t *testing.T) {
email2 := "user2@keyba.se"
time2 := time1.Add(1 * time.Minute)
hash2 := addFileToWorktreeWithInfo(
- t, repo, worktreeFS, "foo", "hello world", msg2, user2, email2, time2)
+ t, repo, worktreeFS, "foo", "hello\n\nworld\nhello world\n", msg2, user2, email2, time2)
commitWorktree(ctx, t, config, h, worktreeFS)
commit1, err := repo.CommitObject(hash1)
@@ -343,8 +343,10 @@ func TestAutogitCommitFile(t *testing.T) {
index %s..%s 100644
--- a/foo
+++ b/foo
-@@ -1 +1 @@
--hello
+@@ -1,3 +1,4 @@
+ hello
+
+ world
+hello world
`, entry1.Hash.String(), entry2.Hash.String())
f2, err := rootFS.Open(
diff --git a/go/kbfs/libkbfs/md_ops.go b/go/kbfs/libkbfs/md_ops.go
index 9f628ffef5e4..b3192fe1daed 100644
--- a/go/kbfs/libkbfs/md_ops.go
+++ b/go/kbfs/libkbfs/md_ops.go
@@ -26,14 +26,10 @@ import (
)
const (
- maxAllowedMerkleGapServer = 13 * time.Hour
- // Our contract with the server states that it won't accept KBFS
- // writes if more than 13 hours have passed since the last Merkle
- // roots (both global and KBFS) were published. Add some padding
- // to that, and if we see any gaps larger than this, we will know
- // we shouldn't be trusting the server. TODO: reduce this once
- // merkle computation is faster.
- maxAllowedMerkleGap = maxAllowedMerkleGapServer + 15*time.Minute
+ // Update 2024-02-16: we decided to increase the merkle gap check to 4 days
+ // in case mdmerkle goes down, to give us more time to fix it.
+ maxAllowedMerkleGapServer = 4 * (time.Hour * 24)
+ maxAllowedMerkleGap = maxAllowedMerkleGapServer + 15*time.Minute
// merkleGapEnforcementStartString indicates when the mdserver
// started rejecting new writes based on the lack of recent merkle
diff --git a/go/libkb/proxy.go b/go/libkb/proxy.go
index 2307c41ba1da..148fbab8ce77 100644
--- a/go/libkb/proxy.go
+++ b/go/libkb/proxy.go
@@ -295,11 +295,7 @@ func ProxyDialWithOpts(ctx context.Context, env *Env, network string, address st
}
}
-// The equivalent of http.Get except it uses the proxy configured in Env
-// `instrumentationTag` should be a static tag for all requests identifying the
-// type of request we are proxying so we don't leak URL information to the
-// instrumenter.
-func ProxyHTTPGet(g *GlobalContext, env *Env, u, instrumentationTag string) (*http.Response, error) {
+func ProxyHTTPClient(g *GlobalContext, env *Env, instrumentationTag string) *http.Client {
xprt := NewInstrumentedRoundTripper(g, func(*http.Request) string { return instrumentationTag },
&http.Transport{
Proxy: MakeProxy(env),
@@ -307,6 +303,15 @@ func ProxyHTTPGet(g *GlobalContext, env *Env, u, instrumentationTag string) (*ht
client := &http.Client{
Transport: xprt,
}
+ return client
+}
+
+// The equivalent of http.Get except it uses the proxy configured in Env
+// `instrumentationTag` should be a static tag for all requests identifying the
+// type of request we are proxying so we don't leak URL information to the
+// instrumenter.
+func ProxyHTTPGet(g *GlobalContext, env *Env, u, instrumentationTag string) (*http.Response, error) {
+ client := ProxyHTTPClient(g, env, instrumentationTag)
return client.Get(u)
}
diff --git a/go/libkb/version.go b/go/libkb/version.go
index e13579876749..04f0164e22dc 100644
--- a/go/libkb/version.go
+++ b/go/libkb/version.go
@@ -4,4 +4,4 @@
package libkb
// Version is the current version (should be MAJOR.MINOR.PATCH)
-const Version = "6.2.7"
+const Version = "6.2.8"
diff --git a/go/release/LICENSE b/go/release/LICENSE
new file mode 100644
index 000000000000..8270818e75e5
--- /dev/null
+++ b/go/release/LICENSE
@@ -0,0 +1,28 @@
+Copyright (c) 2015, Keybase
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of keybase nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
diff --git a/go/release/README.md b/go/release/README.md
new file mode 100644
index 000000000000..2dd5081bc6c7
--- /dev/null
+++ b/go/release/README.md
@@ -0,0 +1,14 @@
+## Release
+
+[![Build Status](https://github.com/keybase/client/go/release/actions/workflows/ci.yml/badge.svg)](https://github.com/keybase/client/go/release/actions)
+[![GoDoc](https://godoc.org/github.com/keybase/client/go/release?status.svg)](https://godoc.org/github.com/keybase/client/go/release)
+
+This is a command line tool for build and release scripts for generating updates, interacting with Github and S3.
+
+### Example Usage
+
+Generating update.json
+
+```
+release update-json --version=1.2.3 --src=/tmp/Keybase.zip --uri=https://s3.amazonaws.com/prerelease.keybase.io/darwin-updates --signature=/tmp/keybase.sig
+```
diff --git a/go/release/github/actions.go b/go/release/github/actions.go
new file mode 100644
index 000000000000..713f04166d05
--- /dev/null
+++ b/go/release/github/actions.go
@@ -0,0 +1,260 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package github
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "regexp"
+ "strconv"
+ "time"
+)
+
+// CreateRelease creates a release for a tag
+func CreateRelease(token string, repo string, tag string, name string) error {
+ params := ReleaseCreate{
+ TagName: tag,
+ Name: name,
+ }
+
+ payload, err := json.Marshal(params)
+ if err != nil {
+ return fmt.Errorf("can't encode release creation params, %v", err)
+ }
+ reader := bytes.NewReader(payload)
+
+ uri := fmt.Sprintf("/repos/keybase/%s/releases", repo)
+ resp, err := DoAuthRequest("POST", githubAPIURL+uri, "application/json", token, nil, reader)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return fmt.Errorf("while submitting %v, %v", string(payload), err)
+ }
+ if resp.StatusCode != http.StatusCreated {
+ if resp.StatusCode == 422 {
+ return fmt.Errorf("github returned %v (this is probably because the release already exists)",
+ resp.Status)
+ }
+ return fmt.Errorf("github returned %v", resp.Status)
+ }
+ return nil
+}
+
+// Upload uploads a file to a tagged repo
+func Upload(token string, repo string, tag string, name string, file string) error {
+ release, err := ReleaseOfTag("keybase", repo, tag, token)
+ if err != nil {
+ return err
+ }
+ v := url.Values{}
+ v.Set("name", name)
+ url := release.CleanUploadURL() + "?" + v.Encode()
+ osfile, err := os.Open(file)
+ if err != nil {
+ return err
+ }
+ resp, err := DoAuthRequest("POST", url, "application/octet-stream", token, nil, osfile)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != http.StatusCreated {
+ if resp.StatusCode == 422 {
+ return fmt.Errorf("github returned %v (this is probably because the release already exists)",
+ resp.Status)
+ }
+ return fmt.Errorf("github returned %v", resp.Status)
+ }
+ return nil
+}
+
+// DownloadSource dowloads source from repo tag
+func DownloadSource(token string, repo string, tag string) error {
+ url := githubAPIURL + fmt.Sprintf("/repos/keybase/%s/tarball/%s", repo, tag)
+ name := fmt.Sprintf("%s-%s.tar.gz", repo, tag)
+ log.Printf("Url: %s", url)
+ return Download(token, url, name)
+}
+
+// DownloadAsset downloads an asset from Github that matches name
+func DownloadAsset(token string, repo string, tag string, name string) error {
+ release, err := ReleaseOfTag("keybase", repo, tag, token)
+ if err != nil {
+ return err
+ }
+
+ assetID := 0
+ for _, asset := range release.Assets {
+ if asset.Name == name {
+ assetID = asset.ID
+ }
+ }
+
+ if assetID == 0 {
+ return fmt.Errorf("could not find asset named %s", name)
+ }
+
+ url := githubAPIURL + fmt.Sprintf(assetDownloadURI, "keybase", repo, assetID)
+ return Download(token, url, name)
+}
+
+// Download from Github
+func Download(token string, url string, name string) error {
+ resp, err := DoAuthRequest("GET", url, "", token, map[string]string{
+ "Accept": "application/octet-stream",
+ }, nil)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return fmt.Errorf("could not fetch releases, %v", err)
+ }
+
+ contentLength, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
+ if err != nil {
+ return err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("github did not respond with 200 OK but with %v", resp.Status)
+ }
+
+ out, err := os.Create(name)
+ if err != nil {
+ return fmt.Errorf("could not create file %s", name)
+ }
+ defer func() { _ = out.Close() }()
+
+ n, err := io.Copy(out, resp.Body)
+ if n != contentLength {
+ return fmt.Errorf("downloaded data did not match content length %d != %d", contentLength, n)
+ }
+ return err
+}
+
+// LatestCommit returns a latest commit for all statuses matching state and contexts
+func LatestCommit(token string, repo string, contexts []string) (*Commit, error) {
+ commits, err := Commits("keybase", repo, token)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, commit := range commits {
+ log.Printf("Checking %s", commit.SHA)
+ statuses, err := getStatuses(token, "keybase", repo, commit.SHA)
+ if err != nil {
+ return nil, err
+ }
+ matching := map[string]Status{}
+ for _, status := range statuses {
+ if stringInSlice(status.Context, contexts) {
+ switch status.State {
+ case "failure":
+ log.Printf("%s (failure)", status.Context)
+ case "success":
+ log.Printf("%s (success)", status.Context)
+ matching[status.Context] = status
+ }
+ }
+ }
+ // If we match all contexts then we've found the commit
+ if len(contexts) == len(matching) {
+ return &commit, nil
+ }
+ }
+ return nil, nil
+}
+
+func stringInSlice(str string, list []string) bool {
+ for _, s := range list {
+ if s == str {
+ return true
+ }
+ }
+ return false
+}
+
+// CIStatuses lists statuses for CI
+func CIStatuses(token string, repo string, commit string) error {
+ log.Printf("Statuses for %s, %q\n", repo, commit)
+ statuses, err := getStatuses(token, "keybase", repo, commit)
+ if err != nil {
+ return err
+ }
+ log.Println("\tStatuses:")
+ for _, status := range statuses {
+ log.Printf("\t%s (%s)", status.Context, status.State)
+ }
+ return nil
+}
+
+// WaitForCI waits for commit in repo to pass CI contexts
+func WaitForCI(token string, repo string, commit string, contexts []string, delay time.Duration, timeout time.Duration) error {
+ start := time.Now()
+ re := regexp.MustCompile("(.*)(/label=.*)")
+ for time.Since(start) < timeout {
+ log.Printf("Checking status for %s, %q (%s)", repo, contexts, commit)
+ statuses, err := overallStatus(token, "keybase", repo, commit)
+ if err != nil {
+ return err
+ }
+ const successStatus = "success"
+ const failureStatus = "failure"
+ const errorStatus = "error"
+
+ // See if the topmost, overall status has passed
+ log.Println("\tOverall:", statuses.State)
+
+ matching := map[string]Status{}
+ log.Println("\tStatuses:")
+ for _, status := range statuses.Statuses {
+ log.Printf("\t%s (%s)", status.Context, status.State)
+ }
+ log.Println("\t")
+ log.Println("\tMatch:")
+
+ // Fill in successes for all contexts first
+ for _, status := range statuses.Statuses {
+ context := re.ReplaceAllString(status.Context, "$1")
+ if stringInSlice(context, contexts) && status.State == successStatus {
+ log.Printf("\t%s (success)", context)
+ matching[context] = status
+ }
+ }
+
+ // Check failures and errors. If we had a success for that context,
+ // we can ignore them. Otherwise we'll fail right away.
+ for _, status := range statuses.Statuses {
+ context := re.ReplaceAllString(status.Context, "$1")
+ if stringInSlice(context, contexts) {
+ switch status.State {
+ case failureStatus, errorStatus:
+ if matching[context].State != successStatus {
+ log.Printf("\t%s (%s)", context, status.State)
+ return fmt.Errorf("Failure in CI for %s", context)
+ }
+ log.Printf("\t%s (ignoring previous failure)", context)
+ }
+ }
+ }
+ log.Println("\t")
+ // If we match all contexts then we've passed
+ if len(contexts) == len(matching) {
+ return nil
+ }
+
+ log.Printf("Waiting %s", delay)
+ time.Sleep(delay)
+ }
+ return fmt.Errorf("Timed out")
+}
diff --git a/go/release/github/api.go b/go/release/github/api.go
new file mode 100644
index 000000000000..21febe62a05c
--- /dev/null
+++ b/go/release/github/api.go
@@ -0,0 +1,131 @@
+// Modified from https://github.com/aktau/github-release/blob/master/api.go
+
+package github
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+)
+
+const (
+ githubAPIURL = "https://api.github.com"
+)
+
+func githubURL(host string) (u *url.URL, err error) {
+ u, err = url.Parse(host)
+ if err != nil {
+ return
+ }
+ data := url.Values{}
+ u.RawQuery = data.Encode()
+ return
+}
+
+// materializeFile takes a physical file or stream (named pipe, user input,
+// ...) and returns an io.Reader and the number of bytes that can be read
+// from it.
+func materializeFile(f *os.File) (io.Reader, int64, error) {
+ fi, err := f.Stat()
+ if err != nil {
+ return nil, 0, err
+ }
+
+ // If the file is actually a char device (like user typed input)
+ // or a named pipe (like a streamed in file), buffer it up.
+ //
+ // When uploading a file, you need to either explicitly set the
+ // Content-Length header or send a chunked request. Since the
+ // github upload server doesn't accept chunked encoding, we have
+ // to set the size of the file manually. Since a stream doesn't have a
+ // predefined length, it's read entirely into a byte buffer.
+ if fi.Mode()&(os.ModeCharDevice|os.ModeNamedPipe) == 1 {
+ var buf bytes.Buffer
+ n, readErr := buf.ReadFrom(f)
+ if readErr != nil {
+ return nil, 0, errors.New("req: could not buffer up input stream: " + readErr.Error())
+ }
+ return &buf, n, readErr
+ }
+
+ // We know the os.File is most likely an actual file now.
+ n, err := getFileSize(f)
+ return f, n, err
+}
+
+// NewAuthRequest creates a new request that sends the auth token
+func NewAuthRequest(method, url, bodyType, token string, headers map[string]string, body io.Reader) (*http.Request, error) {
+ var n int64 // content length
+ var err error
+ if f, ok := body.(*os.File); ok {
+ // Retrieve the content-length and buffer up if necessary.
+ body, n, err = materializeFile(f)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ req, err := http.NewRequest(method, url, body)
+ if err != nil {
+ return nil, err
+ }
+
+ if n != 0 {
+ req.ContentLength = n
+ }
+
+ if bodyType != "" {
+ req.Header.Set("Content-Type", bodyType)
+ }
+ req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
+
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+
+ return req, nil
+}
+
+// DoAuthRequest does an authenticated request to Github
+func DoAuthRequest(method, url, bodyType, token string, headers map[string]string, body io.Reader) (*http.Response, error) {
+ req, err := NewAuthRequest(method, url, bodyType, token, headers, body)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+// Get does a GET request to the Github API
+func Get(token string, url string, v interface{}) error {
+ resp, err := DoAuthRequest("GET", url, "", token, nil, nil)
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+ if err != nil {
+ return fmt.Errorf("Error in http Get %v", err)
+ }
+ return get(resp, url, v)
+}
+
+func get(resp *http.Response, url, v interface{}) error {
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s responded with %v", url, resp.Status)
+ }
+
+ var r io.Reader = resp.Body
+ if err := json.NewDecoder(r).Decode(v); err != nil {
+ return fmt.Errorf("could not unmarshall JSON into Release struct, %v", err)
+ }
+ return nil
+}
diff --git a/go/release/github/assets.go b/go/release/github/assets.go
new file mode 100644
index 000000000000..bf488b13df82
--- /dev/null
+++ b/go/release/github/assets.go
@@ -0,0 +1,25 @@
+// Modified from https://github.com/aktau/github-release/blob/master/assets.go
+
+package github
+
+import (
+ "time"
+)
+
+const (
+ assetDownloadURI = "/repos/%s/%s/releases/assets/%d"
+)
+
+// Asset is a Github API Asset
+type Asset struct {
+ URL string `json:"url"`
+ ID int `json:"id"`
+ Name string `json:"name"`
+ ContentType string `json:"content_type"`
+ State string `json:"state"`
+ Size uint64 `json:"size"`
+ Downloads uint64 `json:"download_count"`
+ Created time.Time `json:"created_at"`
+ Published time.Time `json:"published_at"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+}
diff --git a/go/release/github/commits.go b/go/release/github/commits.go
new file mode 100644
index 000000000000..74e963cb3eb7
--- /dev/null
+++ b/go/release/github/commits.go
@@ -0,0 +1,29 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package github
+
+import "fmt"
+
+// Commit defines a git commit on Github
+type Commit struct {
+ SHA string `json:"sha"`
+}
+
+const (
+ commitListPath = "/repos/%s/%s/commits"
+)
+
+// Commits lists commits from Github repo
+func Commits(user, repo, token string) ([]Commit, error) {
+ url, err := githubURL(githubAPIURL)
+ if err != nil {
+ return nil, err
+ }
+ url.Path = fmt.Sprintf(commitListPath, user, repo)
+ var commits []Commit
+ if err = Get(token, url.String(), &commits); err != nil {
+ return nil, err
+ }
+ return commits, nil
+}
diff --git a/go/release/github/errors.go b/go/release/github/errors.go
new file mode 100644
index 000000000000..1da29aef2824
--- /dev/null
+++ b/go/release/github/errors.go
@@ -0,0 +1,14 @@
+package github
+
+import "fmt"
+
+// ErrNotFound is error type for not found in API
+type ErrNotFound struct {
+ Name string
+ Key string
+ Value string
+}
+
+func (e ErrNotFound) Error() string {
+ return fmt.Sprintf("%s not found with %s: %s", e.Name, e.Key, e.Value)
+}
diff --git a/go/release/github/file.go b/go/release/github/file.go
new file mode 100644
index 000000000000..95bcb5efbbfb
--- /dev/null
+++ b/go/release/github/file.go
@@ -0,0 +1,46 @@
+// Modified from https://github.com/aktau/github-release/blob/master/file.go
+
+package github
+
+import (
+ "fmt"
+ "io"
+ "os"
+)
+
+func getFileSize(f *os.File) (int64, error) {
+ /* first try stat */
+ off, err := fsizeStat(f)
+ if err != nil {
+ /* if that fails, try seek */
+ return fsizeSeek(f)
+ }
+
+ return off, nil
+}
+
+func fsizeStat(f *os.File) (int64, error) {
+ fi, err := f.Stat()
+
+ if err != nil {
+ return 0, err
+ }
+
+ return fi.Size(), nil
+}
+
+func fsizeSeek(f io.Seeker) (int64, error) {
+ off, err := f.Seek(0, 2)
+ if err != nil {
+ return 0, fmt.Errorf("seeking did not work, stdin is not" +
+ "supported yet because github doesn't support chunking" +
+ "requests (and I haven't implemented detecting stdin and" +
+ "buffering yet")
+ }
+
+ _, err = f.Seek(0, 0)
+ if err != nil {
+ return 0, fmt.Errorf("could not seek back in the file")
+ }
+ return off, nil
+}
diff --git a/go/release/github/releases.go b/go/release/github/releases.go
new file mode 100644
index 000000000000..01d5f48b7e89
--- /dev/null
+++ b/go/release/github/releases.go
@@ -0,0 +1,92 @@
+// Modified from https://github.com/aktau/github-release/blob/master/releases.go
+
+package github
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+const (
+ releaseListPath = "/repos/%s/%s/releases"
+ releaseLatestPath = "/repos/%s/%s/releases/latest"
+)
+
+// Release is a Github API Release type
+type Release struct {
+ URL string `json:"url"`
+ PageURL string `json:"html_url"`
+ UploadURL string `json:"upload_url"`
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"body"`
+ TagName string `json:"tag_name"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+ Created *time.Time `json:"created_at"`
+ Published *time.Time `json:"published_at"`
+ Assets []Asset `json:"assets"`
+}
+
+// CleanUploadURL is URL for uploading a release
+func (r *Release) CleanUploadURL() string {
+ bracket := strings.Index(r.UploadURL, "{")
+
+ if bracket == -1 {
+ return r.UploadURL
+ }
+
+ return r.UploadURL[0:bracket]
+}
+
+// ReleaseCreate is a Github API ReleaseCreate type
+type ReleaseCreate struct {
+ TagName string `json:"tag_name"`
+ TargetCommitish string `json:"target_commitish,omitempty"`
+ Name string `json:"name"`
+ Body string `json:"body"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+}
+
+// Releases returns releases for a repo
+func Releases(user, repo, token string) (releases []Release, err error) {
+ u, err := githubURL(githubAPIURL)
+ if err != nil {
+ return nil, err
+ }
+ u.Path = fmt.Sprintf(releaseListPath, user, repo)
+ err = Get(token, u.String(), &releases)
+ if err != nil {
+ return
+ }
+ return
+}
+
+// LatestRelease returns latest release for repo
+func LatestRelease(user, repo, token string) (release *Release, err error) {
+ u, err := githubURL(githubAPIURL)
+ if err != nil {
+ return
+ }
+ u.Path = fmt.Sprintf(releaseLatestPath, user, repo)
+ err = Get(token, u.String(), &release)
+ return
+}
+
+// ReleaseOfTag returns release for tag
+func ReleaseOfTag(user, repo, tag, token string) (*Release, error) {
+ releases, err := Releases(user, repo, token)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, release := range releases {
+ if release.TagName == tag {
+ return &release, nil
+ }
+ }
+
+ return nil, &ErrNotFound{Name: "release", Key: "tag", Value: tag}
+}
diff --git a/go/release/github/statuses.go b/go/release/github/statuses.go
new file mode 100644
index 000000000000..b7447b27e1e5
--- /dev/null
+++ b/go/release/github/statuses.go
@@ -0,0 +1,54 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package github
+
+import "fmt"
+
+// Status defines a git commit on Github
+type Status struct {
+ State string `json:"state"`
+ Context string `json:"context"`
+}
+
+// Statuses defines the overall status of a git commit on Github
+type Statuses struct {
+ State string `json:"state"`
+ Statuses []Status `json:"statuses"`
+}
+
+const (
+ statusesListPath = "/repos/%s/%s/statuses/%s"
+ statusListPath = "/repos/%s/%s/commits/%s/status"
+)
+
+// Statuses lists statuses for a git commit
+func getStatuses(token, user, repo, sha string) ([]Status, error) {
+ url, err := githubURL(githubAPIURL)
+ if err != nil {
+ return nil, err
+ }
+ url.Path = fmt.Sprintf(statusesListPath, user, repo, sha)
+ var statuses []Status
+ if err = Get(token, url.String()+"?per_page=100", &statuses); err != nil {
+ return nil, err
+ }
+ return statuses, nil
+}
+
+// OverallStatus lists the overall status for a git commit.
+// Instead of all the statuses, it gives an overall status
+// if all have passed, plus a list of the most recent results
+// for each context.
+func overallStatus(token, user, repo, sha string) (Statuses, error) {
+ url, err := githubURL(githubAPIURL)
+ if err != nil {
+ return Statuses{}, err
+ }
+ url.Path = fmt.Sprintf(statusListPath, user, repo, sha)
+ var statuses Statuses
+ if err = Get(token, url.String(), &statuses); err != nil {
+ return Statuses{}, err
+ }
+ return statuses, nil
+}
diff --git a/go/release/github/tags.go b/go/release/github/tags.go
new file mode 100644
index 000000000000..cc4ba9c8095f
--- /dev/null
+++ b/go/release/github/tags.go
@@ -0,0 +1,41 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package github
+
+import "fmt"
+
+const (
+ tagListPath = "/repos/%s/%s/tags"
+)
+
+// Tag is a Github API Tag type
+type Tag struct {
+ Name string `json:"name"`
+}
+
+// Tags returns tags for a repo
+func Tags(user, repo, token string) (tags []Tag, err error) {
+ u, err := githubURL(githubAPIURL)
+ if err != nil {
+ return nil, err
+ }
+ u.Path = fmt.Sprintf(tagListPath, user, repo)
+ err = Get(token, u.String(), &tags)
+ if err != nil {
+ return
+ }
+ return
+}
+
+// LatestTag returns latest tag for a repo
+func LatestTag(user, repo, token string) (tag *Tag, err error) {
+ tags, err := Tags(user, repo, token)
+ if err != nil {
+ return
+ }
+ if len(tags) > 0 {
+ tag = &tags[0]
+ }
+ return
+}
diff --git a/go/release/release.go b/go/release/release.go
new file mode 100644
index 000000000000..6110622175d3
--- /dev/null
+++ b/go/release/release.go
@@ -0,0 +1,309 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "runtime"
+ "strings"
+
+ gh "github.com/keybase/client/go/release/github"
+ "github.com/keybase/client/go/release/update"
+ "github.com/keybase/client/go/release/version"
+ "github.com/keybase/client/go/release/winbuild"
+ "gopkg.in/alecthomas/kingpin.v2"
+)
+
+func githubToken(required bool) string {
+ token := os.Getenv("GITHUB_TOKEN")
+ if token == "" && required {
+ log.Fatal("No GITHUB_TOKEN set")
+ }
+ return token
+}
+
+func keybaseToken(required bool) string {
+ token := os.Getenv("KEYBASE_TOKEN")
+ if token == "" && required {
+ log.Fatal("No KEYBASE_TOKEN set")
+ }
+ return token
+}
+
+func tag(version string) string {
+ return fmt.Sprintf("v%s", version)
+}
+
+var (
+ app = kingpin.New("release", "Release tool for build and release scripts")
+ latestVersionCmd = app.Command("latest-version", "Get latest version of a Github repo")
+ latestVersionUser = latestVersionCmd.Flag("user", "Github user").Required().String()
+ latestVersionRepo = latestVersionCmd.Flag("repo", "Repository name").Required().String()
+
+ platformCmd = app.Command("platform", "Get the OS platform name")
+
+ urlCmd = app.Command("url", "Get the github release URL for a repo")
+ urlUser = urlCmd.Flag("user", "Github user").Required().String()
+ urlRepo = urlCmd.Flag("repo", "Repository name").Required().String()
+ urlVersion = urlCmd.Flag("version", "Version").Required().String()
+
+ createCmd = app.Command("create", "Create a Github release")
+ createRepo = createCmd.Flag("repo", "Repository name").Required().String()
+ createVersion = createCmd.Flag("version", "Version").Required().String()
+
+ uploadCmd = app.Command("upload", "Upload a file to a Github release")
+ uploadRepo = uploadCmd.Flag("repo", "Repository name").Required().String()
+ uploadVersion = uploadCmd.Flag("version", "Version").Required().String()
+ uploadSrc = uploadCmd.Flag("src", "Source file").Required().ExistingFile()
+ uploadDest = uploadCmd.Flag("dest", "Destination file").String()
+
+ downloadCmd = app.Command("download", "Download a file from a Github release")
+ downloadRepo = downloadCmd.Flag("repo", "Repository name").Required().String()
+ downloadVersion = downloadCmd.Flag("version", "Version").Required().String()
+ downloadSrc = downloadCmd.Flag("src", "Source file").Required().ExistingFile()
+
+ updateJSONCmd = app.Command("update-json", "Generate update.json file for updater")
+ updateJSONVersion = updateJSONCmd.Flag("version", "Version").Required().String()
+ updateJSONSrc = updateJSONCmd.Flag("src", "Source file").ExistingFile()
+ updateJSONURI = updateJSONCmd.Flag("uri", "URI for location of files").URL()
+ updateJSONSignature = updateJSONCmd.Flag("signature", "Signature file").ExistingFile()
+ updateJSONDescription = updateJSONCmd.Flag("description", "Description file").ExistingFile()
+ updateJSONProps = updateJSONCmd.Flag("prop", "Properties to include").Strings()
+
+ indexHTMLCmd = app.Command("index-html", "Generate index.html for s3 bucket")
+ indexHTMLBucketName = indexHTMLCmd.Flag("bucket-name", "Bucket name to index").Required().String()
+ indexHTMLPrefixes = indexHTMLCmd.Flag("prefixes", "Prefixes to include (comma-separated)").Required().String()
+ indexHTMLSuffix = indexHTMLCmd.Flag("suffix", "Suffix of files").String()
+ indexHTMLDest = indexHTMLCmd.Flag("dest", "Write to file").String()
+ indexHTMLUpload = indexHTMLCmd.Flag("upload", "Upload to S3").String()
+
+ parseVersionCmd = app.Command("version-parse", "Parse a sematic version string")
+ parseVersionString = parseVersionCmd.Arg("version", "Semantic version to parse").Required().String()
+
+ promoteReleasesCmd = app.Command("promote-releases", "Promote releases")
+ promoteReleasesBucketName = promoteReleasesCmd.Flag("bucket-name", "Bucket name to use").Required().String()
+ promoteReleasesPlatform = promoteReleasesCmd.Flag("platform", "Platform (darwin, linux, windows)").Required().String()
+
+ promoteAReleaseCmd = app.Command("promote-a-release", "Promote a specific release")
+ releaseToPromote = promoteAReleaseCmd.Flag("release", "Specific release to promote to public").Required().String()
+ promoteAReleaseBucketName = promoteAReleaseCmd.Flag("bucket-name", "Bucket name to use").Required().String()
+ promoteAReleasePlatform = promoteAReleaseCmd.Flag("platform", "Platform (darwin, linux, windows)").Required().String()
+ promoteAReleaseDryRun = promoteAReleaseCmd.Flag("dry-run", "Announce what would be done without doing it").Bool()
+
+ brokenReleaseCmd = app.Command("broken-release", "Mark a release as broken")
+ brokenReleaseName = brokenReleaseCmd.Flag("release", "Release to mark as broken").Required().String()
+ brokenReleaseBucketName = brokenReleaseCmd.Flag("bucket-name", "Bucket name to use").Required().String()
+ brokenReleasePlatformName = brokenReleaseCmd.Flag("platform", "Platform (darwin, linux, windows)").Required().String()
+
+ promoteTestReleasesCmd = app.Command("promote-test-releases", "Promote test releases")
+ promoteTestReleasesBucketName = promoteTestReleasesCmd.Flag("bucket-name", "Bucket name to use").Required().String()
+ promoteTestReleasesPlatform = promoteTestReleasesCmd.Flag("platform", "Platform (darwin, linux, windows)").Required().String()
+ promoteTestReleasesRelease = promoteTestReleasesCmd.Flag("release", "Specific release to promote to test").String()
+
+ updatesReportCmd = app.Command("updates-report", "Summary of updates/releases")
+ updatesReportBucketName = updatesReportCmd.Flag("bucket-name", "Bucket name to use").Required().String()
+
+ saveLogCmd = app.Command("save-log", "Save log")
+ saveLogBucketName = saveLogCmd.Flag("bucket-name", "Bucket name to use").Required().String()
+ saveLogPath = saveLogCmd.Flag("path", "File to save").Required().String()
+ saveLogNoErr = saveLogCmd.Flag("noerr", "No error status on failure").Bool()
+ saveLogMaxSize = saveLogCmd.Flag("maxsize", "Max size, (default 102400)").Default("102400").Int64()
+
+ latestCommitCmd = app.Command("latest-commit", "Latests commit we can use to safely build from")
+ latestCommitRepo = latestCommitCmd.Flag("repo", "Repository name").Required().String()
+ latestCommitContexts = latestCommitCmd.Flag("context", "Context to check for success").Required().Strings()
+
+ waitForCICmd = app.Command("wait-ci", "Waits on a the latest commit being successful in CI")
+ waitForCIRepo = waitForCICmd.Flag("repo", "Repository name").Required().String()
+ waitForCICommit = waitForCICmd.Flag("commit", "Commit").Required().String()
+ waitForCIContexts = waitForCICmd.Flag("context", "Context to check for success").Required().Strings()
+ waitForCIDelay = waitForCICmd.Flag("delay", "Delay between checks").Default("1m").Duration()
+ waitForCITimeout = waitForCICmd.Flag("timeout", "Delay between checks").Default("1h").Duration()
+
+ announceBuildCmd = app.Command("announce-build", "Inform the API server of the existence of a new build")
+ announceBuildA = announceBuildCmd.Flag("build-a", "The first of the two IDs comprising the new build").Required().String()
+ announceBuildB = announceBuildCmd.Flag("build-b", "The second of the two IDs comprising the new build").Required().String()
+ announceBuildPlatform = announceBuildCmd.Flag("platform", "Platform (darwin, linux, windows)").Required().String()
+
+ setBuildInTestingCmd = app.Command("set-build-in-testing", "Enroll or unenroll a build in smoketesting")
+ setBuildInTestingA = setBuildInTestingCmd.Flag("build-a", "The first build's ID").Required().String()
+ setBuildInTestingPlatform = setBuildInTestingCmd.Flag("platform", "Platform (darwin, linux, windows)").Required().String()
+ setBuildInTestingEnable = setBuildInTestingCmd.Flag("enable", "Enroll the build in smoketesting (boolish string)").Required().String()
+ setBuildInTestingMaxTesters = setBuildInTestingCmd.Flag("max-testers", "Max number of testers for this build").Required().Int()
+
+ ciStatusesCmd = app.Command("ci-statuses", "List statuses for CI")
+ ciStatusesRepo = ciStatusesCmd.Flag("repo", "Repository name").Required().String()
+ ciStatusesCommit = ciStatusesCmd.Flag("commit", "Commit").Required().String()
+
+ getWinBuildNumberCmd = app.Command("winbuildnumber", "Atomically retrieve and increment build number for given version")
+ getWinBuildNumberVersion = getWinBuildNumberCmd.Flag("version", "Major version, e.g. 1.0.30").Required().String()
+ getWinBuildNumberBotID = getWinBuildNumberCmd.Flag("botid", "bot ID").Default("1").String()
+ getWinBuildNumberPlatform = getWinBuildNumberCmd.Flag("platform", "platform").Default("1").String()
+)
+
+func main() {
+ switch kingpin.MustParse(app.Parse(os.Args[1:])) {
+ case latestVersionCmd.FullCommand():
+ tag, err := gh.LatestTag(*latestVersionUser, *latestVersionRepo, githubToken(false))
+ if err != nil {
+ log.Fatal(err)
+ }
+ if strings.HasPrefix(tag.Name, "v") {
+ version := tag.Name[1:]
+ fmt.Printf("%s", version)
+ }
+ case platformCmd.FullCommand():
+ fmt.Printf("%s", runtime.GOOS)
+
+ case urlCmd.FullCommand():
+ release, err := gh.ReleaseOfTag(*urlUser, *urlRepo, tag(*urlVersion), githubToken(false))
+ if _, ok := err.(*gh.ErrNotFound); ok {
+ // No release
+ } else if err != nil {
+ log.Fatal(err)
+ } else {
+ fmt.Printf("%s", release.URL)
+ }
+ case createCmd.FullCommand():
+ err := gh.CreateRelease(githubToken(true), *createRepo, tag(*createVersion), tag(*createVersion))
+ if err != nil {
+ log.Fatal(err)
+ }
+ case uploadCmd.FullCommand():
+ if *uploadDest == "" {
+ uploadDest = uploadSrc
+ }
+ log.Printf("Uploading %s as %s (%s)", *uploadSrc, *uploadDest, tag(*uploadVersion))
+ err := gh.Upload(githubToken(true), *uploadRepo, tag(*uploadVersion), *uploadDest, *uploadSrc)
+ if err != nil {
+ log.Fatal(err)
+ }
+ case downloadCmd.FullCommand():
+ defaultSrc := fmt.Sprintf("keybase-%s-%s.tgz", *downloadVersion, runtime.GOOS)
+ if *downloadSrc == "" {
+ downloadSrc = &defaultSrc
+ }
+ log.Printf("Downloading %s (%s)", *downloadSrc, tag(*downloadVersion))
+ err := gh.DownloadAsset(githubToken(false), *downloadRepo, tag(*downloadVersion), *downloadSrc)
+ if err != nil {
+ log.Fatal(err)
+ }
+ case updateJSONCmd.FullCommand():
+ out, err := update.EncodeJSON(*updateJSONVersion, tag(*updateJSONVersion), *updateJSONDescription, *updateJSONProps, *updateJSONSrc, *updateJSONURI, *updateJSONSignature)
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(os.Stdout, "%s\n", out)
+ case indexHTMLCmd.FullCommand():
+ err := update.WriteHTML(*indexHTMLBucketName, *indexHTMLPrefixes, *indexHTMLSuffix, *indexHTMLDest, *indexHTMLUpload)
+ if err != nil {
+ log.Fatal(err)
+ }
+ case parseVersionCmd.FullCommand():
+ versionFull, versionShort, date, commit, err := version.Parse(*parseVersionString)
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Printf("%s\n", versionFull)
+ log.Printf("%s\n", versionShort)
+ log.Printf("%s\n", date)
+ log.Printf("%s\n", commit)
+ case promoteReleasesCmd.FullCommand():
+ const dryRun bool = false
+ release, err := update.PromoteReleases(*promoteReleasesBucketName, *promoteReleasesPlatform)
+ if err != nil {
+ log.Fatal(err)
+ }
+ err = update.CopyLatest(*promoteReleasesBucketName, *promoteReleasesPlatform, dryRun)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if release == nil {
+ log.Print("Not notifying API server of release")
+ } else {
+ releaseTime, err := update.KBWebPromote(keybaseToken(true), release.Version, *promoteReleasesPlatform, dryRun)
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Printf("Release time set to %v for build %v", releaseTime, release.Version)
+ }
+ case promoteAReleaseCmd.FullCommand():
+ release, err := update.PromoteARelease(*releaseToPromote, *promoteAReleaseBucketName, *promoteAReleasePlatform, *promoteAReleaseDryRun)
+ if err != nil {
+ log.Fatal(err)
+ }
+ err = update.CopyLatest(*promoteAReleaseBucketName, *promoteAReleasePlatform, *promoteAReleaseDryRun)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if release == nil {
+ log.Fatal("No release found")
+ } else {
+ _, err := update.KBWebPromote(keybaseToken(!*promoteAReleaseDryRun), release.Version, *promoteAReleasePlatform, *promoteAReleaseDryRun)
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
+ case promoteTestReleasesCmd.FullCommand():
+ err := update.PromoteTestReleases(*promoteTestReleasesBucketName, *promoteTestReleasesPlatform, *promoteTestReleasesRelease)
+ if err != nil {
+ log.Fatal(err)
+ }
+ case updatesReportCmd.FullCommand():
+ err := update.Report(*updatesReportBucketName, os.Stdout)
+ if err != nil {
+ log.Fatal(err)
+ }
+ case brokenReleaseCmd.FullCommand():
+ _, err := update.ReleaseBroken(*brokenReleaseName, *brokenReleaseBucketName, *brokenReleasePlatformName)
+ if err != nil {
+ log.Fatal(err)
+ }
+ case saveLogCmd.FullCommand():
+
+ url, err := update.SaveLog(*saveLogBucketName, *saveLogPath, *saveLogMaxSize)
+ if err != nil {
+ if *saveLogNoErr {
+ log.Printf("%s", err)
+ return
+ }
+ log.Fatal(err)
+ }
+ fmt.Fprintf(os.Stdout, "%s\n", url)
+ case latestCommitCmd.FullCommand():
+ commit, err := gh.LatestCommit(githubToken(true), *latestCommitRepo, *latestCommitContexts)
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("%s", commit.SHA)
+ case waitForCICmd.FullCommand():
+ err := gh.WaitForCI(githubToken(true), *waitForCIRepo, *waitForCICommit, *waitForCIContexts, *waitForCIDelay, *waitForCITimeout)
+ if err != nil {
+ log.Fatal(err)
+ }
+ case announceBuildCmd.FullCommand():
+ err := update.AnnounceBuild(keybaseToken(true), *announceBuildA, *announceBuildB, *announceBuildPlatform)
+ if err != nil {
+ log.Fatal(err)
+ }
+ case setBuildInTestingCmd.FullCommand():
+ err := update.SetBuildInTesting(keybaseToken(true), *setBuildInTestingA, *setBuildInTestingPlatform, *setBuildInTestingEnable, *setBuildInTestingMaxTesters)
+ if err != nil {
+ log.Fatal(err)
+ }
+ case ciStatusesCmd.FullCommand():
+ err := gh.CIStatuses(githubToken(true), *ciStatusesRepo, *ciStatusesCommit)
+ if err != nil {
+ log.Fatal(err)
+ }
+ case getWinBuildNumberCmd.FullCommand():
+ err := winbuild.GetNextBuildNumber(keybaseToken(true), *getWinBuildNumberVersion, *getWinBuildNumberBotID, *getWinBuildNumberPlatform)
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
+
+}
diff --git a/go/release/update/kbweb.go b/go/release/update/kbweb.go
new file mode 100644
index 000000000000..8087806fd02e
--- /dev/null
+++ b/go/release/update/kbweb.go
@@ -0,0 +1,181 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package update
+
+import (
+ "bytes"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/keybase/client/go/libkb"
+)
+
+const (
+ kbwebAPIUrl = "https://api-0.core.keybaseapi.com"
+)
+
+type kbwebClient struct {
+ http *http.Client
+}
+
+type APIResponseWrapper interface {
+ StatusCode() int
+}
+
+type AppResponseBase struct {
+ Status struct {
+ Code int
+ Desc string
+ }
+}
+
+func (s *AppResponseBase) StatusCode() int {
+ return s.Status.Code
+}
+
+// newKbwebClient constructs a Client
+func newKbwebClient() (*kbwebClient, error) {
+ certPool := x509.NewCertPool()
+ ok := certPool.AppendCertsFromPEM([]byte(libkb.APICA))
+ if !ok {
+ return nil, fmt.Errorf("Could not read CA for keybase.io")
+ }
+ client := &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{RootCAs: certPool},
+ },
+ }
+ return &kbwebClient{http: client}, nil
+}
+
+func (client *kbwebClient) post(keybaseToken string, path string, data []byte, response APIResponseWrapper) error {
+ req, err := http.NewRequest("POST", kbwebAPIUrl+path, bytes.NewBuffer(data))
+ if err != nil {
+ return fmt.Errorf("newrequest failed, %v", err)
+ }
+ req.Header.Add("content-type", "application/json")
+ req.Header.Add("x-keybase-admin-token", keybaseToken)
+ resp, err := client.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("request failed, %v", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("body err, %v", err)
+ }
+
+ if response == nil {
+ response = new(AppResponseBase)
+ }
+ if err := json.Unmarshal(body, &response); err != nil {
+ return fmt.Errorf("json reply err, %v", err)
+ }
+
+ if response.StatusCode() != 0 {
+ return fmt.Errorf("Server returned failure, %s", body)
+ }
+
+ fmt.Printf("Success.\n")
+ return nil
+}
+
+type announceBuildArgs struct {
+ VersionA string `json:"version_a"`
+ VersionB string `json:"version_b"`
+ Platform string `json:"platform"`
+}
+
+// AnnounceBuild tells the API server about the existence of a new build.
+// It does not enroll it in smoke testing.
+func AnnounceBuild(keybaseToken string, buildA string, buildB string, platform string) error {
+ client, err := newKbwebClient()
+ if err != nil {
+ return fmt.Errorf("client create failed, %v", err)
+ }
+ args := &announceBuildArgs{
+ VersionA: buildA,
+ VersionB: buildB,
+ Platform: platform,
+ }
+ jsonStr, err := json.Marshal(args)
+ if err != nil {
+ return fmt.Errorf("json marshal err, %v", err)
+ }
+ var data = jsonStr
+ return client.post(keybaseToken, "/_/api/1.0/pkg/add_build.json", data, nil)
+}
+
+type promoteBuildArgs struct {
+ VersionA string `json:"version_a"`
+ Platform string `json:"platform"`
+}
+
+type promoteBuildResponse struct {
+ AppResponseBase
+ ReleaseTimeMs int64 `json:"release_time"`
+}
+
+// KBWebPromote tells the API server that a new build is promoted.
+func KBWebPromote(keybaseToken string, buildA string, platform string, dryRun bool) (releaseTime time.Time, err error) {
+ client, err := newKbwebClient()
+ if err != nil {
+ return releaseTime, fmt.Errorf("client create failed, %v", err)
+ }
+ args := &promoteBuildArgs{
+ VersionA: buildA,
+ Platform: platform,
+ }
+ jsonStr, err := json.Marshal(args)
+ if err != nil {
+ return releaseTime, fmt.Errorf("json marshal err, %v", err)
+ }
+ var data = jsonStr
+ var response promoteBuildResponse
+ if dryRun {
+ log.Printf("DRYRUN: Would post %s\n", data)
+ return releaseTime, nil
+ }
+ err = client.post(keybaseToken, "/_/api/1.0/pkg/set_released.json", data, &response)
+ if err != nil {
+ return releaseTime, err
+ }
+ releaseTime = time.Unix(0, response.ReleaseTimeMs*int64(time.Millisecond))
+ log.Printf("Release time set to %v for build %v", releaseTime, buildA)
+ return releaseTime, nil
+}
+
+type setBuildInTestingArgs struct {
+ VersionA string `json:"version_a"`
+ Platform string `json:"platform"`
+ InTesting string `json:"in_testing"`
+ MaxTesters int `json:"max_testers"`
+}
+
+// SetBuildInTesting tells the API server to enroll or unenroll a build in smoke testing.
+func SetBuildInTesting(keybaseToken string, buildA string, platform string, inTesting string, maxTesters int) error {
+ client, err := newKbwebClient()
+ if err != nil {
+ return fmt.Errorf("client create failed, %v", err)
+ }
+ args := &setBuildInTestingArgs{
+ VersionA: buildA,
+ Platform: platform,
+ InTesting: inTesting,
+ MaxTesters: maxTesters,
+ }
+ jsonStr, err := json.Marshal(args)
+ if err != nil {
+ return fmt.Errorf("json marshal err: %v", err)
+ }
+ var data = jsonStr
+ return client.post(keybaseToken, "/_/api/1.0/pkg/set_in_testing.json", data, nil)
+}
diff --git a/go/release/update/protocol.go b/go/release/update/protocol.go
new file mode 100644
index 000000000000..b692f6cb0fd4
--- /dev/null
+++ b/go/release/update/protocol.go
@@ -0,0 +1,66 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package update
+
+import "time"
+
+// Asset describes a downloadable file.
+type Asset struct {
+ Name string `codec:"name" json:"name"`
+ URL string `codec:"url" json:"url"`
+ Digest string `codec:"digest" json:"digest"`
+ Signature string `codec:"signature" json:"signature"`
+ LocalPath string `codec:"localPath" json:"localPath"`
+}
+
+// Type is the type of update
+type Type int
+
+const (
+ // Normal is a normal update
+ Normal Type = 0
+ // Bugfix is a bugfix
+ Bugfix Type = 1
+ // Critical is critical
+ Critical Type = 2
+)
+
+// Property is a generic key value pair for custom properties
+type Property struct {
+ Name string `codec:"name" json:"name"`
+ Value string `codec:"value" json:"value"`
+}
+
+// Update defines an update
+type Update struct {
+ Version string `codec:"version" json:"version"`
+ Name string `codec:"name" json:"name"`
+ Description string `codec:"description" json:"description"`
+ Instructions *string `codec:"instructions,omitempty" json:"instructions,omitempty"`
+ Type Type `codec:"type" json:"type"`
+ PublishedAt *Time `codec:"publishedAt,omitempty" json:"publishedAt,omitempty"`
+ Props []Property `codec:"props" json:"props,omitempty"`
+ Asset *Asset `codec:"asset,omitempty" json:"asset,omitempty"`
+}
+
+// Time as millis
+type Time int64
+
+// FromTime converts millis to Time
+func FromTime(t Time) time.Time {
+ if t == 0 {
+ return time.Time{}
+ }
+ return time.Unix(0, int64(t)*1000000)
+}
+
+// ToTime converts Time to millis
+func ToTime(t time.Time) Time {
+ // the result of calling UnixNano on the zero Time is undefined.
+ // https://golang.org/pkg/time/#Time.UnixNano
+ if t.IsZero() {
+ return 0
+ }
+ return Time(t.UnixNano() / 1000000)
+}
diff --git a/go/release/update/s3.go b/go/release/update/s3.go
new file mode 100644
index 000000000000..17f103935592
--- /dev/null
+++ b/go/release/update/s3.go
@@ -0,0 +1,852 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package update
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/alecthomas/template"
+ "github.com/blang/semver"
+ "github.com/keybase/client/go/release/version"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/aws/session"
+ "github.com/aws/aws-sdk-go/service/s3"
+)
+
+const defaultCacheControl = "max-age=60"
+
+const defaultChannel = "v2"
+
+// Section defines a set of releases
+type Section struct {
+ Header string
+ Releases []Release
+}
+
+// Release defines a release bundle
+type Release struct {
+ Name string
+ Key string
+ URL string
+ Version string
+ DateString string
+ Date time.Time
+ Commit string
+}
+
+// ByRelease defines how to sort releases
+type ByRelease []Release
+
+func (s ByRelease) Len() int {
+ return len(s)
+}
+
+func (s ByRelease) Swap(i, j int) {
+ s[i], s[j] = s[j], s[i]
+}
+
+func (s ByRelease) Less(i, j int) bool {
+ // Reverse date order
+ return s[j].Date.Before(s[i].Date)
+}
+
+// Client is an S3 client
+type Client struct {
+ svc *s3.S3
+}
+
+// NewClient constructs a Client
+func NewClient() (*Client, error) {
+ sess, err := session.NewSession(&aws.Config{Region: aws.String("us-east-1")})
+ if err != nil {
+ return nil, err
+ }
+ svc := s3.New(sess)
+ return &Client{svc: svc}, nil
+}
+
+func convertEastern(t time.Time) time.Time {
+ locationNewYork, err := time.LoadLocation("America/New_York")
+ if err != nil {
+ log.Printf("Couldn't load location: %s", err)
+ }
+ return t.In(locationNewYork)
+}
+
+func loadReleases(objects []*s3.Object, bucketName string, prefix string, suffix string, truncate int) []Release {
+ var releases []Release
+ for _, obj := range objects {
+ if strings.HasSuffix(*obj.Key, suffix) {
+ urlString, name := urlStringForKey(*obj.Key, bucketName, prefix)
+ if name == "index.html" {
+ continue
+ }
+ version, _, date, commit, err := version.Parse(name)
+ if err != nil {
+ log.Printf("Couldn't get version from name: %s\n", name)
+ }
+ date = convertEastern(date)
+ releases = append(releases,
+ Release{
+ Name: name,
+ Key: *obj.Key,
+ URL: urlString,
+ Version: version,
+ Date: date,
+ DateString: date.Format("Mon Jan _2 15:04:05 MST 2006"),
+ Commit: commit,
+ })
+ }
+ }
+ // TODO: Should also sanity check that version sort is same as time sort
+ // otherwise something got messed up
+ sort.Sort(ByRelease(releases))
+ if truncate > 0 && len(releases) > truncate {
+ releases = releases[0:truncate]
+ }
+ return releases
+}
+
+// WriteHTML creates an html file for releases
+func WriteHTML(bucketName string, prefixes string, suffix string, outPath string, uploadDest string) error {
+ var sections []Section
+ for _, prefix := range strings.Split(prefixes, ",") {
+
+ objs, listErr := listAllObjects(bucketName, prefix)
+ if listErr != nil {
+ return listErr
+ }
+
+ releases := loadReleases(objs, bucketName, prefix, suffix, 50)
+ if len(releases) > 0 {
+ log.Printf("Found %d release(s) at %s\n", len(releases), prefix)
+ // for _, release := range releases {
+ // log.Printf(" %s %s %s\n", release.Name, release.Version, release.DateString)
+ // }
+ }
+ sections = append(sections, Section{
+ Header: prefix,
+ Releases: releases,
+ })
+ }
+
+ var buf bytes.Buffer
+ err := WriteHTMLForLinks(bucketName, sections, &buf)
+ if err != nil {
+ return err
+ }
+ if outPath != "" {
+ err = makeParentDirs(outPath)
+ if err != nil {
+ return err
+ }
+ err = os.WriteFile(outPath, buf.Bytes(), 0644)
+ if err != nil {
+ return err
+ }
+ }
+
+ if uploadDest != "" {
+ client, err := NewClient()
+ if err != nil {
+ return err
+ }
+
+ log.Printf("Uploading to %s", uploadDest)
+ _, err = client.svc.PutObject(&s3.PutObjectInput{
+ Bucket: aws.String(bucketName),
+ Key: aws.String(uploadDest),
+ CacheControl: aws.String(defaultCacheControl),
+ ACL: aws.String("public-read"),
+ Body: bytes.NewReader(buf.Bytes()),
+ ContentLength: aws.Int64(int64(buf.Len())),
+ ContentType: aws.String("text/html"),
+ })
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+var htmlTemplate = `
+
+
+
+ {{ .Title }}
+
+
+
+ {{ range $index, $sec := .Sections }}
+ {{ $sec.Header }}
+
+ {{ end }}
+
+
+`
+
+// WriteHTMLForLinks writes a summary document for a set of releases
+func WriteHTMLForLinks(title string, sections []Section, writer io.Writer) error {
+ vars := map[string]interface{}{
+ "Title": title,
+ "Sections": sections,
+ }
+
+ t, err := template.New("t").Parse(htmlTemplate)
+ if err != nil {
+ return err
+ }
+
+ return t.Execute(writer, vars)
+}
+
+// Platform defines where platform specific files are (in darwin, linux, windows)
+type Platform struct {
+ Name string
+ Prefix string
+ PrefixSupport string
+ Suffix string
+ LatestName string
+}
+
+// CopyLatest copies latest release to a fixed path
+func CopyLatest(bucketName string, platform string, dryRun bool) error {
+ client, err := NewClient()
+ if err != nil {
+ return err
+ }
+ return client.CopyLatest(bucketName, platform, dryRun)
+}
+
+const (
+ // PlatformTypeDarwin is platform type for OS X
+ PlatformTypeDarwin = "darwin"
+ PlatformTypeDarwinArm64 = "darwin-arm64"
+ // PlatformTypeLinux is platform type for Linux
+ PlatformTypeLinux = "linux"
+ // PlatformTypeWindows is platform type for windows
+ PlatformTypeWindows = "windows"
+)
+
+var platformDarwin = Platform{Name: PlatformTypeDarwin, Prefix: "darwin/", PrefixSupport: "darwin-support/", LatestName: "Keybase.dmg"}
+var platformDarwinArm64 = Platform{Name: PlatformTypeDarwinArm64, Prefix: "darwin-arm64/", PrefixSupport: "darwin-arm64-support/", LatestName: "Keybase-arm64.dmg"}
+var platformLinuxDeb = Platform{Name: "deb", Prefix: "linux_binaries/deb/", Suffix: "_amd64.deb", LatestName: "keybase_amd64.deb"}
+var platformLinuxRPM = Platform{Name: "rpm", Prefix: "linux_binaries/rpm/", Suffix: ".x86_64.rpm", LatestName: "keybase_amd64.rpm"}
+var platformWindows = Platform{Name: PlatformTypeWindows, Prefix: "windows/", PrefixSupport: "windows-support/", LatestName: "keybase_setup_amd64.msi"}
+
+var platformsAll = []Platform{
+ platformDarwin,
+ platformDarwinArm64,
+ platformLinuxDeb,
+ platformLinuxRPM,
+ platformWindows,
+}
+
+// Platforms returns platforms for a name (linux may have multiple platforms) or all platforms is "" is specified
+func Platforms(name string) ([]Platform, error) {
+ switch name {
+ case PlatformTypeDarwin:
+ return []Platform{platformDarwin}, nil
+ case PlatformTypeDarwinArm64:
+ return []Platform{platformDarwinArm64}, nil
+ case PlatformTypeLinux:
+ return []Platform{platformLinuxDeb, platformLinuxRPM}, nil
+ case PlatformTypeWindows:
+ return []Platform{platformWindows}, nil
+ case "":
+ return platformsAll, nil
+ default:
+ return nil, fmt.Errorf("Invalid platform %s", name)
+ }
+}
+
+func listAllObjects(bucketName string, prefix string) ([]*s3.Object, error) {
+ client, err := NewClient()
+ if err != nil {
+ return nil, err
+ }
+
+ marker := ""
+ objs := make([]*s3.Object, 0, 1000)
+ for {
+ resp, err := client.svc.ListObjects(&s3.ListObjectsInput{
+ Bucket: aws.String(bucketName),
+ Delimiter: aws.String("/"),
+ Prefix: aws.String(prefix),
+ Marker: aws.String(marker),
+ })
+ if err != nil {
+ return nil, err
+ }
+ if resp == nil {
+ break
+ }
+
+ out := *resp
+ nextMarker := ""
+ truncated := false
+ if out.NextMarker != nil {
+ nextMarker = *out.NextMarker
+ }
+ if out.IsTruncated != nil {
+ truncated = *out.IsTruncated
+ }
+
+ objs = append(objs, out.Contents...)
+ if !truncated {
+ break
+ }
+
+ log.Printf("Response is truncated, next marker is %s\n", nextMarker)
+ marker = nextMarker
+ }
+
+ return objs, nil
+}
+
+// FindRelease searches for a release matching a predicate
+func (p *Platform) FindRelease(bucketName string, f func(r Release) bool) (*Release, error) {
+ contents, err := listAllObjects(bucketName, p.Prefix)
+ if err != nil {
+ return nil, err
+ }
+
+ releases := loadReleases(contents, bucketName, p.Prefix, p.Suffix, 0)
+ for _, release := range releases {
+ if !strings.HasSuffix(release.Key, p.Suffix) {
+ continue
+ }
+ if f(release) {
+ return &release, nil
+ }
+ }
+ return nil, nil
+}
+
+// Files returns all files associated with this platforms release
+func (p Platform) Files(releaseName string) ([]string, error) {
+ switch p.Name {
+ case PlatformTypeDarwin:
+ return []string{
+ fmt.Sprintf("darwin/Keybase-%s.dmg", releaseName),
+ fmt.Sprintf("darwin-updates/Keybase-%s.zip", releaseName),
+ fmt.Sprintf("darwin-support/update-darwin-prod-%s.json", releaseName),
+ }, nil
+ case PlatformTypeDarwinArm64:
+ return []string{
+ fmt.Sprintf("darwin-arm64/Keybase-%s.dmg", releaseName),
+ fmt.Sprintf("darwin-arm64-updates/Keybase-%s.zip", releaseName),
+ fmt.Sprintf("darwin-arm64-support/update-darwin-prod-%s.json", releaseName),
+ }, nil
+ default:
+ return nil, fmt.Errorf("Unsupported for this platform: %s", p.Name)
+ }
+}
+
+// WriteHTML will generate index.html for the platform
+func (p Platform) WriteHTML(bucketName string) error {
+ return WriteHTML(bucketName, p.Prefix, "", "", p.Prefix+"/index.html")
+}
+
+// CopyLatest copies latest release to a fixed path for the Client
+func (c *Client) CopyLatest(bucketName string, platform string, dryRun bool) error {
+ platforms, err := Platforms(platform)
+ if err != nil {
+ return err
+ }
+ for _, platform := range platforms {
+ var url string
+ // Use update json to look for current DMG (for darwin)
+ // TODO: Fix for linux
+ switch platform.Name {
+ case PlatformTypeDarwin, PlatformTypeDarwinArm64, PlatformTypeWindows:
+ url, err = c.copyFromUpdate(platform, bucketName)
+ default:
+ _, url, err = c.copyFromReleases(platform, bucketName)
+ }
+ if err != nil {
+ return err
+ }
+ if url == "" {
+ continue
+ }
+
+ if dryRun {
+ log.Printf("DRYRUN: Would copy latest %s to %s\n", url, platform.LatestName)
+ return nil
+ }
+
+ _, err := c.svc.CopyObject(&s3.CopyObjectInput{
+ Bucket: aws.String(bucketName),
+ CopySource: aws.String(url),
+ Key: aws.String(platform.LatestName),
+ CacheControl: aws.String(defaultCacheControl),
+ ACL: aws.String("public-read"),
+ })
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (c *Client) copyFromUpdate(platform Platform, bucketName string) (url string, err error) {
+ currentUpdate, path, err := c.CurrentUpdate(bucketName, defaultChannel, platform.Name, "prod")
+ if err != nil {
+ err = fmt.Errorf("Error getting current public update: %s", err)
+ return
+ }
+ if currentUpdate == nil {
+ err = fmt.Errorf("No latest for %s at %s", platform.Name, path)
+ return
+ }
+ switch platform.Name {
+ case PlatformTypeDarwin, PlatformTypeDarwinArm64:
+ url = urlString(bucketName, platform.Prefix, fmt.Sprintf("Keybase-%s.dmg", currentUpdate.Version))
+ case PlatformTypeWindows:
+ url = urlString(bucketName, platform.Prefix, fmt.Sprintf("Keybase_%s.amd64.msi", currentUpdate.Version))
+ default:
+ err = fmt.Errorf("Unsupported platform for copyFromUpdate")
+ }
+ return
+}
+
+func (c *Client) copyFromReleases(platform Platform, bucketName string) (release *Release, url string, err error) {
+ release, err = platform.FindRelease(bucketName, func(r Release) bool { return true })
+ if err != nil || release == nil {
+ return
+ }
+ url, _ = urlStringForKey(release.Key, bucketName, platform.Prefix)
+ return
+}
+
+// CurrentUpdate returns current update for a platform
+func (c *Client) CurrentUpdate(bucketName string, channel string, platformName string, env string) (currentUpdate *Update, path string, err error) {
+ path = updateJSONName(channel, platformName, env)
+ log.Printf("Fetching current update at %s", path)
+ resp, err := c.svc.GetObject(&s3.GetObjectInput{
+ Bucket: aws.String(bucketName),
+ Key: aws.String(path),
+ })
+ if err != nil {
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+ currentUpdate, err = DecodeJSON(resp.Body)
+ return
+}
+
+func promoteRelease(bucketName string, delay time.Duration, hourEastern int, toChannel string, platform Platform, env string, allowDowngrade bool, release string) (*Release, error) {
+ client, err := NewClient()
+ if err != nil {
+ return nil, err
+ }
+ return client.PromoteRelease(bucketName, delay, hourEastern, toChannel, platform, env, allowDowngrade, release)
+}
+
+func updateJSONName(channel string, platformName string, env string) string {
+ if channel == "" {
+ return fmt.Sprintf("update-%s-%s.json", platformName, env)
+ }
+ return fmt.Sprintf("update-%s-%s-%s.json", platformName, env, channel)
+}
+
+// PromoteARelease promotes a specific release to Prod.
+func PromoteARelease(releaseName string, bucketName string, platform string, dryRun bool) (release *Release, err error) {
+ switch platform {
+ case PlatformTypeDarwin, PlatformTypeDarwinArm64, PlatformTypeWindows:
+ // pass
+ default:
+ return nil, fmt.Errorf("Promoting releases is only supported for darwin or windows")
+
+ }
+
+ client, err := NewClient()
+ if err != nil {
+ return nil, err
+ }
+
+ platformRes, err := Platforms(platform)
+ if err != nil {
+ return nil, err
+ }
+ if len(platformRes) != 1 {
+ return nil, fmt.Errorf("Promoting on multiple platforms is not supported")
+ }
+
+ platformType := platformRes[0]
+ release, err = client.promoteAReleaseToProd(releaseName, bucketName, platformType, "prod", defaultChannel, dryRun)
+ if err != nil {
+ return nil, err
+ }
+ if dryRun {
+ return release, nil
+ }
+ log.Printf("Promoted %s release: %s\n", platform, releaseName)
+ return release, nil
+}
+
+func (c *Client) promoteAReleaseToProd(releaseName string, bucketName string, platform Platform, env string, toChannel string, dryRun bool) (release *Release, err error) {
+ var filePath string
+ switch platform.Name {
+ case PlatformTypeDarwin, PlatformTypeDarwinArm64:
+ filePath = fmt.Sprintf("Keybase-%s.dmg", releaseName)
+ case PlatformTypeWindows:
+ filePath = fmt.Sprintf("Keybase_%s.amd64.msi", releaseName)
+ default:
+ return nil, fmt.Errorf("Unsupported for this platform: %s", platform.Name)
+ }
+
+ release, err = platform.FindRelease(bucketName, func(r Release) bool {
+ return r.Name == filePath
+ })
+ if err != nil {
+ return nil, err
+ }
+ if release == nil {
+ return nil, fmt.Errorf("No matching release found")
+ }
+ log.Printf("Found %s release %s (%s), %s", platform.Name, release.Name, time.Since(release.Date), release.Version)
+ jsonName := updateJSONName(toChannel, platform.Name, env)
+ jsonURL := urlString(bucketName, platform.PrefixSupport, fmt.Sprintf("update-%s-%s-%s.json", platform.Name, env, release.Version))
+
+ if dryRun {
+ log.Printf("DRYRUN: Would PutCopy %s to %s\n", jsonURL, jsonName)
+ return release, nil
+ }
+ log.Printf("PutCopying %s to %s\n", jsonURL, jsonName)
+ _, err = c.svc.CopyObject(&s3.CopyObjectInput{
+ Bucket: aws.String(bucketName),
+ CopySource: aws.String(jsonURL),
+ Key: aws.String(jsonName),
+ CacheControl: aws.String(defaultCacheControl),
+ ACL: aws.String("public-read"),
+ })
+ return release, err
+}
+
+// PromoteRelease promotes a release to a channel
+func (c *Client) PromoteRelease(bucketName string, delay time.Duration, beforeHourEastern int, toChannel string, platform Platform, env string, allowDowngrade bool, releaseName string) (*Release, error) {
+ log.Printf("Finding release to promote to %q (%s delay) in env %s", toChannel, delay, env)
+ var release *Release
+ var err error
+
+ if releaseName != "" {
+ releaseName = fmt.Sprintf("Keybase-%s.dmg", releaseName)
+ release, err = platform.FindRelease(bucketName, func(r Release) bool {
+ return r.Name == releaseName
+ })
+ } else {
+ release, err = platform.FindRelease(bucketName, func(r Release) bool {
+ log.Printf("Checking release date %s", r.Date)
+ if delay != 0 && time.Since(r.Date) < delay {
+ return false
+ }
+ hour, _, _ := r.Date.Clock()
+ if beforeHourEastern != 0 && hour >= beforeHourEastern {
+ return false
+ }
+ return true
+ })
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ if release == nil {
+ log.Printf("No matching release found")
+ return nil, nil
+ }
+ log.Printf("Found release %s (%s), %s", release.Name, time.Since(release.Date), release.Version)
+
+ currentUpdate, _, err := c.CurrentUpdate(bucketName, toChannel, platform.Name, env)
+ if err != nil {
+ log.Printf("Error looking for current update: %s (%s)", err, platform.Name)
+ }
+ if currentUpdate != nil {
+ log.Printf("Found current update: %s", currentUpdate.Version)
+ var currentVer semver.Version
+ currentVer, err = semver.Make(currentUpdate.Version)
+ if err != nil {
+ return nil, err
+ }
+ var releaseVer semver.Version
+ releaseVer, err = semver.Make(release.Version)
+ if err != nil {
+ return nil, err
+ }
+
+ if releaseVer.Equals(currentVer) {
+ log.Printf("Release unchanged")
+ return nil, nil
+ } else if releaseVer.LT(currentVer) {
+ if !allowDowngrade {
+ log.Printf("Release older than current update")
+ return nil, nil
+ }
+ log.Printf("Allowing downgrade")
+ }
+ }
+
+ jsonURL := urlString(bucketName, platform.PrefixSupport, fmt.Sprintf("update-%s-%s-%s.json", platform.Name, env, release.Version))
+ jsonName := updateJSONName(toChannel, platform.Name, env)
+ log.Printf("PutCopying %s to %s\n", jsonURL, jsonName)
+ _, err = c.svc.CopyObject(&s3.CopyObjectInput{
+ Bucket: aws.String(bucketName),
+ CopySource: aws.String(jsonURL),
+ Key: aws.String(jsonName),
+ CacheControl: aws.String(defaultCacheControl),
+ ACL: aws.String("public-read"),
+ })
+
+ if err != nil {
+ return nil, err
+ }
+ return release, nil
+}
+
+func copyUpdateJSON(bucketName string, fromChannel string, toChannel string, platformName string, env string) error {
+ client, err := NewClient()
+ if err != nil {
+ return err
+ }
+ jsonNameDest := updateJSONName(toChannel, platformName, env)
+ jsonURLSource := urlString(bucketName, "", updateJSONName(fromChannel, platformName, env))
+
+ log.Printf("PutCopying %s to %s\n", jsonURLSource, jsonNameDest)
+ _, err = client.svc.CopyObject(&s3.CopyObjectInput{
+ Bucket: aws.String(bucketName),
+ CopySource: aws.String(jsonURLSource),
+ Key: aws.String(jsonNameDest),
+ CacheControl: aws.String(defaultCacheControl),
+ ACL: aws.String("public-read"),
+ })
+ return err
+}
+
+func (c *Client) report(tw io.Writer, bucketName string, channel string, platformName string) {
+ update, jsonPath, err := c.CurrentUpdate(bucketName, channel, platformName, "prod")
+ fmt.Fprintf(tw, "%s\t%s\t", platformName, channel)
+ if err != nil {
+ fmt.Fprintln(tw, "Error")
+ } else if update != nil {
+ published := ""
+ if update.PublishedAt != nil {
+ published = convertEastern(FromTime(*update.PublishedAt)).Format(time.UnixDate)
+ }
+ fmt.Fprintf(tw, "%s\t%s\t%s\n", update.Version, published, jsonPath)
+ } else {
+ fmt.Fprintln(tw, "None")
+ }
+}
+
+// Report returns a summary of releases
+func Report(bucketName string, writer io.Writer) error {
+ client, err := NewClient()
+ if err != nil {
+ return err
+ }
+
+ tw := tabwriter.NewWriter(writer, 5, 0, 3, ' ', 0)
+ fmt.Fprintln(tw, "Platform\tChannel\tVersion\tCreated\tSource")
+ client.report(tw, bucketName, "test-v2", PlatformTypeDarwin)
+ client.report(tw, bucketName, "v2", PlatformTypeDarwin)
+ client.report(tw, bucketName, "test-v2", PlatformTypeDarwinArm64)
+ client.report(tw, bucketName, "v2", PlatformTypeDarwinArm64)
+ client.report(tw, bucketName, "test", PlatformTypeLinux)
+ client.report(tw, bucketName, "", PlatformTypeLinux)
+ return tw.Flush()
+}
+
+// promoteTestReleaseForDarwin creates a test release for darwin
+func promoteTestReleaseForDarwin(bucketName string, release string) (*Release, error) {
+ return promoteRelease(bucketName, time.Duration(0), 0, "test-v2", platformDarwin, "prod", true, release)
+}
+
+func promoteTestReleaseForDarwinArm64(bucketName string, release string) (*Release, error) {
+ return promoteRelease(bucketName, time.Duration(0), 0, "test-v2", platformDarwinArm64, "prod", true, release)
+}
+
+// promoteTestReleaseForLinux creates a test release for linux
+func promoteTestReleaseForLinux(bucketName string) error {
+ // This just copies public to test since we don't do promotion on this platform yet
+ return copyUpdateJSON(bucketName, "", "test", PlatformTypeLinux, "prod")
+}
+
+// promoteTestReleaseForWindows creates a test release for windows
+func promoteTestReleaseForWindows(bucketName string) error {
+ // This just copies public to test since we don't do promotion on this platform yet
+ return copyUpdateJSON(bucketName, "", "test", PlatformTypeWindows, "prod")
+}
+
+// PromoteTestReleases creates test releases for a platform
+func PromoteTestReleases(bucketName string, platformName string, release string) error {
+ switch platformName {
+ case PlatformTypeDarwin:
+ _, err := promoteTestReleaseForDarwin(bucketName, release)
+ return err
+ case PlatformTypeDarwinArm64:
+ _, err := promoteTestReleaseForDarwinArm64(bucketName, release)
+ return err
+ case PlatformTypeLinux:
+ return promoteTestReleaseForLinux(bucketName)
+ case PlatformTypeWindows:
+ return promoteTestReleaseForWindows(bucketName)
+ default:
+ return fmt.Errorf("Invalid platform %s", platformName)
+ }
+}
+
+// PromoteReleases creates releases for a platform
+func PromoteReleases(bucketName string, platformType string) (release *Release, err error) {
+ var platform Platform
+ switch platformType {
+ case PlatformTypeDarwin:
+ platform = platformDarwin
+ case PlatformTypeDarwinArm64:
+ platform = platformDarwinArm64
+ default:
+ log.Printf("Promoting releases is unsupported for %s", platformType)
+ return
+ }
+ release, err = promoteRelease(bucketName, time.Hour*27, 10, defaultChannel, platform, "prod", false, "")
+ if err != nil {
+ return nil, err
+ }
+ if release != nil {
+ log.Printf("Promoted (darwin) release: %s\n", release.Name)
+ }
+ return release, nil
+}
+
+// ReleaseBroken marks a release as broken. The releaseName is the version,
+// for example, 1.2.3+400-deadbeef.
+func ReleaseBroken(releaseName string, bucketName string, platformName string) ([]string, error) {
+ client, err := NewClient()
+ if err != nil {
+ return nil, err
+ }
+ platforms, err := Platforms(platformName)
+ if err != nil {
+ return nil, err
+ }
+ removed := []string{}
+ for _, platform := range platforms {
+ files, err := platform.Files(releaseName)
+ if err != nil {
+ return nil, err
+ }
+ for _, path := range files {
+ sourceURL := urlString(bucketName, "", path)
+ brokenPath := fmt.Sprintf("broken/%s", path)
+ log.Printf("Copying %s to %s", sourceURL, brokenPath)
+
+ _, err := client.svc.CopyObject(&s3.CopyObjectInput{
+ Bucket: aws.String(bucketName),
+ CopySource: aws.String(sourceURL),
+ Key: aws.String(brokenPath),
+ CacheControl: aws.String(defaultCacheControl),
+ ACL: aws.String("public-read"),
+ })
+ if err != nil {
+ log.Printf("There was an error trying to (put) copy %s: %s", sourceURL, err)
+ continue
+ }
+
+ log.Printf("Deleting: %s", path)
+ _, err = client.svc.DeleteObject(&s3.DeleteObjectInput{Bucket: aws.String(bucketName), Key: aws.String(path)})
+ if err != nil {
+ return removed, err
+ }
+ removed = append(removed, path)
+ }
+
+ // Update html for platform
+ if err := platform.WriteHTML(bucketName); err != nil {
+ log.Printf("Error updating html: %s", err)
+ }
+
+ // Fix test releases if needed
+ if err := PromoteTestReleases(bucketName, platform.Name, ""); err != nil {
+ log.Printf("Error fixing test releases: %s", err)
+ }
+ }
+ log.Printf("Deleted %d files for %s", len(removed), releaseName)
+ if len(removed) == 0 {
+ return removed, fmt.Errorf("No files to remove for %s", releaseName)
+ }
+
+ return removed, nil
+}
+
+// SaveLog saves log to S3 bucket (last maxNumBytes) and returns the URL.
+// The log is publicly readable on S3 but the url is not discoverable.
+func SaveLog(bucketName string, localPath string, maxNumBytes int64) (string, error) {
+ client, err := NewClient()
+ if err != nil {
+ return "", err
+ }
+
+ file, err := os.Open(localPath)
+ if err != nil {
+ return "", fmt.Errorf("Error opening: %s", err)
+ }
+ defer func() { _ = file.Close() }()
+
+ stat, err := os.Stat(localPath)
+ if err != nil {
+ return "", fmt.Errorf("Error in stat: %s", err)
+ }
+ if maxNumBytes > stat.Size() {
+ maxNumBytes = stat.Size()
+ }
+
+ data := make([]byte, maxNumBytes)
+ start := stat.Size() - maxNumBytes
+ _, err = file.ReadAt(data, start)
+ if err != nil {
+ return "", fmt.Errorf("Error reading: %s", err)
+ }
+
+ filename := filepath.Base(localPath)
+ logID, err := RandomID()
+ if err != nil {
+ return "", err
+ }
+ uploadDest := filepath.ToSlash(filepath.Join("logs", fmt.Sprintf("%s-%s%s", filename, logID, ".txt")))
+
+ _, err = client.svc.PutObject(&s3.PutObjectInput{
+ Bucket: aws.String(bucketName),
+ Key: aws.String(uploadDest),
+ CacheControl: aws.String(defaultCacheControl),
+ ACL: aws.String("public-read"),
+ Body: bytes.NewReader(data),
+ ContentLength: aws.Int64(int64(len(data))),
+ ContentType: aws.String("text/plain"),
+ })
+ if err != nil {
+ return "", err
+ }
+
+ url := urlStringNoEscape(bucketName, uploadDest)
+ return url, nil
+}
diff --git a/go/release/update/s3_test.go b/go/release/update/s3_test.go
new file mode 100644
index 000000000000..b36b185789bb
--- /dev/null
+++ b/go/release/update/s3_test.go
@@ -0,0 +1,21 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package update
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TODO: Enable when we have test S3 credentials.
+// TODO: Remove // nolint
+func testFindRelease(t *testing.T) { // nolint
+ first := func(r Release) bool { return true }
+ release, err := platformDarwin.FindRelease("prerelease.keybase.io", first)
+ require.NoError(t, err)
+ t.Logf("Release: %#v", release)
+ assert.NotEqual(t, "", release.URL)
+}
diff --git a/go/release/update/update.go b/go/release/update/update.go
new file mode 100644
index 000000000000..8501383bb313
--- /dev/null
+++ b/go/release/update/update.go
@@ -0,0 +1,130 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package update
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+ "path"
+ "strings"
+
+ releaseVersion "github.com/keybase/client/go/release/version"
+)
+
+// EncodeJSON returns JSON (as bytes) for an update
+func EncodeJSON(version string, name string, descriptionPath string, props []string, src string, uri fmt.Stringer, signaturePath string) ([]byte, error) {
+ upd := Update{
+ Version: version,
+ Name: name,
+ }
+
+ // Get published at from version string
+ _, _, date, _, err := releaseVersion.Parse(version)
+ if err == nil {
+ t := ToTime(date)
+ upd.PublishedAt = &t
+ }
+
+ if src != "" && uri != nil {
+ fileName := path.Base(src)
+
+ // Or if we can't parse use the src file modification time
+ if upd.PublishedAt == nil {
+ var srcInfo os.FileInfo
+ srcInfo, err = os.Stat(src)
+ if err != nil {
+ return nil, err
+ }
+ t := ToTime(srcInfo.ModTime())
+ upd.PublishedAt = &t
+ }
+
+ urlString := fmt.Sprintf("%s/%s", uri.String(), url.QueryEscape(fileName))
+ asset := Asset{
+ Name: fileName,
+ URL: urlString,
+ }
+
+ digest, err := digest(src)
+ if err != nil {
+ return nil, fmt.Errorf("Error creating digest: %s", err)
+ }
+ asset.Digest = digest
+
+ if signaturePath != "" {
+ sig, err := readFile(signaturePath)
+ if err != nil {
+ return nil, err
+ }
+ asset.Signature = sig
+ }
+
+ if descriptionPath != "" {
+ desc, err := readFile(descriptionPath)
+ if err != nil {
+ return nil, err
+ }
+ upd.Description = desc
+ }
+
+ upd.Asset = &asset
+ }
+
+ if props != nil {
+ uprops := []Property{}
+ for _, p := range props {
+ splitp := strings.SplitN(p, ":", 2)
+ if len(splitp) == 2 {
+ uprops = append(uprops, Property{Name: splitp[0], Value: splitp[1]})
+ }
+ }
+ if len(uprops) > 0 {
+ upd.Props = uprops
+ }
+ }
+
+ return json.MarshalIndent(upd, "", " ")
+}
+
+// DecodeJSON returns an update object from JSON (bytes)
+func DecodeJSON(r io.Reader) (*Update, error) {
+ var obj Update
+ if err := json.NewDecoder(r).Decode(&obj); err != nil {
+ return nil, err
+ }
+ return &obj, nil
+}
+
+func readFile(path string) (string, error) {
+ sigFile, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = sigFile.Close() }()
+ data, err := io.ReadAll(sigFile)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func digest(p string) (digest string, err error) {
+ hasher := sha256.New()
+ f, err := os.Open(p)
+ if err != nil {
+ return
+ }
+ defer func() { _ = f.Close() }()
+ if _, ioerr := io.Copy(hasher, f); ioerr != nil {
+ err = ioerr
+ return
+ }
+ digest = hex.EncodeToString(hasher.Sum(nil))
+ return
+}
diff --git a/go/release/update/util.go b/go/release/update/util.go
new file mode 100644
index 000000000000..ef16d89029cd
--- /dev/null
+++ b/go/release/update/util.go
@@ -0,0 +1,106 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package update
+
+import (
+ "crypto/rand"
+ "encoding/base32"
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func urlStringForKey(key string, bucketName string, prefix string) (string, string) {
+ name := key[len(prefix):]
+ return fmt.Sprintf("https://s3.amazonaws.com/%s/%s%s", bucketName, prefix, url.QueryEscape(name)), name
+}
+
+func urlString(bucketName string, prefix string, name string) string {
+ if prefix == "" {
+ return fmt.Sprintf("https://s3.amazonaws.com/%s/%s", bucketName, url.QueryEscape(name))
+ }
+ return fmt.Sprintf("https://s3.amazonaws.com/%s/%s%s", bucketName, prefix, url.QueryEscape(name))
+}
+
+func urlStringNoEscape(bucketName string, name string) string {
+ return fmt.Sprintf("https://s3.amazonaws.com/%s/%s", bucketName, name)
+}
+
+func makeParentDirs(filename string) error {
+ dir, _ := filepath.Split(filename)
+ exists, err := fileExists(dir)
+ if err != nil {
+ return err
+ }
+
+ if !exists {
+ err = os.MkdirAll(dir, 0755)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func fileExists(path string) (bool, error) {
+ _, err := os.Stat(path)
+ if err == nil {
+ return true, nil
+ }
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, err
+}
+
+// CombineErrors returns a single error for multiple errors, or nil if none
+func CombineErrors(errs ...error) error {
+ errs = RemoveNilErrors(errs)
+ if len(errs) == 0 {
+ return nil
+ } else if len(errs) == 1 {
+ return errs[0]
+ }
+
+ msgs := []string{}
+ for _, err := range errs {
+ msgs = append(msgs, err.Error())
+ }
+ return fmt.Errorf("There were multiple errors: %s", strings.Join(msgs, "; "))
+}
+
+// RemoveNilErrors returns error slice with nil errors removed
+func RemoveNilErrors(errs []error) []error {
+ var r []error
+ for _, err := range errs {
+ if err != nil {
+ r = append(r, err)
+ }
+ }
+ return r
+}
+
+// RandomID returns a random identifier
+func RandomID() (string, error) {
+ buf, err := RandBytes(32)
+ if err != nil {
+ return "", err
+ }
+ str := base32.StdEncoding.EncodeToString(buf)
+ str = strings.ReplaceAll(str, "=", "")
+ return str, nil
+}
+
+var randRead = rand.Read
+
+// RandBytes returns random bytes of length
+func RandBytes(length int) ([]byte, error) {
+ buf := make([]byte, length)
+ if _, err := randRead(buf); err != nil {
+ return nil, err
+ }
+ return buf, nil
+}
diff --git a/go/release/version/version.go b/go/release/version/version.go
new file mode 100644
index 000000000000..41a6960f8e9b
--- /dev/null
+++ b/go/release/version/version.go
@@ -0,0 +1,26 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package version
+
+import (
+ "fmt"
+ "regexp"
+ "time"
+)
+
+// Parse parses version, time and commit info from string
+func Parse(name string) (version string, versionShort string, t time.Time, commit string, err error) {
+ versionRegex := regexp.MustCompile(`(\d+\.\d+\.\d+)[-.](\d+)[+.]([[:alnum:]]+)`)
+ parts := versionRegex.FindAllStringSubmatch(name, -1)
+ if len(parts) == 0 || len(parts[0]) < 4 {
+ err = fmt.Errorf("Unable to parse: %s", name)
+ return
+ }
+ versionShort = parts[0][1]
+ date := parts[0][2]
+ commit = parts[0][3]
+ version = fmt.Sprintf("%s-%s+%s", versionShort, date, commit)
+ t, _ = time.Parse("20060102150405", date)
+ return
+}
diff --git a/go/release/version/version_test.go b/go/release/version/version_test.go
new file mode 100644
index 000000000000..8d8734a0eee6
--- /dev/null
+++ b/go/release/version/version_test.go
@@ -0,0 +1,30 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package version
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParse(t *testing.T) {
+ input := "Keybase-1.0.14-20160312013917+cd6f696.zip"
+ version, versionShort, versionTime, commit, err := Parse(input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if version != "1.0.14-20160312013917+cd6f696" {
+ t.Errorf("Failed to parse version properly: %s", version)
+ }
+ if versionShort != "1.0.14" {
+ t.Errorf("Failed to parse version properly: %s", versionShort)
+ }
+ timeCheck, _ := time.Parse("20060102150405", "20160312013917")
+ if versionTime != timeCheck {
+ t.Errorf("Failed to parse time properly: %s", timeCheck)
+ }
+ if commit != "cd6f696" {
+ t.Errorf("Failed to parse commit properly: %s", commit)
+ }
+}
diff --git a/go/release/winbuild/winbuild.go b/go/release/winbuild/winbuild.go
new file mode 100644
index 000000000000..713af7f1ef19
--- /dev/null
+++ b/go/release/winbuild/winbuild.go
@@ -0,0 +1,59 @@
+package winbuild
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+)
+
+const (
+ buildNumAPIUrl = "https://keybase.io/_/api/1.0/pkg/build_number.json"
+)
+
+type buildNumberResponse struct {
+ Status struct {
+ Code int `json:"code"`
+ Name string `json:"name"`
+ } `json:"status"`
+ BuildNumber int `json:"build_number"`
+}
+
+func GetNextBuildNumber(keybaseToken string, version string, botId string, platform string) error {
+
+ form := url.Values{}
+ form.Set("version", version)
+ form.Add("bot_id", botId)
+ form.Add("platform", platform)
+ req, err := http.NewRequest("POST", buildNumAPIUrl, bytes.NewBufferString(form.Encode()))
+ if err != nil {
+ return fmt.Errorf("newrequest failed, %v", err)
+ }
+ req.Header.Add("X-keybase-admin-token", keybaseToken)
+ req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("request failed, %v", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("body err, %v", err)
+ }
+
+ var reply buildNumberResponse
+ if err := json.Unmarshal(body, &reply); err != nil {
+ return fmt.Errorf("json reply err, %v", err)
+ }
+
+ if reply.Status.Code != 0 {
+ return fmt.Errorf("Server returned failure, %s", body)
+ }
+
+ fmt.Printf("%d\n", reply.BuildNumber)
+ return nil
+}
diff --git a/go/teams/hidden/loader.go b/go/teams/hidden/loader.go
index b102f134c589..ac2fb3a19024 100644
--- a/go/teams/hidden/loader.go
+++ b/go/teams/hidden/loader.go
@@ -10,7 +10,7 @@ import (
)
const (
- MaxDelayInCommittingHiddenLinks = 7 * 24 * time.Hour
+ MaxDelayInCommittingHiddenLinks = 30 * 24 * time.Hour
)
// LoaderPackage contains a snapshot of the hidden team chain, used during the process of loading a team.
diff --git a/go/updater/LICENSE b/go/updater/LICENSE
new file mode 100644
index 000000000000..2d54c6560ee7
--- /dev/null
+++ b/go/updater/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Keybase
+
+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.
+
diff --git a/go/updater/README.md b/go/updater/README.md
new file mode 100644
index 000000000000..179648e04841
--- /dev/null
+++ b/go/updater/README.md
@@ -0,0 +1,53 @@
+# Updater
+
+[![Build Status](https://github.com/keybase/client/go/updater/actions/workflows/ci.yml/badge.svg)](https://github.com/keybase/client/go/updater/actions)
+[![GoDoc](https://godoc.org/github.com/keybase/client/go/updater?status.svg)](https://godoc.org/github.com/keybase/client/go/updater)
+
+**Warning**: This isn't ready for non-Keybase libraries to use yet!
+
+The goals of this library are to provide an updater that:
+
+- Is simple
+- Works on all our platforms (at least OS X, Windows, Linux)
+- Recovers from non-fatal errors
+- Every request or command execution should timeout (nothing blocks)
+- Can recover from failures in its environment
+- Can run as an unprivileged background service
+- Has minimal, vendored dependencies
+- Is well tested
+- Is secure
+- Reports failures and activity
+- Can notify the user of any non-transient failures
+
+This updater library is used to support updating (in background and on-demand)
+for Keybase apps and services.
+
+### Packages
+
+The main package is the updater core, there are other support packages:
+
+- command: Executes a command with a timeout
+- keybase: Keybase specific behavior for updates
+- osx: MacOS specific UI
+- process: Utilities to find and terminate Processes
+- saltpack: Verify updates with [saltpack](https://saltpack.org/)
+- service: Runs the updater as a background service
+- sources: Update sources for remote locations (like S3), or locally (for testing)
+- test: Test resources
+- util: Utilities for updating, such as digests, env, file, http, unzip, etc.
+- watchdog: Utility to monitor processes and restart them (like launchd), for use with updater service
+- windows: Windows specific UI
+
+### Development
+
+This library should pass the [gometalinter](https://github.com/alecthomas/gometalinter).
+
+There is a pre-commit hook available:
+
+```
+pip install pre-commit
+go get -u github.com/alecthomas/gometalinter
+gometalinter --install --update
+pre-commit install
+pre-commit run -a
+```
diff --git a/go/updater/SECURITY.md b/go/updater/SECURITY.md
new file mode 100644
index 000000000000..c86d892edce1
--- /dev/null
+++ b/go/updater/SECURITY.md
@@ -0,0 +1,21 @@
+## Security
+
+In the future, we will be looking to integrate with [TUF](https://theupdateframework.github.io/)
+in order to make updates more secure. In the meantime, this document describes
+what the updater (in the context of the Keybase application) protects against.
+
+The updater may not protect against certain attacks.
+
+- Rollback attacks: The updater doesn't prevent an earlier update from being applied
+- Indefinite freeze attacks: An attacker could reply with old metadata
+- Endless data attacks: An attacker could cause the client to download endless data
+- Slow retrieval attacks: An attacker could prevent an update by being slow
+- Extraneous dependencies attacks: The updater doesn't know about dependencies and will only download and apply a single asset
+- Mix-and-match attacks: An attacker could mix metadata (use an old asset with new update)
+
+The Keybase updater does do the following (to prevent basic attacks):
+
+- Uses TLS with a pinned certificate for api-0.core.keybaseapi.com (update source) for metadata
+- Uses TLS to download asset
+- Verifies asset digest (SHA256)
+- Verifies asset saltpack signature (key IDs are pinned)
diff --git a/go/updater/command/README.md b/go/updater/command/README.md
new file mode 100644
index 000000000000..322d9db938f8
--- /dev/null
+++ b/go/updater/command/README.md
@@ -0,0 +1,3 @@
+## Command
+
+Executes a command with a timeout.
diff --git a/go/updater/command/command.go b/go/updater/command/command.go
new file mode 100644
index 000000000000..5e3e7be085da
--- /dev/null
+++ b/go/updater/command/command.go
@@ -0,0 +1,154 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package command
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Log is the logging interface for the command package
+type Log interface {
+ Debugf(s string, args ...interface{})
+ Infof(s string, args ...interface{})
+ Warningf(s string, args ...interface{})
+ Errorf(s string, args ...interface{})
+}
+
+// Program is a program at path with arguments
+type Program struct {
+ Path string
+ Args []string
+}
+
+// ArgsWith returns program args with passed in args
+func (p Program) ArgsWith(args []string) []string {
+ if p.Args == nil || len(p.Args) == 0 {
+ return args
+ }
+ if len(args) == 0 {
+ return p.Args
+ }
+ return append(p.Args, args...)
+}
+
+// Result is the result of running a command
+type Result struct {
+ Stdout bytes.Buffer
+ Stderr bytes.Buffer
+ Process *os.Process
+}
+
+// CombinedOutput returns Stdout and Stderr as a single string.
+func (r Result) CombinedOutput() string {
+ strs := []string{}
+ if sout := r.Stdout.String(); sout != "" {
+ strs = append(strs, fmt.Sprintf("[stdout]: %s", sout))
+ }
+ if serr := r.Stderr.String(); serr != "" {
+ strs = append(strs, fmt.Sprintf("[stderr]: %s", serr))
+ }
+ return strings.Join(strs, ", ")
+}
+
+type execCmd func(name string, arg ...string) *exec.Cmd
+
+// Exec runs a command and returns the stdout/err output and error if any
+func Exec(name string, args []string, timeout time.Duration, log Log) (Result, error) {
+ return execWithFunc(name, args, nil, exec.Command, timeout, log)
+}
+
+// ExecWithEnv runs a command with an environment and returns the stdout/err output and error if any
+func ExecWithEnv(name string, args []string, env []string, timeout time.Duration, log Log) (Result, error) {
+ return execWithFunc(name, args, env, exec.Command, timeout, log)
+}
+
+// exec runs a command and returns a Result and error if any.
+// We will send TERM signal and wait 1 second or timeout, whichever is less,
+// before calling KILL.
+func execWithFunc(name string, args []string, env []string, execCmd execCmd, timeout time.Duration, log Log) (Result, error) {
+ var result Result
+ log.Debugf("Execute: %s %s", name, args)
+ if name == "" {
+ return result, fmt.Errorf("No command")
+ }
+ if timeout < 0 {
+ return result, fmt.Errorf("Invalid timeout: %s", timeout)
+ }
+ cmd := execCmd(name, args...)
+ if cmd == nil {
+ return result, fmt.Errorf("No command")
+ }
+ cmd.Stdout = &result.Stdout
+ cmd.Stderr = &result.Stderr
+ if env != nil {
+ cmd.Env = env
+ }
+ err := cmd.Start()
+ if err != nil {
+ return result, err
+ }
+ result.Process = cmd.Process
+ doneCh := make(chan error)
+ go func() {
+ doneCh <- cmd.Wait()
+ close(doneCh)
+ }()
+ // Wait for the command to finish or time out
+ select {
+ case cmdErr := <-doneCh:
+ log.Debugf("Executed %s %s", name, args)
+ return result, cmdErr
+ case <-time.After(timeout):
+ // Timed out
+ log.Warningf("Process timed out")
+ }
+ // If no process, nothing to kill
+ if cmd.Process == nil {
+ return result, fmt.Errorf("No process")
+ }
+
+ // Signal the process to terminate gracefully
+ // Wait a second or timeout for termination, whichever less
+ termWait := time.Second
+ if timeout < termWait {
+ termWait = timeout
+ }
+ log.Warningf("Command timed out, terminating (will wait %s before killing)", termWait)
+ err = cmd.Process.Signal(syscall.SIGTERM)
+ if err != nil {
+ log.Warningf("Error sending terminate: %s", err)
+ }
+ select {
+ case <-doneCh:
+ log.Warningf("Terminated")
+ case <-time.After(termWait):
+ // Bring out the big guns
+ log.Warningf("Command failed to terminate, killing")
+ if err := cmd.Process.Kill(); err != nil {
+ log.Warningf("Error trying to kill process: %s", err)
+ } else {
+ log.Warningf("Killed process")
+ }
+ }
+ return result, fmt.Errorf("Timed out")
+}
+
+// ExecForJSON runs a command (with timeout) expecting JSON output with obj interface
+func ExecForJSON(command string, args []string, obj interface{}, timeout time.Duration, log Log) error {
+ result, err := execWithFunc(command, args, nil, exec.Command, timeout, log)
+ if err != nil {
+ return err
+ }
+ if err := json.NewDecoder(&result.Stdout).Decode(&obj); err != nil {
+ return fmt.Errorf("Error in result: %s", err)
+ }
+ return nil
+}
diff --git a/go/updater/command/command_test.go b/go/updater/command/command_test.go
new file mode 100644
index 000000000000..6fbad1ce62e6
--- /dev/null
+++ b/go/updater/command/command_test.go
@@ -0,0 +1,189 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package command
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/keybase/go-logging"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testLog = &logging.Logger{Module: "test"}
+
+func TestExecEmpty(t *testing.T) {
+ result, err := Exec("", nil, time.Second, testLog)
+ assert.EqualError(t, err, "No command")
+ assert.Equal(t, result.Stdout.String(), "")
+ assert.Equal(t, result.Stderr.String(), "")
+}
+
+func TestExecInvalid(t *testing.T) {
+ result, err := Exec("invalidexecutable", nil, time.Second, testLog)
+ assert.Error(t, err)
+ require.True(t, strings.HasPrefix(err.Error(), `exec: "invalidexecutable": executable file not found in `))
+ assert.Equal(t, result.Stdout.String(), "")
+ assert.Equal(t, result.Stderr.String(), "")
+}
+
+func TestExecEcho(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Unsupported on windows")
+ }
+ result, err := Exec("echo", []string{"arg1", "arg2"}, time.Second, testLog)
+ assert.NoError(t, err)
+ assert.Equal(t, result.Stdout.String(), "arg1 arg2\n")
+}
+
+func TestExecNil(t *testing.T) {
+ execCmd := func(name string, arg ...string) *exec.Cmd {
+ return nil
+ }
+ _, err := execWithFunc("echo", []string{"arg1", "arg2"}, nil, execCmd, time.Second, testLog)
+ require.Error(t, err)
+}
+
+func TestExecTimeout(t *testing.T) {
+ start := time.Now()
+ timeout := 10 * time.Millisecond
+ result, err := Exec("sleep", []string{"10"}, timeout, testLog)
+ elapsed := time.Since(start)
+ t.Logf("We elapsed %s", elapsed)
+ if elapsed < timeout {
+ t.Error("We didn't actually sleep more than a second")
+ }
+ assert.Equal(t, result.Stdout.String(), "")
+ assert.Equal(t, result.Stderr.String(), "")
+ require.EqualError(t, err, "Timed out")
+}
+
+func TestExecBadTimeout(t *testing.T) {
+ result, err := Exec("sleep", []string{"1"}, -time.Second, testLog)
+ assert.Equal(t, result.Stdout.String(), "")
+ assert.Equal(t, result.Stderr.String(), "")
+ assert.EqualError(t, err, "Invalid timeout: -1s")
+}
+
+type testObj struct {
+ StringVar string `json:"stringVar"`
+ NumberVar int `json:"numberVar"`
+ BoolVar bool `json:"boolVar"`
+ ObjectVar testNestedObj `json:"objectVar"`
+}
+
+type testNestedObj struct {
+ FloatVar float64 `json:"floatVar"`
+}
+
+const testJSON = `{
+ "stringVar": "hi",
+ "numberVar": 1,
+ "boolVar": true,
+ "objectVar": {
+ "floatVar": 1.23
+ }
+}`
+
+var testVal = testObj{
+ StringVar: "hi",
+ NumberVar: 1,
+ BoolVar: true,
+ ObjectVar: testNestedObj{
+ FloatVar: 1.23,
+ },
+}
+
+func TestExecForJSON(t *testing.T) {
+ var testValOut testObj
+ err := ExecForJSON("echo", []string{testJSON}, &testValOut, time.Second, testLog)
+ assert.NoError(t, err)
+ t.Logf("Out: %#v", testValOut)
+ if !reflect.DeepEqual(testVal, testValOut) {
+ t.Errorf("Invalid object: %#v", testValOut)
+ }
+}
+
+func TestExecForJSONEmpty(t *testing.T) {
+ err := ExecForJSON("", nil, nil, time.Second, testLog)
+ require.Error(t, err)
+}
+
+func TestExecForJSONInvalidObject(t *testing.T) {
+ // Valid JSON, but not the right object
+ validJSON := `{"stringVar": true}`
+ var testValOut testObj
+ err := ExecForJSON("echo", []string{validJSON}, &testValOut, time.Second, testLog)
+ require.Error(t, err)
+ t.Logf("Error: %s", err)
+}
+
+// TestExecForJSONAddingInvalidInput tests valid JSON input with invalid input after.
+// We still succeed in this case since we got valid input to start.
+func TestExecForJSONAddingInvalidInput(t *testing.T) {
+ var testValOut testObj
+ err := ExecForJSON("echo", []string{testJSON + "bad input"}, &testValOut, time.Second, testLog)
+ assert.NoError(t, err)
+ t.Logf("Out: %#v", testValOut)
+ if !reflect.DeepEqual(testVal, testValOut) {
+ t.Errorf("Invalid object: %#v", testValOut)
+ }
+}
+
+func TestExecForJSONTimeout(t *testing.T) {
+ var testValOut testObj
+ err := ExecForJSON("sleep", []string{"10"}, &testValOut, 10*time.Millisecond, testLog)
+ if assert.Error(t, err) {
+ assert.Equal(t, err.Error(), "Timed out")
+ }
+}
+
+// TestExecTimeoutProcessKilled checks to make sure process is killed after timeout
+func TestExecTimeoutProcessKilled(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Unsupported on windows")
+ }
+ result, err := execWithFunc("sleep", []string{"10"}, nil, exec.Command, 10*time.Millisecond, testLog)
+ assert.Equal(t, result.Stdout.String(), "")
+ assert.Equal(t, result.Stderr.String(), "")
+ assert.Error(t, err)
+ require.NotNil(t, result.Process)
+ findProcess, _ := os.FindProcess(result.Process.Pid)
+ // This should error since killing a non-existant process should error
+ perr := findProcess.Kill()
+ assert.NotNil(t, perr, "Should have errored killing since killing non-existant process should error")
+}
+
+// TestExecNoExit runs a go binary called test from package go-updater/test,
+// that should be installed prior to running the tests.
+func TestExecNoExit(t *testing.T) {
+ path := filepath.Join(os.Getenv("GOPATH"), "bin", "test")
+ _, err := Exec(path, []string{"noexit"}, 10*time.Millisecond, testLog)
+ require.EqualError(t, err, "Timed out")
+}
+
+func TestExecOutput(t *testing.T) {
+ path := filepath.Join(os.Getenv("GOPATH"), "bin", "test")
+ result, err := execWithFunc(path, []string{"output"}, nil, exec.Command, time.Second, testLog)
+ assert.NoError(t, err)
+ assert.Equal(t, "stdout output\n", result.Stdout.String())
+ assert.Equal(t, "stderr output\n", result.Stderr.String())
+}
+
+func TestProgramArgsWith(t *testing.T) {
+ assert.Equal(t, []string(nil), Program{Args: nil}.ArgsWith(nil))
+ assert.Equal(t, []string(nil), Program{Args: []string{}}.ArgsWith(nil))
+ assert.Equal(t, []string{}, Program{Args: nil}.ArgsWith([]string{}))
+ assert.Equal(t, []string{}, Program{Args: []string{}}.ArgsWith([]string{}))
+ assert.Equal(t, []string{"1"}, Program{Args: []string{"1"}}.ArgsWith(nil))
+ assert.Equal(t, []string{"1", "2"}, Program{Args: []string{"1"}}.ArgsWith([]string{"2"}))
+ assert.Equal(t, []string{"2"}, Program{Args: []string{}}.ArgsWith([]string{"2"}))
+}
diff --git a/go/updater/command/command_unix_test.go b/go/updater/command/command_unix_test.go
new file mode 100644
index 000000000000..27d5f8e8b8b4
--- /dev/null
+++ b/go/updater/command/command_unix_test.go
@@ -0,0 +1,28 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+//go:build linux || darwin
+// +build linux darwin
+
+package command
+
+import (
+ "os/exec"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExecWithEnv(t *testing.T) {
+ result, err := execWithFunc("printenv", []string{"TESTENV"}, []string{"TESTENV=ok"}, exec.Command, time.Second, testLog)
+ assert.NoError(t, err)
+ assert.Equal(t, result.Stdout.String(), "ok\n")
+}
+
+func TestExecWithNoEnv(t *testing.T) {
+ // Check there is a PATH env var if we pass nil
+ result, err := execWithFunc("printenv", []string{"PATH"}, nil, exec.Command, time.Second, testLog)
+ assert.NoError(t, err)
+ assert.NotEqual(t, result.Stdout.String(), "")
+}
diff --git a/go/updater/command/command_windows_test.go b/go/updater/command/command_windows_test.go
new file mode 100644
index 000000000000..557aeba6e483
--- /dev/null
+++ b/go/updater/command/command_windows_test.go
@@ -0,0 +1,21 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+//go:build windows
+// +build windows
+
+package command
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExecEchoWindows(t *testing.T) {
+ result, err := Exec("cmd", []string{"/c", "echo", "arg1", "arg2"}, time.Second, testLog)
+ assert.NoError(t, err)
+ assert.Equal(t, strings.TrimSpace(result.Stdout.String()), "arg1 arg2")
+}
diff --git a/go/updater/error.go b/go/updater/error.go
new file mode 100644
index 000000000000..7cb23e1731cf
--- /dev/null
+++ b/go/updater/error.go
@@ -0,0 +1,105 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package updater
+
+import "fmt"
+
+// ErrorType is a unique short string denoting the error category
+type ErrorType string
+
+const (
+ // UnknownError is for if we had an unknown error
+ UnknownError ErrorType = "unknown"
+ // CancelError is for if we canceled
+ CancelError ErrorType = "cancel"
+ // ConfigError is for errors reading/saving config
+ ConfigError ErrorType = "config"
+ // ConfigError is for when the GUI is active
+ GUIBusyError ErrorType = "guiBusy"
+)
+
+// Errors corresponding to each stage in the update process
+const (
+ // FindError is an error trying to find the update
+ FindError ErrorType = "find"
+ // PromptError is an UI prompt error
+ PromptError ErrorType = "prompt"
+ // DownloadError is an error trying to download the update
+ DownloadError ErrorType = "download"
+ // ApplyError is an error applying the update
+ ApplyError ErrorType = "apply"
+ // VerifyError is an error verifing the update (signature or digest)
+ VerifyError ErrorType = "verify"
+)
+
+func (t ErrorType) String() string {
+ return string(t)
+}
+
+// Error is an update error with a type/category for reporting
+type Error struct {
+ errorType ErrorType
+ source error
+}
+
+// NewError constructs an Error from a source error
+func NewError(errorType ErrorType, err error) Error {
+ return Error{errorType: errorType, source: err}
+}
+
+// TypeString returns a unique short string to denote the error type
+func (e Error) TypeString() string {
+ return e.errorType.String()
+}
+
+// IsCancel returns true if error was from a cancel
+func (e Error) IsCancel() bool {
+ return e.errorType == CancelError
+}
+
+// IsGUIBusy returns true if the UI was active
+func (e Error) IsGUIBusy() bool {
+ return e.errorType == GUIBusyError
+}
+
+// Error returns description for an UpdateError
+func (e Error) Error() string {
+ if e.source == nil {
+ return fmt.Sprintf("Update Error (%s)", e.TypeString())
+ }
+ return fmt.Sprintf("Update Error (%s): %s", e.TypeString(), e.source.Error())
+}
+
+// CancelErr can be returned by lifecycle methods to abort an update
+func CancelErr(err error) Error {
+ return NewError(CancelError, err)
+}
+
+func guiBusyErr(err error) Error {
+ return NewError(GUIBusyError, err)
+}
+
+func promptErr(err error) Error {
+ return NewError(PromptError, err)
+}
+
+func findErr(err error) Error {
+ return NewError(FindError, err)
+}
+
+func downloadErr(err error) Error {
+ return NewError(DownloadError, err)
+}
+
+func verifyErr(err error) Error {
+ return NewError(VerifyError, err)
+}
+
+func applyErr(err error) Error {
+ return NewError(ApplyError, err)
+}
+
+func configErr(err error) Error {
+ return NewError(ConfigError, err)
+}
diff --git a/go/updater/error_test.go b/go/updater/error_test.go
new file mode 100644
index 000000000000..6c8d703c2b61
--- /dev/null
+++ b/go/updater/error_test.go
@@ -0,0 +1,21 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package updater
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewError(t *testing.T) {
+ err := NewError(PromptError, fmt.Errorf("There was an error prompting"))
+ assert.EqualError(t, err, "Update Error (prompt): There was an error prompting")
+}
+
+func TestNewErrorNil(t *testing.T) {
+ err := NewError(PromptError, nil)
+ assert.EqualError(t, err, "Update Error (prompt)")
+}
diff --git a/go/updater/keybase/README.md b/go/updater/keybase/README.md
new file mode 100644
index 000000000000..72af89e9e4a0
--- /dev/null
+++ b/go/updater/keybase/README.md
@@ -0,0 +1,20 @@
+## Keybase
+
+Keybase specific behavior for updates.
+
+### Environment
+
+To change delay (how often check occurs, 1m = 1 minute, 1h = 1 hour):
+```
+launchctl setenv KEYBASE_UPDATER_DELAY 1m
+```
+
+To make the updater always apply an update after a check (even if same version):
+```
+launchctl setenv KEYBASE_UPDATER_FORCE true
+```
+
+Then restart the updater:
+```
+keybase launchd restart keybase.updater
+```
diff --git a/go/updater/keybase/config.go b/go/updater/keybase/config.go
new file mode 100644
index 000000000000..3d7b46910e56
--- /dev/null
+++ b/go/updater/keybase/config.go
@@ -0,0 +1,258 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/command"
+ "github.com/keybase/client/go/updater/util"
+)
+
+// Config is Keybase specific configuration for the updater
+type Config interface {
+ updater.Config
+ keybasePath() string
+ promptProgram() (command.Program, error)
+ notifyProgram() string
+ destinationPath() string
+ updaterOptions() updater.UpdateOptions
+}
+
+type config struct {
+ // appName is the name of the app, e.g. "Keybase"
+ appName string
+ // pathToKeybase is the location of the keybase executable
+ pathToKeybase string
+ // log is the logging location
+ log Log
+ // store is the config values
+ store store
+ // autoOverride is whether the current auto setting should be temporarily overridden
+ autoOverride bool
+ // ignoreSnooze corresponds to UpdateOptions.IgnoreSnooze
+ ignoreSnooze bool
+}
+
+// store is the config values
+type store struct {
+ // InstallID is an identifier returned by the API on first update that is a
+ // sent on subsequent requests.
+ InstallID string `json:"installId"`
+ // Auto is the whether to update automatically; this value and AutoSet below
+ // should be true for an update to be automatically applied.
+ Auto bool `json:"auto"`
+ // AutoSet is whether a user set the Auto config
+ AutoSet bool `json:"autoSet"`
+ // LastAppliedVersion is for detecting upgrade error condition
+ LastAppliedVersion string `json:"lastAppliedVersion"`
+}
+
+// newConfig loads a config, which is valid even if it has an error
+func newConfig(appName string, pathToKeybase string, log Log, ignoreSnooze bool) (*config, error) {
+ cfg := newDefaultConfig(appName, pathToKeybase, log, ignoreSnooze)
+ err := cfg.load()
+ return &cfg, err
+}
+
+func newDefaultConfig(appName string, pathToKeybase string, log Log, ignoreSnooze bool) config {
+ return config{
+ appName: appName,
+ pathToKeybase: pathToKeybase,
+ log: log,
+ ignoreSnooze: ignoreSnooze,
+ }
+}
+
+// load the config
+func (c *config) load() error {
+ path, err := c.path()
+ if err != nil {
+ return nil
+ }
+ return c.loadFromPath(path)
+}
+
+func (c *config) loadFromPath(path string) error {
+ file, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("Unable to open config file: %s", err)
+ }
+ if file == nil {
+ return fmt.Errorf("No file")
+ }
+ defer util.Close(file)
+
+ decoder := json.NewDecoder(file)
+ var decodeStore store
+ if err := decoder.Decode(&decodeStore); err != nil {
+ return err
+ }
+ c.store = decodeStore
+ return nil
+}
+
+func (c config) path() (string, error) {
+ configDir, err := Dir(c.appName)
+ if err != nil {
+ return "", err
+ }
+ if configDir == "" {
+ return "", fmt.Errorf("No config dir")
+ }
+ path := filepath.Join(configDir, "updater.json")
+ return path, nil
+}
+
+func (c config) updateCheckTouchPath() (string, error) {
+ configDir, err := Dir(c.appName)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(configDir, "updater.last"), nil
+}
+
+// IsLastUpdateCheckTimeRecent returns true if we've updated within duration.
+// If there is any kind of error, returns true.
+func (c config) IsLastUpdateCheckTimeRecent(d time.Duration) bool {
+ path, err := c.updateCheckTouchPath()
+ if err != nil {
+ c.log.Errorf("Error getting check path: %s", err)
+ return true
+ }
+ t, err := util.FileModTime(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ c.log.Infof("No last update time")
+ } else {
+ c.log.Errorf("Error getting last update time: %s", err)
+ }
+ return true
+ }
+ recent := time.Since(t) < d
+ c.log.Debugf("Last update time (is recent? %s): %s", strconv.FormatBool(recent), t)
+ return recent
+}
+
+// SetLastUpdateCheckTime touches file to set last update time.
+func (c config) SetLastUpdateCheckTime() {
+ path, err := c.updateCheckTouchPath()
+ if err != nil {
+ c.log.Errorf("Error getting check path: %s", err)
+ return
+ }
+ terr := util.Touch(path)
+ if terr != nil {
+ c.log.Errorf("Error setting last update time: %s", terr)
+ return
+ }
+ c.log.Debugf("Set last update time")
+}
+
+func (c config) save() error {
+ path, err := c.path()
+ if err != nil {
+ return err
+ }
+ return c.saveToPath(path)
+}
+
+func (c config) saveToPath(path string) error {
+ b, err := json.MarshalIndent(c.store, "", " ")
+ if err != nil {
+ return fmt.Errorf("Error marshaling config: %s", err)
+ }
+ file := util.NewFile(path, b, 0600)
+ err = util.MakeParentDirs(path, 0700, c.log)
+ if err != nil {
+ return err
+ }
+ return file.Save(c.log)
+}
+
+// GetUpdateAuto is the whether to update automatically and whether the user has
+// set this value. Both should be true for an update to be automatically
+// applied.
+func (c config) GetUpdateAuto() (bool, bool) {
+ return c.store.Auto, c.store.AutoSet
+}
+
+func (c *config) SetUpdateAuto(auto bool) error {
+ c.store.Auto = auto
+ c.store.AutoSet = true
+ return c.save()
+}
+
+// For overriding the current Auto setting
+func (c config) GetUpdateAutoOverride() bool {
+ return c.autoOverride
+}
+
+func (c *config) SetUpdateAutoOverride(auto bool) error {
+ c.autoOverride = auto
+ return nil
+}
+
+// For reporting the last version applied
+func (c config) GetLastAppliedVersion() string {
+ return c.store.LastAppliedVersion
+}
+
+func (c *config) SetLastAppliedVersion(version string) error {
+ c.store.LastAppliedVersion = version
+ return c.save()
+}
+
+// GetInstallID is an identifier returned by the API on first update that is a
+// sent on subsequent requests.
+func (c config) GetInstallID() string {
+ return c.store.InstallID
+}
+
+func (c *config) SetInstallID(installID string) error {
+ c.store.InstallID = installID
+ return c.save()
+}
+
+func (c config) updaterOptions() updater.UpdateOptions {
+ version := c.keybaseVersion()
+ osVersion := c.osVersion()
+ osArch := c.osArch()
+ platform := runtime.GOOS
+ if platform == "darwin" && osArch == "arm64" {
+ platform = "darwin-arm64"
+ }
+
+ return updater.UpdateOptions{
+ Version: version,
+ Platform: platform,
+ Arch: osArch,
+ DestinationPath: c.destinationPath(),
+ Env: "prod",
+ OSVersion: osVersion,
+ UpdaterVersion: updater.Version,
+ IgnoreSnooze: c.ignoreSnooze,
+ }
+}
+
+func (c config) keybasePath() string {
+ return c.pathToKeybase
+}
+
+func (c config) keybaseVersion() string {
+ result, err := command.Exec(c.keybasePath(), []string{"version", "-S"}, 20*time.Second, c.log)
+ if err != nil {
+ c.log.Warningf("Couldn't get keybase version: %s (%s)", err, result.CombinedOutput())
+ return ""
+ }
+ return strings.TrimSpace(result.Stdout.String())
+}
diff --git a/go/updater/keybase/config_test.go b/go/updater/keybase/config_test.go
new file mode 100644
index 000000000000..a7ca19b53d4c
--- /dev/null
+++ b/go/updater/keybase/config_test.go
@@ -0,0 +1,178 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/util"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func testConfigWithIgnoreSnooze(t *testing.T, ignoreSnooze bool) (*config, error) {
+ testPathToKeybase := filepath.Join(os.Getenv("GOPATH"), "bin", "test")
+ appName, err := util.RandomID("KeybaseTest.")
+ require.NoError(t, err)
+ return newConfig(appName, testPathToKeybase, testLog, ignoreSnooze)
+}
+
+func testConfig(t *testing.T) (*config, error) {
+ return testConfigWithIgnoreSnooze(t, false)
+}
+
+func TestConfig(t *testing.T) {
+ cfg, err := testConfig(t) // Will error since load fails on first newConfig
+ assert.NotNil(t, err, "%s", err)
+ path, err := cfg.path()
+ assert.NoError(t, err)
+ assert.NotEqual(t, path, "", "No config path")
+
+ configDir, err := Dir(cfg.appName)
+ defer util.RemoveFileAtPath(configDir)
+ assert.NoError(t, err)
+ assert.NotEqual(t, configDir, "", "Config dir empty")
+ defer util.RemoveFileAtPath(configDir)
+
+ err = cfg.SetUpdateAuto(false)
+ assert.NoError(t, err)
+ auto, autoSet := cfg.GetUpdateAuto()
+ assert.True(t, autoSet, "Auto should be set")
+ assert.False(t, auto, "Auto should be false")
+ err = cfg.SetUpdateAuto(true)
+ assert.NoError(t, err)
+ auto, autoSet = cfg.GetUpdateAuto()
+ assert.True(t, autoSet, "Auto should be set")
+ assert.True(t, auto, "Auto should be true")
+
+ err = cfg.SetInstallID("deadbeef")
+ assert.NoError(t, err)
+ assert.Equal(t, cfg.GetInstallID(), "deadbeef")
+
+ err = cfg.save()
+ require.NoError(t, err)
+
+ override := cfg.GetUpdateAutoOverride()
+ assert.False(t, override, "AutoOverride should be false")
+ err = cfg.SetUpdateAutoOverride(true)
+ require.NoError(t, err)
+ override = cfg.GetUpdateAutoOverride()
+ assert.True(t, override, "AutoOverride should be set")
+
+ options := cfg.updaterOptions()
+ t.Logf("Options: %#v", options)
+
+ expectedOptions := updater.UpdateOptions{
+ Version: "1.2.3-400+cafebeef",
+ Platform: runtime.GOOS,
+ DestinationPath: options.DestinationPath,
+ Channel: "",
+ Env: "prod",
+ IgnoreSnooze: false,
+ Arch: cfg.osArch(),
+ Force: false,
+ OSVersion: cfg.osVersion(),
+ UpdaterVersion: updater.Version,
+ }
+
+ assert.Equal(t, options, expectedOptions)
+
+ // Load new config and make sure it has the same values
+ cfg2, err := newConfig(cfg.appName, cfg.pathToKeybase, testLog, true)
+ assert.NoError(t, err)
+ path, err = cfg2.path()
+ assert.NoError(t, err)
+ assert.NotEqual(t, path, "", "No config path")
+
+ expectedOptions2 := expectedOptions
+ expectedOptions2.IgnoreSnooze = true
+
+ options2 := cfg2.updaterOptions()
+ assert.Equal(t, options2, expectedOptions2)
+
+ auto2, autoSet2 := cfg2.GetUpdateAuto()
+ assert.True(t, autoSet2, "Auto should be set")
+ assert.True(t, auto2, "Auto should be true")
+ assert.Equal(t, cfg2.GetInstallID(), "deadbeef")
+}
+
+func TestConfigBadPath(t *testing.T) {
+ cfg := newDefaultConfig("", "", testLog, false)
+
+ var badPath string
+ if runtime.GOOS == "windows" {
+ badPath = `x:\updater.json` // Shouldn't be writable
+ } else {
+ badPath = filepath.Join("/testdir", "updater.json") // Shouldn't be writable
+ }
+
+ err := cfg.loadFromPath(badPath)
+ t.Logf("Error: %#v", err)
+ assert.NotNil(t, err, "Expected error")
+
+ saveErr := cfg.saveToPath(badPath)
+ t.Logf("Error: %#v", saveErr)
+ assert.NotNil(t, saveErr, "Expected error")
+
+ auto, autoSet := cfg.GetUpdateAuto()
+ assert.False(t, autoSet, "Auto should not be set")
+ assert.False(t, auto, "Auto should be false")
+ assert.Equal(t, cfg.GetInstallID(), "")
+}
+
+func TestConfigExtra(t *testing.T) {
+ data := `{
+ "extra": "extrafield",
+ "installId": "deadbeef",
+ "auto": false,
+ "autoSet": true
+ }`
+ path := filepath.Join(os.TempDir(), "TestConfigExtra")
+ defer util.RemoveFileAtPath(path)
+ err := os.WriteFile(path, []byte(data), 0644)
+ assert.NoError(t, err)
+
+ cfg := newDefaultConfig("", "", testLog, false)
+ err = cfg.loadFromPath(path)
+ assert.NoError(t, err)
+
+ t.Logf("Config: %#v", cfg.store)
+ assert.Equal(t, cfg.GetInstallID(), "deadbeef")
+ auto, autoSet := cfg.GetUpdateAuto()
+ assert.False(t, auto)
+ assert.True(t, autoSet)
+}
+
+// TestConfigBadType tests that if a parsing error occurs, we have the default
+// config
+func TestConfigBadType(t *testing.T) {
+ // installId has wrong type
+ data := `{
+ "auto": true,
+ "installId": 1
+ }`
+ path := filepath.Join(os.TempDir(), "TestConfigBadType")
+ defer util.RemoveFileAtPath(path)
+ err := os.WriteFile(path, []byte(data), 0644)
+ assert.NoError(t, err)
+
+ cfg := newDefaultConfig("", "", testLog, false)
+ err = cfg.loadFromPath(path)
+ assert.Error(t, err)
+ auto, autoSet := cfg.GetUpdateAuto()
+ assert.False(t, auto)
+ assert.False(t, autoSet)
+}
+
+func TestKeybaseVersionInvalid(t *testing.T) {
+ _, filename, _, _ := runtime.Caller(0)
+ testPathToKeybase := filepath.Join(filepath.Dir(filename), "../test/err.sh")
+ cfg, _ := newConfig("KeybaseTest", testPathToKeybase, testLog, false)
+ version := cfg.keybaseVersion()
+ assert.Equal(t, "", version)
+}
diff --git a/go/updater/keybase/context.go b/go/updater/keybase/context.go
new file mode 100644
index 000000000000..815e6a67518b
--- /dev/null
+++ b/go/updater/keybase/context.go
@@ -0,0 +1,179 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/command"
+ "github.com/keybase/client/go/updater/saltpack"
+)
+
+// validCodeSigningKIDs are the list of valid code signing IDs for saltpack verify
+var validCodeSigningKIDs = map[string]bool{
+ "01209092ae4e790763dc7343851b977930f35b16cf43ab0ad900a2af3d3ad5cea1a10a": true, // keybot (device)
+ "012045891a45f03cec001196ad05207f3f80045b2b9f0ca38288a85f8120ac74db960a": true, // max (tiber - 2019-01)
+ "012065ae849d1949a8b0021b165b0edaf722e2a7a9036e07817e056e2d721bddcc0e0a": true, // max (cry glass)
+ "01202a70fa31596ae2afabbbea827c7d1efb205c4b02b2b98b8f8c75915be433ccb50a": true, // mike (demise sort)
+ "0120f2f55c76151b3eaf91d20dfb673d8591d8b49fd5cb210a10f6e0dd8724bf34f30a": true, // mike (lisa-5k-redux)
+ "0120deaa8ae7d06ea9aa49cc678ec49f2b1e1dddb63683e384db539a8649c47925f90a": true, // winbot (device)
+}
+
+// Log is the logging interface for the keybase package
+type Log interface {
+ Debug(...interface{})
+ Info(...interface{})
+ Debugf(s string, args ...interface{})
+ Infof(s string, args ...interface{})
+ Warningf(s string, args ...interface{})
+ Errorf(s string, args ...interface{})
+}
+
+// context is an updater.Context implementation
+type context struct {
+ // config is updater config
+ config Config
+ // log is the logger
+ log Log
+ // isCheckCommand is whether the updater is being invoked with the check command
+ isCheckCommand bool
+}
+
+// endpoints define all the url locations for reporting, etc
+type endpoints struct {
+ update string
+ action string
+ success string
+ err string
+}
+
+var defaultEndpoints = endpoints{
+ update: "https://api-0.core.keybaseapi.com/_/api/1.0/pkg/update.json",
+ action: "https://api-0.core.keybaseapi.com/_/api/1.0/pkg/act.json",
+ success: "https://api-0.core.keybaseapi.com/_/api/1.0/pkg/success.json",
+ err: "https://api-0.core.keybaseapi.com/_/api/1.0/pkg/error.json",
+}
+
+func newContext(cfg Config, log Log) *context {
+ ctx := context{
+ config: cfg,
+ log: log,
+ }
+ return &ctx
+}
+
+func newContextCheckCmd(cfg Config, log Log, isCheckCommand bool) *context {
+ ctx := newContext(cfg, log)
+ ctx.isCheckCommand = isCheckCommand
+ return ctx
+}
+
+// UpdaterMode describes how updater should behave.
+type UpdaterMode int
+
+const (
+ _ UpdaterMode = iota
+ Service // used in service mode; never ignores snooze
+ Check // ignores snooze
+ CheckPassive // does not ignore snooze
+)
+
+// IsCheck returns true if we are not running in service mode.
+func (m UpdaterMode) IsCheck() bool {
+ return m == Check || m == CheckPassive
+}
+
+// IgnoreSnooze returns true if we should ignore snooze.
+func (m UpdaterMode) IgnoreSnooze() bool {
+ return m == Check
+}
+
+// NewUpdaterContext returns an updater context for Keybase
+func NewUpdaterContext(appName string, pathToKeybase string, log Log, mode UpdaterMode) (updater.Context, *updater.Updater) {
+ cfg, err := newConfig(appName, pathToKeybase, log, mode.IgnoreSnooze())
+ if err != nil {
+ log.Warningf("Error loading config for context: %s", err)
+ }
+
+ src := NewUpdateSource(cfg, log)
+
+ // For testing, you can use a local updater source.
+ // Add your local device signing key to `validCodeSigningKIDs` above (note that the first and last byte are stripped off).
+ // (cd /Applications; ditto -c -k --sequesterRsrc --keepParent Keybase.app /tmp/Keybase.zip)
+ // keybase sign --saltpack-version "1" -d -i "/tmp/Keybase.zip" -o "/tmp/update.sig"
+ // release update-json --version=`keybase version -S` --src=/tmp/Keybase.zip --uri=/tmp --signature=/tmp/update.sig > /tmp/update.json
+ // Uncomment the following line and the `sources` import above.
+ // src := sources.NewLocalUpdateSource("/tmp/Keybase.zip", "/tmp/update.json", log)
+ // cd $GOPATH/src/github.com/keybase/client/go/updater/service
+ // go build
+ // cp service /Applications/Keybase.app/Contents/SharedSupport/bin/updater
+ // keybase launchd stop keybase.updater
+ // keybase update check
+
+ upd := updater.NewUpdater(src, cfg, log)
+ return newContextCheckCmd(cfg, log, mode.IsCheck()), upd
+}
+
+// UpdateOptions returns update options
+func (c *context) UpdateOptions() updater.UpdateOptions {
+ return c.config.updaterOptions()
+}
+
+// GetUpdateUI returns Update UI
+func (c *context) GetUpdateUI() updater.UpdateUI {
+ return c
+}
+
+// GetLog returns log
+func (c context) GetLog() Log {
+ return c.log
+}
+
+// Verify verifies the signature
+func (c context) Verify(update updater.Update) error {
+ return saltpack.VerifyDetachedFileAtPath(update.Asset.LocalPath, update.Asset.Signature, validCodeSigningKIDs, c.log)
+}
+
+type checkInUseResult struct {
+ InUse bool `json:"in_use"`
+}
+
+func (c context) checkInUse() (bool, error) {
+ var result checkInUseResult
+ if err := command.ExecForJSON(c.config.keybasePath(), []string{"update", "check-in-use"}, &result, time.Minute, c.log); err != nil {
+ return false, err
+ }
+ return result.InUse, nil
+}
+
+// BeforeApply is called before an update is applied
+func (c context) BeforeApply(update updater.Update) error {
+ inUse, err := c.checkInUse()
+ if err != nil {
+ c.log.Warningf("Error trying to check in use: %s", err)
+ }
+ if inUse {
+ if cancel := c.PausedPrompt(); cancel {
+ return fmt.Errorf("Canceled by user from paused prompt")
+ }
+ }
+ return nil
+}
+
+func (c context) AfterUpdateCheck(update *updater.Update) {
+ if update != nil {
+ // If we received an update from the check let's exit, so the watchdog
+ // process (e.g. launchd on darwin) can restart us, no matter what, even if
+ // there was an error, and even if the update was or wasn't applied.
+ // There is no difference between doing another update check in a loop after
+ // delay and restarting the service.
+ c.log.Infof("%s", "Exiting for restart")
+ // Allow the log to write, since os.Exit can be abrupt
+ time.Sleep(2 * time.Second)
+ os.Exit(0)
+ }
+}
diff --git a/go/updater/keybase/context_darwin_test.go b/go/updater/keybase/context_darwin_test.go
new file mode 100644
index 000000000000..0ec402333f27
--- /dev/null
+++ b/go/updater/keybase/context_darwin_test.go
@@ -0,0 +1,70 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+//go:build darwin
+// +build darwin
+
+package keybase
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/command"
+ "github.com/stretchr/testify/require"
+)
+
+type testConfigPausedPrompt struct {
+ config
+ inUse bool
+ force bool
+}
+
+func (c testConfigPausedPrompt) promptProgram() (command.Program, error) {
+ if c.force {
+ return command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"echo", `{"button": "Force update"}`},
+ }, nil
+ }
+ return command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"echo", `{"button": "Try again later"}`},
+ }, nil
+}
+
+func (c testConfigPausedPrompt) keybasePath() string {
+ _, filename, _, _ := runtime.Caller(0)
+ if c.inUse {
+ return filepath.Join(filepath.Dir(filename), "../test/keybase-check-in-use-true.sh")
+ }
+ return filepath.Join(filepath.Dir(filename), "../test/keybase-check-in-use-false.sh")
+}
+
+func (c testConfigPausedPrompt) updaterOptions() updater.UpdateOptions {
+ return updater.UpdateOptions{}
+}
+
+func (c testConfigPausedPrompt) destinationPath() string {
+ return "/Applications/Test.app"
+}
+
+func TestContextCheckInUse(t *testing.T) {
+ // In use, force
+ ctx := newContext(&testConfigPausedPrompt{inUse: true, force: true}, testLog)
+ err := ctx.BeforeApply(updater.Update{})
+ require.NoError(t, err)
+
+ // Not in use
+ ctx = newContext(&testConfigPausedPrompt{inUse: false}, testLog)
+ err = ctx.BeforeApply(updater.Update{})
+ require.NoError(t, err)
+
+ // In use, user cancels
+ ctx = newContext(&testConfigPausedPrompt{inUse: true, force: false}, testLog)
+ err = ctx.BeforeApply(updater.Update{})
+ require.EqualError(t, err, "Canceled by user from paused prompt")
+}
diff --git a/go/updater/keybase/context_test.go b/go/updater/keybase/context_test.go
new file mode 100644
index 000000000000..1e084f228c8b
--- /dev/null
+++ b/go/updater/keybase/context_test.go
@@ -0,0 +1,87 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// testSignatureKeybot is "This is a test message" signed by keybot
+const testSignatureKeybot = `BEGIN KEYBASE SALTPACK DETACHED SIGNATURE.
+ kXR7VktZdyH7rvq v5wcIkPOwDJ1n11 M8RnkLKQGO2f3Bb fzCeMYz4S6oxLAy
+ Cco4N255JFQSlh7 IZiojdPCOssX5DX pEcVEdujw3EsDuI FOTpFB77NK4tqLr
+ Dgme7xtCaR4QRl2 hchPpr65lKLKSFy YVZcF2xUVN3gjpM vPFUMwg0JTBAG8x
+ Z. END KEYBASE SALTPACK DETACHED SIGNATURE.
+`
+
+var testMessagePath, testMessage2Path string
+
+func init() {
+ _, filename, _, _ := runtime.Caller(0)
+ testMessagePath = filepath.Join(filepath.Dir(filename), "../test/message1.txt")
+ testMessage2Path = filepath.Join(filepath.Dir(filename), "../test/message2.txt")
+}
+
+// testSignatureInvalidSigner is "This is a test message" signed by gabrielh who
+// is not in valid signing IDs.
+const testSignatureInvalidSigner = `BEGIN KEYBASE SALTPACK DETACHED SIGNATURE.
+ kXR7VktZdyH7rvq v5wcIkPOwGV4GkV Zj40Ut1jYS2euBu Ti6z39EdDX7Ne1P
+ i0ToOCpSPXyNeSm Zr6r5UOEZnblXeU gLhEpUSRpLFMlKe MWkq61Yaa8XyFvt
+ 29NjGzUokNPHPB2 A97cMmFTeGP6Y5V RNRhtwBT3iJoyMv E9RcQhs1717z2aa
+ c. END KEYBASE SALTPACK DETACHED SIGNATURE.`
+
+func testContext(t *testing.T) *context {
+ cfg, _ := testConfig(t)
+ ctx := newContext(cfg, testLog)
+ require.NotNil(t, ctx)
+ return ctx
+}
+
+func testContextUpdate(path string, signature string) updater.Update {
+ return updater.Update{
+ Asset: &updater.Asset{
+ Signature: signature,
+ LocalPath: path,
+ },
+ }
+}
+
+func TestContext(t *testing.T) {
+ ctx := testContext(t)
+
+ // Check options not empty
+ options := ctx.UpdateOptions()
+ assert.NotEqual(t, options.Version, "")
+}
+
+func TestContextVerify(t *testing.T) {
+ ctx := testContext(t)
+ err := ctx.Verify(testContextUpdate(testMessagePath, testSignatureKeybot))
+ assert.NoError(t, err)
+}
+
+func TestContextVerifyFail(t *testing.T) {
+ ctx := testContext(t)
+ err := ctx.Verify(testContextUpdate(testMessage2Path, testSignatureInvalidSigner))
+ require.Error(t, err)
+}
+
+func TestContextVerifyNoValidIDs(t *testing.T) {
+ ctx := testContext(t)
+ err := ctx.Verify(testContextUpdate(testMessagePath, testSignatureInvalidSigner))
+ require.Error(t, err)
+ assert.Equal(t, "error verifying signature: unknown signer KID: 0120ad6ec4c0132ca7627b3c4d72c650323abec004da51dc086fd0ec2b4f82e6e4860a", err.Error())
+}
+
+func TestContextVerifyBadSignature(t *testing.T) {
+ ctx := testContext(t)
+ err := ctx.Verify(testContextUpdate(testMessagePath, "BEGIN KEYBASE SALTPACK DETACHED SIGNATURE. END KEYBASE SALTPACK DETACHED SIGNATURE."))
+ require.Error(t, err)
+}
diff --git a/go/updater/keybase/https.go b/go/updater/keybase/https.go
new file mode 100644
index 000000000000..fc281f16d764
--- /dev/null
+++ b/go/updater/keybase/https.go
@@ -0,0 +1,34 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/keybase/client/go/libkb"
+)
+
+func httpClient(timeout time.Duration) (*http.Client, error) {
+ return httpClientWithCert(libkb.APICA, timeout)
+}
+
+func httpClientWithCert(cert string, timeout time.Duration) (*http.Client, error) {
+ certPool := x509.NewCertPool()
+ if ok := certPool.AppendCertsFromPEM([]byte(cert)); !ok {
+ return nil, fmt.Errorf("Unable to add cert")
+ }
+ if certPool == nil {
+ return nil, fmt.Errorf("No cert pool")
+ }
+ tlsConfig := &tls.Config{RootCAs: certPool}
+ transport := &http.Transport{TLSClientConfig: tlsConfig}
+ return &http.Client{
+ Transport: transport,
+ Timeout: timeout,
+ }, nil
+}
diff --git a/go/updater/keybase/https_test.go b/go/updater/keybase/https_test.go
new file mode 100644
index 000000000000..245a06de683c
--- /dev/null
+++ b/go/updater/keybase/https_test.go
@@ -0,0 +1,79 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/updater/util"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHTTPClient(t *testing.T) {
+ req, err := http.NewRequest("GET", "https://api-0.core.keybaseapi.com/_/api/1.0/user/lookup.json?github=gabriel", nil)
+ require.NoError(t, err)
+ client, err := httpClient(time.Minute)
+ require.NoError(t, err)
+ resp, err := client.Do(req)
+ defer util.DiscardAndCloseBodyIgnoreError(resp)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+}
+
+func TestHTTPClientWithOtherCert(t *testing.T) {
+ otherCert := `-----BEGIN CERTIFICATE-----
+MIIHgzCCBmugAwIBAgIIM0yeg6/uf0swDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE
+BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl
+cm5ldCBBdXRob3JpdHkgRzIwHhcNMTUwMjExMTIwNzIzWhcNMTUwNTEyMDAwMDAw
+WjBmMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwN
+TW91bnRhaW4gVmlldzETMBEGA1UECgwKR29vZ2xlIEluYzEVMBMGA1UEAwwMKi5n
+b29nbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxHRttIni
+mH4jfS8UAj/JdFvNCq1e9Fq/25aVhGQTgbdB3rR78Xg9BI7KCc9THq55VNXoovSS
+3GE+mnUura7yd1e7JRhZJDcB/ybMuxcYpwZhZoOPxD12mmflZYMj5/ucgza6ahTX
+WfSlNxHno7Ktc/Qv/tC6vDF8lKU7u+xGtJatA7bKYvoSFTQHBLIxLYT+zfuzlqEM
+mXY7qoIanWuDTMKRWiBDkPxIjKMbHUBXYINvXciG2R41962JbV/T/pkk9oW4+XcI
+r2DOh2vDyzHN9Eg/dTS1h4XdRQ3MnTZjQOCbyfgo/bUAzRMsPnLzK6XeRFR1DfUo
+uoJNi6ikD0PGjQIDAQABo4IEUDCCBEwwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsG
+AQUFBwMCMIIDJgYDVR0RBIIDHTCCAxmCDCouZ29vZ2xlLmNvbYINKi5hbmRyb2lk
+LmNvbYIWKi5hcHBlbmdpbmUuZ29vZ2xlLmNvbYISKi5jbG91ZC5nb29nbGUuY29t
+ghYqLmdvb2dsZS1hbmFseXRpY3MuY29tggsqLmdvb2dsZS5jYYILKi5nb29nbGUu
+Y2yCDiouZ29vZ2xlLmNvLmlugg4qLmdvb2dsZS5jby5qcIIOKi5nb29nbGUuY28u
+dWuCDyouZ29vZ2xlLmNvbS5hcoIPKi5nb29nbGUuY29tLmF1gg8qLmdvb2dsZS5j
+b20uYnKCDyouZ29vZ2xlLmNvbS5jb4IPKi5nb29nbGUuY29tLm14gg8qLmdvb2ds
+ZS5jb20udHKCDyouZ29vZ2xlLmNvbS52boILKi5nb29nbGUuZGWCCyouZ29vZ2xl
+LmVzggsqLmdvb2dsZS5mcoILKi5nb29nbGUuaHWCCyouZ29vZ2xlLml0ggsqLmdv
+b2dsZS5ubIILKi5nb29nbGUucGyCCyouZ29vZ2xlLnB0ghIqLmdvb2dsZWFkYXBp
+cy5jb22CDyouZ29vZ2xlYXBpcy5jboIUKi5nb29nbGVjb21tZXJjZS5jb22CESou
+Z29vZ2xldmlkZW8uY29tggwqLmdzdGF0aWMuY26CDSouZ3N0YXRpYy5jb22CCiou
+Z3Z0MS5jb22CCiouZ3Z0Mi5jb22CFCoubWV0cmljLmdzdGF0aWMuY29tggwqLnVy
+Y2hpbi5jb22CECoudXJsLmdvb2dsZS5jb22CFioueW91dHViZS1ub2Nvb2tpZS5j
+b22CDSoueW91dHViZS5jb22CFioueW91dHViZWVkdWNhdGlvbi5jb22CCyoueXRp
+bWcuY29tggthbmRyb2lkLmNvbYIEZy5jb4IGZ29vLmdsghRnb29nbGUtYW5hbHl0
+aWNzLmNvbYIKZ29vZ2xlLmNvbYISZ29vZ2xlY29tbWVyY2UuY29tggp1cmNoaW4u
+Y29tggh5b3V0dS5iZYILeW91dHViZS5jb22CFHlvdXR1YmVlZHVjYXRpb24uY29t
+MGgGCCsGAQUFBwEBBFwwWjArBggrBgEFBQcwAoYfaHR0cDovL3BraS5nb29nbGUu
+Y29tL0dJQUcyLmNydDArBggrBgEFBQcwAYYfaHR0cDovL2NsaWVudHMxLmdvb2ds
+ZS5jb20vb2NzcDAdBgNVHQ4EFgQUdPnb3QnnxO5TWnRfHV+VMTczAk8wDAYDVR0T
+AQH/BAIwADAfBgNVHSMEGDAWgBRK3QYWG7z2aLV29YG2u2IaulqBLzAXBgNVHSAE
+EDAOMAwGCisGAQQB1nkCBQEwMAYDVR0fBCkwJzAloCOgIYYfaHR0cDovL3BraS5n
+b29nbGUuY29tL0dJQUcyLmNybDANBgkqhkiG9w0BAQUFAAOCAQEAOXT8s6bpqgKy
+VTPw0oQMTdIyE8Q9RN1Tsrl9UfF4tELCW+WaEzhtgSpzVcMYJZQOK7Nc2feG0oxs
+L6BmChjtD10HVO7GPfsIkg52TBmDV1kBiJlfT3DtJvUcO4HN/4wCyCVQUEWd+YpK
+nwWmpXUuNO4qoX0H8QgTiPNu/rmNlYanXmQmn+KsJenf5qgnmFMVFk+86MeWPbRs
+KRX1qernJeMz9dsC7h7jGAYfKMQ+LHyAEPNNwyFL8HxrcuQAf9vO1caj27xgBDNl
+tUa2UA5ETydhoj3BcgHJoiP7m+GZABeyaFwtuf4fD2Dm8FtG1owFubUkGo8Bulv4
+OtWvrDGSUA==
+-----END CERTIFICATE-----`
+
+ req, err := http.NewRequest("GET", "https://api-0.core.keybaseapi.com/_/api/1.0/user/lookup.json?github=gabriel", nil)
+ require.NoError(t, err)
+ client, err := httpClientWithCert(otherCert, time.Minute)
+ require.NoError(t, err)
+ resp, err := client.Do(req)
+ defer util.DiscardAndCloseBodyIgnoreError(resp)
+ require.EqualError(t, err, `Get "https://api-0.core.keybaseapi.com/_/api/1.0/user/lookup.json?github=gabriel": x509: certificate signed by unknown authority`)
+}
diff --git a/go/updater/keybase/keybase_test.go b/go/updater/keybase/keybase_test.go
new file mode 100644
index 000000000000..43ecbd6b371d
--- /dev/null
+++ b/go/updater/keybase/keybase_test.go
@@ -0,0 +1,35 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "time"
+
+ "github.com/keybase/go-logging"
+)
+
+var testLog = &logging.Logger{Module: "test"}
+
+func newServer(updateJSON string) *httptest.Server {
+ return newServerWithDelay(updateJSON, 0)
+}
+
+func newServerWithDelay(updateJSON string, delay time.Duration) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if delay > 0 {
+ time.Sleep(delay)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintln(w, updateJSON)
+ }))
+}
+
+func newServerForError(err error) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, err.Error(), 500)
+ }))
+}
diff --git a/go/updater/keybase/platform_darwin.go b/go/updater/keybase/platform_darwin.go
new file mode 100644
index 000000000000..6c121bdf7aaa
--- /dev/null
+++ b/go/updater/keybase/platform_darwin.go
@@ -0,0 +1,374 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/kardianos/osext"
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/command"
+ "github.com/keybase/client/go/updater/process"
+ "github.com/keybase/client/go/updater/util"
+)
+
+// execPath returns the app bundle path where this executable is located
+func (c config) execPath() string {
+ path, err := osext.Executable()
+ if err != nil {
+ c.log.Warningf("Error trying to determine our executable path: %s", err)
+ return ""
+ }
+ return path
+}
+
+// destinationPath returns the app bundle path where this executable is located
+func (c config) destinationPath() string {
+ return appBundleForPath(c.execPath())
+}
+
+func appBundleForPath(path string) string {
+ if path == "" {
+ return ""
+ }
+ paths := strings.SplitN(path, ".app", 2)
+ // If no match, return ""
+ if len(paths) <= 1 {
+ return ""
+ }
+ return paths[0] + ".app"
+}
+
+func libraryDir() (string, error) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(homeDir, "Library"), nil
+}
+
+// Dir returns where to store config
+func Dir(appName string) (string, error) {
+ if appName == "" {
+ return "", fmt.Errorf("No app name for dir")
+ }
+ libDir, err := libraryDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(libDir, "Application Support", appName), nil
+}
+
+// CacheDir returns where to store temporary files
+func CacheDir(appName string) (string, error) {
+ if appName == "" {
+ return "", fmt.Errorf("No app name for dir")
+ }
+ return filepath.Join(os.TempDir(), appName), nil
+}
+
+// LogDir is where to log
+func LogDir(appName string) (string, error) {
+ libDir, err := libraryDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(libDir, "Logs"), nil
+}
+
+func (c config) osVersion() string {
+ result, err := command.Exec("/usr/bin/sw_vers", []string{"-productVersion"}, 5*time.Second, c.log)
+ if err != nil {
+ c.log.Warningf("Error trying to determine OS version: %s (%s)", err, result.CombinedOutput())
+ return ""
+ }
+ return strings.TrimSpace(result.Stdout.String())
+}
+
+func (c config) osArch() string {
+ r, err := syscall.Sysctl("sysctl.proc_translated")
+ if err == nil {
+ if r == "\x00\x00\x00" || r == "\x01\x00\x00" {
+ // running on apple silicon, maybe in rosetta mode.
+ // return arm64 here to upgrade users to the arm64 built version
+ return "arm64"
+ }
+ }
+ c.log.Warningf("Error trying to determine OS arch: %s sysct.proc_translated=%s", err, r)
+ cmd := exec.Command("uname", "-m")
+ var buf bytes.Buffer
+ cmd.Stdout = &buf
+ err = cmd.Run()
+ if err != nil {
+ c.log.Warningf("Error trying to determine OS arch, falling back to compile time arch: %s (%s)", err, cmd.Stderr)
+ return runtime.GOARCH
+ }
+ return strings.TrimSuffix(buf.String(), "\n")
+}
+
+func (c config) promptProgram() (command.Program, error) {
+ destinationPath := c.destinationPath()
+ if destinationPath == "" {
+ return command.Program{}, fmt.Errorf("No destination path")
+ }
+ return command.Program{
+ Path: filepath.Join(destinationPath, "Contents", "Resources", "KeybaseUpdater.app", "Contents", "MacOS", "Updater"),
+ }, nil
+}
+
+func (c config) notifyProgram() string {
+ // No notify program for Darwin
+ return ""
+}
+
+func (c context) BeforeUpdatePrompt(update updater.Update, options updater.UpdateOptions) error {
+ return nil
+}
+
+// UpdatePrompt is called when the user needs to accept an update
+func (c context) UpdatePrompt(update updater.Update, options updater.UpdateOptions, promptOptions updater.UpdatePromptOptions) (*updater.UpdatePromptResponse, error) {
+ promptProgram, err := c.config.promptProgram()
+ if err != nil {
+ return nil, err
+ }
+ return c.updatePrompt(promptProgram, update, options, promptOptions, time.Hour)
+}
+
+// PausedPrompt is called when the we can't update cause the app is in use.
+// We return true if the use wants to cancel the update.
+func (c context) PausedPrompt() bool {
+ promptProgram, err := c.config.promptProgram()
+ if err != nil {
+ c.log.Warningf("Error trying to get prompt path: %s", err)
+ return false
+ }
+ cancelUpdate, err := c.pausedPrompt(promptProgram, 5*time.Minute)
+ if err != nil {
+ c.log.Warningf("Error in paused prompt: %s", err)
+ return false
+ }
+ return cancelUpdate
+}
+
+func (c context) GetAppStatePath() string {
+ home, _ := Dir("keybase")
+ return filepath.Join(home, "app-state.json")
+}
+
+func (c context) IsCheckCommand() bool {
+ return c.isCheckCommand
+}
+
+const serviceInBundlePath = "/Contents/SharedSupport/bin/keybase"
+const kbfsInBundlePath = "/Contents/SharedSupport/bin/kbfs"
+
+type processPaths struct {
+ serviceProcPath string
+ kbfsProcPath string
+ appPath string
+ appProcPath string
+}
+
+func (c context) lookupProcessPaths() (p processPaths, _ error) {
+ appPath := c.config.destinationPath() // "/Applications/Keybase.app"
+ if appPath == "" {
+ return p, fmt.Errorf("No app path")
+ }
+ appBundleName := filepath.Base(appPath) // "Keybase.app"
+
+ p.appPath = appPath
+ p.serviceProcPath = appBundleName + serviceInBundlePath
+ p.kbfsProcPath = appBundleName + kbfsInBundlePath
+ p.appProcPath = appBundleName + "/Contents/MacOS/"
+
+ return p, nil
+}
+
+// stop will quit the app and any services
+func (c context) stop() error {
+ // Stop app
+ appExitResult, appExitErr := command.Exec(c.config.keybasePath(), []string{"ctl", "app-exit"}, 30*time.Second, c.log)
+ c.log.Infof("Stop output: %s", appExitResult.CombinedOutput())
+ if appExitErr != nil {
+ c.log.Warningf("Error requesting app exit: %s", appExitErr)
+ }
+
+ // Stop the redirector so it can be upgraded for all users.
+ _, redirectorErr := command.Exec(c.config.keybasePath(), []string{"uninstall", "--components=redirector"}, time.Minute, c.log)
+ if redirectorErr != nil {
+ c.log.Warningf("Error stopping the redirector: %s", redirectorErr)
+ }
+
+ // Stop services
+ servicesExitResult, servicesExitErr := command.Exec(c.config.keybasePath(), []string{"ctl", "stop", "--include=service,kbfs"}, 30*time.Second, c.log)
+ c.log.Infof("Stop output: %s", servicesExitResult.CombinedOutput())
+ if servicesExitErr != nil {
+ c.log.Warningf("Error stopping services: %s", servicesExitErr)
+ }
+
+ paths, err := c.lookupProcessPaths()
+ if err != nil {
+ return err
+ }
+
+ // SIGKILL the app if it failed to exit (if it exited, then this doesn't do anything)
+ c.log.Infof("Killing (if failed to exit) %s", paths.appProcPath)
+ process.KillAll(process.NewMatcher(paths.appProcPath, process.PathContains, c.log), c.log)
+
+ return nil
+}
+
+// AfterApply is called after an update is applied
+func (c context) AfterApply(update updater.Update) error {
+ if err := c.stop(); err != nil {
+ c.log.Warningf("Error trying to stop: %s", err)
+ }
+
+ if err := c.start(10*time.Second, time.Second); err != nil {
+ c.log.Warningf("Error trying to start the app: %s", err)
+ }
+ return nil
+}
+
+// Start the app.
+// The wait is how log to wait for processes and the app to start before
+// reporting that an error occurred.
+func (c context) start(wait time.Duration, delay time.Duration) error {
+ procPaths, err := c.lookupProcessPaths()
+ if err != nil {
+ return err
+ }
+
+ if err := OpenAppDarwin(procPaths.appPath, c.log); err != nil {
+ c.log.Warningf("Error opening app: %s", err)
+ }
+
+ // Check to make sure processes started
+ c.log.Debugf("Checking processes: %#v", procPaths)
+ serviceProcErr := c.checkProcess(procPaths.serviceProcPath, wait, delay)
+ kbfsProcErr := c.checkProcess(procPaths.kbfsProcPath, wait, delay)
+ appProcErr := c.checkProcess(procPaths.appProcPath, wait, delay)
+
+ return util.CombineErrors(serviceProcErr, kbfsProcErr, appProcErr)
+}
+
+func (c context) checkProcess(match string, wait time.Duration, delay time.Duration) error {
+ matcher := process.NewMatcher(match, process.PathContains, c.log)
+ procs, err := process.FindProcesses(matcher, wait, delay, c.log)
+ if err != nil {
+ return fmt.Errorf("Error checking on process (%s): %s", match, err)
+ }
+ if len(procs) == 0 {
+ return fmt.Errorf("No process found for %s", match)
+ }
+ return nil
+}
+
+// OpenAppDarwin starts an app
+func OpenAppDarwin(appPath string, log process.Log) error {
+ return openAppDarwin("/usr/bin/open", appPath, time.Second, log)
+}
+
+func openAppDarwin(bin string, appPath string, retryDelay time.Duration, log process.Log) error {
+ tryOpen := func() error {
+ env := append(os.Environ(), "KEYBASE_RESTORE_UI=true", "KEYBASE_START_UI=hideWindow", "KEYBASE_OPEN_FROM=updater")
+ result, err := command.ExecWithEnv(bin, []string{appPath}, env, time.Minute, log)
+ if err != nil {
+ return fmt.Errorf("Open error: %s; %s", err, result.CombinedOutput())
+ }
+ return nil
+ }
+ // We need to try 10 times because Gatekeeper has some issues, for example,
+ // http://www.openradar.me/23614087
+ var err error
+ for i := 0; i < 10; i++ {
+ err = tryOpen()
+ if err == nil {
+ break
+ }
+ log.Errorf("Open error (trying again in %s): %s", retryDelay, err)
+ time.Sleep(retryDelay)
+ }
+ return err
+}
+
+func (c context) check(sourcePath string, destinationPath string) error {
+ // Check to make sure the update source path is a real directory
+ ok, err := util.IsDirReal(sourcePath)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return fmt.Errorf("Source path isn't a directory")
+ }
+ return nil
+}
+
+func (c context) apply(localPath string, destinationPath string, tmpDir string) error {
+ // The file name we unzip over should match the (base) file in the destination path
+ filename := filepath.Base(destinationPath)
+ return util.UnzipOver(localPath, filename, destinationPath, c.check, tmpDir, c.log)
+}
+
+func (c context) Apply(update updater.Update, options updater.UpdateOptions, tmpDir string) error {
+ if update.Asset == nil {
+ return fmt.Errorf("No asset")
+ }
+ localPath := update.Asset.LocalPath
+ destinationPath := options.DestinationPath
+
+ err := c.apply(localPath, destinationPath, tmpDir)
+ switch err := err.(type) {
+ case nil:
+ case *os.LinkError:
+ if err.Op == "rename" && err.Old == "/Applications/Keybase.app" {
+ c.log.Infof("The error was a problem renaming (moving) the app, let's trying installing the app via keybase install --components=app which has more privileges")
+
+ // Unzip and get source path
+ unzipPath, err := util.UnzipPath(localPath, c.log)
+ defer util.RemoveFileAtPath(unzipPath)
+ if err != nil {
+ return err
+ }
+ sourcePath := filepath.Join(unzipPath, filepath.Base(destinationPath))
+
+ _, installErr := command.Exec(c.config.keybasePath(), []string{"install", "--components=app", fmt.Sprintf("--source-path=%s", sourcePath)}, time.Minute, c.log)
+ if installErr != nil {
+ c.log.Errorf("Error trying to install the app (privileged): %s", installErr)
+ return installErr
+ }
+ } else {
+ return err
+ }
+ default:
+ return err
+ }
+
+ // Update spotlight
+ c.log.Debugf("Updating spotlight: %s", destinationPath)
+ spotlightResult, spotLightErr := command.Exec("/usr/bin/mdimport", []string{destinationPath}, 20*time.Second, c.log)
+ if spotLightErr != nil {
+ c.log.Warningf("Error trying to update spotlight: %s, (%s)", spotLightErr, spotlightResult.CombinedOutput())
+ }
+
+ // Remove quantantine (if any)
+ c.log.Debugf("Remove quarantine: %s", destinationPath)
+ quarantineResult, quarantineErr := command.Exec("/usr/bin/xattr", []string{"-d", "com.apple.quarantine", destinationPath}, 20*time.Second, c.log)
+ if quarantineErr != nil {
+ c.log.Warningf("Error trying to remove quarantine: %s, (%s)", quarantineErr, quarantineResult.CombinedOutput())
+ }
+
+ return nil
+}
+
+// DeepClean is called when a faulty upgrade has been detected
+func (c context) DeepClean() {}
diff --git a/go/updater/keybase/platform_darwin_test.go b/go/updater/keybase/platform_darwin_test.go
new file mode 100644
index 000000000000..e08d246efae0
--- /dev/null
+++ b/go/updater/keybase/platform_darwin_test.go
@@ -0,0 +1,135 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+//go:build darwin
+// +build darwin
+
+package keybase
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/process"
+ "github.com/keybase/client/go/updater/util"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAppBundleForPath(t *testing.T) {
+ assert.Equal(t, "", appBundleForPath(""))
+ assert.Equal(t, "", appBundleForPath("foo"))
+ assert.Equal(t, "/Applications/Keybase.app", appBundleForPath("/Applications/Keybase.app"))
+ assert.Equal(t, "/Applications/Keybase.app", appBundleForPath("/Applications/Keybase.app/Contents/SharedSupport/bin/keybase"))
+ assert.Equal(t, "/Applications/Keybase.app", appBundleForPath("/Applications/Keybase.app/Contents/Resources/Foo.app/Contents/MacOS/Foo"))
+ assert.Equal(t, "", appBundleForPath("/Applications/Keybase.ap"))
+ assert.Equal(t, "/Applications/Keybase.app", appBundleForPath("/Applications/Keybase.app/"))
+}
+
+type testConfigDarwin struct {
+ testConfigPlatform
+}
+
+func (c testConfigDarwin) destinationPath() string {
+ _, filename, _, _ := runtime.Caller(0)
+ return filepath.Join(filepath.Dir(filename), "../test/Test.app")
+}
+
+func TestUpdatePrompt(t *testing.T) {
+ config := &testConfigPlatform{
+ Args: []string{"echo", `{
+ "action": "apply",
+ "autoUpdate": true
+ }`},
+ }
+ ctx := newContext(config, testLog)
+ resp, err := ctx.UpdatePrompt(testUpdate, testOptions, updater.UpdatePromptOptions{})
+ assert.NoError(t, err)
+ assert.NotNil(t, resp)
+}
+
+func TestOpenDarwin(t *testing.T) {
+ _, filename, _, _ := runtime.Caller(0)
+ appPath := filepath.Join(filepath.Dir(filename), "../test/Test.app")
+ matcher := process.NewMatcher(appPath, process.PathPrefix, testLog)
+ defer process.TerminateAll(matcher, 200*time.Millisecond, testLog)
+ err := OpenAppDarwin(appPath, testLog)
+ assert.NoError(t, err)
+}
+
+func TestOpenDarwinError(t *testing.T) {
+ _, filename, _, _ := runtime.Caller(0)
+ binErr := filepath.Join(filepath.Dir(filename), "../test/err.sh")
+ appPath := filepath.Join(filepath.Dir(filename), "../test/Test.app")
+ err := openAppDarwin(binErr, appPath, time.Millisecond, testLog)
+ assert.Error(t, err)
+}
+
+func TestFindPIDsLaunchd(t *testing.T) {
+ procPath := "/sbin/launchd"
+ matcher := process.NewMatcher(procPath, process.PathEqual, testLog)
+ pids, err := process.FindPIDsWithMatchFn(matcher.Fn(), testLog)
+ assert.NoError(t, err)
+ t.Logf("Pids: %#v", pids)
+ require.True(t, len(pids) >= 1)
+}
+
+func TestApplyNoAsset(t *testing.T) {
+ ctx := newContext(&testConfigPlatform{}, testLog)
+ tmpDir, err := util.MakeTempDir("TestApplyNoAsset.", 0700)
+ defer util.RemoveFileAtPath(tmpDir)
+ require.NoError(t, err)
+ err = ctx.Apply(testUpdate, testOptions, tmpDir)
+ require.EqualError(t, err, "No asset")
+}
+
+func TestApplyAsset(t *testing.T) {
+ ctx := newContext(&testConfigPlatform{}, testLog)
+ tmpDir, err := util.MakeTempDir("TestApplyAsset.", 0700)
+ defer util.RemoveFileAtPath(tmpDir)
+ require.NoError(t, err)
+
+ _, filename, _, _ := runtime.Caller(0)
+ zipPath := filepath.Join(filepath.Dir(filename), "../test/Test.app.zip")
+ update := updater.Update{
+ Asset: &updater.Asset{
+ LocalPath: zipPath,
+ },
+ }
+
+ options := updater.UpdateOptions{DestinationPath: filepath.Join(os.TempDir(), "Test.app")}
+
+ err = ctx.Apply(update, options, tmpDir)
+ require.NoError(t, err)
+}
+
+func cleanupProc(appPath string) {
+ process.TerminateAll(process.NewMatcher(appPath, process.PathPrefix, testLog), 200*time.Millisecond, testLog)
+}
+
+func TestStop(t *testing.T) {
+ ctx := newContext(&testConfigDarwin{}, testLog)
+ appPath := ctx.config.destinationPath()
+
+ err := OpenAppDarwin(appPath, testLog)
+ defer cleanupProc(appPath)
+ require.NoError(t, err)
+
+ err = ctx.stop()
+ require.NoError(t, err)
+}
+
+func TestStartReportError(t *testing.T) {
+ ctx := newContext(&testConfigDarwin{}, testLog)
+ appPath := ctx.config.destinationPath()
+ defer cleanupProc(appPath)
+
+ err := ctx.start(0, 0)
+ assert.True(t, strings.Contains(err.Error(), "There were multiple errors: No process found for Test.app/Contents/SharedSupport/bin/keybase; No process found for Test.app/Contents/SharedSupport/bin/kbfs"))
+
+}
diff --git a/go/updater/keybase/platform_linux.go b/go/updater/keybase/platform_linux.go
new file mode 100644
index 000000000000..51c80160cb57
--- /dev/null
+++ b/go/updater/keybase/platform_linux.go
@@ -0,0 +1,137 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/command"
+)
+
+func (c config) destinationPath() string {
+ // No destination path for Linux
+ return ""
+}
+
+// Dir returns where to store config and log files
+func Dir(appName string) (string, error) {
+ dir := os.Getenv("XDG_CONFIG_HOME")
+ if dir != "" {
+ return dir, nil
+ }
+ usr, err := user.Current()
+ if err != nil {
+ return "", err
+ }
+ if appName == "" {
+ return "", fmt.Errorf("No app name for dir")
+ }
+ return filepath.Join(usr.HomeDir, ".config", appName), nil
+}
+
+// CacheDir returns where to store temporary files
+func CacheDir(appName string) (string, error) {
+ return LogDir(appName)
+}
+
+// LogDir is where to log
+func LogDir(appName string) (string, error) {
+ dir := os.Getenv("XDG_CACHE_HOME")
+ if dir != "" {
+ return dir, nil
+ }
+ usr, err := user.Current()
+ if err != nil {
+ return "", err
+ }
+ if appName == "" {
+ return "", fmt.Errorf("No app name for dir")
+ }
+ return filepath.Join(usr.HomeDir, ".cache", appName), nil
+}
+
+func (c config) osVersion() string {
+ result, err := command.Exec("uname", []string{"-mrs"}, 5*time.Second, c.log)
+ if err != nil {
+ c.log.Warningf("Error trying to determine OS version: %s (%s)", err, result.CombinedOutput())
+ return ""
+ }
+ return strings.TrimSpace(result.Stdout.String())
+}
+
+func (c config) osArch() string {
+ cmd := exec.Command("uname", "-m")
+ var buf bytes.Buffer
+ cmd.Stdout = &buf
+ err := cmd.Run()
+ if err != nil {
+ c.log.Warningf("Error trying to determine OS arch, falling back to compile time arch: %s (%s)", err, cmd.Stderr)
+ return runtime.GOARCH
+ }
+ return strings.TrimSuffix(buf.String(), "\n")
+}
+
+func (c config) promptProgram() (command.Program, error) {
+ return command.Program{}, fmt.Errorf("Unsupported")
+}
+
+func (c config) notifyProgram() string {
+ return "notify-send"
+}
+
+func (c context) BeforeUpdatePrompt(update updater.Update, options updater.UpdateOptions) error {
+ notifyArgs := []string{
+ "-i", "/usr/share/icons/hicolor/128x128/apps/keybase.png",
+ fmt.Sprintf("New Keybase version: %s", update.Version),
+ "Please update Keybase using your system package manager.",
+ }
+ result, err := command.Exec("notify-send", notifyArgs, 5*time.Second, c.log)
+ if err != nil {
+ c.log.Warningf("Error running notify-send: %s (%s)", err, result.CombinedOutput())
+ }
+ c.ReportAction(updater.UpdatePromptResponse{
+ Action: updater.UpdateActionSnooze,
+ AutoUpdate: false,
+ SnoozeDuration: 0,
+ }, &update, options)
+ return updater.CancelErr(fmt.Errorf("Linux uses system package manager"))
+}
+
+func (c context) UpdatePrompt(update updater.Update, options updater.UpdateOptions, promptOptions updater.UpdatePromptOptions) (*updater.UpdatePromptResponse, error) {
+ // No update prompt for Linux
+ return &updater.UpdatePromptResponse{Action: updater.UpdateActionContinue}, nil
+}
+
+func (c context) PausedPrompt() bool {
+ return false
+}
+
+func (c context) Apply(update updater.Update, options updater.UpdateOptions, tmpDir string) error {
+ return nil
+}
+
+func (c context) AfterApply(update updater.Update) error {
+ return nil
+}
+
+func (c context) GetAppStatePath() string {
+ home, _ := Dir("keybase")
+ return filepath.Join(home, "app-state.json")
+}
+
+func (c context) IsCheckCommand() bool {
+ return c.isCheckCommand
+}
+
+// DeepClean is called when a faulty upgrade has been detected
+func (c context) DeepClean() {}
diff --git a/go/updater/keybase/platform_linux_test.go b/go/updater/keybase/platform_linux_test.go
new file mode 100644
index 000000000000..2628f447c2f9
--- /dev/null
+++ b/go/updater/keybase/platform_linux_test.go
@@ -0,0 +1,44 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+//go:build linux
+// +build linux
+
+package keybase
+
+import (
+ "testing"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/util"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBeforeUpdatePrompt(t *testing.T) {
+ ctx := newContext(&testConfigPlatform{}, testLog)
+ err := ctx.BeforeUpdatePrompt(testUpdate, testOptions)
+ assert.EqualError(t, err, "Update Error (cancel): Linux uses system package manager")
+}
+
+func TestUpdatePrompt(t *testing.T) {
+ ctx := newContext(&testConfigPlatform{}, testLog)
+ resp, err := ctx.UpdatePrompt(testUpdate, testOptions, updater.UpdatePromptOptions{})
+ assert.Equal(t, &updater.UpdatePromptResponse{Action: updater.UpdateActionContinue}, resp)
+ require.NoError(t, err)
+}
+
+func TestPausedPrompt(t *testing.T) {
+ ctx := newContext(&testConfigPlatform{}, testLog)
+ cancel := ctx.PausedPrompt()
+ assert.False(t, cancel)
+}
+
+func TestApplyNoAsset(t *testing.T) {
+ ctx := newContext(&testConfigPlatform{}, testLog)
+ tmpDir, err := util.MakeTempDir("TestApplyNoAsset.", 0700)
+ defer util.RemoveFileAtPath(tmpDir)
+ require.NoError(t, err)
+ err = ctx.Apply(testUpdate, testOptions, tmpDir)
+ require.NoError(t, err)
+}
diff --git a/go/updater/keybase/platform_test.go b/go/updater/keybase/platform_test.go
new file mode 100644
index 000000000000..14b034818a8f
--- /dev/null
+++ b/go/updater/keybase/platform_test.go
@@ -0,0 +1,37 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/keybase/client/go/updater/command"
+)
+
+type testConfigPlatform struct {
+ config
+ ProgramPath string
+ Args []string
+}
+
+func (c testConfigPlatform) promptProgram() (command.Program, error) {
+ programPath, args := c.ProgramPath, c.Args
+ if programPath == "" {
+ programPath = filepath.Join(os.Getenv("GOPATH"), "bin", "test")
+ }
+
+ return command.Program{
+ Path: programPath,
+ Args: args,
+ }, nil
+}
+
+func (c testConfigPlatform) notifyProgram() string {
+ return "echo"
+}
+
+func (c testConfigPlatform) keybasePath() string {
+ return filepath.Join(os.Getenv("GOPATH"), "bin", "test")
+}
diff --git a/go/updater/keybase/platform_windows.go b/go/updater/keybase/platform_windows.go
new file mode 100644
index 000000000000..ed2b5cf5bb15
--- /dev/null
+++ b/go/updater/keybase/platform_windows.go
@@ -0,0 +1,548 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "syscall"
+ "time"
+ "unsafe"
+
+ "github.com/kardianos/osext"
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/command"
+ "github.com/keybase/client/go/updater/process"
+ "github.com/keybase/client/go/updater/util"
+ "github.com/keybase/go-ps"
+ "golang.org/x/sys/windows"
+ "golang.org/x/sys/windows/registry"
+)
+
+type guid struct {
+ Data1 uint32
+ Data2 uint16
+ Data3 uint16
+ Data4 [8]byte
+}
+
+var (
+ // FOLDERID_LocalAppData
+ // F1B32785-6FBA-4FCF-9D55-7B8E7F157091
+ folderIDLocalAppData = guid{0xF1B32785, 0x6FBA, 0x4FCF, [8]byte{0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91}}
+
+ // FOLDERID_RoamingAppData
+ // {3EB685DB-65F9-4CF6-A03A-E3EF65729F3D}
+ folderIDRoamingAppData = guid{0x3EB685DB, 0x65F9, 0x4CF6, [8]byte{0xA0, 0x3A, 0xE3, 0xEF, 0x65, 0x72, 0x9F, 0x3D}}
+)
+
+var (
+ modShell32 = windows.NewLazySystemDLL("Shell32.dll")
+ modOle32 = windows.NewLazySystemDLL("Ole32.dll")
+ procSHGetKnownFolderPath = modShell32.NewProc("SHGetKnownFolderPath")
+ procCoTaskMemFree = modOle32.NewProc("CoTaskMemFree")
+)
+
+func coTaskMemFree(pv uintptr) (err error) {
+ _, _, errno := syscall.Syscall(procCoTaskMemFree.Addr(), 1, uintptr(pv), 0, 0)
+ if errno != 0 {
+ err = errno
+ return err
+ }
+ return nil
+}
+
+func getDataDir(id guid) (folder string, err error) {
+
+ var pszPath uintptr
+ r0, _, _ := procSHGetKnownFolderPath.Call(uintptr(unsafe.Pointer(&id)), uintptr(0), uintptr(0), uintptr(unsafe.Pointer(&pszPath)))
+ if r0 != 0 {
+ return "", errors.New("can't get known folder")
+ }
+ defer func() { err = coTaskMemFree(pszPath) }()
+
+ // go vet: "possible misuse of unsafe.Pointer"
+ folder = syscall.UTF16ToString((*[1 << 16]uint16)(unsafe.Pointer(pszPath))[:])
+ if len(folder) == 0 {
+ return "", errors.New("can't get known folder")
+ }
+
+ return folder, nil
+}
+
+func localDataDir() (string, error) {
+ return getDataDir(folderIDLocalAppData)
+}
+
+func roamingDataDir() (string, error) {
+ return getDataDir(folderIDRoamingAppData)
+}
+
+func (c config) destinationPath() string {
+ pathName, err := osext.Executable()
+ if err != nil {
+ c.log.Warningf("Error trying to determine our executable path: %s", err)
+ return ""
+ }
+ dir, _ := filepath.Split(pathName)
+ return dir
+}
+
+// Dir returns where to store config and log files
+func Dir(appName string) (string, error) {
+ dir, err := localDataDir()
+ if err != nil {
+ return "", err
+ }
+ if dir == "" {
+ return "", fmt.Errorf("No LocalDataDir")
+ }
+ if appName == "" {
+ return "", fmt.Errorf("No app name for dir")
+ }
+ return filepath.Join(dir, appName), nil
+}
+
+// CacheDir returns where to store temporary files
+func CacheDir(appName string) (string, error) {
+ return Dir(appName)
+}
+
+// LogDir is where to log
+func LogDir(appName string) (string, error) {
+ return Dir(appName)
+}
+
+func (c config) osVersion() string {
+ result, err := command.Exec("cmd", []string{"/c", "ver"}, 5*time.Second, c.log)
+ if err != nil {
+ c.log.Warningf("Error trying to determine OS version: %s (%s)", err, result.CombinedOutput())
+ return ""
+ }
+ return strings.TrimSpace(result.Stdout.String())
+}
+
+func (c config) osArch() string {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, `Hardware\Description\System\CentralProcessor\0`, registry.QUERY_VALUE)
+ if err != nil {
+ return err.Error()
+ }
+ defer k.Close()
+
+ s, _, err := k.GetStringValue("Identifier")
+ if err != nil {
+ return err.Error()
+ }
+ words := strings.Fields(s)
+ if len(words) < 1 {
+ return "empty"
+ }
+ return strings.TrimSuffix(words[0], "\n")
+}
+
+func (c config) notifyProgram() string {
+ // No notify program for Windows
+ return runtime.GOARCH
+}
+
+func (c *context) BeforeUpdatePrompt(update updater.Update, options updater.UpdateOptions) error {
+ return nil
+}
+
+func (c config) promptProgram() (command.Program, error) {
+ destinationPath := c.destinationPath()
+ if destinationPath == "" {
+ return command.Program{}, fmt.Errorf("No destination path")
+ }
+
+ return command.Program{
+ Path: filepath.Join(destinationPath, "prompter.exe"),
+ }, nil
+}
+
+func (c context) UpdatePrompt(update updater.Update, options updater.UpdateOptions, promptOptions updater.UpdatePromptOptions) (*updater.UpdatePromptResponse, error) {
+ promptProgram, err := c.config.promptProgram()
+ if err != nil {
+ return nil, err
+ }
+
+ if promptOptions.OutPath == "" {
+ promptOptions.OutPath, err = util.WriteTempFile("updatePrompt", []byte{}, 0700)
+ if err != nil {
+ return nil, err
+ }
+ defer util.RemoveFileAtPath(promptOptions.OutPath)
+ }
+
+ promptJSONInput, err := c.promptInput(update, options, promptOptions)
+ if err != nil {
+ return nil, fmt.Errorf("Error generating input: %s", err)
+ }
+
+ _, err = command.Exec(promptProgram.Path, promptProgram.ArgsWith([]string{string(promptJSONInput)}), time.Hour, c.log)
+ if err != nil {
+ return nil, fmt.Errorf("Error running command: %s", err)
+ }
+
+ result, err := c.updaterPromptResultFromFile(promptOptions.OutPath)
+ if err != nil {
+ return nil, fmt.Errorf("Error reading result: %s", err)
+ }
+ return c.responseForResult(*result)
+}
+
+// updaterPromptResultFromFile gets the result from path decodes it
+func (c context) updaterPromptResultFromFile(path string) (*updaterPromptInputResult, error) {
+ resultRaw, err := util.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var result updaterPromptInputResult
+ if err := json.Unmarshal(resultRaw, &result); err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+func (c context) PausedPrompt() bool {
+ return false
+}
+
+type componentProductFunc func(componentKey registry.Key, productValueName, componentPath string)
+
+type ComponentsChecker struct {
+ context
+ RegAccess uint32
+ RegWow uint32
+ PerComponent componentProductFunc
+}
+
+func (i *ComponentsChecker) deleteProductsFunc(componentKey registry.Key, productValueName, componentPath string) {
+ i.log.Infof("Found Keybase component %s, deleting\n", componentPath)
+ err := componentKey.DeleteValue(productValueName)
+ if err != nil {
+ i.log.Infof("Error DeleteValue %s: %s\n", productValueName, err.Error())
+ }
+}
+
+// checkRegistryComponents returns true if any component has more than one keybase product code
+func (c *ComponentsChecker) checkRegistryComponents() (result bool) {
+ // e.g.
+ // [HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-21-2398092721-582601651-115936829-1001\Components\024E69EF1A837C752BFB37F494D86925]
+ // "D6A082CFDEED2984C8688664C76174BC"="C:\\Users\\chris\\AppData\\Local\\Keybase\\Gui\\resources\\app\\images\\icons\\icon-facebook-visibility.gif"
+ // "50DC76D18793BC24DA7D4D681AE74262"="C:\\Users\\chris\\AppData\\Local\\Keybase\\Gui\\resources\\app\\images\\icons\\icon-facebook-visibility.gif"
+
+ readAccess := registry.ENUMERATE_SUB_KEYS | registry.QUERY_VALUE | c.RegWow
+
+ rootName := "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Installer\\UserData"
+
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, rootName, readAccess)
+ if err != nil {
+ c.log.Infof("Error opening uninstall subkeys: %s\n", err.Error())
+ return
+ }
+ defer k.Close()
+
+ UIDs, err := k.ReadSubKeyNames(-1)
+ if err != nil {
+ c.log.Infof("Error reading subkeys: %s\n", err.Error())
+ return
+ }
+ for _, UID := range UIDs {
+ componentsKey, err := registry.OpenKey(k, UID+"\\Components", readAccess)
+ if err != nil {
+ c.log.Infof("Error opening subkey %s: %s\n", UID+"\\Components", err.Error())
+ continue
+ }
+
+ componentKeyNames, err := componentsKey.ReadSubKeyNames(-1)
+ if err != nil {
+ c.log.Infof("Error reading subkeys: %s\n", err.Error())
+ continue
+ }
+
+ for _, componentKeyName := range componentKeyNames {
+ componentKey, err := registry.OpenKey(componentsKey, componentKeyName, readAccess|c.RegAccess)
+ if err != nil {
+ c.log.Infof("Error opening subkey %s: %s\n", componentKeyName, err.Error())
+ // No need to list all the components we couldn't open in write mode.
+ // This is expected when run without elevated permissions.
+ c.log.Infof("skipping subsequent subkeys of %s\n", UID+"\\Components")
+ continue
+ }
+
+ productValueNames, err := componentKey.ReadValueNames(-1)
+ if err != nil {
+ c.log.Infof("Error reading values: %s\n", err.Error())
+ continue
+ }
+
+ for n, productValueName := range productValueNames {
+ componentPath, _, err := componentKey.GetStringValue(productValueName)
+ if err == nil && strings.Contains(componentPath, "\\AppData\\Local\\Keybase\\") {
+ if c.PerComponent != nil {
+ c.PerComponent(componentKey, productValueName, componentPath)
+ }
+ if n > 0 {
+ result = true
+ c.log.Infof("Found multiple Keybase product codes on %s\n", componentPath)
+ }
+ }
+ }
+ componentKey.Close()
+ }
+ componentsKey.Close()
+ }
+ return result
+}
+
+type KeybaseCommand string
+
+const (
+ KeybaseCommandStart KeybaseCommand = "watchdog"
+ KeybaseCommandStop KeybaseCommand = "stop"
+)
+
+func (c context) runKeybase(cmd KeybaseCommand) {
+ path, err := Dir("Keybase")
+ if err != nil {
+ c.log.Infof("Error getting Keybase directory: %s", err.Error())
+ return
+ }
+
+ args := []string{filepath.Join(path, "keybase.exe"), "ctl", string(cmd)}
+
+ _, err = command.Exec(filepath.Join(path, "keybaserq.exe"), args, time.Minute, c.log)
+ if err != nil {
+ c.log.Infof("Error %s'ing keybase", cmd, err.Error())
+ }
+}
+
+func (c context) deleteProductFiles() {
+ path, err := Dir("Keybase")
+ if err != nil {
+ c.log.Infof("Error getting Keybase directory: %s", err.Error())
+ return
+ }
+ err = c.stopKeybaseProcesses()
+ if err != nil {
+ c.log.Infof("Error stopping keybase processes: %s", err.Error())
+ return
+ }
+
+ err = os.RemoveAll(filepath.Join(path, "Gui"))
+ if err != nil {
+ c.log.Infof("Error removing Gui directory: %s", err.Error())
+ }
+
+ files, err := filepath.Glob(filepath.Join(path, "*.exe"))
+ if err != nil {
+ c.log.Infof("Error getting exe files: %s", err.Error())
+ } else {
+ for _, f := range files {
+ c.log.Infof("Removing %s", f)
+ if err = os.Remove(f); err != nil {
+ c.log.Infof("Error removing file: %s", err.Error())
+ }
+ }
+ }
+}
+
+// DeepClean is only invoked from the command line, for now.
+// Eventually we may need to do full uninstalls but that is kind of risky
+func (c context) DeepClean() {
+ i := &ComponentsChecker{context: c, RegAccess: registry.SET_VALUE}
+ i.PerComponent = i.deleteProductsFunc
+ i.checkRegistryComponents()
+ i.RegWow = registry.WOW64_32KEY
+ i.checkRegistryComponents()
+ c.deleteProductFiles()
+}
+
+func (c context) Apply(update updater.Update, options updater.UpdateOptions, tmpDir string) error {
+ skipSilent := false
+ if update.Asset == nil || update.Asset.LocalPath == "" {
+ return fmt.Errorf("No asset")
+ }
+ err := c.stopKeybaseProcesses()
+ if err != nil {
+ return err
+ }
+ if c.config.GetLastAppliedVersion() == update.Version {
+ c.log.Info("Previously applied version detected")
+ err = c.config.SetLastAppliedVersion("")
+ if err != nil {
+ return err
+ }
+ skipSilent = true
+ }
+
+ runCommand := update.Asset.LocalPath
+ args := []string{}
+ if strings.HasSuffix(runCommand, "msi") || strings.HasSuffix(runCommand, "MSI") {
+ args = append([]string{
+ "/i",
+ runCommand,
+ "/log",
+ filepath.Join(
+ os.TempDir(),
+ fmt.Sprintf("KeybaseMsi_%d%02d%02d%02d%02d%02d.log",
+ time.Now().Year(),
+ time.Now().Month(),
+ time.Now().Day(),
+ time.Now().Hour(),
+ time.Now().Minute(),
+ time.Now().Second(),
+ ),
+ ),
+ }, args...)
+ runCommand = "msiexec.exe"
+ }
+ auto, _ := c.config.GetUpdateAuto()
+ if auto && !c.config.GetUpdateAutoOverride() && !skipSilent {
+ args = append(args, "/quiet", "/norestart")
+ }
+ err = c.config.SetLastAppliedVersion(update.Version)
+ if err != nil {
+ return err
+ }
+ _, err = command.Exec(runCommand, args, time.Hour, c.log)
+ return err
+}
+
+// Note that when a Windows installer runs, it kills the running updater, even
+// before AfterApply() runs
+func (c context) AfterApply(update updater.Update) error {
+ return nil
+}
+
+// app-state.json is written in the roaming settings directory, which
+// seems to be where Electron chooses
+func (c context) GetAppStatePath() string {
+ roamingDir, _ := roamingDataDir()
+ return filepath.Join(roamingDir, "Keybase", "app-state.json")
+}
+
+func (c context) IsCheckCommand() bool {
+ return c.isCheckCommand
+}
+
+// findWatchdogPid looks up all of the running keybase processes and finds the
+// one that's a parent of another. This one is the watchdog.
+func (c context) findWatchdogPid() (watchdogPid int, found bool, err error) {
+ c.log.Infof("findWatchdogPid")
+ path, err := Dir("Keybase")
+ if err != nil {
+ return 0, false, err
+ }
+ // find all of the keybase processes
+ keybaseBinPath := filepath.Join(path, "keybase.exe")
+ matcher := process.NewMatcher(keybaseBinPath, process.PathEqual, c.log)
+ kbProcesses, err := process.FindProcesses(matcher, time.Second, 200*time.Millisecond, c.log)
+ if err != nil {
+ return 0, false, err
+ }
+ // build a map of pid -> process
+ pidLookup := make(map[int]ps.Process, len(kbProcesses))
+ for _, proc := range kbProcesses {
+ pidLookup[proc.Pid()] = proc
+ }
+ // find the process whose parent process (ppid) is the pid of one of the other processes
+ myPid := os.Getpid()
+ var parentProcessPids []int
+ for _, proc := range pidLookup {
+ parentPid := proc.PPid()
+ if parentPid == myPid {
+ // under no circumstances should we accidentally terminate this process
+ c.log.Warningf("findWatchdogPid: this process appears to have children keybase processes, which is unexpected")
+ continue
+ }
+ if _, parentIsAlsoInList := pidLookup[parentPid]; parentIsAlsoInList {
+ parentProcessPids = append(parentProcessPids, parentPid)
+ }
+ }
+ if len(parentProcessPids) == 0 {
+ c.log.Infof("findWatchdogPid: no keybase processes have children")
+ return 0, false, nil
+ }
+ if len(parentProcessPids) > 1 {
+ c.log.Errorf("findWatchdogPid: found %d candidate processes for the watchdog, but there should only be 1", len(parentProcessPids))
+ return 0, false, nil
+ }
+ c.log.Infof("findWatchdogPid: %d", parentProcessPids[0])
+ return parentProcessPids[0], true, nil
+}
+
+// stopTheWatchdog looks for a keybase process which is the parent of the
+// running keybase service. if such a process is running, it might kill this update
+// when it terminates after the service, so stop the watchdog first.
+func (c context) stopTheWatchdog() error {
+ c.log.Infof("stopTheWatchdog: looking for the watchdog process to stop it")
+
+ watchdogPid, found, err := c.findWatchdogPid()
+ if err != nil {
+ c.log.Errorf("error finding watchdog pid: %v", err.Error())
+ return err
+ }
+ if !found {
+ c.log.Infof("keybase appears to be running without the watchdog, if update fails, please try again")
+ return nil
+ }
+ c.log.Infof("found the watchdog process at pid %d, terminating it...", watchdogPid)
+ err = process.TerminatePID(watchdogPid, 0*time.Second /*unused on windows*/, c.log)
+ if err != nil {
+ c.log.Errorf("error terminating the watchdog: %f", err.Error())
+ return err
+ }
+ time.Sleep(5 * time.Second)
+ return nil
+}
+
+// copied from watchdog
+func (c context) stopKeybaseProcesses() error {
+ path, err := Dir("Keybase")
+ if err != nil {
+ c.log.Infof("Error getting Keybase directory: %s", err.Error())
+ return err
+ }
+
+ c.log.Infof("attempting to stop the watchdog")
+ err = c.stopTheWatchdog()
+ if err != nil {
+ c.log.Infof("Error stopping the watchdog: %s", err.Error())
+ return err
+ }
+ c.log.Infof("watchdog is down, time to take down everything but the updater")
+ c.runKeybase(KeybaseCommandStop)
+ time.Sleep(time.Second)
+
+ // Terminate any executing processes
+ ospid := os.Getpid()
+
+ exes, err := filepath.Glob(filepath.Join(path, "*.exe"))
+ if err != nil {
+ c.log.Errorf("Unable to glob exe files: %s", err)
+ }
+ guiExes, err := filepath.Glob(filepath.Join(path, "Gui", "*.exe"))
+ if err != nil {
+ c.log.Errorf("Unable to glob exe files: %s", err)
+ } else {
+ exes = append(exes, guiExes...)
+ }
+
+ c.log.Infof("Terminating any existing programs we will be updating %+v", exes)
+ for _, program := range exes {
+ matcher := process.NewMatcher(program, process.PathEqual, c.log)
+ matcher.ExceptPID(ospid)
+ c.log.Infof("Terminating %s", program)
+ process.TerminateAll(matcher, time.Second, c.log)
+ }
+ return nil
+}
diff --git a/go/updater/keybase/platform_windows_test.go b/go/updater/keybase/platform_windows_test.go
new file mode 100644
index 000000000000..365e0b5abfe1
--- /dev/null
+++ b/go/updater/keybase/platform_windows_test.go
@@ -0,0 +1,66 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+//go:build windows
+// +build windows
+
+package keybase
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/util"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUpdatePrompt(t *testing.T) {
+ outPath := util.TempPath("", "TestUpdatePrompt.")
+ defer util.RemoveFileAtPath(outPath)
+ promptOptions := updater.UpdatePromptOptions{OutPath: outPath}
+ out := `{"action":"apply","autoUpdate":true}` + "\n"
+
+ programPath := filepath.Join(os.Getenv("GOPATH"), "bin", "test.exe")
+ args := []string{
+ fmt.Sprintf("-out=%s", out),
+ fmt.Sprintf("-outPath=%s", outPath),
+ "writeToFile"}
+ ctx := newContext(&testConfigPlatform{ProgramPath: programPath, Args: args}, testLog)
+ resp, err := ctx.UpdatePrompt(testUpdate, testOptions, promptOptions)
+ require.NoError(t, err)
+ assert.Equal(t, &updater.UpdatePromptResponse{Action: updater.UpdateActionApply, AutoUpdate: true}, resp)
+}
+
+func TestApplyNoAsset(t *testing.T) {
+ ctx := newContext(&testConfigPlatform{}, testLog)
+ tmpDir, err := util.MakeTempDir("TestApplyNoAsset.", 0700)
+ defer util.RemoveFileAtPath(tmpDir)
+ require.NoError(t, err)
+ err = ctx.Apply(testUpdate, testOptions, tmpDir)
+ require.EqualError(t, err, "No asset")
+}
+
+func TestApplyAsset(t *testing.T) {
+ ctx := newContext(&testConfigPlatform{}, testLog)
+ tmpDir, err := util.MakeTempDir("TestApplyAsset.", 0700)
+ defer util.RemoveFileAtPath(tmpDir)
+ require.NoError(t, err)
+
+ exePath := filepath.Join(os.Getenv("GOPATH"), "bin", "test.exe")
+ localPath := filepath.Join(tmpDir, "test.exe")
+ err = util.CopyFile(exePath, localPath, testLog)
+ require.NoError(t, err)
+
+ update := updater.Update{
+ Asset: &updater.Asset{
+ LocalPath: exePath,
+ },
+ }
+
+ err = ctx.Apply(update, updater.UpdateOptions{}, tmpDir)
+ require.NoError(t, err)
+}
diff --git a/go/updater/keybase/prompt.go b/go/updater/keybase/prompt.go
new file mode 100644
index 000000000000..e23c8d583779
--- /dev/null
+++ b/go/updater/keybase/prompt.go
@@ -0,0 +1,124 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/command"
+ "github.com/keybase/client/go/updater/util"
+)
+
+type updaterPromptInput struct {
+ Title string `json:"title"`
+ Message string `json:"message"`
+ Description string `json:"description"`
+ AutoUpdate bool `json:"autoUpdate"`
+ OutPath string `json:"outPath"` // Used for windows instead of stdout
+}
+
+type updaterPromptInputResult struct {
+ Action string `json:"action"`
+ AutoUpdate bool `json:"autoUpdate"`
+ SnoozeDuration int `json:"snooze_duration"`
+}
+
+func (c context) promptInput(update updater.Update, options updater.UpdateOptions, promptOptions updater.UpdatePromptOptions) (string, error) {
+ description := update.Description
+ if description == "" {
+ description = "Please visit https://keybase.io for more information."
+ }
+ promptJSONInput, err := json.Marshal(updaterPromptInput{
+ // Note we use util.Semver to shorten to the Major.Minor.Patch format
+ // because of spacing restrictions of 700 characters on macOS and
+ // better readability.
+ Title: fmt.Sprintf("Keybase Update: Version %s", util.Semver(update.Version)),
+ Message: fmt.Sprintf("The version you are currently running (%s) is outdated.", util.Semver(options.Version)),
+ Description: description,
+ AutoUpdate: promptOptions.AutoUpdate,
+ OutPath: promptOptions.OutPath,
+ })
+ return string(promptJSONInput), err
+}
+
+func (c context) updatePrompt(promptProgram command.Program, update updater.Update, options updater.UpdateOptions, promptOptions updater.UpdatePromptOptions, timeout time.Duration) (*updater.UpdatePromptResponse, error) {
+
+ promptJSONInput, err := c.promptInput(update, options, promptOptions)
+ if err != nil {
+ return nil, fmt.Errorf("Error generating input: %s", err)
+ }
+
+ var result updaterPromptInputResult
+ if err := command.ExecForJSON(promptProgram.Path, promptProgram.ArgsWith([]string{promptJSONInput}), &result, timeout, c.log); err != nil {
+ return nil, fmt.Errorf("Error running command: %s", err)
+ }
+ return c.responseForResult(result)
+}
+
+func (c context) responseForResult(result updaterPromptInputResult) (*updater.UpdatePromptResponse, error) {
+ autoUpdate := false
+
+ var updateAction updater.UpdateAction
+ switch result.Action {
+ case "apply":
+ updateAction = updater.UpdateActionApply
+ autoUpdate = result.AutoUpdate
+ case "snooze":
+ updateAction = updater.UpdateActionSnooze
+ default:
+ updateAction = updater.UpdateActionCancel
+ }
+
+ return &updater.UpdatePromptResponse{
+ Action: updateAction,
+ AutoUpdate: autoUpdate,
+ SnoozeDuration: result.SnoozeDuration,
+ }, nil
+}
+
+type promptInput struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Message string `json:"message"`
+ Buttons []string `json:"buttons"`
+}
+
+type promptInputResult struct {
+ Button string `json:"button"`
+}
+
+// pausedPrompt returns whether to cancel update and/or error.
+// If the user explicit wants to cancel the update, this may be different from
+// an error occurring, in which case
+func (c context) pausedPrompt(promptProgram command.Program, timeout time.Duration) (bool, error) {
+ const btnForce = "Force update"
+ const btnCancel = "Try again later"
+ promptJSONInput, err := json.Marshal(promptInput{
+ Type: "generic",
+ Title: "Update Paused",
+ Message: "You have files, folders or a terminal open in Keybase.\n\nYou can force the update. That would be like yanking a USB drive and plugging it right back in. It'll instantly give you the latest version of Keybase, but you'll need to reopen any files you're working with. If you're working in the terminal, you'll need to cd out of /keybase and back in.",
+ Buttons: []string{btnForce, btnCancel},
+ })
+ if err != nil {
+ return false, fmt.Errorf("Error generating input: %s", err)
+ }
+
+ var result promptInputResult
+ if err := command.ExecForJSON(promptProgram.Path, promptProgram.ArgsWith([]string{string(promptJSONInput)}), &result, timeout, c.log); err != nil {
+ return false, fmt.Errorf("Error running command: %s", err)
+ }
+
+ switch result.Button {
+ case btnForce:
+ return false, nil
+ case btnCancel:
+ // Cancel update
+ return true, nil
+ default:
+ return false, fmt.Errorf("Unexpected button result: %s", result.Button)
+ }
+}
diff --git a/go/updater/keybase/prompt_test.go b/go/updater/keybase/prompt_test.go
new file mode 100644
index 000000000000..30fc335bf9ab
--- /dev/null
+++ b/go/updater/keybase/prompt_test.go
@@ -0,0 +1,150 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/command"
+ "github.com/stretchr/testify/assert"
+)
+
+func testPromptWithProgram(t *testing.T, promptProgram command.Program, timeout time.Duration) (*updater.UpdatePromptResponse, error) {
+ cfg, _ := testConfig(t)
+ ctx := newContext(cfg, testLog)
+ assert.NotNil(t, ctx)
+
+ update := updater.Update{
+ Version: "1.2.3-400+sha",
+ Name: "Test",
+ Description: "Bug fixes",
+ }
+
+ updaterOptions := cfg.updaterOptions()
+
+ promptOptions := updater.UpdatePromptOptions{AutoUpdate: false}
+ return ctx.updatePrompt(promptProgram, update, updaterOptions, promptOptions, timeout)
+}
+
+func TestPromptTimeout(t *testing.T) {
+ promptProgram := command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"sleep"},
+ }
+ resp, err := testPromptWithProgram(t, promptProgram, 10*time.Millisecond)
+ assert.Error(t, err)
+ assert.Nil(t, resp)
+}
+
+func TestPromptInvalidResponse(t *testing.T) {
+ promptProgram := command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"echo", `{invalid}`},
+ }
+ resp, err := testPromptWithProgram(t, promptProgram, time.Second)
+ assert.Error(t, err)
+ assert.Nil(t, resp)
+}
+
+func TestPromptApply(t *testing.T) {
+ promptProgram := command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"echo", `{
+ "action": "apply",
+ "autoUpdate": true
+ }`},
+ }
+ resp, err := testPromptWithProgram(t, promptProgram, time.Second)
+ assert.NoError(t, err)
+ if assert.NotNil(t, resp) {
+ assert.True(t, resp.AutoUpdate)
+ assert.Equal(t, updater.UpdateActionApply, resp.Action)
+ }
+}
+
+func TestPromptSnooze(t *testing.T) {
+ promptProgram := command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"echo", `{
+ "action": "snooze",
+ "autoUpdate": true
+ }`},
+ }
+ resp, err := testPromptWithProgram(t, promptProgram, time.Second)
+ assert.NoError(t, err)
+ if assert.NotNil(t, resp) {
+ assert.False(t, resp.AutoUpdate)
+ assert.Equal(t, updater.UpdateActionSnooze, resp.Action)
+ }
+}
+
+func TestPromptCancel(t *testing.T) {
+ promptProgram := command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"echo", `{
+ "action": "cancel",
+ "autoUpdate": true
+ }`},
+ }
+ resp, err := testPromptWithProgram(t, promptProgram, time.Second)
+ assert.NoError(t, err)
+ if assert.NotNil(t, resp) {
+ assert.False(t, resp.AutoUpdate)
+ assert.Equal(t, updater.UpdateActionCancel, resp.Action)
+ }
+}
+
+func TestPromptNoOutput(t *testing.T) {
+ promptProgram := command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"echo"},
+ }
+ resp, err := testPromptWithProgram(t, promptProgram, time.Second)
+ assert.NoError(t, err)
+ if assert.NotNil(t, resp) {
+ assert.False(t, resp.AutoUpdate)
+ assert.Equal(t, updater.UpdateActionCancel, resp.Action)
+ }
+}
+
+func TestPromptError(t *testing.T) {
+ promptProgram := command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"err"},
+ }
+ cancel, err := testPausedPromptWithProgram(t, promptProgram, time.Second)
+ assert.Error(t, err)
+ assert.False(t, cancel)
+}
+
+func testPausedPromptWithProgram(t *testing.T, promptProgram command.Program, timeout time.Duration) (bool, error) {
+ cfg, _ := testConfig(t)
+ ctx := newContext(cfg, testLog)
+ assert.NotNil(t, ctx)
+ return ctx.pausedPrompt(promptProgram, timeout)
+}
+
+func TestPausedPromptForce(t *testing.T) {
+ promptProgram := command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"echo", `{"button": "Force update"}`},
+ }
+ cancel, err := testPausedPromptWithProgram(t, promptProgram, time.Second)
+ assert.NoError(t, err)
+ assert.False(t, cancel)
+}
+
+func TestPausedPromptCancel(t *testing.T) {
+ promptProgram := command.Program{
+ Path: filepath.Join(os.Getenv("GOPATH"), "bin", "test"),
+ Args: []string{"echo", `{"button": "Try again later"}`},
+ }
+ cancel, err := testPausedPromptWithProgram(t, promptProgram, time.Second)
+ assert.NoError(t, err)
+ assert.True(t, cancel)
+}
diff --git a/go/updater/keybase/report.go b/go/updater/keybase/report.go
new file mode 100644
index 000000000000..ac078aa45f25
--- /dev/null
+++ b/go/updater/keybase/report.go
@@ -0,0 +1,95 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/util"
+)
+
+// ReportError notifies the API server of a client updater error
+func (c context) ReportError(err error, update *updater.Update, options updater.UpdateOptions) {
+ if reportErr := c.reportError(err, update, options, defaultEndpoints.err, time.Minute); reportErr != nil {
+ c.log.Warningf("Error notifying about an error: %s", reportErr)
+ }
+}
+
+func (c context) reportError(err error, update *updater.Update, options updater.UpdateOptions, uri string, timeout time.Duration) error {
+ var errorType string
+ switch uerr := err.(type) {
+ case updater.Error:
+ errorType = uerr.TypeString()
+ default:
+ errorType = string(updater.UnknownError)
+ }
+
+ data := url.Values{}
+ data.Add("error_type", errorType)
+ data.Add("description", err.Error())
+ return c.report(data, update, options, uri, timeout)
+}
+
+// ReportAction notifies the API server of a client updater action
+func (c context) ReportAction(actionResponse updater.UpdatePromptResponse, update *updater.Update, options updater.UpdateOptions) {
+ if err := c.reportAction(actionResponse, update, options, defaultEndpoints.action, time.Minute); err != nil {
+ c.log.Warningf("Error notifying about an action (%s): %s", actionResponse.Action, err)
+ }
+}
+
+func (c context) reportAction(actionResponse updater.UpdatePromptResponse, update *updater.Update, options updater.UpdateOptions, uri string, timeout time.Duration) error {
+ data := url.Values{}
+ data.Add("action", actionResponse.Action.String())
+ autoUpdate, _ := c.config.GetUpdateAuto()
+ data.Add("auto_update", util.URLValueForBool(autoUpdate))
+ if actionResponse.SnoozeDuration > 0 {
+ data.Add("snooze_duration", fmt.Sprintf("%d", actionResponse.SnoozeDuration))
+ }
+ return c.report(data, update, options, uri, timeout)
+}
+
+func (c context) ReportSuccess(update *updater.Update, options updater.UpdateOptions) {
+ if err := c.reportSuccess(update, options, defaultEndpoints.success, time.Minute); err != nil {
+ c.log.Warningf("Error notifying about success: %s", err)
+ }
+}
+
+func (c context) reportSuccess(update *updater.Update, options updater.UpdateOptions, uri string, timeout time.Duration) error {
+ data := url.Values{}
+ return c.report(data, update, options, uri, timeout)
+}
+
+func (c context) report(data url.Values, update *updater.Update, options updater.UpdateOptions, uri string, timeout time.Duration) error {
+ if update != nil {
+ data.Add("install_id", update.InstallID)
+ data.Add("request_id", update.RequestID)
+ }
+ data.Add("version", options.Version)
+ data.Add("upd_version", options.UpdaterVersion)
+
+ req, err := http.NewRequest("POST", uri, bytes.NewBufferString(data.Encode()))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
+ client, err := httpClient(timeout)
+ if err != nil {
+ return err
+ }
+ c.log.Infof("Reporting: %s %v", uri, data)
+ resp, err := client.Do(req)
+ defer util.DiscardAndCloseBodyIgnoreError(resp)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("Notify error returned bad HTTP status %v", resp.Status)
+ }
+ return nil
+}
diff --git a/go/updater/keybase/report_test.go b/go/updater/keybase/report_test.go
new file mode 100644
index 000000000000..1a8aa1362cc9
--- /dev/null
+++ b/go/updater/keybase/report_test.go
@@ -0,0 +1,105 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const testReportTimeout = time.Second
+
+var testUpdate = updater.Update{
+ InstallID: "deadbeef",
+ RequestID: "cafedead",
+ Version: "1.2.2+fedcba",
+}
+
+var testOptions = updater.UpdateOptions{
+ Version: "1.2.3-400+abcdef",
+}
+
+func TestReportError(t *testing.T) {
+ server := newServer("{}")
+ defer server.Close()
+
+ updateErr := updater.NewError(updater.PromptError, fmt.Errorf("Test error"))
+ ctx := testContext(t)
+ err := ctx.reportError(updateErr, &testUpdate, testOptions, server.URL, testReportTimeout)
+ assert.NoError(t, err)
+}
+
+func TestReportErrorEmpty(t *testing.T) {
+ server := newServer("{}")
+ defer server.Close()
+
+ updateErr := updater.NewError(updater.UnknownError, nil)
+ emptyOptions := updater.UpdateOptions{}
+ ctx := testContext(t)
+ err := ctx.reportError(updateErr, nil, emptyOptions, server.URL, testReportTimeout)
+ assert.NoError(t, err)
+}
+
+func TestReportBadResponse(t *testing.T) {
+ server := newServerForError(fmt.Errorf("Bad response"))
+ defer server.Close()
+
+ ctx := testContext(t)
+ err := ctx.report(url.Values{}, &testUpdate, testOptions, server.URL, testReportTimeout)
+ assert.EqualError(t, err, "Notify error returned bad HTTP status 500 Internal Server Error")
+}
+
+func TestReportTimeout(t *testing.T) {
+ server := newServerWithDelay(updateJSONResponse, 100*time.Millisecond)
+ defer server.Close()
+
+ ctx := testContext(t)
+ err := ctx.report(url.Values{}, &testUpdate, testOptions, server.URL, 2*time.Millisecond)
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), "context deadline exceeded"), err.Error())
+}
+
+func TestReportActionApply(t *testing.T) {
+ server := newServer("{}")
+ defer server.Close()
+
+ ctx := testContext(t)
+ actionResponse := updater.UpdatePromptResponse{
+ Action: updater.UpdateActionApply,
+ AutoUpdate: false,
+ SnoozeDuration: 0,
+ }
+ err := ctx.reportAction(actionResponse, &testUpdate, testOptions, server.URL, testReportTimeout)
+ assert.NoError(t, err)
+}
+
+func TestReportActionEmpty(t *testing.T) {
+ server := newServer("{}")
+ defer server.Close()
+
+ ctx := testContext(t)
+ actionResponse := updater.UpdatePromptResponse{
+ Action: "",
+ AutoUpdate: false,
+ SnoozeDuration: 0,
+ }
+ err := ctx.reportAction(actionResponse, &testUpdate, testOptions, server.URL, testReportTimeout)
+ assert.NoError(t, err)
+}
+
+func TestReportSuccess(t *testing.T) {
+ server := newServer("{}")
+ defer server.Close()
+
+ ctx := testContext(t)
+ err := ctx.reportSuccess(&testUpdate, testOptions, server.URL, testReportTimeout)
+ assert.NoError(t, err)
+}
diff --git a/go/updater/keybase/source.go b/go/updater/keybase/source.go
new file mode 100644
index 000000000000..7a8ccd6fe499
--- /dev/null
+++ b/go/updater/keybase/source.go
@@ -0,0 +1,108 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/util"
+)
+
+// UpdateSource finds releases/updates on keybase.io
+type UpdateSource struct {
+ cfg *config
+ log Log
+ endpoint string
+}
+
+// NewUpdateSource contructs an update source for keybase.io
+func NewUpdateSource(cfg *config, log Log) UpdateSource {
+ return newUpdateSource(cfg, defaultEndpoints.update, log)
+}
+
+func newUpdateSource(cfg *config, endpoint string, log Log) UpdateSource {
+ return UpdateSource{
+ cfg: cfg,
+ endpoint: endpoint,
+ log: log,
+ }
+}
+
+// Description returns description for update source
+func (k UpdateSource) Description() string {
+ return "Keybase.io"
+}
+
+// FindUpdate returns update for updater and options
+func (k UpdateSource) FindUpdate(options updater.UpdateOptions) (*updater.Update, error) {
+ return k.findUpdate(options, time.Minute)
+}
+
+func (k UpdateSource) findUpdate(options updater.UpdateOptions, timeout time.Duration) (*updater.Update, error) {
+ if options.URL != "" {
+ return nil, fmt.Errorf("Custom URLs not supported for this update source")
+ }
+
+ u, err := url.Parse(k.endpoint)
+ if err != nil {
+ return nil, err
+ }
+
+ urlValues := url.Values{}
+ urlValues.Add("install_id", k.cfg.GetInstallID())
+ urlValues.Add("version", options.Version)
+ urlValues.Add("platform", options.Platform)
+ urlValues.Add("run_mode", options.Env)
+ urlValues.Add("os_version", options.OSVersion)
+ urlValues.Add("upd_version", options.UpdaterVersion)
+ urlValues.Add("arch", options.Arch)
+ urlValues.Add("ignore_snooze", util.URLValueForBool(options.IgnoreSnooze))
+
+ force := util.EnvBool("KEYBASE_UPDATER_FORCE", false)
+ if force {
+ k.log.Info("KEYBASE_UPDATER_FORCE is true, will force update")
+ urlValues.Add("force", util.URLValueForBool(force))
+ }
+
+ autoUpdate, _ := k.cfg.GetUpdateAuto()
+ urlValues.Add("auto_update", util.URLValueForBool(autoUpdate))
+
+ u.RawQuery = urlValues.Encode()
+ urlString := u.String()
+
+ req, err := http.NewRequest("GET", urlString, nil)
+ if err != nil {
+ return nil, err
+ }
+ client, err := httpClient(timeout)
+ if err != nil {
+ return nil, err
+ }
+ k.log.Infof("Request %#v", urlString)
+ resp, err := client.Do(req)
+ defer util.DiscardAndCloseBodyIgnoreError(resp)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("Find update returned bad HTTP status %v", resp.Status)
+ }
+
+ var reader io.Reader = resp.Body
+ var update updater.Update
+ if err = json.NewDecoder(reader).Decode(&update); err != nil {
+ return nil, fmt.Errorf("Invalid API response %s", err)
+ }
+
+ k.log.Debugf("Received update response: %#v", update)
+
+ return &update, nil
+}
diff --git a/go/updater/keybase/source_test.go b/go/updater/keybase/source_test.go
new file mode 100644
index 000000000000..69ddc5932a38
--- /dev/null
+++ b/go/updater/keybase/source_test.go
@@ -0,0 +1,138 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package keybase
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type testAPIServer struct {
+ server *httptest.Server
+ lastRequest *http.Request
+}
+
+func (t testAPIServer) shutdown() {
+ t.server.Close()
+}
+
+func newTestAPIServer(t *testing.T, jsonString string) *testAPIServer {
+ apiServer := &testAPIServer{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ apiServer.lastRequest = req
+
+ buf := bytes.NewBuffer([]byte(jsonString))
+ w.Header().Set("Content-Type", "application/json")
+ _, err := io.Copy(w, buf)
+ require.NoError(t, err)
+ }))
+
+ apiServer.server = server
+ return apiServer
+}
+
+const updateJSONResponse = `{
+ "version": "1.0.15-20160414190014+fdfce90",
+ "name": "v1.0.15-20160414190014+fdfce90",
+ "installId": "deadbeef",
+ "description": "This is an update!",
+ "type": 0,
+ "publishedAt": 1460660414000,
+ "asset": {
+ "name": "Keybase-1.0.15-20160414190014+fdfce90.zip",
+ "url": "https://prerelease.keybase.io/darwin-updates/Keybase-1.0.15-20160414190014%2Bfdfce90.zip",
+ "digest": "65675b91d0a05f98fcfb44c260f1f6e2c5ba6d6c9d37c84f873c75b65be7d9c4",
+ "signature": "BEGIN KEYBASE SALTPACK DETACHED SIGNATURE. kXR7VktZdyH7rvq v5wcIkPOwDJ1n11 M8RnkLKQGO2f3Bb fzCeMYz4S6oxLAy Cco4N255JFgnUxK yZ7SITOx8887cOR aeLbQGWBTMZWEQR hL6bhOCR8CqdXaQ 71lCQkT4WsnqAZe 7bbU2Xrsl50sLbJ BN19a9r6bQBYjce gfK0xY0064VY6CW 9. END KEYBASE SALTPACK DETACHED SIGNATURE.\n",
+ "localPath": ""
+ }
+ }`
+
+func TestUpdateSource(t *testing.T) {
+ server := newServer(updateJSONResponse)
+ defer server.Close()
+
+ cfg, _ := testConfig(t)
+ updateSource := newUpdateSource(cfg, server.URL, testLog)
+ update, err := updateSource.FindUpdate(testOptions)
+ assert.NoError(t, err)
+ require.NotNil(t, update)
+ assert.Equal(t, update.Version, "1.0.15-20160414190014+fdfce90")
+ assert.Equal(t, update.Name, "v1.0.15-20160414190014+fdfce90")
+ assert.Equal(t, update.InstallID, "deadbeef")
+ assert.Equal(t, update.Description, "This is an update!")
+ assert.True(t, update.PublishedAt == 1460660414000)
+ assert.Equal(t, update.Asset.Name, "Keybase-1.0.15-20160414190014+fdfce90.zip")
+ assert.Equal(t, update.Asset.URL, "https://prerelease.keybase.io/darwin-updates/Keybase-1.0.15-20160414190014%2Bfdfce90.zip")
+}
+
+func TestUpdateSourceBadResponse(t *testing.T) {
+ server := newServerForError(fmt.Errorf("Bad response"))
+ defer server.Close()
+
+ cfg, _ := testConfig(t)
+ updateSource := newUpdateSource(cfg, server.URL, testLog)
+ update, err := updateSource.FindUpdate(testOptions)
+ assert.EqualError(t, err, "Find update returned bad HTTP status 500 Internal Server Error")
+ assert.Nil(t, update, "Shouldn't have update")
+}
+
+func TestUpdateSourceTimeout(t *testing.T) {
+ server := newServerWithDelay(updateJSONResponse, 5*time.Millisecond)
+ defer server.Close()
+
+ cfg, _ := testConfig(t)
+ updateSource := newUpdateSource(cfg, server.URL, testLog)
+ update, err := updateSource.findUpdate(testOptions, 2*time.Millisecond)
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), "context deadline exceeded"), err.Error())
+ assert.Nil(t, update)
+}
+
+func TestUpdateSourceRequest(t *testing.T) {
+ testAPIServer := newTestAPIServer(t, updateJSONResponse)
+ defer testAPIServer.shutdown()
+
+ cfg, _ := testConfig(t)
+ updateSource := newUpdateSource(cfg, testAPIServer.server.URL, testLog)
+
+ var options = updater.UpdateOptions{
+ Version: "1.2.3-400+abcdef",
+ Platform: "platform",
+ Channel: "channel",
+ Env: "env",
+ Arch: "arch",
+ Force: true,
+ OSVersion: "100.1",
+ UpdaterVersion: "200.2",
+ }
+
+ // Request update
+ update, err := updateSource.FindUpdate(options)
+ require.NoError(t, err)
+ require.NotNil(t, testAPIServer.lastRequest)
+ require.Equal(t, "/?arch=arch&auto_update=0&ignore_snooze=0&install_id=&os_version=100.1&platform=platform&run_mode=env&upd_version=200.2&version=1.2.3-400%2Babcdef", testAPIServer.lastRequest.RequestURI)
+
+ // Change install ID and auto update
+ require.Equal(t, "deadbeef", update.InstallID)
+ err = cfg.SetInstallID(update.InstallID)
+ require.NoError(t, err)
+ err = cfg.SetUpdateAuto(true)
+ require.NoError(t, err)
+
+ // Request again and double check install ID and auto update param changed
+ _, err = updateSource.FindUpdate(options)
+ require.NoError(t, err)
+ require.NotNil(t, testAPIServer.lastRequest)
+ assert.Equal(t, "/?arch=arch&auto_update=1&ignore_snooze=0&install_id=deadbeef&os_version=100.1&platform=platform&run_mode=env&upd_version=200.2&version=1.2.3-400%2Babcdef", testAPIServer.lastRequest.RequestURI)
+}
diff --git a/go/updater/keybase/winlayout.log b/go/updater/keybase/winlayout.log
new file mode 100644
index 000000000000..fe9560a26409
--- /dev/null
+++ b/go/updater/keybase/winlayout.log
@@ -0,0 +1,71 @@
+NOTE: this is a phony log for unit testing
+
+[2F98:1DF4][2016-06-23T13:30:03]i001: Burn v3.10.2.2516, Windows v10.0 (Build 10586: Service Pack 0), path: C:\Users\Steve\AppData\Local\Temp\{AF1AE293-D5E6-4106-B5EB-8EB97596AFE4}\.cr\Keybase_1.0.16-20160621133121+95ba468.386.exe
+[2F98:1DF4][2016-06-23T13:30:03]i000: Initializing string variable 'DokanProduct64' to value '{65A3A964-3DC3-0100-0000-160621082245}'
+[2F98:1DF4][2016-06-23T13:30:03]i000: Initializing string variable 'DokanProduct86' to value '{65A3A986-3DC3-0100-0000-160621082245}'
+[2F98:1DF4][2016-06-23T13:30:03]i009: Command Line: '-burn.clean.room=c:\work\src\github.com\keybase\client\packaging\windows\10.0.16.45\Keybase_1.0.16-20160621133121+95ba468.386.exe /layout /quiet /log c:\work\src\github.com\keybase\client\go\updater\keybase\winlayout.log'
+[2F98:1DF4][2016-06-23T13:30:03]i000: Setting string variable 'WixBundleOriginalSource' to value 'c:\work\src\github.com\keybase\client\packaging\windows\10.0.16.45\Keybase_1.0.16-20160621133121+95ba468.386.exe'
+[2F98:1DF4][2016-06-23T13:30:03]i000: Setting string variable 'WixBundleOriginalSourceFolder' to value 'c:\work\src\github.com\keybase\client\packaging\windows\10.0.16.45\'
+[2F98:1DF4][2016-06-23T13:30:03]i000: Setting string variable 'LOGPATH_PROP' to value 'c:\work\src\github.com\keybase\client\go\updater\keybase\winlayout.log'
+[2F98:1DF4][2016-06-23T13:30:03]i000: Setting string variable 'WixBundleName' to value 'Keybase'
+[2F98:1DF4][2016-06-23T13:30:03]i000: Setting string variable 'WixBundleManufacturer' to value 'Keybase, Inc.'
+[2F98:3D58][2016-06-23T13:30:03]i000: Setting numeric variable 'WixStdBALanguageId' to value 1033
+[2F98:3D58][2016-06-23T13:30:03]i000: Setting version variable 'WixBundleFileVersion' to value '1.0.16.45'
+[2F98:1DF4][2016-06-23T13:30:03]i100: Detect begin, 3 packages
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{65A3A986-3DC3-0100-0000-160621082245}'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT DokanUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{65A3A986-3DC3-0100-0000-160621082245}'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT DokanUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{65A3A964-3DC3-0100-0000-160621082245}'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT DokanUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{65A3A964-3DC3-0100-0000-160621082245}'
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{357F272E-BE0E-409F-8E39-0BB9827F5716}_is1'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT InnoUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{357F272E-BE0E-409F-8E39-0BB9827F5716}_is1'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT InnoUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{DEB2E54C-C39F-4DC8-93A7-ABE0AB91DDCA}_is1'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT InnoUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{DEB2E54C-C39F-4DC8-93A7-ABE0AB91DDCA}_is1'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT InnoUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{1B2672D9-2BAD-4C11-BA53-A75AF6FD7789}_is1'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT InnoUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{1B2672D9-2BAD-4C11-BA53-A75AF6FD7789}_is1'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT InnoUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{70E747DE-4E09-44B0-ACAD-784AA9D79C02}_is1'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT InnoUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry key not found. Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{70E747DE-4E09-44B0-ACAD-784AA9D79C02}_is1'
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry value not found. Key = 'SOFTWARE\Keybase', Value = 'TargetDokanUninstallKey'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT TargetDokanUninstallKey' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i000: Registry value not found. Key = 'SOFTWARE\Keybase', Value = 'TargetDokanUninstallKey'
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'NOT InnoUninstallString' evaluates to true.
+[2F98:1DF4][2016-06-23T13:30:03]i052: Condition 'DokanUninstallString and NOT (WixBundleAction=3 AND ( TargetDokanUninstallKey <> DokanProduct64 AND TargetDokanUninstallKey <> DokanProduct86 ))' evaluates to false.
+[2F98:1DF4][2016-06-23T13:30:03]i101: Detected package: runquiet.exe, state: Present, cached: None
+[2F98:1DF4][2016-06-23T13:30:03]i101: Detected package: DokanSetup_redist.exe, state: Absent, cached: None
+[2F98:1DF4][2016-06-23T13:30:03]i101: Detected package: KeybasePrograms, state: Absent, cached: None
+[2F98:1DF4][2016-06-23T13:30:03]i199: Detect complete, result: 0x0
+[2F98:1DF4][2016-06-23T13:30:03]i200: Plan begin, 3 packages, action: Layout
+[2F98:1DF4][2016-06-23T13:30:03]i201: Planned package: runquiet.exe, state: Present, default requested: Cache, ba requested: Cache, execute: None, rollback: None, cache: No, uncache: No, dependency: None
+[2F98:1DF4][2016-06-23T13:30:03]i201: Planned package: DokanSetup_redist.exe, state: Absent, default requested: Cache, ba requested: Cache, execute: None, rollback: None, cache: No, uncache: No, dependency: None
+[2F98:1DF4][2016-06-23T13:30:03]i201: Planned package: KeybasePrograms, state: Absent, default requested: Cache, ba requested: Cache, execute: None, rollback: None, cache: No, uncache: No, dependency: None
+[2F98:1DF4][2016-06-23T13:30:03]i299: Plan complete, result: 0x0
+[2F98:1DF4][2016-06-23T13:30:03]i300: Apply begin
+[2F98:1DF4][2016-06-23T13:30:03]i399: Apply complete, result: 0x0, restart: None, ba requested restart: No
+[2F98:1DF4][2016-06-23T13:30:03]i500: Shutting down, exit code: 0x0
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: DokanProduct64 = {65A3A964-3DC3-0100-0000-160621082245}
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: DokanProduct86 = {65A3A986-3DC3-0100-0000-160621082245}
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: LOGPATH_PROP = c:\work\src\github.com\keybase\client\go\updater\keybase\winlayout.log
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleAction = 2
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleElevated = 0
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleFileVersion = 1.0.16.45
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleInstalled = 0
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleManufacturer = Keybase, Inc.
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleName = Keybase
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleOriginalSource = c:\work\src\github.com\keybase\client\packaging\windows\10.0.16.45\Keybase_1.0.16-20160621133121+95ba468.386.exe
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleOriginalSourceFolder = c:\work\src\github.com\keybase\client\packaging\windows\10.0.16.45\
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleProviderKey = {eec341cb-b399-4008-a5cd-e2f46af3f788}
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleSourceProcessFolder = c:\work\src\github.com\keybase\client\packaging\windows\10.0.16.45\
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleSourceProcessPath = c:\work\src\github.com\keybase\client\packaging\windows\10.0.16.45\Keybase_1.0.16-20160621133121+95ba468.386.exe
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleTag =
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixBundleVersion = 1.0.16.45
+[2F98:1DF4][2016-06-23T13:30:03]i410: Variable: WixStdBALanguageId = 1033
+[2F98:1DF4][2016-06-23T13:30:03]i007: Exit code: 0x0, restarting: No
diff --git a/go/updater/osx/.gitignore b/go/updater/osx/.gitignore
new file mode 100644
index 000000000000..f86224615d74
--- /dev/null
+++ b/go/updater/osx/.gitignore
@@ -0,0 +1,19 @@
+# Xcode
+#
+build/
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+xcuserdata
+*.xccheckout
+*.moved-aside
+DerivedData
+*.hmap
+*.ipa
+*.xcuserstate
+
diff --git a/go/updater/osx/README.md b/go/updater/osx/README.md
new file mode 100644
index 000000000000..482df2ee4bb6
--- /dev/null
+++ b/go/updater/osx/README.md
@@ -0,0 +1,49 @@
+## Updater
+
+This is an (OS X) app which shows dialog prompts for use via the command line (from the go-updater).
+
+See keybase/prompt.go and keybase/platform_darwin for usage of this app.
+
+### Update Prompt
+
+The update prompt takes as input a single argument JSON string:
+
+```json
+{
+ "title": "Keybase Update: Version 1.2.3-400",
+ "message": "The version you are currently running is outdated.",
+ "description": "See keybase.io for more details on this update.",
+ "autoUpdate": false
+}
+```
+
+The response is a single JSON string:
+
+```json
+{
+ "action": "snooze",
+ "autoUpdate": false
+}
+```
+
+
+### Generic Prompt
+
+There is also a generic prompt which takes as input a single argument JSON string:
+
+```json
+{
+ "type": "generic",
+ "title": "Keybase Warning",
+ "message": "The Keybase app is currently in use. We maybe need to interrupt current activity to perform the update",
+ "buttons": ["Cancel", "Force Update"]
+}
+```
+
+The response is a single JSON string (of the selected button):
+
+```json
+{
+ "button": "Force Update"
+}
+```
diff --git a/go/updater/osx/Updater.xcodeproj/project.pbxproj b/go/updater/osx/Updater.xcodeproj/project.pbxproj
new file mode 100644
index 000000000000..b2456b72d17e
--- /dev/null
+++ b/go/updater/osx/Updater.xcodeproj/project.pbxproj
@@ -0,0 +1,446 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 46;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 00791F631CBB2589003DBC85 /* TextView.m in Sources */ = {isa = PBXBuildFile; fileRef = 00791F621CBB2589003DBC85 /* TextView.m */; };
+ 009873051CC73C9A006CC7FF /* NSDictionary+Extension.m in Sources */ = {isa = PBXBuildFile; fileRef = 009873041CC73C9A006CC7FF /* NSDictionary+Extension.m */; };
+ 00C19B761CB6CE0500BDFDAD /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 00C19B751CB6CE0500BDFDAD /* AppDelegate.m */; };
+ 00C19B791CB6CE0500BDFDAD /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 00C19B781CB6CE0500BDFDAD /* main.m */; };
+ 00C19B7B1CB6CE0500BDFDAD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 00C19B7A1CB6CE0500BDFDAD /* Assets.xcassets */; };
+ 00C19B7E1CB6CE0500BDFDAD /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 00C19B7C1CB6CE0500BDFDAD /* MainMenu.xib */; };
+ 00E8F4261CBF184900532C84 /* PromptTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E8F4251CBF184900532C84 /* PromptTests.m */; };
+ 00E8F42F1CBF1B4A00532C84 /* Prompt.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E8F42E1CBF1B4A00532C84 /* Prompt.m */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 00E8F4281CBF184900532C84 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 00C19B691CB6CE0500BDFDAD /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 00C19B701CB6CE0500BDFDAD;
+ remoteInfo = Updater;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+ 0017DD331CB72F130051E666 /* build.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = build.sh; sourceTree = ""; };
+ 00791F611CBB2589003DBC85 /* TextView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TextView.h; sourceTree = ""; };
+ 00791F621CBB2589003DBC85 /* TextView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TextView.m; sourceTree = ""; };
+ 009873031CC73C9A006CC7FF /* NSDictionary+Extension.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDictionary+Extension.h"; sourceTree = ""; };
+ 009873041CC73C9A006CC7FF /* NSDictionary+Extension.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+Extension.m"; sourceTree = ""; };
+ 00C19B711CB6CE0500BDFDAD /* Updater.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Updater.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 00C19B741CB6CE0500BDFDAD /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; };
+ 00C19B751CB6CE0500BDFDAD /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; };
+ 00C19B781CB6CE0500BDFDAD /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; };
+ 00C19B7A1CB6CE0500BDFDAD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 00C19B7D1CB6CE0500BDFDAD /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; };
+ 00C19B7F1CB6CE0500BDFDAD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 00E8F4231CBF184900532C84 /* UpdaterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UpdaterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 00E8F4251CBF184900532C84 /* PromptTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PromptTests.m; sourceTree = ""; };
+ 00E8F4271CBF184900532C84 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 00E8F42D1CBF1B4A00532C84 /* Prompt.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Prompt.h; sourceTree = ""; };
+ 00E8F42E1CBF1B4A00532C84 /* Prompt.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Prompt.m; sourceTree = ""; };
+ 00E8F4301CBF1CFE00532C84 /* Defines.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Defines.h; sourceTree = ""; };
+ 00FDEE401CB6D50E00B267E1 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 00C19B6E1CB6CE0500BDFDAD /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 00E8F4201CBF184900532C84 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 00C19B681CB6CE0500BDFDAD = {
+ isa = PBXGroup;
+ children = (
+ 0017DD331CB72F130051E666 /* build.sh */,
+ 00FDEE401CB6D50E00B267E1 /* README.md */,
+ 00C19B731CB6CE0500BDFDAD /* Updater */,
+ 00E8F4241CBF184900532C84 /* UpdaterTests */,
+ 00C19B721CB6CE0500BDFDAD /* Products */,
+ );
+ sourceTree = "";
+ };
+ 00C19B721CB6CE0500BDFDAD /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 00C19B711CB6CE0500BDFDAD /* Updater.app */,
+ 00E8F4231CBF184900532C84 /* UpdaterTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 00C19B731CB6CE0500BDFDAD /* Updater */ = {
+ isa = PBXGroup;
+ children = (
+ 00C19B741CB6CE0500BDFDAD /* AppDelegate.h */,
+ 00C19B751CB6CE0500BDFDAD /* AppDelegate.m */,
+ 00E8F4301CBF1CFE00532C84 /* Defines.h */,
+ 00E8F42D1CBF1B4A00532C84 /* Prompt.h */,
+ 00E8F42E1CBF1B4A00532C84 /* Prompt.m */,
+ 00791F611CBB2589003DBC85 /* TextView.h */,
+ 00791F621CBB2589003DBC85 /* TextView.m */,
+ 009873031CC73C9A006CC7FF /* NSDictionary+Extension.h */,
+ 009873041CC73C9A006CC7FF /* NSDictionary+Extension.m */,
+ 00C19B7A1CB6CE0500BDFDAD /* Assets.xcassets */,
+ 00C19B7C1CB6CE0500BDFDAD /* MainMenu.xib */,
+ 00C19B7F1CB6CE0500BDFDAD /* Info.plist */,
+ 00C19B771CB6CE0500BDFDAD /* Supporting Files */,
+ );
+ path = Updater;
+ sourceTree = "";
+ };
+ 00C19B771CB6CE0500BDFDAD /* Supporting Files */ = {
+ isa = PBXGroup;
+ children = (
+ 00C19B781CB6CE0500BDFDAD /* main.m */,
+ );
+ name = "Supporting Files";
+ sourceTree = "";
+ };
+ 00E8F4241CBF184900532C84 /* UpdaterTests */ = {
+ isa = PBXGroup;
+ children = (
+ 00E8F4251CBF184900532C84 /* PromptTests.m */,
+ 00E8F4271CBF184900532C84 /* Info.plist */,
+ );
+ path = UpdaterTests;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 00C19B701CB6CE0500BDFDAD /* Updater */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 00C19B821CB6CE0600BDFDAD /* Build configuration list for PBXNativeTarget "Updater" */;
+ buildPhases = (
+ 00C19B6D1CB6CE0500BDFDAD /* Sources */,
+ 00C19B6E1CB6CE0500BDFDAD /* Frameworks */,
+ 00C19B6F1CB6CE0500BDFDAD /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Updater;
+ productName = Updater;
+ productReference = 00C19B711CB6CE0500BDFDAD /* Updater.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 00E8F4221CBF184900532C84 /* UpdaterTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 00E8F42C1CBF184900532C84 /* Build configuration list for PBXNativeTarget "UpdaterTests" */;
+ buildPhases = (
+ 00E8F41F1CBF184900532C84 /* Sources */,
+ 00E8F4201CBF184900532C84 /* Frameworks */,
+ 00E8F4211CBF184900532C84 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 00E8F4291CBF184900532C84 /* PBXTargetDependency */,
+ );
+ name = UpdaterTests;
+ productName = UpdaterTests;
+ productReference = 00E8F4231CBF184900532C84 /* UpdaterTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 00C19B691CB6CE0500BDFDAD /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ LastUpgradeCheck = 0800;
+ ORGANIZATIONNAME = Keybase;
+ TargetAttributes = {
+ 00C19B701CB6CE0500BDFDAD = {
+ CreatedOnToolsVersion = 7.3;
+ DevelopmentTeam = 99229SGT5K;
+ ProvisioningStyle = Manual;
+ };
+ 00E8F4221CBF184900532C84 = {
+ CreatedOnToolsVersion = 7.3;
+ TestTargetID = 00C19B701CB6CE0500BDFDAD;
+ };
+ };
+ };
+ buildConfigurationList = 00C19B6C1CB6CE0500BDFDAD /* Build configuration list for PBXProject "Updater" */;
+ compatibilityVersion = "Xcode 3.2";
+ developmentRegion = English;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ English,
+ en,
+ Base,
+ );
+ mainGroup = 00C19B681CB6CE0500BDFDAD;
+ productRefGroup = 00C19B721CB6CE0500BDFDAD /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 00C19B701CB6CE0500BDFDAD /* Updater */,
+ 00E8F4221CBF184900532C84 /* UpdaterTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 00C19B6F1CB6CE0500BDFDAD /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 00C19B7B1CB6CE0500BDFDAD /* Assets.xcassets in Resources */,
+ 00C19B7E1CB6CE0500BDFDAD /* MainMenu.xib in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 00E8F4211CBF184900532C84 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 00C19B6D1CB6CE0500BDFDAD /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 009873051CC73C9A006CC7FF /* NSDictionary+Extension.m in Sources */,
+ 00C19B791CB6CE0500BDFDAD /* main.m in Sources */,
+ 00E8F42F1CBF1B4A00532C84 /* Prompt.m in Sources */,
+ 00791F631CBB2589003DBC85 /* TextView.m in Sources */,
+ 00C19B761CB6CE0500BDFDAD /* AppDelegate.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 00E8F41F1CBF184900532C84 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 00E8F4261CBF184900532C84 /* PromptTests.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 00E8F4291CBF184900532C84 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 00C19B701CB6CE0500BDFDAD /* Updater */;
+ targetProxy = 00E8F4281CBF184900532C84 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 00C19B7C1CB6CE0500BDFDAD /* MainMenu.xib */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 00C19B7D1CB6CE0500BDFDAD /* Base */,
+ );
+ name = MainMenu.xib;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 00C19B801CB6CE0500BDFDAD /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.11;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ };
+ name = Debug;
+ };
+ 00C19B811CB6CE0500BDFDAD /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.11;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ };
+ name = Release;
+ };
+ 00C19B831CB6CE0600BDFDAD /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "Developer ID Application: Keybase, Inc. (99229SGT5K)";
+ COMBINE_HIDPI_IMAGES = YES;
+ DEVELOPMENT_TEAM = 99229SGT5K;
+ INFOPLIST_FILE = Updater/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 10.9;
+ PRODUCT_BUNDLE_IDENTIFIER = keybase.Updater;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Debug;
+ };
+ 00C19B841CB6CE0600BDFDAD /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "Developer ID Application: Keybase, Inc. (99229SGT5K)";
+ COMBINE_HIDPI_IMAGES = YES;
+ DEVELOPMENT_TEAM = 99229SGT5K;
+ INFOPLIST_FILE = Updater/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 10.9;
+ PRODUCT_BUNDLE_IDENTIFIER = keybase.Updater;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Release;
+ };
+ 00E8F42A1CBF184900532C84 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ COMBINE_HIDPI_IMAGES = YES;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ INFOPLIST_FILE = UpdaterTests/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks";
+ PRODUCT_BUNDLE_IDENTIFIER = me.rel.UpdaterTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Updater.app/Contents/MacOS/Updater";
+ };
+ name = Debug;
+ };
+ 00E8F42B1CBF184900532C84 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ COMBINE_HIDPI_IMAGES = YES;
+ GCC_PREPROCESSOR_DEFINITIONS = "";
+ INFOPLIST_FILE = UpdaterTests/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks";
+ PRODUCT_BUNDLE_IDENTIFIER = me.rel.UpdaterTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Updater.app/Contents/MacOS/Updater";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 00C19B6C1CB6CE0500BDFDAD /* Build configuration list for PBXProject "Updater" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 00C19B801CB6CE0500BDFDAD /* Debug */,
+ 00C19B811CB6CE0500BDFDAD /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 00C19B821CB6CE0600BDFDAD /* Build configuration list for PBXNativeTarget "Updater" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 00C19B831CB6CE0600BDFDAD /* Debug */,
+ 00C19B841CB6CE0600BDFDAD /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 00E8F42C1CBF184900532C84 /* Build configuration list for PBXNativeTarget "UpdaterTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 00E8F42A1CBF184900532C84 /* Debug */,
+ 00E8F42B1CBF184900532C84 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 00C19B691CB6CE0500BDFDAD /* Project object */;
+}
diff --git a/go/updater/osx/Updater.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/go/updater/osx/Updater.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 000000000000..229c226d61bf
--- /dev/null
+++ b/go/updater/osx/Updater.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/go/updater/osx/Updater/AppDelegate.h b/go/updater/osx/Updater/AppDelegate.h
new file mode 100644
index 000000000000..3b5fe4a5112c
--- /dev/null
+++ b/go/updater/osx/Updater/AppDelegate.h
@@ -0,0 +1,14 @@
+//
+// AppDelegate.h
+// Updater
+//
+// Created by Gabriel on 4/7/16.
+// Copyright © 2016 Keybase. All rights reserved.
+//
+
+#import
+
+@interface AppDelegate : NSObject
+
+@end
+
diff --git a/go/updater/osx/Updater/AppDelegate.m b/go/updater/osx/Updater/AppDelegate.m
new file mode 100644
index 000000000000..13c25226f86c
--- /dev/null
+++ b/go/updater/osx/Updater/AppDelegate.m
@@ -0,0 +1,60 @@
+//
+// AppDelegate.m
+// Updater
+//
+// Created by Gabriel on 4/7/16.
+// Copyright © 2016 Keybase. All rights reserved.
+//
+
+#import "AppDelegate.h"
+
+#import "Defines.h"
+#import "Prompt.h"
+
+@implementation AppDelegate
+
+- (void)applicationDidFinishLaunching:(NSNotification *)notification {
+ // Check if test environment
+ if ([self isRunningTests]) return;
+
+ // Run as accessory (no dock or menu).
+ // The update prompt window will still be modal but won't take focus away from
+ // other apps when it pops up.
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self run];
+ });
+}
+
+- (void)run {
+ NSString *inputString = @"{}";
+
+ NSArray *args = NSProcessInfo.processInfo.arguments;
+ if (args.count > 0) {
+ NSArray *subargs = [args subarrayWithRange:NSMakeRange(1, args.count-1)];
+ if (subargs.count >= 1) {
+ inputString = subargs[0];
+ }
+ }
+
+ [Prompt showPromptWithInputString:inputString presenter:^NSModalResponse(NSAlert *alert) {
+ return [alert runModal];
+ } completion:^(NSData *output) {
+ if (!!output) {
+ [[NSFileHandle fileHandleWithStandardOutput] writeData:output];
+ }
+ fflush(stdout);
+ fflush(stderr);
+ exit(0);
+ }];
+}
+
+- (BOOL)isRunningTests {
+ // The Xcode test environment is a little awkward. Instead of using TEST preprocessor macro, check env.
+ NSDictionary *environment = [[NSProcessInfo processInfo] environment];
+ NSString *testFilePath = environment[@"XCTestConfigurationFilePath"];
+ return !!testFilePath;
+}
+
+@end
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/Contents.json b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000000..7cd4f8e120c9
--- /dev/null
+++ b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images" : [
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "icon_16x16.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "icon_16x16@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "icon_32x32.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "icon_32x32@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "icon_128x128.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "icon_128x128@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "icon_256x256.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "icon_256x256@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "icon_512x512.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "icon_512x512@2x.png",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
new file mode 100644
index 000000000000..36d6d09e5175
Binary files /dev/null and b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
new file mode 100644
index 000000000000..704d0328c367
Binary files /dev/null and b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
new file mode 100644
index 000000000000..66984e7766a2
Binary files /dev/null and b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
new file mode 100644
index 000000000000..f85becec473f
Binary files /dev/null and b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
new file mode 100644
index 000000000000..91fff82d31c4
Binary files /dev/null and b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
new file mode 100644
index 000000000000..ec2df530844d
Binary files /dev/null and b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
new file mode 100644
index 000000000000..c497a429c093
Binary files /dev/null and b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
new file mode 100644
index 000000000000..a8e342c74f47
Binary files /dev/null and b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
new file mode 100644
index 000000000000..03e4c12a6403
Binary files /dev/null and b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ
diff --git a/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
new file mode 100644
index 000000000000..2d02913cc507
Binary files /dev/null and b/go/updater/osx/Updater/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ
diff --git a/go/updater/osx/Updater/Base.lproj/MainMenu.xib b/go/updater/osx/Updater/Base.lproj/MainMenu.xib
new file mode 100644
index 000000000000..549f9f622cd1
--- /dev/null
+++ b/go/updater/osx/Updater/Base.lproj/MainMenu.xib
@@ -0,0 +1,667 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/go/updater/osx/Updater/Defines.h b/go/updater/osx/Updater/Defines.h
new file mode 100644
index 000000000000..d7cc0a6cc20f
--- /dev/null
+++ b/go/updater/osx/Updater/Defines.h
@@ -0,0 +1,11 @@
+//
+// Defines.h
+// Updater
+//
+// Created by Gabriel on 4/13/16.
+// Copyright © 2016 Keybase. All rights reserved.
+//
+
+#import
+
+#define KBMakeError(MSG, ...) [NSError errorWithDomain:@"Updater" code:-1 userInfo:@{NSLocalizedDescriptionKey:[NSString stringWithFormat:MSG, ##__VA_ARGS__], NSLocalizedRecoveryOptionsErrorKey: @[@"OK"]}]
diff --git a/go/updater/osx/Updater/Info.plist b/go/updater/osx/Updater/Info.plist
new file mode 100644
index 000000000000..d22f90fcb193
--- /dev/null
+++ b/go/updater/osx/Updater/Info.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIconFile
+
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0.7
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0.7
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ LSUIElement
+
+ NSHumanReadableCopyright
+ Copyright © 2016 Keybase. All rights reserved.
+ NSMainNibFile
+ MainMenu
+ NSPrincipalClass
+ NSApplication
+
+
diff --git a/go/updater/osx/Updater/NSDictionary+Extension.h b/go/updater/osx/Updater/NSDictionary+Extension.h
new file mode 100644
index 000000000000..407e460a1d7a
--- /dev/null
+++ b/go/updater/osx/Updater/NSDictionary+Extension.h
@@ -0,0 +1,34 @@
+//
+// NSDictionary+Extension.h
+// Updater
+//
+// Created by Gabriel on 4/19/16.
+// Copyright © 2016 Keybase. All rights reserved.
+//
+
+#import
+
+@interface NSDictionary (Extension)
+
+/*!
+ Get BOOL value for key.
+ @param key Key
+ @result YES if boolValue; If key not found or is NSNull, returns NO.
+ */
+- (BOOL)kb_boolForKey:(id)key;
+
+/*!
+ NSString for key.
+ @param key
+ @result NSString
+ */
+- (NSString *)kb_stringForKey:(id)key;
+
+/*!
+ NSArray of NSString for key.
+ @param key
+ @result NSArray
+ */
+- (NSArray *)kb_stringArrayForKey:(id)key;
+
+@end
diff --git a/go/updater/osx/Updater/NSDictionary+Extension.m b/go/updater/osx/Updater/NSDictionary+Extension.m
new file mode 100644
index 000000000000..264e637fbe07
--- /dev/null
+++ b/go/updater/osx/Updater/NSDictionary+Extension.m
@@ -0,0 +1,43 @@
+//
+// NSDictionary+Extension.m
+// Updater
+//
+// Created by Gabriel on 4/19/16.
+// Copyright © 2016 Keybase. All rights reserved.
+//
+
+#import "NSDictionary+Extension.h"
+
+@implementation NSDictionary (Extension)
+
+- (BOOL)kb_boolForKey:(id)key withDefault:(BOOL)defaultValue {
+ id value = [self objectForKey:key];
+ if (!value || [value isEqual:[NSNull null]]) return defaultValue;
+ // It can be error prone to check is something is a BOOL object type (NSNumber with internal bool),
+ // so we'll use boolValue.
+ if (![value respondsToSelector:@selector(boolValue)]) return defaultValue;
+ return [value boolValue];
+}
+
+- (BOOL)kb_boolForKey:(id)key {
+ return [self kb_boolForKey:key withDefault:NO];
+}
+
+- (NSString *)kb_stringForKey:(id)key {
+ id value = [self objectForKey:key];
+ if (!value || [value isEqual:[NSNull null]]) return nil;
+ if (![value isKindOfClass:[NSString class]]) return nil;
+ return value;
+}
+
+- (NSArray *)kb_stringArrayForKey:(id)key {
+ id value = [self objectForKey:key];
+ if (!value || [value isEqual:[NSNull null]]) return nil;
+ if (![value isKindOfClass:[NSArray class]]) return nil;
+ for (id obj in value) {
+ if (![obj isKindOfClass:NSString.class]) return nil;
+ }
+ return value;
+}
+
+@end
diff --git a/go/updater/osx/Updater/Prompt.h b/go/updater/osx/Updater/Prompt.h
new file mode 100644
index 000000000000..908a49d90ca7
--- /dev/null
+++ b/go/updater/osx/Updater/Prompt.h
@@ -0,0 +1,21 @@
+//
+// Prompt.h
+// Updater
+//
+// Created by Gabriel on 4/13/16.
+// Copyright © 2016 Keybase. All rights reserved.
+//
+
+#import
+
+#import
+
+@interface Prompt : NSObject
+
++ (void)showPromptWithInputString:(NSString *)inputString presenter:(NSModalResponse (^)(NSAlert *alert))presenter completion:(void (^)(NSData *output))completion;
+
++ (void)showUpdatePrompt:(NSDictionary *)input presenter:(NSModalResponse (^)(NSAlert *alert))presenter completion:(void (^)(NSData *output))completion;
+
++ (void)showGenericPrompt:(NSDictionary *)input presenter:(NSModalResponse (^)(NSAlert *alert))presenter completion:(void (^)(NSData *output))completion;
+
+@end
diff --git a/go/updater/osx/Updater/Prompt.m b/go/updater/osx/Updater/Prompt.m
new file mode 100644
index 000000000000..1b4b74bc6f51
--- /dev/null
+++ b/go/updater/osx/Updater/Prompt.m
@@ -0,0 +1,161 @@
+//
+// Prompt.m
+// Updater
+//
+// Created by Gabriel on 4/13/16.
+// Copyright © 2016 Keybase. All rights reserved.
+//
+
+#import "Prompt.h"
+#import "TextView.h"
+#import "Defines.h"
+#import "NSDictionary+Extension.h"
+
+@interface FView : NSView
+@end
+
+@implementation Prompt
+
++ (NSDictionary *)parseInputString:(NSString *)inputString defaultValue:(NSDictionary *)defaultValue {
+ NSData *data = [inputString dataUsingEncoding:NSUTF8StringEncoding];
+ if (!data) {
+ NSLog(@"No data for input");
+ return defaultValue;
+ }
+ NSError *error = nil;
+ id input = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
+ if (!!error) {
+ NSLog(@"Error parsing input: %@", error);
+ return defaultValue;
+ }
+ if (!input) {
+ NSLog(@"No input");
+ return defaultValue;
+ }
+ if (![input isKindOfClass:[NSDictionary class]]) {
+ NSLog(@"Invalid input type");
+ return defaultValue;
+ }
+ return input;
+}
+
++ (void)showPromptWithInputString:(NSString *)inputString presenter:(NSModalResponse (^)(NSAlert *alert))presenter completion:(void (^)(NSData *output))completion {
+
+ // Try to parse input, if there is any error use a default empty dictionary.
+ NSDictionary *input = [self parseInputString:inputString defaultValue:@{}];
+
+ // If input defines buttons it's a generic prompt
+ if ([input[@"type"] isEqual:@"generic"]) {
+ [self showGenericPrompt:input presenter:presenter completion:completion];
+ return;
+ }
+
+ [self showUpdatePrompt:input presenter:presenter completion:completion];
+}
+
++ (void)showUpdatePrompt:(NSDictionary *)input presenter:(NSModalResponse (^)(NSAlert *alert))presenter completion:(void (^)(NSData *output))completion {
+ NSString *title = [input kb_stringForKey:@"title"];
+ NSString *message = [input kb_stringForKey:@"message"];
+ NSString *description = [input kb_stringForKey:@"description"];
+ BOOL autoUpdate = [input kb_boolForKey:@"autoUpdate"];
+
+ if (!title) title = @"Keybase Update";
+ if (!message) message = @"There is an update available.";
+ if (!description) description = @"Please visit keybase.io for more information.";
+
+ if ([title length] > 700) title = [title substringToIndex:699];
+ if ([message length] > 700) message = [message substringToIndex:699];
+
+ NSAlert *alert = [[NSAlert alloc] init];
+ alert.messageText = title;
+ alert.informativeText = message;
+ [alert addButtonWithTitle:@"Update"];
+ [alert addButtonWithTitle:@"Ignore"];
+
+ FView *accessoryView = [[FView alloc] init];
+ TextView *textView = [[TextView alloc] init];
+ textView.editable = NO;
+ textView.view.textContainerInset = CGSizeMake(5, 5);
+
+ NSFont *font = [NSFont fontWithName:@"Monaco" size:10];
+ [textView setText:description font:font color:[NSColor blackColor] alignment:NSLeftTextAlignment lineBreakMode:NSLineBreakByWordWrapping];
+ textView.borderType = NSBezelBorder;
+ textView.frame = CGRectMake(0, 0, 500, 160);
+ [accessoryView addSubview:textView];
+
+ NSButton *autoCheckbox = [[NSButton alloc] init];
+ autoCheckbox.title = @"Update automatically";
+ autoCheckbox.state = autoUpdate ? NSOnState : NSOffState;
+ [autoCheckbox setButtonType:NSSwitchButton];
+ autoCheckbox.frame = CGRectMake(0, 160, 500, 30);
+ [accessoryView addSubview:autoCheckbox];
+ accessoryView.frame = CGRectMake(0, 0, 500, 190);
+ alert.accessoryView = accessoryView;
+
+ [alert setAlertStyle:NSInformationalAlertStyle];
+
+
+ NSModalResponse response = presenter(alert);
+
+ BOOL autoUpdateResponse = NO;
+
+ NSString *action = @"";
+ if (response == NSAlertFirstButtonReturn) {
+ action = @"apply";
+ autoUpdateResponse = autoCheckbox.state == NSOnState ? YES : NO;
+ } else if (response == NSAlertSecondButtonReturn) {
+ action = @"snooze";
+ }
+ NSLog(@"Action: %@", action);
+
+ NSError *error = nil;
+ NSDictionary *result = @{
+ @"action": action,
+ @"autoUpdate": @(autoUpdateResponse),
+ };
+
+ NSData *data = [NSJSONSerialization dataWithJSONObject:result options:0 error:&error];
+ if (!!error) {
+ NSLog(@"Error generating JSON response: %@", error);
+ }
+ completion(data);
+}
+
++ (void)showGenericPrompt:(NSDictionary *)input presenter:(NSModalResponse (^)(NSAlert *alert))presenter completion:(void (^)(NSData *output))completion {
+ NSString *title = [input kb_stringForKey:@"title"];
+ NSString *message = [input kb_stringForKey:@"message"];
+ NSArray *buttons = [input kb_stringArrayForKey:@"buttons"];
+
+ if ([title length] > 700) title = [title substringToIndex:699];
+ if ([message length] > 700) message = [message substringToIndex:699];
+
+ NSAlert *alert = [[NSAlert alloc] init];
+ alert.messageText = title;
+ alert.informativeText = message;
+ for (NSString *button in buttons) {
+ [alert addButtonWithTitle:button];
+ }
+ [alert setAlertStyle:NSInformationalAlertStyle];
+
+ NSModalResponse response = presenter(alert);
+
+ NSString *buttonSelected = buttons[response-NSAlertFirstButtonReturn];
+
+ NSError *error = nil;
+ NSDictionary *result = @{@"button": buttonSelected};
+ NSData *data = [NSJSONSerialization dataWithJSONObject:result options:0 error:&error];
+ if (!!error) {
+ NSLog(@"Error generating JSON response: %@", error);
+ }
+ completion(data);
+}
+
+@end
+
+@implementation FView
+
+- (BOOL)isFlipped {
+ return YES;
+}
+
+@end
diff --git a/go/updater/osx/Updater/TextView.h b/go/updater/osx/Updater/TextView.h
new file mode 100644
index 000000000000..ab3c9ff20f89
--- /dev/null
+++ b/go/updater/osx/Updater/TextView.h
@@ -0,0 +1,34 @@
+//
+// TextView.h
+// Updater
+//
+// Created by Gabriel on 4/10/16.
+// Copyright © 2016 Keybase. All rights reserved.
+//
+
+#import
+#import
+
+@class TextView;
+
+typedef BOOL (^TextViewOnPaste)(TextView *textView);
+typedef void (^TextViewOnChange)(TextView *textView);
+
+@interface TextView : NSScrollView
+
+@property (readonly) NSTextView *view;
+@property (nonatomic) NSAttributedString *attributedText;
+@property (nonatomic) NSString *text;
+@property (nonatomic, getter=isEditable) BOOL editable;
+
+@property (copy) TextViewOnChange onChange;
+@property (copy) TextViewOnPaste onPaste;
+
+- (void)viewInit;
+
+- (void)setText:(NSString *)text font:(NSFont *)font color:(NSColor *)color;
+- (void)setText:(NSString *)text font:(NSFont *)font color:(NSColor *)color alignment:(NSTextAlignment)alignment lineBreakMode:(NSLineBreakMode)lineBreakMode;
+
+- (void)setEnabled:(BOOL)enabled;
+
+@end
diff --git a/go/updater/osx/Updater/TextView.m b/go/updater/osx/Updater/TextView.m
new file mode 100644
index 000000000000..7e60338b51ce
--- /dev/null
+++ b/go/updater/osx/Updater/TextView.m
@@ -0,0 +1,154 @@
+//
+// TextView.m
+// Updater
+//
+// Created by Gabriel on 2/12/15.
+// Copyright (c) 2015 Keybase. All rights reserved.
+//
+
+#import "TextView.h"
+
+@interface KBNSTextView : NSTextView
+@property (weak) TextView *parent;
+@end
+
+@interface TextView ()
+@property KBNSTextView *view;
+@end
+
+@implementation TextView
+
+- (instancetype)initWithFrame:(NSRect)frameRect {
+ if ((self = [super initWithFrame:frameRect])) {
+ [self viewInit];
+ }
+ return self;
+}
+
+- (instancetype)initWithCoder:(NSCoder *)coder {
+ if ((self = [super initWithCoder:coder])) {
+ [self viewInit];
+ }
+ return self;
+}
+
+- (void)viewInit {
+ self.identifier = self.className;
+ KBNSTextView *view = [[KBNSTextView alloc] init];
+ view.parent = self;
+ _view = view;
+ _view.autoresizingMask = NSViewHeightSizable|NSViewWidthSizable;
+ _view.backgroundColor = NSColor.whiteColor;
+ _view.editable = YES;
+ _view.delegate = self;
+
+ [self setDocumentView:_view];
+ self.hasVerticalScroller = YES;
+ self.verticalScrollElasticity = NSScrollElasticityAllowed;
+ self.autohidesScrollers = YES;
+}
+
+// Adding this method and passing to super makes responsive scroll work correctly.
+// Without this method scrolling using trackpad is slow and chunky.
+// AppKit checks if this method is being overridden by a subclass, which works around the issue somehow.
+- (void)scrollWheel:(NSEvent *)event {
+ [super scrollWheel:event];
+}
+
+- (CGSize)sizeThatFits:(CGSize)size {
+ return self.frame.size;
+}
+
+- (BOOL)becomeFirstResponder {
+ return [_view becomeFirstResponder];
+}
+
+- (BOOL)resignFirstResponder {
+ return [_view resignFirstResponder];
+}
+
+- (void)setEnabled:(BOOL)enabled {
+ _view.selectable = enabled;
+ _view.editable = enabled;
+}
+
+- (NSString *)description {
+ return [NSString stringWithFormat:@"%@ %@", super.description, self.attributedText];
+}
+
+- (NSString *)text {
+ return [_view.textStorage string];
+}
+
+- (void)setText:(NSString *)text {
+ _view.string = text ? text : @"";
+}
+
+- (void)setAttributedText:(NSAttributedString *)attributedText {
+ if (!attributedText) attributedText = [[NSAttributedString alloc] initWithString:@""];
+ NSAssert(_view.textStorage, @"No text storage");
+ [_view.textStorage setAttributedString:attributedText];
+ _view.needsDisplay = YES;
+}
+
+- (NSAttributedString *)attributedText {
+ return _view.textStorage;
+}
+
+- (void)setText:(NSString *)text font:(NSFont *)font color:(NSColor *)color {
+ [self setText:text font:font color:color alignment:NSLeftTextAlignment lineBreakMode:NSLineBreakByWordWrapping];
+}
+
+- (void)setText:(NSString *)text font:(NSFont *)font color:(NSColor *)color alignment:(NSTextAlignment)alignment lineBreakMode:(NSLineBreakMode)lineBreakMode {
+ if (!text) {
+ self.attributedText = nil;
+ return;
+ }
+ NSMutableAttributedString *str = [[NSMutableAttributedString alloc] initWithString:text];
+
+ NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init];
+ paragraphStyle.alignment = alignment;
+ paragraphStyle.lineBreakMode = lineBreakMode;
+
+ NSDictionary *attributes = @{NSForegroundColorAttributeName:color, NSFontAttributeName:font, NSParagraphStyleAttributeName:paragraphStyle};
+ [str setAttributes:attributes range:NSMakeRange(0, str.length)];
+
+ self.attributedText = str;
+}
+
+- (BOOL)isEditable {
+ return _view.isEditable;
+}
+
+- (void)setEditable:(BOOL)editable {
+ [_view setEditable:editable];
+}
+
+// Returns YES if you should call super paste (use the default paste impl)
+- (BOOL)onPasted {
+ BOOL paste = YES;
+ if (self.onPaste) paste = self.onPaste(self);
+ [self didChange];
+ return paste;
+}
+
+- (void)didChange {
+ if (self.onChange) self.onChange(self);
+}
+
+#pragma mark NSTextViewDelegate
+
+- (void)textDidChange:(NSNotification *)notification {
+ [self didChange];
+}
+
+@end
+
+
+@implementation KBNSTextView
+
+- (void)paste:(id)sender {
+ if ([self.parent onPasted]) [super paste:sender];
+}
+
+@end
\ No newline at end of file
diff --git a/go/updater/osx/Updater/main.m b/go/updater/osx/Updater/main.m
new file mode 100644
index 000000000000..5bed3657af67
--- /dev/null
+++ b/go/updater/osx/Updater/main.m
@@ -0,0 +1,13 @@
+//
+// main.m
+// Updater
+//
+// Created by Gabriel on 4/7/16.
+// Copyright © 2016 Gabriel Handford. All rights reserved.
+//
+
+#import
+
+int main(int argc, const char * argv[]) {
+ return NSApplicationMain(argc, argv);
+}
diff --git a/go/updater/osx/UpdaterTests/Info.plist b/go/updater/osx/UpdaterTests/Info.plist
new file mode 100644
index 000000000000..ba72822e8728
--- /dev/null
+++ b/go/updater/osx/UpdaterTests/Info.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ BNDL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1
+
+
diff --git a/go/updater/osx/UpdaterTests/PromptTests.m b/go/updater/osx/UpdaterTests/PromptTests.m
new file mode 100644
index 000000000000..27e4bd692311
--- /dev/null
+++ b/go/updater/osx/UpdaterTests/PromptTests.m
@@ -0,0 +1,91 @@
+//
+// PromptTests.m
+// UpdaterTests
+//
+// Created by Gabriel on 4/13/16.
+// Copyright © 2016 Gabriel Handford. All rights reserved.
+//
+
+#import
+
+#import "Prompt.h"
+
+@interface PromptTests : XCTestCase
+@end
+
+@implementation PromptTests
+
+- (void)assertOutput:(NSData *)output action:(NSString *)action autoUpdate:(BOOL)autoUpdate {
+ NSString *stringOutput = [[NSString alloc] initWithData:output encoding:NSUTF8StringEncoding];
+ NSLog(@"Checking output: %@", stringOutput);
+ NSString *expected = [NSString stringWithFormat:@"{\"action\":\"%@\",\"autoUpdate\":%@}", action, (autoUpdate ? @"true" : @"false")];
+ XCTAssertEqualObjects(expected, stringOutput);
+}
+
+- (void)testUpdatePrompt {
+ NSData *data = [NSJSONSerialization dataWithJSONObject:@{@"title": @"Title", @"message": @"", @"description": @"", @"autoUpdate": @NO} options:0 error:nil];
+ NSString *str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+ [Prompt showPromptWithInputString:str presenter:^NSModalResponse(NSAlert *alert) {
+ return NSAlertFirstButtonReturn;
+ } completion:^(NSError *error, NSData *output) {
+ NSLog(@"Error: %@", error);
+ XCTAssertNil(error);
+ [self assertOutput:output action:@"apply" autoUpdate:NO];
+ }];
+}
+
+- (void)testUpdatePromptAutoUpdate {
+ NSData *data = [NSJSONSerialization dataWithJSONObject:@{@"title": @"Title", @"message": @"", @"description": @"", @"autoUpdate": @YES} options:0 error:nil];
+ NSString *str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+ [Prompt showPromptWithInputString:str presenter:^NSModalResponse(NSAlert *alert) {
+ return NSAlertSecondButtonReturn;
+ } completion:^(NSError *error, NSData *output) {
+ NSLog(@"Error: %@", error);
+ XCTAssertNil(error);
+ [self assertOutput:output action:@"snooze" autoUpdate:YES];
+ }];
+}
+
+- (void)testUpdatePromptNoSettings {
+ [Prompt showPromptWithInputString:@"" presenter:^NSModalResponse(NSAlert *alert) {
+ return NSAlertSecondButtonReturn;
+ } completion:^(NSError *error, NSData *output) {
+ NSLog(@"Error: %@", error);
+ XCTAssertNil(error);
+ [self assertOutput:output action:@"snooze" autoUpdate:NO];
+ }];
+}
+
+- (void)testUpdatePromptInvalidJSONInput {
+ NSData *data = [NSJSONSerialization dataWithJSONObject:@{@"title": @{}, @"message": @{}, @"description": @{}, @"autoUpdate": @{}} options:0 error:nil];
+ NSString *str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+ [Prompt showPromptWithInputString:str presenter:^NSModalResponse(NSAlert *alert) {
+ return NSAlertSecondButtonReturn;
+ } completion:^(NSError *error, NSData *output) {
+ [self assertOutput:output action:@"snooze" autoUpdate:NO];
+ }];
+}
+
+- (void)testUpdatePromptInvalidJSONRoot {
+ NSData *data = [NSJSONSerialization dataWithJSONObject:@[] options:0 error:nil];
+ NSString *str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+ [Prompt showPromptWithInputString:str presenter:^NSModalResponse(NSAlert *alert) {
+ return NSAlertSecondButtonReturn;
+ } completion:^(NSError *error, NSData *output) {
+ NSLog(@"Error: %@", error);
+ XCTAssertNil(error);
+ [self assertOutput:output action:@"snooze" autoUpdate:NO];
+ }];
+}
+
+- (void)testUpdatePromptBadJSON {
+ [Prompt showPromptWithInputString:@"badjson" presenter:^NSModalResponse(NSAlert *alert) {
+ return NSAlertFirstButtonReturn;
+ } completion:^(NSError *error, NSData *output) {
+ NSLog(@"Error: %@", error);
+ NSLog(@"Output: %@", output);
+ XCTAssertNil(error);
+ }];
+}
+
+@end
diff --git a/go/updater/osx/build.sh b/go/updater/osx/build.sh
new file mode 100755
index 000000000000..90a791fb0bf1
--- /dev/null
+++ b/go/updater/osx/build.sh
@@ -0,0 +1,51 @@
+#!/bin/bash
+
+set -e -u -o pipefail # Fail on error
+
+dir=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
+cd "$dir"
+
+app_name="KeybaseUpdater"
+plist="$dir/Updater/Info.plist"
+scheme="Updater"
+code_sign_identity=${CODE_SIGN_IDENTITY:-"90524F7BEAEACD94C7B473787F4949582F904104"}
+xcode_configuration="Release"
+install_app_path="/Applications/Keybase.app/Contents/Resources/$app_name.app"
+
+build_dir="$dir/build"
+mkdir -p "$build_dir"
+archive_path="$build_dir/$app_name.xcarchive"
+
+echo "Plist: $plist"
+app_version="`/usr/libexec/plistBuddy -c "Print :CFBundleShortVersionString" $plist`"
+
+echo "Archiving"
+xcodebuild archive -scheme "$scheme" -project "$dir/Updater.xcodeproj" -configuration "$xcode_configuration" -archivePath "$archive_path" | xcpretty -c
+
+echo "Exporting"
+tmp_dir="/tmp"
+tmp_app_path="$tmp_dir/$app_name.app"
+export_dest="$tmp_dir/updater-build"
+rm -rf "$tmp_app_path"
+rm -rf "$export_dest"
+xcodebuild -exportArchive -archivePath "$archive_path" -exportOptionsPlist export.plist -exportPath "$export_dest" | xcpretty -c
+mv "$export_dest/Updater.app" "$tmp_app_path"
+echo "Exported to $tmp_app_path"
+
+echo "Codesigning with $code_sign_identity"
+codesign --verbose --force --deep --timestamp --options runtime --sign "$code_sign_identity" "$tmp_app_path"
+echo "Checking codesigning..."
+codesign -dvvvv "$tmp_app_path"
+echo " "
+spctl --assess --verbose=4 "$tmp_app_path"
+echo " "
+
+cd "$tmp_dir"
+tgz="$app_name-$app_version-darwin.tgz"
+echo "Packing $tgz"
+tar zcvpf "$tgz" "$app_name.app"
+echo "Created $tmp_dir/$tgz"
+
+rm -rf "$install_app_path"
+cp -R "$tmp_app_path" "$install_app_path"
+echo "Copied $tmp_app_path to $install_app_path"
diff --git a/go/updater/osx/export.plist b/go/updater/osx/export.plist
new file mode 100644
index 000000000000..6056e7d1f5aa
--- /dev/null
+++ b/go/updater/osx/export.plist
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/go/updater/process/README.md b/go/updater/process/README.md
new file mode 100644
index 000000000000..854922780879
--- /dev/null
+++ b/go/updater/process/README.md
@@ -0,0 +1,3 @@
+## Process
+
+Find and terminate processes by name or path.
diff --git a/go/updater/process/matcher.go b/go/updater/process/matcher.go
new file mode 100644
index 000000000000..5b10c8c86e4f
--- /dev/null
+++ b/go/updater/process/matcher.go
@@ -0,0 +1,83 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package process
+
+import (
+ "strings"
+
+ "github.com/keybase/go-ps"
+)
+
+// MatchFn is a process matching function
+type MatchFn func(ps.Process) bool
+
+// Matcher can match a process
+type Matcher struct {
+ match string
+ matchType MatchType
+ exceptPID int
+ log Log
+}
+
+// MatchType is how to match
+type MatchType string
+
+const (
+ // PathEqual matches path equals string
+ PathEqual MatchType = "path-equal"
+ // PathContains matches path contains string
+ PathContains MatchType = "path-contains"
+ // PathPrefix matches path has string prefix
+ PathPrefix MatchType = "path-prefix"
+ // ExecutableEqual matches executable name equals string
+ ExecutableEqual MatchType = "executable-equal"
+)
+
+// NewMatcher returns a new matcher
+func NewMatcher(match string, matchType MatchType, log Log) Matcher {
+ return Matcher{match: match, matchType: matchType, log: log}
+}
+
+// ExceptPID will not match specified pid
+func (m *Matcher) ExceptPID(p int) {
+ m.exceptPID = p
+}
+
+func (m Matcher) matchPathFn(pathFn func(path, str string) bool) MatchFn {
+ return func(p ps.Process) bool {
+ if m.exceptPID != 0 && p.Pid() == m.exceptPID {
+ return false
+ }
+ path, err := p.Path()
+ if err != nil {
+ return false
+ }
+ return pathFn(path, m.match)
+ }
+}
+
+func (m Matcher) matchExecutableFn(execFn func(executable, str string) bool) MatchFn {
+ return func(p ps.Process) bool {
+ if m.exceptPID != 0 && p.Pid() == m.exceptPID {
+ return false
+ }
+ return execFn(p.Executable(), m.match)
+ }
+}
+
+// Fn is the matching function
+func (m Matcher) Fn() MatchFn {
+ switch m.matchType {
+ case PathEqual:
+ return m.matchPathFn(func(s, t string) bool { return s == t })
+ case PathContains:
+ return m.matchPathFn(strings.Contains)
+ case PathPrefix:
+ return m.matchPathFn(strings.HasPrefix)
+ case ExecutableEqual:
+ return m.matchExecutableFn(func(s, t string) bool { return s == t })
+ default:
+ return nil
+ }
+}
diff --git a/go/updater/process/process.go b/go/updater/process/process.go
new file mode 100644
index 000000000000..6896da00678c
--- /dev/null
+++ b/go/updater/process/process.go
@@ -0,0 +1,217 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package process
+
+import (
+ "fmt"
+ "os"
+ "runtime"
+ "syscall"
+ "time"
+
+ "github.com/keybase/go-ps"
+)
+
+// Log is the logging interface for the process package
+type Log interface {
+ Debugf(s string, args ...interface{})
+ Infof(s string, args ...interface{})
+ Warningf(s string, args ...interface{})
+ Errorf(s string, args ...interface{})
+}
+
+type processesFn func() ([]ps.Process, error)
+type breakFn func([]ps.Process) bool
+
+// FindProcesses returns processes containing string matching process path
+func FindProcesses(matcher Matcher, wait time.Duration, delay time.Duration, log Log) ([]ps.Process, error) {
+ breakFn := func(procs []ps.Process) bool {
+ return len(procs) > 0
+ }
+ return findProcesses(matcher, breakFn, wait, delay, log)
+}
+
+// WaitForExit returns processes (if any) that are still running after wait
+func WaitForExit(matcher Matcher, wait time.Duration, delay time.Duration, log Log) ([]ps.Process, error) {
+ breakFn := func(procs []ps.Process) bool {
+ return len(procs) == 0
+ }
+ return findProcesses(matcher, breakFn, wait, delay, log)
+}
+
+func findProcesses(matcher Matcher, breakFn breakFn, wait time.Duration, delay time.Duration, log Log) ([]ps.Process, error) {
+ start := time.Now()
+ for {
+ log.Debugf("Find process %s (%s < %s)", matcher.match, time.Since(start), wait)
+ procs, err := findProcessesWithFn(ps.Processes, matcher.Fn(), 0)
+ if err != nil {
+ return nil, err
+ }
+ if breakFn(procs) {
+ return procs, nil
+ }
+ if time.Since(start) >= wait {
+ break
+ }
+ time.Sleep(delay)
+ }
+ return nil, nil
+}
+
+// findProcessWithPID returns a process for a pid.
+// Consider using os.FindProcess instead if suitable since this may be
+// inefficient.
+func findProcessWithPID(pid int) (ps.Process, error) {
+ matchPID := func(p ps.Process) bool { return p.Pid() == pid }
+ procs, err := findProcessesWithFn(ps.Processes, matchPID, 1)
+ if err != nil {
+ return nil, err
+ }
+ if len(procs) == 0 {
+ return nil, nil
+ }
+ return procs[0], nil
+}
+
+// Currently findProcessWithPID is only used in tests, ignore deadcode warning
+var _ = findProcessWithPID
+
+// findProcessesWithFn finds processes using match function.
+// If max is != 0, then we will return that max number of processes.
+func findProcessesWithFn(fn processesFn, matchFn MatchFn, max int) ([]ps.Process, error) {
+ processes, err := fn()
+ if err != nil {
+ return nil, fmt.Errorf("Error listing processes: %s", err)
+ }
+ if processes == nil {
+ return nil, nil
+ }
+ procs := []ps.Process{}
+ for _, p := range processes {
+ if matchFn(p) {
+ procs = append(procs, p)
+ }
+ if max != 0 && len(procs) >= max {
+ break
+ }
+ }
+ return procs, nil
+}
+
+// FindPIDsWithMatchFn returns pids for processes matching function
+func FindPIDsWithMatchFn(matchFn MatchFn, log Log) ([]int, error) {
+ return findPIDsWithFn(ps.Processes, matchFn, log)
+}
+
+func findPIDsWithFn(fn processesFn, matchFn MatchFn, log Log) ([]int, error) {
+ procs, err := findProcessesWithFn(fn, matchFn, 0)
+ if err != nil {
+ log.Errorf("Error finding matching processes")
+ return nil, err
+ }
+ pids := []int{}
+ for _, p := range procs {
+ pids = append(pids, p.Pid())
+ }
+ log.Debugf("Found %d matching processes with pids: %s", len(procs), pids)
+ return pids, nil
+}
+
+// TerminateAll stops all processes with executable names that contains the matching string.
+// It returns the pids that were terminated.
+// This method only logs errors, if you need error handling, you can should use a different implementation.
+func TerminateAll(matcher Matcher, killDelay time.Duration, log Log) []int {
+ return TerminateAllWithProcessesFn(ps.Processes, matcher.Fn(), killDelay, log)
+}
+
+// TerminateAllWithProcessesFn stops processes processesFn that satify the matchFn.
+// It returns the pids that were terminated.
+// This method only logs errors, if you need error handling, you can should use a different implementation.
+func TerminateAllWithProcessesFn(fn processesFn, matchFn MatchFn, killDelay time.Duration, log Log) (terminatedPids []int) {
+ pids, err := findPIDsWithFn(fn, matchFn, log)
+ if err != nil {
+ log.Errorf("Error finding process: %s", err)
+ return terminatedPids
+ }
+ if len(pids) == 0 {
+ return terminatedPids
+ }
+ for _, pid := range pids {
+ if err := TerminatePID(pid, killDelay, log); err != nil {
+ log.Errorf("Error terminating %d: %s", pid, err)
+ continue
+ }
+ log.Debugf("Successfully terminated %s", pid)
+ terminatedPids = append(terminatedPids, pid)
+ }
+ return terminatedPids
+}
+
+// TerminatePID is an overly simple way to terminate a PID.
+// On darwin and linux, it calls SIGTERM, then waits a killDelay and then calls
+// SIGKILL. We don't mind if we call SIGKILL on an already terminated process,
+// since there could be a race anyway where the process exits right after we
+// check if it's still running but before the SIGKILL.
+// The killDelay is not used on windows.
+func TerminatePID(pid int, killDelay time.Duration, log Log) error {
+ log.Debugf("Searching OS for %d to terminate", pid)
+ process, err := os.FindProcess(pid)
+ if err != nil {
+ return fmt.Errorf("Error finding OS process: %s", err)
+ }
+ if process == nil {
+ return fmt.Errorf("No process found with pid %d", pid)
+ }
+
+ // Sending SIGTERM is not supported on windows, so we can use process.Kill()
+ if runtime.GOOS == "windows" {
+ return process.Kill()
+ }
+
+ log.Debugf("Terminating: %#v", process)
+ err = process.Signal(syscall.SIGTERM)
+ if err != nil {
+ log.Warningf("Error sending terminate: %s", err)
+ }
+ log.Debugf("Waiting %s", killDelay)
+ time.Sleep(killDelay)
+ log.Debugf("Done waiting")
+ // Ignore SIGKILL error since it will be that the process wasn't running if
+ // the terminate above succeeded. If terminate didn't succeed above, then
+ // this SIGKILL is a measure of last resort, and an error would signify that
+ // something in the environment has gone terribly wrong.
+ _ = process.Kill()
+ return err
+}
+
+// KillAll kills all processes that match
+func KillAll(matcher Matcher, log Log) (pids []int) {
+ pids, err := findPIDsWithFn(ps.Processes, matcher.Fn(), log)
+ if err != nil {
+ log.Errorf("Error finding process: %s", err)
+ return
+ }
+ if len(pids) == 0 {
+ return
+ }
+ for _, pid := range pids {
+ if err := KillPID(pid, log); err != nil {
+ log.Errorf("Error killing %d: %s", pid, err)
+ }
+ }
+ return
+}
+
+// KillPID kills process at pid (sends a SIGKILL on unix)
+func KillPID(pid int, log Log) error {
+ log.Debugf("Searching OS for %d to kill", pid)
+ process, err := os.FindProcess(pid)
+ if err != nil {
+ return fmt.Errorf("Error finding OS process: %s", err)
+ }
+ if process == nil {
+ return fmt.Errorf("No process found with pid %d", pid)
+ }
+ return process.Kill()
+}
diff --git a/go/updater/process/process_test.go b/go/updater/process/process_test.go
new file mode 100644
index 000000000000..d86bc3c747a0
--- /dev/null
+++ b/go/updater/process/process_test.go
@@ -0,0 +1,200 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package process
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/updater/util"
+ "github.com/keybase/go-logging"
+ "github.com/keybase/go-ps"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testLog = &logging.Logger{Module: "test"}
+
+var matchAll = func(p ps.Process) bool { return true }
+
+func cleanupProc(cmd *exec.Cmd, procPath string) {
+ if cmd != nil && cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ if procPath != "" {
+ _ = os.Remove(procPath)
+ }
+}
+
+func procTestPath(name string) (string, string) {
+ // Copy test executable to tmp
+ if runtime.GOOS == "windows" {
+ return filepath.Join(os.Getenv("GOPATH"), "bin", "test.exe"), filepath.Join(os.TempDir(), name+".exe")
+ }
+ return filepath.Join(os.Getenv("GOPATH"), "bin", "test"), filepath.Join(os.TempDir(), name)
+}
+
+func procPath(t *testing.T, name string) string {
+ // Copy test executable to tmp
+ srcPath, destPath := procTestPath(name)
+ err := util.CopyFile(srcPath, destPath, testLog)
+ require.NoError(t, err)
+ err = os.Chmod(destPath, 0777)
+ require.NoError(t, err)
+ // Temp dir might have symlinks in which case we need the eval'ed path
+ destPath, err = filepath.EvalSymlinks(destPath)
+ require.NoError(t, err)
+ return destPath
+}
+
+func TestFindPIDsWithFn(t *testing.T) {
+ pids, err := findPIDsWithFn(ps.Processes, matchAll, testLog)
+ assert.NoError(t, err)
+ assert.True(t, len(pids) > 1)
+
+ fn := func() ([]ps.Process, error) {
+ return nil, fmt.Errorf("Testing error")
+ }
+ processes, err := findPIDsWithFn(fn, matchAll, testLog)
+ assert.Nil(t, processes)
+ assert.Error(t, err)
+
+ fn = func() ([]ps.Process, error) {
+ return nil, nil
+ }
+ processes, err = findPIDsWithFn(fn, matchAll, testLog)
+ assert.Equal(t, []int{}, processes)
+ assert.NoError(t, err)
+}
+
+func TestTerminatePID(t *testing.T) {
+ procPath := procPath(t, "testTerminatePID")
+ cmd := exec.Command(procPath, "sleep")
+ err := cmd.Start()
+ defer cleanupProc(cmd, procPath)
+ require.NoError(t, err)
+ require.NotNil(t, cmd.Process)
+
+ err = TerminatePID(cmd.Process.Pid, time.Millisecond, testLog)
+ assert.NoError(t, err)
+}
+
+func assertTerminated(t *testing.T, pid int, stateStr string) {
+ process, err := os.FindProcess(pid)
+ require.NoError(t, err)
+ state, err := process.Wait()
+ require.NoError(t, err)
+ assert.Equal(t, stateStr, state.String())
+}
+
+func TestTerminatePIDInvalid(t *testing.T) {
+ err := TerminatePID(-5, time.Millisecond, testLog)
+ assert.Error(t, err)
+}
+
+func TestTerminateAllFn(t *testing.T) {
+ fn := func() ([]ps.Process, error) {
+ return nil, fmt.Errorf("Testing error")
+ }
+ TerminateAllWithProcessesFn(fn, matchAll, time.Millisecond, testLog)
+
+ fn = func() ([]ps.Process, error) {
+ return nil, nil
+ }
+ TerminateAllWithProcessesFn(fn, matchAll, time.Millisecond, testLog)
+}
+
+func startProcess(t *testing.T, path string, testCommand string) (string, int, *exec.Cmd) {
+ cmd := exec.Command(path, testCommand)
+ err := cmd.Start()
+ require.NoError(t, err)
+ require.NotNil(t, cmd.Process)
+ return path, cmd.Process.Pid, cmd
+}
+
+func TestTerminateAllPathEqual(t *testing.T) {
+ procPath := procPath(t, "testTerminateAllPathEqual")
+ defer util.RemoveFileAtPath(procPath)
+ matcher := NewMatcher(procPath, PathEqual, testLog)
+ testTerminateAll(t, procPath, matcher, 2)
+}
+
+func TestTerminateAllExecutableEqual(t *testing.T) {
+ procPath := procPath(t, "testTerminateAllExecutableEqual")
+ defer util.RemoveFileAtPath(procPath)
+ matcher := NewMatcher(filepath.Base(procPath), ExecutableEqual, testLog)
+ testTerminateAll(t, procPath, matcher, 2)
+}
+
+func TestTerminateAllPathContains(t *testing.T) {
+ procPath := procPath(t, "testTerminateAllPathContains")
+ defer util.RemoveFileAtPath(procPath)
+ procDir, procFile := filepath.Split(procPath)
+ match := procDir[1:] + procFile[:20]
+ t.Logf("Match: %q", match)
+ matcher := NewMatcher(match, PathContains, testLog)
+ testTerminateAll(t, procPath, matcher, 2)
+}
+
+func TestTerminateAllPathPrefix(t *testing.T) {
+ procPath := procPath(t, "testTerminateAllPathPrefix")
+ defer util.RemoveFileAtPath(procPath)
+ procDir, procFile := filepath.Split(procPath)
+ match := procDir + procFile[:20]
+ t.Logf("Match: %q", match)
+ matcher := NewMatcher(match, PathPrefix, testLog)
+ testTerminateAll(t, procPath, matcher, 2)
+}
+
+func testTerminateAll(t *testing.T, path string, matcher Matcher, numProcs int) {
+ var exitStatus string
+ if runtime.GOOS == "windows" {
+ exitStatus = "exit status 1"
+ } else {
+ exitStatus = "signal: terminated"
+ }
+
+ pids := []int{}
+ for i := 0; i < numProcs; i++ {
+ procPath, pid, cmd := startProcess(t, path, "sleep")
+ t.Logf("Started process %q (%d)", procPath, pid)
+ pids = append(pids, pid)
+ defer cleanupProc(cmd, "")
+ }
+
+ time.Sleep(time.Second)
+
+ terminatePids := TerminateAll(matcher, time.Second, testLog)
+ for _, p := range pids {
+ assert.Contains(t, terminatePids, p)
+ assertTerminated(t, p, exitStatus)
+ }
+}
+
+func TestFindProcessWait(t *testing.T) {
+ procPath := procPath(t, "testFindProcessWait")
+ cmd := exec.Command(procPath, "sleep")
+ defer cleanupProc(cmd, procPath)
+
+ // Ensure it's not already running
+ procs, err := FindProcesses(NewMatcher(procPath, PathEqual, testLog), time.Millisecond, 0, testLog)
+ require.NoError(t, err)
+ require.Equal(t, 0, len(procs))
+
+ go func() {
+ time.Sleep(10 * time.Millisecond)
+ startErr := cmd.Start()
+ require.NoError(t, startErr)
+ }()
+
+ // Wait up to second for process to be running
+ procs, err = FindProcesses(NewMatcher(procPath, PathEqual, testLog), time.Second, 10*time.Millisecond, testLog)
+ require.NoError(t, err)
+ require.True(t, len(procs) == 1)
+}
diff --git a/go/updater/protocol.go b/go/updater/protocol.go
new file mode 100644
index 000000000000..b80dbc4fc239
--- /dev/null
+++ b/go/updater/protocol.go
@@ -0,0 +1,122 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package updater
+
+// Asset describes a downloadable file
+type Asset struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Digest string `json:"digest"`
+ Signature string `json:"signature"`
+ LocalPath string `json:"localPath"`
+}
+
+// UpdateType is the update type.
+// This is an int type for compatibility.
+type UpdateType int
+
+const (
+ // UpdateTypeNormal is a normal update
+ UpdateTypeNormal UpdateType = 0
+ // UpdateTypeBugFix is a bugfix update
+ UpdateTypeBugFix UpdateType = 1
+ // UpdateTypeCritical is a critical update
+ UpdateTypeCritical UpdateType = 2
+)
+
+// Property is a generic key value pair for custom properties
+type Property struct {
+ Name string `codec:"name" json:"name"`
+ Value string `codec:"value" json:"value"`
+}
+
+// Update defines an update to apply
+type Update struct {
+ Version string `json:"version"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ InstallID string `json:"installId"`
+ RequestID string `json:"requestId"`
+ Type UpdateType `json:"type"`
+ PublishedAt int64 `json:"publishedAt"`
+ Props []Property `codec:"props" json:"props,omitempty"`
+ Asset *Asset `json:"asset,omitempty"`
+ NeedUpdate bool `json:"needUpdate"`
+}
+
+func (u Update) missingAsset() bool {
+ return u.Asset == nil || u.Asset.URL == ""
+
+}
+
+// UpdateOptions are options used to find an update
+type UpdateOptions struct {
+ // Version is the current version of the app
+ Version string `json:"version"`
+ // Platform is the os type (darwin, darwin-arm64, windows, linux)
+ Platform string `json:"platform"`
+ // DestinationPath is where to apply the update to
+ DestinationPath string `json:"destinationPath"`
+ // URL can override where the updater looks
+ URL string `json:"URL"`
+ // Channel is an alternative channel to get updates from (test, prerelease)
+ Channel string `json:"channel"`
+ // Env is an environment or run mode (prod, staging, devel)
+ Env string `json:"env"`
+ // Arch is an architecure description (x64, i386, arm)
+ Arch string `json:"arch"`
+ // Force is whether to apply the update, even if older or same version
+ Force bool `json:"force"`
+ // OSVersion is the version of the OS
+ OSVersion string `json:"osVersion"`
+ // UpdaterVersion is the version of the updater service
+ UpdaterVersion string `json:"updaterVersion"`
+ // IgnoreSnooze tells the server to send update despite we have snoozed
+ // recently or not.
+ IgnoreSnooze bool `json:"ignoreSnooze"`
+}
+
+// UpdateAction is the update action requested by the user
+type UpdateAction string
+
+const (
+ // UpdateActionApply means the user accepted and to perform update
+ UpdateActionApply UpdateAction = "apply"
+ // UpdateActionAuto means that auto update is set and to perform update
+ UpdateActionAuto UpdateAction = "auto"
+ // UpdateActionSnooze snoozes an update
+ UpdateActionSnooze UpdateAction = "snooze"
+ // UpdateActionCancel cancels an update
+ UpdateActionCancel UpdateAction = "cancel"
+ // UpdateActionError means an error occurred
+ UpdateActionError UpdateAction = "error"
+ // UpdateActionContinue means no update action was available and the update should continue
+ UpdateActionContinue UpdateAction = "continue"
+ // UpdateActionUIBusy means the UI was busy and the update should be attempted later
+ UpdateActionUIBusy UpdateAction = "uiBusy"
+)
+
+// String is a unique string label for the action
+func (u UpdateAction) String() string {
+ return string(u)
+}
+
+// UpdatePromptOptions are the options for UpdatePrompt
+type UpdatePromptOptions struct {
+ AutoUpdate bool `json:"autoUpdate"`
+ OutPath string `json:"outPath"` // Used for windows instead of stdout
+}
+
+// UpdatePromptResponse is the result for UpdatePrompt
+type UpdatePromptResponse struct {
+ Action UpdateAction `json:"action"`
+ AutoUpdate bool `json:"autoUpdate"`
+ SnoozeDuration int `json:"snooze_duration"`
+}
+
+// UpdateUI is a UI interface
+type UpdateUI interface {
+ // UpdatePrompt prompts for an update
+ UpdatePrompt(Update, UpdateOptions, UpdatePromptOptions) (*UpdatePromptResponse, error)
+}
diff --git a/go/updater/saltpack/saltpack.go b/go/updater/saltpack/saltpack.go
new file mode 100644
index 000000000000..cfeac8fdb885
--- /dev/null
+++ b/go/updater/saltpack/saltpack.go
@@ -0,0 +1,88 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package saltpack
+
+import (
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/keybase/client/go/kbcrypto"
+ "github.com/keybase/client/go/protocol/keybase1"
+ "github.com/keybase/client/go/updater/util"
+ sp "github.com/keybase/saltpack"
+ "github.com/keybase/saltpack/basic"
+)
+
+// Log is log interface for this package
+type Log interface {
+ Debugf(s string, args ...interface{})
+ Infof(s string, args ...interface{})
+}
+
+// VerifyDetachedFileAtPath verifies a file
+func VerifyDetachedFileAtPath(path string, signature string, validKIDs map[string]bool, log Log) error {
+ file, err := os.Open(path)
+ defer util.Close(file)
+ if err != nil {
+ return err
+ }
+ err = VerifyDetached(file, signature, validKIDs, log)
+ if err != nil {
+ return fmt.Errorf("error verifying signature: %s", err)
+ }
+ return nil
+}
+
+func SigningPublicKeyToKeybaseKID(k sp.SigningPublicKey) (ret keybase1.KID) {
+ if k == nil {
+ return ret
+ }
+ p := k.ToKID()
+ return keybase1.KIDFromRawKey(p, byte(kbcrypto.KIDNaclEddsa))
+}
+
+func checkSender(key sp.SigningPublicKey, validKIDs map[string]bool, log Log) error {
+ if key == nil {
+ return fmt.Errorf("no key")
+ }
+ kid := SigningPublicKeyToKeybaseKID(key)
+ if kid.IsNil() {
+ return fmt.Errorf("no KID for key")
+ }
+ log.Infof("Signed by %s", kid)
+ if !validKIDs[kid.String()] {
+ return fmt.Errorf("unknown signer KID: %s", kid)
+ }
+ log.Debugf("Valid KID: %s", kid)
+ return nil
+}
+
+// VerifyDetached verifies a message signature
+func VerifyDetached(reader io.Reader, signature string, validKIDs map[string]bool, log Log) error {
+ if reader == nil {
+ return fmt.Errorf("no reader")
+ }
+ check := func(key sp.SigningPublicKey) error {
+ return checkSender(key, validKIDs, log)
+ }
+ return VerifyDetachedCheckSender(reader, []byte(signature), check)
+}
+
+// VerifyDetachedCheckSender verifies a message signature
+func VerifyDetachedCheckSender(message io.Reader, signature []byte, checkSender func(sp.SigningPublicKey) error) error {
+ kr := basic.NewKeyring()
+ var skey sp.SigningPublicKey
+ var err error
+ skey, _, err = sp.Dearmor62VerifyDetachedReader(sp.CheckKnownMajorVersion, message, string(signature), kr)
+ if err != nil {
+ return err
+ }
+
+ if err = checkSender(skey); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/go/updater/saltpack/saltpack_test.go b/go/updater/saltpack/saltpack_test.go
new file mode 100644
index 000000000000..e080cd761d47
--- /dev/null
+++ b/go/updater/saltpack/saltpack_test.go
@@ -0,0 +1,123 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package saltpack
+
+import (
+ "bytes"
+ "io"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/keybase/go-logging"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testLog = &logging.Logger{Module: "test"}
+
+var validCodeSigningKIDs = map[string]bool{
+ "0120d7539e27e83a9c8caf8701199c6985c0a96801ff7cb69456e9b3a8a8446c66080a": true, // joshblum (saltine)
+}
+
+const message1 = "This is a test message\n"
+
+// This is the output of running:
+//
+// echo "This is a test message" | keybase sign -d
+const signature1 = `BEGIN KEYBASE SALTPACK DETACHED SIGNATURE. kXR7VktZdyH7rvq
+v5weRa8moXPeKBe e2YLT0PnyHzCrVi RbC1J5uJtYgYyLW eGg4qzsWqkXuVtJ yTsutKVn8DT97Oe
+mnvASPWsbU2VjnR t4EChFoYF1RSi75 MvyyWify9iZldeI 0OTYM5yKLpbCrX5 yD0Tmjf2txwg7Jx
+UVbWQUb01SmoAzq f. END KEYBASE SALTPACK DETACHED SIGNATURE.`
+
+var testZipPath string
+
+// keybase sign -d -i test.zip
+const testZipSignature = `BEGIN KEYBASE SALTPACK DETACHED SIGNATURE.
+kXR7VktZdyH7rvq v5weRa8moXPeKBe e2YLT0PnyHzCrVi RbC1J5uJtYgYyLW eGg4qzsWqkb7hcX
+GTVc0vsEUVwBCly qhPdOL0mE19kfxg A4fMqpNGNTY0jtO iMpjwwuIyLBxkCC jHzMiJFskzluz2S
+otWUI0nTu2vG2Fx Mgeyqm20Ug8j7Bi N. END KEYBASE SALTPACK DETACHED SIGNATURE.`
+
+func init() {
+ _, filename, _, _ := runtime.Caller(0)
+ testZipPath = filepath.Join(filepath.Dir(filename), "../test/test.zip")
+}
+
+func TestVerify(t *testing.T) {
+ reader := bytes.NewReader([]byte(message1))
+ err := VerifyDetached(reader, signature1, validCodeSigningKIDs, testLog)
+ assert.NoError(t, err)
+}
+
+func TestVerifyDetachedFileAtPath(t *testing.T) {
+ err := VerifyDetachedFileAtPath(testZipPath, testZipSignature, validCodeSigningKIDs, testLog)
+ assert.NoError(t, err)
+}
+
+func TestVerifyFail(t *testing.T) {
+ invalid := bytes.NewReader([]byte("This is a test message changed\n"))
+ err := VerifyDetached(invalid, signature1, validCodeSigningKIDs, testLog)
+ require.EqualError(t, err, "invalid signature")
+}
+
+func TestVerifyFailDetachedFileAtPath(t *testing.T) {
+ err := VerifyDetachedFileAtPath(testZipPath, testZipSignature, map[string]bool{}, testLog)
+ require.Error(t, err)
+}
+
+func TestVerifyNoValidIDs(t *testing.T) {
+ reader := bytes.NewReader([]byte(message1))
+ err := VerifyDetached(reader, signature1, nil, testLog)
+ require.EqualError(t, err, "unknown signer KID: 0120d7539e27e83a9c8caf8701199c6985c0a96801ff7cb69456e9b3a8a8446c66080a")
+}
+
+func TestVerifyBadValidIDs(t *testing.T) {
+ var badCodeSigningKIDs = map[string]bool{
+ "whatever": true,
+ }
+
+ reader := bytes.NewReader([]byte(message1))
+ err := VerifyDetached(reader, signature1, badCodeSigningKIDs, testLog)
+ require.EqualError(t, err, "unknown signer KID: 0120d7539e27e83a9c8caf8701199c6985c0a96801ff7cb69456e9b3a8a8446c66080a")
+}
+
+func TestVerifyNilInput(t *testing.T) {
+ err := VerifyDetached(nil, signature1, validCodeSigningKIDs, testLog)
+ require.EqualError(t, err, "no reader")
+}
+
+func TestVerifyNoSignature(t *testing.T) {
+ reader := bytes.NewReader([]byte(message1))
+ err := VerifyDetached(reader, "", validCodeSigningKIDs, testLog)
+ require.Equal(t, io.ErrUnexpectedEOF, err)
+}
+
+type testSigningKey struct {
+ kid []byte
+}
+
+func (t testSigningKey) ToKID() []byte {
+ return t.kid
+}
+
+func (t testSigningKey) Verify(message []byte, signature []byte) error {
+ panic("Unsupported")
+}
+
+func TestCheckNilSender(t *testing.T) {
+ err := checkSender(nil, validCodeSigningKIDs, testLog)
+ require.Error(t, err)
+}
+
+func TestCheckNoKID(t *testing.T) {
+ err := checkSender(testSigningKey{kid: nil}, validCodeSigningKIDs, testLog)
+ require.Error(t, err)
+}
+
+func TestVerifyNoFile(t *testing.T) {
+ err := VerifyDetachedFileAtPath("/invalid", signature1, validCodeSigningKIDs, testLog)
+ assert.Error(t, err)
+ require.True(t, strings.HasPrefix(err.Error(), "open /invalid: "))
+}
diff --git a/go/updater/service/README.md b/go/updater/service/README.md
new file mode 100644
index 000000000000..0cd2f8ba5e3e
--- /dev/null
+++ b/go/updater/service/README.md
@@ -0,0 +1,3 @@
+## Service
+
+Runs the updater as a background service.
diff --git a/go/updater/service/build.sh b/go/updater/service/build.sh
new file mode 100755
index 000000000000..639b48633f3c
--- /dev/null
+++ b/go/updater/service/build.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+set -e -u -o pipefail # Fail on error
+
+dir=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
+cd "$dir"
+
+build_dir=${BUILD_DIR:-/Applications/Keybase.app/Contents/SharedSupport/bin}
+dest="$build_dir/updater"
+
+echo "Building go-updater/service to $dest"
+GO15VENDOREXPERIMENT=1 go build -a -o "$dest" github.com/keybase/client/go/updater/service
diff --git a/go/updater/service/logger.go b/go/updater/service/logger.go
new file mode 100644
index 000000000000..300f070d208f
--- /dev/null
+++ b/go/updater/service/logger.go
@@ -0,0 +1,70 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+
+ "github.com/keybase/client/go/updater/keybase"
+)
+
+type logger struct{}
+
+// Debug is log implementation
+func (l logger) Debug(s ...interface{}) {
+ log.Printf("DEBG %s\n", s)
+}
+
+// Info is log implementation
+func (l logger) Info(s ...interface{}) {
+ log.Printf("INFO %s\n", s)
+}
+
+// Debugf is log implementation
+func (l logger) Debugf(s string, args ...interface{}) {
+ log.Printf("DEBG %s\n", fmt.Sprintf(s, args...))
+}
+
+// Infof is log implementation
+func (l logger) Infof(s string, args ...interface{}) {
+ log.Printf("INFO %s\n", fmt.Sprintf(s, args...))
+}
+
+// Warning is log implementation
+func (l logger) Warning(s ...interface{}) {
+ log.Printf("WARN %s\n", s)
+}
+
+// Warningf is log implementation
+func (l logger) Warningf(s string, args ...interface{}) {
+ log.Printf("WARN %s\n", fmt.Sprintf(s, args...))
+}
+
+// Error is log implementation
+func (l logger) Error(s ...interface{}) {
+ log.Printf("ERR %s\n", s)
+}
+
+// Errorf is log implementation
+func (l logger) Errorf(s string, args ...interface{}) {
+ log.Printf("ERR %s\n", fmt.Sprintf(s, args...))
+}
+
+func (l logger) setLogToFile(appName string, fileName string) (*os.File, string, error) {
+ dir, err := keybase.LogDir(appName)
+ if err != nil {
+ return nil, "", err
+ }
+ logPath := filepath.Join(dir, fileName)
+ logFile, err := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600)
+ if err != nil {
+ return nil, "", err
+ }
+ log.Printf("Logging to %s", logPath)
+ log.SetOutput(logFile)
+ return logFile, logPath, nil
+}
diff --git a/go/updater/service/logger_test.go b/go/updater/service/logger_test.go
new file mode 100644
index 000000000000..41bbb1431f15
--- /dev/null
+++ b/go/updater/service/logger_test.go
@@ -0,0 +1,42 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package main
+
+import (
+ "testing"
+
+ "github.com/keybase/client/go/updater/keybase"
+ "github.com/keybase/client/go/updater/util"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLoggerNil(t *testing.T) {
+ log := logger{}
+ log.Debug(nil)
+ log.Debugf("")
+ log.Info(nil)
+ log.Infof("")
+ log.Warning(nil)
+ log.Warningf("")
+ log.Error(nil)
+ log.Errorf("")
+}
+
+func TestLoggerFile(t *testing.T) {
+ log := logger{}
+
+ dir, err := keybase.LogDir("KeybaseTest")
+ require.NoError(t, err)
+ if exists, _ := util.FileExists(dir); !exists {
+ t.Logf("Creating %s", dir)
+ dirErr := util.MakeDirs(dir, 0700, testLog)
+ require.NoError(t, dirErr)
+ defer util.RemoveFileAtPath(dir)
+ }
+
+ _, path, err := log.setLogToFile("KeybaseTest", "TestLoggerFile.log")
+ defer util.RemoveFileAtPath(path)
+ require.NoError(t, err)
+ log.Debug("test")
+}
diff --git a/go/updater/service/main.go b/go/updater/service/main.go
new file mode 100644
index 000000000000..802eaaacbf73
--- /dev/null
+++ b/go/updater/service/main.go
@@ -0,0 +1,150 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+
+ "github.com/kardianos/osext"
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/keybase"
+ "github.com/keybase/client/go/updater/util"
+)
+
+type flags struct {
+ version bool
+ logToFile bool
+ appName string
+ pathToKeybase string
+ command string
+}
+
+func main() {
+ f, args := loadFlags()
+ if len(args) > 0 {
+ f.command = args[0]
+ }
+ if err := run(f); err != nil {
+ os.Exit(1)
+ }
+}
+
+func loadFlags() (flags, []string) {
+ f := flags{}
+ flag.BoolVar(&f.version, "version", false, "Show version")
+ flag.BoolVar(&f.logToFile, "log-to-file", false, "Log to file")
+ flag.StringVar(&f.pathToKeybase, "path-to-keybase", "", "Path to keybase executable")
+ flag.StringVar(&f.appName, "app-name", defaultAppName(), "App name")
+ flag.Parse()
+ args := flag.Args()
+ return f, args
+}
+
+func defaultAppName() string {
+ if runtime.GOOS == "linux" {
+ return "keybase"
+ }
+ return "Keybase"
+}
+
+func run(f flags) error {
+ if f.version {
+ fmt.Printf("%s\n", updater.Version)
+ return nil
+ }
+ ulog := logger{}
+
+ if f.logToFile {
+ logFile, _, err := ulog.setLogToFile(f.appName, "keybase.updater.log")
+ if err != nil {
+ ulog.Errorf("Error setting logging to file: %s", err)
+ }
+ defer util.Close(logFile)
+ }
+
+ // Set default path to keybase if not set
+ if f.pathToKeybase == "" {
+ path, err := osext.Executable()
+ if err != nil {
+ ulog.Warning("Error determining our executable path: %s", err)
+ } else {
+ dir, _ := filepath.Split(path)
+ pathToKeybase := filepath.Join(dir, "keybase")
+ ulog.Debugf("Using default path to keybase: %s", pathToKeybase)
+ f.pathToKeybase = pathToKeybase
+ }
+ }
+
+ if f.pathToKeybase == "" {
+ ulog.Warning("Missing -path-to-keybase")
+ }
+
+ switch f.command {
+ case "need-update":
+ ctx, updater := keybase.NewUpdaterContext(f.appName, f.pathToKeybase, ulog, keybase.Check)
+ needUpdate, err := updater.NeedUpdate(ctx)
+ if err != nil {
+ ulog.Error(err)
+ return err
+ }
+ // Keybase service expects to parse this output as a boolean.
+ // Do not change unless changing in both locations
+ // https: //github.com/keybase/client/blob/master/go/client/cmd_update.go
+ fmt.Println(needUpdate)
+ case "check":
+ if err := updateCheckFromFlags(f, ulog); err != nil {
+ ulog.Error(err)
+ return err
+ }
+ case "download-latest":
+ ctx, updater := keybase.NewUpdaterContext(f.appName, f.pathToKeybase, ulog, keybase.CheckPassive)
+ updateAvailable, _, err := updater.CheckAndDownload(ctx)
+ if err != nil {
+ ulog.Error(err)
+ return err
+ }
+ // Keybase service expects to parse this output as a boolean.
+ // Do not change unless changing in both locations
+ // https: //github.com/keybase/client/blob/master/go/client/cmd_update.go
+ fmt.Println(updateAvailable)
+ case "apply-downloaded":
+ ctx, updater := keybase.NewUpdaterContext(f.appName, f.pathToKeybase, ulog, keybase.Check)
+ applied, err := updater.ApplyDownloaded(ctx)
+ if err != nil {
+ ulog.Error(err)
+ return err
+ }
+ fmt.Println(applied)
+ case "service", "":
+ svc := serviceFromFlags(f, ulog)
+ svc.Run()
+ case "clean":
+ if runtime.GOOS == "windows" {
+ ctx, _ := keybase.NewUpdaterContext(f.appName, f.pathToKeybase, ulog, keybase.CheckPassive)
+ fmt.Printf("Doing DeepClean\n")
+ ctx.DeepClean()
+ } else {
+ ulog.Errorf("Unknown command: %s", f.command)
+ }
+ default:
+ ulog.Errorf("Unknown command: %s", f.command)
+ }
+ return nil
+}
+
+func serviceFromFlags(f flags, ulog logger) *service {
+ ulog.Infof("Updater %s", updater.Version)
+ ctx, upd := keybase.NewUpdaterContext(f.appName, f.pathToKeybase, ulog, keybase.Service)
+ return newService(upd, ctx, ulog, f.appName)
+}
+
+func updateCheckFromFlags(f flags, ulog logger) error {
+ ctx, updater := keybase.NewUpdaterContext(f.appName, f.pathToKeybase, ulog, keybase.Check)
+ _, err := updater.Update(ctx)
+ return err
+}
diff --git a/go/updater/service/main_test.go b/go/updater/service/main_test.go
new file mode 100644
index 000000000000..8d5b0b08e093
--- /dev/null
+++ b/go/updater/service/main_test.go
@@ -0,0 +1,34 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package main
+
+import (
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestServiceFlags(t *testing.T) {
+ f := flags{
+ pathToKeybase: "keybase",
+ }
+ svc := serviceFromFlags(f, logger{})
+ require.NotNil(t, svc)
+}
+
+func TestServiceFlagsEmpty(t *testing.T) {
+ svc := serviceFromFlags(flags{}, logger{})
+ require.NotNil(t, svc)
+}
+
+func TestLoadFlags(t *testing.T) {
+ f, _ := loadFlags()
+ if runtime.GOOS == "linux" {
+ assert.Equal(t, "keybase", f.appName)
+ } else {
+ assert.Equal(t, "Keybase", f.appName)
+ }
+}
diff --git a/go/updater/service/pid.go b/go/updater/service/pid.go
new file mode 100644
index 000000000000..305d207735e6
--- /dev/null
+++ b/go/updater/service/pid.go
@@ -0,0 +1,74 @@
+//go:build !windows
+// +build !windows
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "syscall"
+)
+
+// LockPIDFile manages a lock file containing the PID for the current process.
+type LockPIDFile struct {
+ name string
+ file *os.File
+ log Log
+}
+
+// NewLockPIDFile creates a LockPIDFile for filename name.
+func NewLockPIDFile(name string, log Log) *LockPIDFile {
+ return &LockPIDFile{name: name, log: log}
+}
+
+// Lock writes the pid to filename after acquiring a lock on the file.
+// When the process exits, the lock will be released.
+func (f *LockPIDFile) Lock() (err error) {
+ // make the parent directory
+ _, err = os.Stat(filepath.Dir(f.name))
+ if os.IsNotExist(err) {
+ err = os.MkdirAll(filepath.Dir(f.name), 0700)
+ if err != nil {
+ return err
+ }
+ } else if err != nil {
+ return err
+ }
+
+ if f.file, err = os.OpenFile(f.name, os.O_CREATE|os.O_RDWR, 0600); err != nil {
+ return err
+ }
+
+ // LOCK_EX = exclusive
+ // LOCK_NB = nonblocking
+ if err = syscall.Flock(int(f.file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+ f.file.Close()
+ f.file = nil
+ return err
+ }
+
+ pid := os.Getpid()
+ fmt.Fprintf(f.file, "%d", pid)
+ if err = f.file.Sync(); err != nil {
+ return err
+ }
+
+ f.log.Debugf("Locked pidfile %s for pid=%d", f.name, pid)
+
+ return nil
+}
+
+// Close releases the lock by closing and removing the file.
+func (f *LockPIDFile) Close() (err error) {
+ if f.file != nil {
+ if e1 := f.file.Close(); e1 != nil {
+ f.log.Warningf("Error closing pid file: %s\n", e1)
+ }
+ f.log.Debugf("Cleaning up pidfile %s", f.name)
+ if err = os.Remove(f.name); err != nil {
+ f.log.Warningf("Error removing pidfile: %s\n", err)
+ }
+ }
+ return
+}
diff --git a/go/updater/service/service.go b/go/updater/service/service.go
new file mode 100644
index 000000000000..cdc30091d6b6
--- /dev/null
+++ b/go/updater/service/service.go
@@ -0,0 +1,65 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package main
+
+import (
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/util"
+)
+
+// Log is the logging interface for the service package
+type Log interface {
+ Debug(...interface{})
+ Info(...interface{})
+ Debugf(s string, args ...interface{})
+ Infof(s string, args ...interface{})
+ Warningf(s string, args ...interface{})
+ Errorf(s string, args ...interface{})
+}
+
+type service struct {
+ updater *updater.Updater
+ updateChecker *updater.UpdateChecker
+ context updater.Context
+ log Log
+ appName string
+ ch chan int
+}
+
+func newService(upd *updater.Updater, context updater.Context, log Log, appName string) *service {
+ svc := service{
+ updater: upd,
+ context: context,
+ log: log,
+ appName: appName,
+ ch: make(chan int),
+ }
+ return &svc
+}
+
+func (s *service) Start() {
+ if s.updateChecker == nil {
+ tickDuration := util.EnvDuration("KEYBASE_UPDATER_DELAY", updater.DefaultTickDuration)
+ s.updater.SetTickDuration(tickDuration)
+ updateChecker := updater.NewUpdateChecker(s.updater, s.context, tickDuration, s.log)
+ s.updateChecker = &updateChecker
+ }
+ s.updateChecker.Start()
+}
+
+func (s *service) Run() {
+ closer, err := s.lockPID()
+ if err != nil {
+ s.log.Errorf("updater service not starting due to lockPID error: %s", err)
+ return
+ }
+ defer closer.Close()
+
+ s.Start()
+ <-s.ch
+}
+
+func (s *service) Quit() {
+ s.ch <- 0
+}
diff --git a/go/updater/service/service_nix.go b/go/updater/service/service_nix.go
new file mode 100644
index 000000000000..e3a1f081118d
--- /dev/null
+++ b/go/updater/service/service_nix.go
@@ -0,0 +1,24 @@
+//go:build !windows
+// +build !windows
+
+package main
+
+import (
+ "io"
+ "path/filepath"
+
+ "github.com/keybase/client/go/updater/keybase"
+)
+
+func (s *service) lockPID() (io.Closer, error) {
+ cacheDir, err := keybase.CacheDir(s.appName)
+ if err != nil {
+ return nil, err
+ }
+ lockPID := NewLockPIDFile(filepath.Join(cacheDir, "updater.pid"), s.log)
+ if err := lockPID.Lock(); err != nil {
+ return nil, err
+ }
+ s.log.Debug("update pid file %s created, updater service starting", lockPID.name)
+ return lockPID, nil
+}
diff --git a/go/updater/service/service_test.go b/go/updater/service/service_test.go
new file mode 100644
index 000000000000..4f59fb291cfc
--- /dev/null
+++ b/go/updater/service/service_test.go
@@ -0,0 +1,28 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package main
+
+import (
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/updater/keybase"
+ "github.com/keybase/go-logging"
+ "github.com/stretchr/testify/assert"
+)
+
+var testLog = &logging.Logger{Module: "test"}
+
+func TestService(t *testing.T) {
+ ctx, upd := keybase.NewUpdaterContext("KeybaseTest", "keybase", testLog, keybase.Service)
+ svc := newService(upd, ctx, testLog, "KeybaseTest")
+ assert.NotNil(t, svc)
+
+ go func() {
+ t.Log("Waiting")
+ time.Sleep(10 * time.Millisecond)
+ svc.Quit()
+ }()
+ svc.Run()
+}
diff --git a/go/updater/service/service_windows.go b/go/updater/service/service_windows.go
new file mode 100644
index 000000000000..dadd1264d9b5
--- /dev/null
+++ b/go/updater/service/service_windows.go
@@ -0,0 +1,16 @@
+//go:build windows
+// +build windows
+
+package main
+
+import "io"
+
+type nopCloser struct{}
+
+func (n *nopCloser) Close() error {
+ return nil
+}
+
+func (s *service) lockPID() (io.Closer, error) {
+ return &nopCloser{}, nil
+}
diff --git a/go/updater/sources/README.md b/go/updater/sources/README.md
new file mode 100644
index 000000000000..b42a58d89161
--- /dev/null
+++ b/go/updater/sources/README.md
@@ -0,0 +1,7 @@
+## Sources
+
+Examples for local and remote update sources.
+
+The remote update source is compatible with a static location like S3.
+
+The local update source is used primarily for testing (locally).
diff --git a/go/updater/sources/local.go b/go/updater/sources/local.go
new file mode 100644
index 000000000000..00f375b6756c
--- /dev/null
+++ b/go/updater/sources/local.go
@@ -0,0 +1,54 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package sources
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/util"
+)
+
+// LocalUpdateSource finds releases/updates from a path (used primarily for testing)
+type LocalUpdateSource struct {
+ path string
+ jsonPath string
+ log Log
+}
+
+// NewLocalUpdateSource returns local update source
+func NewLocalUpdateSource(path string, jsonPath string, log Log) LocalUpdateSource {
+ return LocalUpdateSource{
+ path: path,
+ jsonPath: jsonPath,
+ log: log,
+ }
+}
+
+// Description is local update source description
+func (k LocalUpdateSource) Description() string {
+ return "Local"
+}
+
+// FindUpdate returns update for options
+func (k LocalUpdateSource) FindUpdate(options updater.UpdateOptions) (*updater.Update, error) {
+ jsonFile, err := os.Open(k.jsonPath)
+ defer util.Close(jsonFile)
+ if err != nil {
+ return nil, err
+ }
+
+ var update updater.Update
+ if err := json.NewDecoder(jsonFile).Decode(&update); err != nil {
+ return nil, fmt.Errorf("Invalid update JSON: %s", err)
+ }
+
+ update.Asset.URL = fmt.Sprintf("file://%s", k.path)
+ // TODO: Only do if version is newer or forced (this source is used for testing, so ok to hardcode NeedUpdate)
+ update.NeedUpdate = true
+ k.log.Debugf("Returning update: %#v", update)
+ return &update, nil
+}
diff --git a/go/updater/sources/local_test.go b/go/updater/sources/local_test.go
new file mode 100644
index 000000000000..c803ef67ca98
--- /dev/null
+++ b/go/updater/sources/local_test.go
@@ -0,0 +1,29 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package sources
+
+import (
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/go-logging"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var log = &logging.Logger{Module: "test"}
+
+func TestLocalUpdateSource(t *testing.T) {
+ _, filename, _, _ := runtime.Caller(0)
+ path := filepath.Join(filepath.Dir(filename), "../test/test.zip")
+ jsonPath := filepath.Join(filepath.Dir(filename), "../test/update.json")
+ local := NewLocalUpdateSource(path, jsonPath, log)
+ assert.Equal(t, local.Description(), "Local")
+
+ update, err := local.FindUpdate(updater.UpdateOptions{})
+ require.NoError(t, err)
+ require.NotNil(t, update)
+}
diff --git a/go/updater/sources/log.go b/go/updater/sources/log.go
new file mode 100644
index 000000000000..5ed8371f8570
--- /dev/null
+++ b/go/updater/sources/log.go
@@ -0,0 +1,12 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package sources
+
+// Log is the logging interface for the sources package
+type Log interface {
+ Debugf(s string, args ...interface{})
+ Infof(s string, args ...interface{})
+ Warningf(s string, args ...interface{})
+ Errorf(s string, args ...interface{})
+}
diff --git a/go/updater/sources/remote.go b/go/updater/sources/remote.go
new file mode 100644
index 000000000000..fb4db4cd25ff
--- /dev/null
+++ b/go/updater/sources/remote.go
@@ -0,0 +1,79 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package sources
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/util"
+)
+
+// RemoteUpdateSource finds releases/updates from custom url feed (used primarily for testing)
+type RemoteUpdateSource struct {
+ defaultURI string
+ log Log
+}
+
+// NewRemoteUpdateSource builds remote update source without defaults. The url used is passed
+// via options instead.
+func NewRemoteUpdateSource(defaultURI string, log Log) RemoteUpdateSource {
+ return RemoteUpdateSource{
+ defaultURI: defaultURI,
+ log: log,
+ }
+}
+
+// Description returns update source description
+func (r RemoteUpdateSource) Description() string {
+ return "Remote"
+}
+
+func (r RemoteUpdateSource) sourceURL(options updater.UpdateOptions) string {
+ params := util.JoinPredicate([]string{options.Platform, options.Env, options.Channel}, "-", func(s string) bool { return s != "" })
+ url := options.URL
+ if url == "" {
+ url = r.defaultURI
+ }
+ if params == "" {
+ return fmt.Sprintf("%s/update.json", url)
+ }
+ return fmt.Sprintf("%s/update-%s.json", url, params)
+}
+
+// FindUpdate returns update for options
+func (r RemoteUpdateSource) FindUpdate(options updater.UpdateOptions) (*updater.Update, error) {
+ sourceURL := r.sourceURL(options)
+ req, err := http.NewRequest("GET", sourceURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ client := &http.Client{
+ Timeout: time.Minute,
+ }
+ r.log.Infof("Request %#v", sourceURL)
+ resp, err := client.Do(req)
+ defer util.DiscardAndCloseBodyIgnoreError(resp)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ err = fmt.Errorf("Updater remote returned bad status %v", resp.Status)
+ return nil, err
+ }
+
+ var reader io.Reader = resp.Body
+ var update updater.Update
+ if err = json.NewDecoder(reader).Decode(&update); err != nil {
+ return nil, fmt.Errorf("Bad updater remote response %s", err)
+ }
+
+ r.log.Debugf("Received update response: %#v", update)
+ return &update, nil
+}
diff --git a/go/updater/sources/remote_test.go b/go/updater/sources/remote_test.go
new file mode 100644
index 000000000000..09a793622925
--- /dev/null
+++ b/go/updater/sources/remote_test.go
@@ -0,0 +1,37 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package sources
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/keybase/client/go/updater"
+ "github.com/keybase/client/go/updater/util"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRemoteUpdateSource(t *testing.T) {
+ _, filename, _, _ := runtime.Caller(0)
+ jsonPath := filepath.Join(filepath.Dir(filename), "../test/update.json")
+ data, err := util.ReadFile(jsonPath)
+ require.NoError(t, err)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintln(w, string(data))
+ }))
+
+ local := NewRemoteUpdateSource(server.URL, log)
+ assert.Equal(t, local.Description(), "Remote")
+
+ update, err := local.FindUpdate(updater.UpdateOptions{})
+ require.NoError(t, err)
+ require.NotNil(t, update)
+}
diff --git a/go/updater/test/README.md b/go/updater/test/README.md
new file mode 100644
index 000000000000..bacdf1ae9709
--- /dev/null
+++ b/go/updater/test/README.md
@@ -0,0 +1,3 @@
+## Test
+
+These are resources used in tests.
diff --git a/go/updater/test/Test.app.zip b/go/updater/test/Test.app.zip
new file mode 100644
index 000000000000..e962d03fb32b
Binary files /dev/null and b/go/updater/test/Test.app.zip differ
diff --git a/go/updater/test/Test.app/Contents/Info.plist b/go/updater/test/Test.app/Contents/Info.plist
new file mode 100644
index 000000000000..47d51e213015
--- /dev/null
+++ b/go/updater/test/Test.app/Contents/Info.plist
@@ -0,0 +1,52 @@
+
+
+
+
+ BuildMachineOSBuild
+ 15E65
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ Test
+ CFBundleIdentifier
+ keybase.Test
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Test
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleSupportedPlatforms
+
+ MacOSX
+
+ CFBundleVersion
+ 1
+ DTCompiler
+ com.apple.compilers.llvm.clang.1_0
+ DTPlatformBuild
+ 7D1014
+ DTPlatformVersion
+ GM
+ DTSDKBuild
+ 15E60
+ DTSDKName
+ macosx10.11
+ DTXcode
+ 0731
+ DTXcodeBuild
+ 7D1014
+ LSMinimumSystemVersion
+ 10.9
+ NSHumanReadableCopyright
+ Copyright © 2016 Keybase. All rights reserved.
+ NSMainNibFile
+ MainMenu
+ NSPrincipalClass
+ NSApplication
+
+
diff --git a/go/updater/test/Test.app/Contents/MacOS/Test b/go/updater/test/Test.app/Contents/MacOS/Test
new file mode 100755
index 000000000000..120b00dd4647
Binary files /dev/null and b/go/updater/test/Test.app/Contents/MacOS/Test differ
diff --git a/go/updater/test/Test.app/Contents/PkgInfo b/go/updater/test/Test.app/Contents/PkgInfo
new file mode 100644
index 000000000000..bd04210fb49f
--- /dev/null
+++ b/go/updater/test/Test.app/Contents/PkgInfo
@@ -0,0 +1 @@
+APPL????
\ No newline at end of file
diff --git a/go/updater/test/Test.app/Contents/Resources/Base.lproj/MainMenu.nib b/go/updater/test/Test.app/Contents/Resources/Base.lproj/MainMenu.nib
new file mode 100644
index 000000000000..a6f616800371
Binary files /dev/null and b/go/updater/test/Test.app/Contents/Resources/Base.lproj/MainMenu.nib differ
diff --git a/go/updater/test/Test.app/Contents/_CodeSignature/CodeResources b/go/updater/test/Test.app/Contents/_CodeSignature/CodeResources
new file mode 100644
index 000000000000..dafd1cd06ee0
--- /dev/null
+++ b/go/updater/test/Test.app/Contents/_CodeSignature/CodeResources
@@ -0,0 +1,129 @@
+
+
+
+
+ files
+
+ Resources/Base.lproj/MainMenu.nib
+
+ hash
+
+ d+QK+VLOmER7t5Kt/VCfB5nDBUs=
+
+ optional
+
+
+
+ files2
+
+ Resources/Base.lproj/MainMenu.nib
+
+ hash
+
+ d+QK+VLOmER7t5Kt/VCfB5nDBUs=
+
+ hash2
+
+ w4GRCip+qDaMq5FQ7fS9Lls+KdR5g4AbjXGGKuivHXM=
+
+ optional
+
+
+
+ rules
+
+ ^Resources/
+
+ ^Resources/.*\.lproj/
+
+ optional
+
+ weight
+ 1000
+
+ ^Resources/.*\.lproj/locversion.plist$
+
+ omit
+
+ weight
+ 1100
+
+ ^version.plist$
+
+
+ rules2
+
+ .*\.dSYM($|/)
+
+ weight
+ 11
+
+ ^(.*/)?\.DS_Store$
+
+ omit
+
+ weight
+ 2000
+
+ ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/
+
+ nested
+
+ weight
+ 10
+
+ ^.*
+
+ ^Info\.plist$
+
+ omit
+
+ weight
+ 20
+
+ ^PkgInfo$
+
+ omit
+
+ weight
+ 20
+
+ ^Resources/
+
+ weight
+ 20
+
+ ^Resources/.*\.lproj/
+
+ optional
+
+ weight
+ 1000
+
+ ^Resources/.*\.lproj/locversion.plist$
+
+ omit
+
+ weight
+ 1100
+
+ ^[^/]+$
+
+ nested
+
+ weight
+ 10
+
+ ^embedded\.provisionprofile$
+
+ weight
+ 20
+
+ ^version\.plist$
+
+ weight
+ 20
+
+
+
+
diff --git a/go/updater/test/err.sh b/go/updater/test/err.sh
new file mode 100755
index 000000000000..f019ff95bab7
--- /dev/null
+++ b/go/updater/test/err.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+
+exit 1
diff --git a/go/updater/test/keybase-check-in-use-false.sh b/go/updater/test/keybase-check-in-use-false.sh
new file mode 100755
index 000000000000..56628f3213a1
--- /dev/null
+++ b/go/updater/test/keybase-check-in-use-false.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+cat << EOS
+{
+ "in_use": false
+}
+EOS
diff --git a/go/updater/test/keybase-check-in-use-true.sh b/go/updater/test/keybase-check-in-use-true.sh
new file mode 100755
index 000000000000..86baaa8914f4
--- /dev/null
+++ b/go/updater/test/keybase-check-in-use-true.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+cat << EOS
+{
+ "in_use": true
+}
+EOS
diff --git a/go/updater/test/message1.txt b/go/updater/test/message1.txt
new file mode 100644
index 000000000000..3211ff27d324
--- /dev/null
+++ b/go/updater/test/message1.txt
@@ -0,0 +1 @@
+This is a test message
diff --git a/go/updater/test/message2.txt b/go/updater/test/message2.txt
new file mode 100644
index 000000000000..c6a3e4f14c44
--- /dev/null
+++ b/go/updater/test/message2.txt
@@ -0,0 +1 @@
+This is a different test message
diff --git a/go/updater/test/test-corrupted.zip b/go/updater/test/test-corrupted.zip
new file mode 100755
index 000000000000..8d8a49d001af
Binary files /dev/null and b/go/updater/test/test-corrupted.zip differ
diff --git a/go/updater/test/test-corrupted2.zip b/go/updater/test/test-corrupted2.zip
new file mode 100755
index 000000000000..90e0d6623656
Binary files /dev/null and b/go/updater/test/test-corrupted2.zip differ
diff --git a/go/updater/test/test-invalid.zip b/go/updater/test/test-invalid.zip
new file mode 100644
index 000000000000..b0ca6162dcd8
--- /dev/null
+++ b/go/updater/test/test-invalid.zip
@@ -0,0 +1 @@
+Not a zip file
diff --git a/go/updater/test/test-uid-503.zip b/go/updater/test/test-uid-503.zip
new file mode 100644
index 000000000000..6c1f0c4d415e
Binary files /dev/null and b/go/updater/test/test-uid-503.zip differ
diff --git a/go/updater/test/test-with-sym.zip b/go/updater/test/test-with-sym.zip
new file mode 100644
index 000000000000..49e837f97aed
Binary files /dev/null and b/go/updater/test/test-with-sym.zip differ
diff --git a/go/updater/test/test.go b/go/updater/test/test.go
new file mode 100644
index 000000000000..69ffa03d009c
--- /dev/null
+++ b/go/updater/test/test.go
@@ -0,0 +1,94 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+)
+
+type flags struct {
+ out string
+ outPath string
+}
+
+// This is a test executable built and installed prior to test run, which is
+// useful for testing some command.go functions.
+func main() {
+ f := flags{}
+ flag.StringVar(&f.out, "out", "", "Output")
+ flag.StringVar(&f.outPath, "outPath", "", "Output path")
+ flag.Parse()
+ var arg = flag.Arg(0)
+
+ switch arg {
+ case "noexit":
+ noexit()
+ case "output":
+ output()
+ case "echo":
+ echo(flag.Arg(1))
+ case "writeToFile":
+ writeToFile(f.out, f.outPath)
+ case "version":
+ echo("1.2.3-400+cafebeef")
+ case "err":
+ log.Fatal("Error")
+ case "sleep":
+ time.Sleep(10 * time.Second)
+ case "/layout":
+ if flag.NArg() < 4 {
+ log.Fatal("Error in /layout command: requires \"/layout /quiet /log filename\"")
+ }
+ copyFakeLayout(flag.Arg(3))
+ default:
+ log.Printf("test")
+ }
+}
+
+func noexit() {
+ c := make(chan os.Signal, 1)
+ signal.Notify(c, syscall.SIGTERM)
+ go func() {
+ <-c
+ fmt.Printf("Got SIGTERM, not exiting on purpose")
+ // Don't exit on SIGTERM, so we can test timeout with SIGKILL
+ }()
+ fmt.Printf("Waiting for 10 seconds...")
+ time.Sleep(10 * time.Second)
+}
+
+func output() {
+ fmt.Fprintln(os.Stdout, "stdout output")
+ fmt.Fprintln(os.Stderr, "stderr output")
+}
+
+func echo(s string) {
+ fmt.Fprintln(os.Stdout, s)
+}
+
+func writeToFile(s string, path string) {
+ err := os.WriteFile(path, []byte(s), 0700)
+ if err != nil {
+ log.Fatalf("Error writing to file: %s", err)
+ }
+}
+
+func copyFakeLayout(dst string) {
+ // Read all content of src to data
+ data, err := os.ReadFile("winlayout.log")
+ if err != nil {
+ log.Fatalf("Error reading winlayout.log: %s", err)
+ }
+ // Write data to dst
+ err = os.WriteFile(dst, data, 0644)
+ if err != nil {
+ log.Fatalf("Error writing to %s: %s", dst, err)
+ }
+}
diff --git a/go/updater/test/test.zip b/go/updater/test/test.zip
new file mode 100644
index 000000000000..c86b80de7af0
Binary files /dev/null and b/go/updater/test/test.zip differ
diff --git a/go/updater/test/testfile b/go/updater/test/testfile
new file mode 100644
index 000000000000..9f4b6d8bfeaf
--- /dev/null
+++ b/go/updater/test/testfile
@@ -0,0 +1 @@
+This is a test file
diff --git a/go/updater/test/update.json b/go/updater/test/update.json
new file mode 100644
index 000000000000..bd539c52d8ff
--- /dev/null
+++ b/go/updater/test/update.json
@@ -0,0 +1,15 @@
+{
+ "version": "1.2.3-400+abcdef",
+ "name": "1.2.3-400+abcdef",
+ "installId": "deadbeef",
+ "description": "This is an update!",
+ "type": 0,
+ "publishedAt": 1460660414000,
+ "asset": {
+ "name": "Test-1.2.3-400+abcdef.zip",
+ "url": "test.zip",
+ "digest": "3a147f31b25a6027bda15367def6f4499e29a9b531855c0ac881a8f3a83a12b9",
+ "signature": "BEGIN KEYBASE SALTPACK DETACHED SIGNATURE. kXR7VktZdyH7rvq v5wcIkPOwDJ1n11 M8RnkLKQGO2f3Bb fzCeMYz4S6oxLAy Cco4N255JFTAn8O 78IT0oJCfKGRxGx NGkFZsPsFKcFSjt pXUAgKgYFpxs8XM 2Nbn5qzg3t3rky3 bX8iMOXbqWLewah a7GnOT5bOlbzf8V 1uhiECJ0N6IvRBp D. END KEYBASE SALTPACK DETACHED SIGNATURE.\n",
+ "localPath": ""
+ }
+}
diff --git a/go/updater/update_checker.go b/go/updater/update_checker.go
new file mode 100644
index 000000000000..148209151cb6
--- /dev/null
+++ b/go/updater/update_checker.go
@@ -0,0 +1,79 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package updater
+
+import "time"
+
+const DefaultTickDuration = time.Hour
+
+// UpdateChecker runs updates checks every check duration
+type UpdateChecker struct {
+ updater *Updater
+ ctx Context
+ ticker *time.Ticker
+ log Log
+ tickDuration time.Duration // tickDuration is the ticker delay
+ count int // count is number of times we've checked
+}
+
+// NewUpdateChecker creates an update checker
+func NewUpdateChecker(updater *Updater, ctx Context, tickDuration time.Duration, log Log) UpdateChecker {
+ return UpdateChecker{
+ updater: updater,
+ ctx: ctx,
+ log: log,
+ tickDuration: tickDuration,
+ }
+}
+
+func (u *UpdateChecker) check() error {
+ u.count++
+ update, err := u.updater.Update(u.ctx)
+ u.ctx.AfterUpdateCheck(update)
+ return err
+}
+
+// Check checks for an update.
+func (u *UpdateChecker) Check() {
+ u.updater.config.SetLastUpdateCheckTime()
+ if err := u.check(); err != nil {
+ u.log.Errorf("Error in update: %s", err)
+ }
+}
+
+// Start starts the update checker. Returns false if we are already running.
+func (u *UpdateChecker) Start() bool {
+ if u.ticker != nil {
+ return false
+ }
+ u.ticker = time.NewTicker(u.tickDuration)
+ go func() {
+ // If we haven't done an update recently, check now.
+ // If there is an error getting the last update time, we don't trigger a
+ // check and let the ticker below trigger it.
+ if !u.updater.config.IsLastUpdateCheckTimeRecent(u.tickDuration) {
+ u.Check()
+ }
+
+ u.log.Debugf("Starting (ticker %s)", u.tickDuration)
+ for range u.ticker.C {
+ u.log.Debugf("%s", "Checking for update (ticker)")
+ u.Check()
+ }
+ }()
+ return true
+}
+
+// Stop stops the update checker
+func (u *UpdateChecker) Stop() {
+ if u.ticker != nil {
+ u.ticker.Stop()
+ u.ticker = nil
+ }
+}
+
+// Count is number of times the check has been called
+func (u UpdateChecker) Count() int {
+ return u.count
+}
diff --git a/go/updater/update_checker_test.go b/go/updater/update_checker_test.go
new file mode 100644
index 000000000000..975183e2a1d6
--- /dev/null
+++ b/go/updater/update_checker_test.go
@@ -0,0 +1,103 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package updater
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUpdateCheckerStart(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer func() {
+ // Give time for checker to stop before closing
+ time.Sleep(20 * time.Millisecond)
+ testServer.Close()
+ }()
+ updater, err := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{})
+ assert.NoError(t, err)
+
+ checker := NewUpdateChecker(updater, testUpdateCheckUI{}, 5*time.Millisecond, testLog)
+ defer checker.Stop()
+ started := checker.Start()
+ require.True(t, started)
+ started = checker.Start()
+ require.False(t, started)
+ // Wait for the count to increase (to prevent flakeyness on slow CIs)
+ for i := 0; checker.Count() == 0 && i < 10; i++ {
+ time.Sleep(5 * time.Millisecond)
+ }
+ assert.True(t, checker.Count() >= 1)
+
+ checker.Stop()
+}
+
+type testUpdateCheckUI struct {
+ verifyError error
+}
+
+func (u testUpdateCheckUI) BeforeUpdatePrompt(_ Update, _ UpdateOptions) error {
+ return nil
+}
+
+func (u testUpdateCheckUI) UpdatePrompt(_ Update, _ UpdateOptions, _ UpdatePromptOptions) (*UpdatePromptResponse, error) {
+ return &UpdatePromptResponse{Action: UpdateActionApply}, nil
+}
+
+func (u testUpdateCheckUI) BeforeApply(update Update) error {
+ return nil
+}
+
+func (u testUpdateCheckUI) Apply(update Update, options UpdateOptions, tmpDir string) error {
+ return nil
+}
+
+func (u testUpdateCheckUI) AfterApply(update Update) error {
+ return nil
+}
+
+func (u testUpdateCheckUI) GetUpdateUI() UpdateUI {
+ return u
+}
+
+func (u testUpdateCheckUI) Verify(update Update) error {
+ return u.verifyError
+}
+
+func (u testUpdateCheckUI) AfterUpdateCheck(update *Update) {}
+
+func (u testUpdateCheckUI) UpdateOptions() UpdateOptions {
+ return newDefaultTestUpdateOptions()
+}
+
+func (u testUpdateCheckUI) ReportAction(_ UpdatePromptResponse, _ *Update, _ UpdateOptions) {}
+
+func (u testUpdateCheckUI) ReportError(_ error, _ *Update, _ UpdateOptions) {}
+
+func (u testUpdateCheckUI) ReportSuccess(_ *Update, _ UpdateOptions) {}
+
+func (u testUpdateCheckUI) GetAppStatePath() string {
+ return ""
+}
+
+func (u testUpdateCheckUI) IsCheckCommand() bool {
+ return true
+}
+
+func (u testUpdateCheckUI) DeepClean() {}
+
+func TestUpdateCheckerError(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+ updater, err := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{})
+ assert.NoError(t, err)
+
+ checker := NewUpdateChecker(updater, testUpdateCheckUI{verifyError: fmt.Errorf("Test verify error")}, time.Minute, testLog)
+ err = checker.check()
+ require.Error(t, err)
+}
diff --git a/go/updater/updater.go b/go/updater/updater.go
new file mode 100644
index 000000000000..63c51a12bc3b
--- /dev/null
+++ b/go/updater/updater.go
@@ -0,0 +1,569 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package updater
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/keybase/client/go/updater/util"
+)
+
+// Version is the updater version
+const Version = "0.3.8"
+
+// Updater knows how to find and apply updates
+type Updater struct {
+ source UpdateSource
+ config Config
+ log Log
+ guiBusyCount int
+ tickDuration time.Duration
+}
+
+// UpdateSource defines where the updater can find updates
+type UpdateSource interface {
+ // Description is a short description about the update source
+ Description() string
+ // FindUpdate finds an update given options
+ FindUpdate(options UpdateOptions) (*Update, error)
+}
+
+// Context defines options, UI and hooks for the updater.
+// This is where you can define custom behavior specific to your apps.
+type Context interface {
+ GetUpdateUI() UpdateUI
+ UpdateOptions() UpdateOptions
+ Verify(update Update) error
+ BeforeUpdatePrompt(update Update, options UpdateOptions) error
+ BeforeApply(update Update) error
+ Apply(update Update, options UpdateOptions, tmpDir string) error
+ AfterApply(update Update) error
+ ReportError(err error, update *Update, options UpdateOptions)
+ ReportAction(updatePromptResponse UpdatePromptResponse, update *Update, options UpdateOptions)
+ ReportSuccess(update *Update, options UpdateOptions)
+ AfterUpdateCheck(update *Update)
+ GetAppStatePath() string
+ IsCheckCommand() bool
+ DeepClean()
+}
+
+// Config defines configuration for the Updater
+type Config interface {
+ GetUpdateAuto() (bool, bool)
+ SetUpdateAuto(b bool) error
+ GetUpdateAutoOverride() bool
+ SetUpdateAutoOverride(bool) error
+ GetInstallID() string
+ SetInstallID(installID string) error
+ IsLastUpdateCheckTimeRecent(d time.Duration) bool
+ SetLastUpdateCheckTime()
+ SetLastAppliedVersion(string) error
+ GetLastAppliedVersion() string
+}
+
+// Log is the logging interface for this package
+type Log interface {
+ Debug(...interface{})
+ Info(...interface{})
+ Debugf(s string, args ...interface{})
+ Infof(s string, args ...interface{})
+ Warningf(s string, args ...interface{})
+ Errorf(s string, args ...interface{})
+}
+
+// NewUpdater constructs an Updater
+func NewUpdater(source UpdateSource, config Config, log Log) *Updater {
+ return &Updater{
+ source: source,
+ config: config,
+ log: log,
+ tickDuration: DefaultTickDuration,
+ }
+}
+
+func (u *Updater) SetTickDuration(dur time.Duration) {
+ u.tickDuration = dur
+}
+
+// Update checks, downloads and performs an update
+func (u *Updater) Update(ctx Context) (*Update, error) {
+ options := ctx.UpdateOptions()
+ update, err := u.update(ctx, options)
+ report(ctx, err, update, options)
+ return update, err
+}
+
+// update returns the update received, and an error if the update was not
+// performed. The error with be of type Error. The error may be due to the user
+// (or system) canceling an update, in which case error.IsCancel() will be true.
+func (u *Updater) update(ctx Context, options UpdateOptions) (*Update, error) {
+ update, err := u.checkForUpdate(ctx, options)
+ if err != nil {
+ return nil, findErr(err)
+ }
+ if update == nil || !update.NeedUpdate {
+ // No update available
+ return nil, nil
+ }
+ u.log.Infof("Got update with version: %s", update.Version)
+
+ if update.missingAsset() {
+ return update, nil
+ }
+
+ if err := u.CleanupPreviousUpdates(); err != nil {
+ u.log.Infof("Error cleaning up previous downloads: %v", err)
+ }
+
+ tmpDir := u.tempDir()
+ defer u.Cleanup(tmpDir)
+ if err := u.downloadAsset(update.Asset, tmpDir, options); err != nil {
+ return update, downloadErr(err)
+ }
+
+ err = ctx.BeforeUpdatePrompt(*update, options)
+ if err != nil {
+ return update, err
+ }
+
+ // Prompt for update
+ updatePromptResponse, err := u.promptForUpdateAction(ctx, *update, options)
+ if err != nil {
+ return update, promptErr(err)
+ }
+ switch updatePromptResponse.Action {
+ case UpdateActionApply:
+ ctx.ReportAction(updatePromptResponse, update, options)
+ case UpdateActionAuto:
+ ctx.ReportAction(updatePromptResponse, update, options)
+ case UpdateActionSnooze:
+ ctx.ReportAction(updatePromptResponse, update, options)
+ return update, CancelErr(fmt.Errorf("Snoozed update"))
+ case UpdateActionCancel:
+ ctx.ReportAction(updatePromptResponse, update, options)
+ return update, CancelErr(fmt.Errorf("Canceled"))
+ case UpdateActionError:
+ return update, promptErr(fmt.Errorf("Unknown prompt error"))
+ case UpdateActionContinue:
+ // Continue
+ case UpdateActionUIBusy:
+ // Return nil so that AfterUpdateCheck won't exit the service
+ return nil, guiBusyErr(fmt.Errorf("User active, retrying later"))
+ }
+
+ // If we are auto-updating, do a final check if the user is active before
+ // killing the app. Note this can cause some churn with re-downloading the
+ // update on the next attempt.
+ if updatePromptResponse.Action == UpdateActionAuto && !ctx.IsCheckCommand() {
+ isActive, err := u.checkUserActive(ctx)
+ if err == nil && isActive {
+ return nil, guiBusyErr(fmt.Errorf("User active, retrying later"))
+ }
+ }
+
+ u.log.Infof("Verify asset: %s", update.Asset.LocalPath)
+ if err := ctx.Verify(*update); err != nil {
+ return update, verifyErr(err)
+ }
+
+ if err := u.apply(ctx, *update, options, tmpDir); err != nil {
+ return update, err
+ }
+
+ return update, nil
+}
+
+func (u *Updater) ApplyDownloaded(ctx Context) (bool, error) {
+ options := ctx.UpdateOptions()
+
+ // 1. check with the api server again for the latest update to be sure that a
+ // new update has not come out since our last call to CheckAndDownload
+ u.log.Infof("Attempting to apply previously downloaded update")
+ update, err := u.checkForUpdate(ctx, options)
+ if err != nil {
+ return false, findErr(err)
+ }
+
+ // Only report apply success/failure
+ applied, err := u.applyDownloaded(ctx, update, options)
+ defer report(ctx, err, update, options)
+ if err != nil {
+ return false, err
+ }
+ return applied, nil
+
+}
+
+// ApplyDownloaded will look for an previously downloaded update and attempt to apply it without prompting.
+// CheckAndDownload must be called first so that we have a download asset available to apply.
+func (u *Updater) applyDownloaded(ctx Context, update *Update, options UpdateOptions) (applied bool, err error) {
+ if update == nil || !update.NeedUpdate {
+ return false, fmt.Errorf("No previously downloaded update to apply since client is update to date")
+ }
+ u.log.Infof("Got update with version: %s", update.Version)
+
+ if update.missingAsset() {
+ return false, fmt.Errorf("Update contained no asset to apply. Update version: %s", update.Version)
+ }
+
+ // 2. check the disk via FindDownloadedAsset. Compare our API result to this
+ // result. If the downloaded update is stale, clear it and start over.
+ downloadedAssetPath, err := u.FindDownloadedAsset(update.Asset.Name)
+ if err != nil {
+ return false, err
+ }
+ defer func() {
+ if err := u.CleanupPreviousUpdates(); err != nil {
+ u.log.Infof("Error cleaning up previous downloads: %v", err)
+ }
+ }()
+ if downloadedAssetPath == "" {
+ return false, fmt.Errorf("No downloaded asset found for version: %s", update.Version)
+ }
+ update.Asset.LocalPath = downloadedAssetPath
+
+ // 3. otherwise use the update on disk and apply it.
+ if err = util.CheckDigest(update.Asset.Digest, downloadedAssetPath, u.log); err != nil {
+ return false, verifyErr(err)
+ }
+ u.log.Infof("Verify asset: %s", downloadedAssetPath)
+ if err := ctx.Verify(*update); err != nil {
+ return false, verifyErr(err)
+ }
+
+ tmpDir := os.TempDir()
+ if err := u.apply(ctx, *update, options, tmpDir); err != nil {
+ return false, err
+ }
+
+ return true, nil
+}
+
+func (u *Updater) apply(ctx Context, update Update, options UpdateOptions, tmpDir string) error {
+ u.log.Info("Before apply")
+ if err := ctx.BeforeApply(update); err != nil {
+ return applyErr(err)
+ }
+
+ u.log.Info("Applying update")
+ if err := ctx.Apply(update, options, tmpDir); err != nil {
+ u.log.Info("Apply error: %v", err)
+ return applyErr(err)
+ }
+
+ u.log.Info("After apply")
+ if err := ctx.AfterApply(update); err != nil {
+ return applyErr(err)
+ }
+
+ return nil
+}
+
+// downloadAsset will download the update to a temporary path (if not cached),
+// check the digest, and set the LocalPath property on the asset.
+func (u *Updater) downloadAsset(asset *Asset, tmpDir string, options UpdateOptions) error {
+ if asset == nil {
+ return fmt.Errorf("No asset to download")
+ }
+ downloadOptions := util.DownloadURLOptions{
+ Digest: asset.Digest,
+ RequireDigest: true,
+ UseETag: true,
+ Log: u.log,
+ }
+
+ downloadPath := filepath.Join(tmpDir, asset.Name)
+ // If asset had a file extension, lets add it back on
+ if err := util.DownloadURL(asset.URL, downloadPath, downloadOptions); err != nil {
+ return err
+ }
+
+ asset.LocalPath = downloadPath
+ return nil
+}
+
+// checkForUpdate checks a update source (like a remote API) for an update.
+// It may set an InstallID, if the server tells us to.
+func (u *Updater) checkForUpdate(ctx Context, options UpdateOptions) (*Update, error) {
+ u.log.Infof("Checking for update, current version is %s", options.Version)
+ u.log.Infof("Using updater source: %s", u.source.Description())
+ u.log.Debugf("Using options: %#v", options)
+
+ update, findErr := u.source.FindUpdate(options)
+ if findErr != nil {
+ return nil, findErr
+ }
+ if update == nil {
+ return nil, nil
+ }
+
+ // Save InstallID if we received one
+ if update.InstallID != "" && u.config.GetInstallID() != update.InstallID {
+ u.log.Debugf("Saving install ID: %s", update.InstallID)
+ if err := u.config.SetInstallID(update.InstallID); err != nil {
+ u.log.Warningf("Error saving install ID: %s", err)
+ ctx.ReportError(configErr(fmt.Errorf("Error saving install ID: %s", err)), update, options)
+ }
+ }
+
+ return update, nil
+}
+
+// NeedUpdate returns true if we are out-of-date.
+func (u *Updater) NeedUpdate(ctx Context) (upToDate bool, err error) {
+ update, err := u.checkForUpdate(ctx, ctx.UpdateOptions())
+ if err != nil {
+ return false, err
+ }
+ return update.NeedUpdate, nil
+}
+
+func (u *Updater) CheckAndDownload(ctx Context) (updateAvailable, updateWasDownloaded bool, err error) {
+ options := ctx.UpdateOptions()
+ update, err := u.checkForUpdate(ctx, options)
+ if err != nil {
+ return false, false, err
+ }
+
+ if !update.NeedUpdate || update.missingAsset() {
+ return false, false, nil
+ }
+
+ var tmpDir string
+ defer func() {
+ // If anything in this process errors cleanup the downloaded asset
+ if err != nil {
+ if err := u.CleanupPreviousUpdates(); err != nil {
+ u.log.Infof("Error cleaning up previous downloads: %v", err)
+ }
+ }
+ if tmpDir != "" {
+ u.Cleanup(tmpDir)
+ }
+ }()
+ var digestChecked bool
+ downloadedAssetPath, err := u.FindDownloadedAsset(update.Asset.Name)
+ if downloadedAssetPath == "" || err != nil {
+ u.log.Infof("Could not find existing download asset for version: %s. Downloading new asset.", update.Version)
+ tmpDir = u.tempDir()
+ // This will set update.Asset.LocalPath
+ if err := u.downloadAsset(update.Asset, tmpDir, options); err != nil {
+ return false, false, downloadErr(err)
+ }
+ updateWasDownloaded = true
+ digestChecked = true
+ downloadedAssetPath = update.Asset.LocalPath
+ }
+ // Verify depends on LocalPath being set to the downloaded asset
+ update.Asset.LocalPath = downloadedAssetPath
+
+ u.log.Infof("Verify asset: %s", downloadedAssetPath)
+ if err := ctx.Verify(*update); err != nil {
+ return false, false, verifyErr(err)
+ }
+
+ if !digestChecked {
+ if err = util.CheckDigest(update.Asset.Digest, downloadedAssetPath, u.log); err != nil {
+ return false, false, verifyErr(err)
+ }
+ }
+
+ return true, updateWasDownloaded, nil
+}
+
+// promptForUpdateAction prompts the user for permission to apply an update
+func (u *Updater) promptForUpdateAction(ctx Context, update Update, options UpdateOptions) (UpdatePromptResponse, error) {
+ u.log.Debug("Prompt for update")
+
+ auto, autoSet := u.config.GetUpdateAuto()
+ autoOverride := u.config.GetUpdateAutoOverride()
+ u.log.Debugf("Auto update: %s (set=%s autoOverride=%s)", strconv.FormatBool(auto), strconv.FormatBool(autoSet), strconv.FormatBool(autoOverride))
+ if auto && !autoOverride {
+ if !ctx.IsCheckCommand() {
+ // If there's an error getting active status, we'll just update
+ isActive, err := u.checkUserActive(ctx)
+ if err == nil && isActive {
+ return UpdatePromptResponse{UpdateActionUIBusy, false, 0}, nil
+ }
+ u.guiBusyCount = 0
+ }
+ return UpdatePromptResponse{UpdateActionAuto, false, 0}, nil
+ }
+
+ updateUI := ctx.GetUpdateUI()
+
+ // If auto update never set, default to true
+ autoUpdate := auto || !autoSet
+ promptOptions := UpdatePromptOptions{AutoUpdate: autoUpdate}
+ updatePromptResponse, err := updateUI.UpdatePrompt(update, options, promptOptions)
+ if err != nil {
+ return UpdatePromptResponse{UpdateActionError, false, 0}, err
+ }
+ if updatePromptResponse == nil {
+ return UpdatePromptResponse{UpdateActionError, false, 0}, fmt.Errorf("No response")
+ }
+
+ if updatePromptResponse.Action != UpdateActionContinue {
+ u.log.Debugf("Update prompt response: %#v", updatePromptResponse)
+ if err := u.config.SetUpdateAuto(updatePromptResponse.AutoUpdate); err != nil {
+ u.log.Warningf("Error setting auto preference: %s", err)
+ ctx.ReportError(configErr(fmt.Errorf("Error setting auto preference: %s", err)), &update, options)
+ }
+ }
+
+ return *updatePromptResponse, nil
+}
+
+type guiAppState struct {
+ IsUserActive bool `json:"isUserActive"`
+ ChangedAtMs int64 `json:"changedAtMs"`
+}
+
+func (u *Updater) checkUserActive(ctx Context) (bool, error) {
+ if time.Duration(u.guiBusyCount)*u.tickDuration >= time.Hour*6 { // Allow the update through after 6 hours
+ u.log.Warningf("Waited for GUI %d times - ignoring busy", u.guiBusyCount)
+ return false, nil
+ }
+
+ // Read app-state.json, written by the GUI
+ rawState, err := util.ReadFile(ctx.GetAppStatePath())
+ if err != nil {
+ u.log.Warningf("Error reading GUI state - proceeding", err)
+ return false, err
+ }
+
+ guistate := guiAppState{}
+ if err = json.Unmarshal(rawState, &guistate); err != nil {
+ u.log.Warningf("Error parsing GUI state - proceeding", err)
+ return false, err
+ }
+ // check if the user is currently active or was active in the last 5
+ // minutes.
+ isActive := guistate.IsUserActive || time.Since(time.Unix(guistate.ChangedAtMs/1000, 0)) <= time.Minute*5
+ if isActive {
+ u.guiBusyCount++
+ u.log.Infof("GUI busy on attempt %d", u.guiBusyCount)
+ }
+
+ return isActive, nil
+}
+
+func report(ctx Context, err error, update *Update, options UpdateOptions) {
+ if err != nil {
+ // Don't report cancels or GUI busy
+ if e, ok := err.(Error); ok {
+ if e.IsCancel() || e.IsGUIBusy() {
+ return
+ }
+ }
+ ctx.ReportError(err, update, options)
+ } else if update != nil {
+ ctx.ReportSuccess(update, options)
+ }
+}
+
+// tempDir, if specified, will contain files that were replaced during an update
+// and will be removed after an update. The temp dir should already exist.
+func (u *Updater) tempDir() string {
+ tmpDir := util.TempPath("", "KeybaseUpdater.")
+ if err := util.MakeDirs(tmpDir, 0700, u.log); err != nil {
+ u.log.Warningf("Error trying to create temp dir: %s", err)
+ return ""
+ }
+ return tmpDir
+}
+
+var tempDirRE = regexp.MustCompile(`^KeybaseUpdater.([ABCDEFGHIJKLMNOPQRSTUVWXYZ234567]{52}|\d{18,})$`)
+
+// CleanupPreviousUpdates removes temporary files from previous updates.
+func (u *Updater) CleanupPreviousUpdates() (err error) {
+ parent := os.TempDir()
+ if parent == "" || parent == "." {
+ return fmt.Errorf("temp directory is '%v'", parent)
+ }
+ files, err := os.ReadDir(parent)
+ if err != nil {
+ return fmt.Errorf("listing parent directory: %v", err)
+ }
+ for _, fi := range files {
+ if !fi.IsDir() {
+ continue
+ }
+ if tempDirRE.MatchString(fi.Name()) {
+ targetPath := filepath.Join(parent, fi.Name())
+ u.log.Debugf("Cleaning old download: %v", targetPath)
+ err = os.RemoveAll(targetPath)
+ if err != nil {
+ u.log.Infof("Error deleting old temp dir %v: %v", fi.Name(), err)
+ }
+ }
+ }
+ return nil
+}
+
+// Cleanup removes temporary files from this update
+func (u *Updater) Cleanup(tmpDir string) {
+ if tmpDir != "" {
+ u.log.Debugf("Remove temporary directory: %q", tmpDir)
+ if err := os.RemoveAll(tmpDir); err != nil {
+ u.log.Warningf("Error removing temporary directory %q: %s", tmpDir, err)
+ }
+ }
+}
+
+// Inspect previously downloaded updates to avoid redownloading
+func (u *Updater) FindDownloadedAsset(assetName string) (matchingAssetPath string, err error) {
+ if assetName == "" {
+ return "", fmt.Errorf("No asset name provided")
+ }
+ parent := os.TempDir()
+ if parent == "" || parent == "." {
+ return matchingAssetPath, fmt.Errorf("temp directory is %v", parent)
+ }
+
+ files, err := os.ReadDir(parent)
+ if err != nil {
+ return matchingAssetPath, fmt.Errorf("listing parent directory: %v", err)
+ }
+
+ for _, fi := range files {
+ if !fi.IsDir() || !tempDirRE.MatchString(fi.Name()) {
+ continue
+ }
+
+ keybaseTempDirAbs := filepath.Join(parent, fi.Name())
+ walkErr := filepath.Walk(keybaseTempDirAbs, func(fullPath string, info os.FileInfo, inErr error) (err error) {
+ if inErr != nil {
+ return inErr
+ }
+
+ if info.IsDir() {
+ if fullPath == keybaseTempDirAbs {
+ return nil
+ }
+ return filepath.SkipDir
+ }
+
+ path := strings.TrimPrefix(fullPath, keybaseTempDirAbs+string(filepath.Separator))
+ if path == assetName {
+ matchingAssetPath = fullPath
+ return filepath.SkipDir
+ }
+
+ return nil
+ })
+
+ if walkErr != nil {
+ return "", walkErr
+ }
+ }
+ return matchingAssetPath, nil
+}
diff --git a/go/updater/updater_test.go b/go/updater/updater_test.go
new file mode 100644
index 000000000000..257763c22edd
--- /dev/null
+++ b/go/updater/updater_test.go
@@ -0,0 +1,798 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package updater
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/keybase/client/go/updater/saltpack"
+ "github.com/keybase/client/go/updater/util"
+ "github.com/keybase/go-logging"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testLog = &logging.Logger{Module: "test"}
+
+var testZipPath string
+
+var testAppStatePath = filepath.Join(os.TempDir(), "KBTest_app_state.json")
+
+const (
+ // shasum -a 256 test/test.zip
+ validDigest = "54970995e4d02da631e0634162ef66e2663e0eee7d018e816ac48ed6f7811c84"
+ // keybase sign -d -i test.zip
+ validSignature = `BEGIN KEYBASE SALTPACK DETACHED SIGNATURE.
+kXR7VktZdyH7rvq v5weRa8moXPeKBe e2YLT0PnyHzCrVi RbC1J5uJtYgYyLW eGg4qzsWqkb7hcX
+GTVc0vsEUVwBCly qhPdOL0mE19kfxg A4fMqpNGNTY0jtO iMpjwwuIyLBxkCC jHzMiJFskzluz2S
+otWUI0nTu2vG2Fx Mgeyqm20Ug8j7Bi N. END KEYBASE SALTPACK DETACHED SIGNATURE.`
+ invalidDigest = "74970995e4d02da631e0634162ef66e2663e0eee7d018e816ac48ed6f7811c84"
+ invalidSignature = `BEGIN KEYBASE SALTPACK DETACHED SIGNATURE.
+ QXR7VktZdyH7rvq v5wcIkPOwDJ1n11 M8RnkLKQGO2f3Bb fzCeMYz4S6oxLAy
+ Cco4N255JFzv2PX E6WWdobANV4guJI iEE8XJb6uudCX4x QWZfnamVAaZpXuW
+ vdz65rE7oZsLSdW oxMsbBgG9NVpSJy x3CD6LaC9GlZ4IS ofzkHe401mHjr7M M. END
+ KEYBASE SALTPACK DETACHED SIGNATURE.`
+)
+
+func makeKeybaseUpdateTempDir(t *testing.T, updater *Updater, testAsset *Asset) (tmpDir string) {
+ // This creates a real KebyaseUpdater.[ID] directory in os.TempDir
+ // Then we download the test zip to this directory from testServer
+ tmpDir, err := util.MakeTempDir("KeybaseUpdater.", 0700)
+ require.NoError(t, err)
+ err = updater.downloadAsset(testAsset, tmpDir, UpdateOptions{})
+ require.NoError(t, err)
+ return tmpDir
+}
+
+func init() {
+ _, filename, _, _ := runtime.Caller(0)
+ testZipPath = filepath.Join(filepath.Dir(filename), "test/test.zip")
+}
+
+func newTestUpdater(t *testing.T) (*Updater, error) {
+ return newTestUpdaterWithServer(t, nil, nil, &testConfig{})
+}
+
+func newTestUpdaterWithServer(t *testing.T, testServer *httptest.Server, update *Update, config Config) (*Updater, error) {
+ return NewUpdater(testUpdateSource{testServer: testServer, config: config, update: update}, config, testLog), nil
+}
+
+func newTestContext(options UpdateOptions, cfg Config, response *UpdatePromptResponse) *testUpdateUI {
+ return &testUpdateUI{options: options, cfg: cfg, response: response}
+}
+
+type testUpdateUI struct {
+ options UpdateOptions
+ cfg Config
+ response *UpdatePromptResponse
+ promptErr error
+ verifyErr error
+ beforeApplyErr error
+ afterApplyErr error
+ errReported error
+ actionReported UpdateAction
+ autoUpdateReported bool
+ updateReported *Update
+ successReported bool
+ isCheckCommand bool
+}
+
+func (u testUpdateUI) BeforeUpdatePrompt(_ Update, _ UpdateOptions) error {
+ return nil
+}
+
+func (u testUpdateUI) UpdatePrompt(_ Update, _ UpdateOptions, _ UpdatePromptOptions) (*UpdatePromptResponse, error) {
+ if u.promptErr != nil {
+ return nil, u.promptErr
+ }
+ return u.response, nil
+}
+
+func (u testUpdateUI) BeforeApply(update Update) error {
+ return u.beforeApplyErr
+}
+
+func (u testUpdateUI) Apply(update Update, options UpdateOptions, tmpDir string) error {
+ return nil
+}
+
+func (u testUpdateUI) AfterApply(update Update) error {
+ return u.afterApplyErr
+}
+
+func (u testUpdateUI) GetUpdateUI() UpdateUI {
+ return u
+}
+
+func (u testUpdateUI) Verify(update Update) error {
+ if u.verifyErr != nil {
+ return u.verifyErr
+ }
+ var validCodeSigningKIDs = map[string]bool{
+ "0120d7539e27e83a9c8caf8701199c6985c0a96801ff7cb69456e9b3a8a8446c66080a": true, // joshblum (saltine)
+ }
+ return saltpack.VerifyDetachedFileAtPath(update.Asset.LocalPath, update.Asset.Signature, validCodeSigningKIDs, testLog)
+}
+
+func (u *testUpdateUI) ReportError(err error, update *Update, options UpdateOptions) {
+ u.errReported = err
+}
+
+func (u *testUpdateUI) ReportAction(actionResponse UpdatePromptResponse, update *Update, options UpdateOptions) {
+ u.actionReported = actionResponse.Action
+ autoUpdate, _ := u.cfg.GetUpdateAuto()
+ u.autoUpdateReported = autoUpdate
+ u.updateReported = update
+}
+
+func (u *testUpdateUI) ReportSuccess(update *Update, options UpdateOptions) {
+ u.successReported = true
+ u.updateReported = update
+}
+
+func (u *testUpdateUI) AfterUpdateCheck(update *Update) {}
+
+func (u testUpdateUI) UpdateOptions() UpdateOptions {
+ return u.options
+}
+
+func (u testUpdateUI) GetAppStatePath() string {
+ return testAppStatePath
+}
+
+func (u testUpdateUI) IsCheckCommand() bool {
+ return u.isCheckCommand
+}
+
+func (u testUpdateUI) DeepClean() {}
+
+type testUpdateSource struct {
+ testServer *httptest.Server
+ config Config
+ update *Update
+ findErr error
+}
+
+func (u testUpdateSource) Description() string {
+ return "Test"
+}
+
+func testUpdate(uri string) *Update {
+ return newTestUpdate(uri, true)
+}
+
+func newTestUpdate(uri string, needUpdate bool) *Update {
+ update := &Update{
+ Version: "1.0.1",
+ Name: "Test",
+ Description: "Bug fixes",
+ InstallID: "deadbeef",
+ RequestID: "cafedead",
+ NeedUpdate: needUpdate,
+ }
+ if uri != "" {
+ update.Asset = &Asset{
+ Name: "test.zip",
+ URL: uri,
+ Digest: validDigest,
+ Signature: validSignature,
+ }
+ }
+ return update
+}
+
+func (u testUpdateSource) FindUpdate(options UpdateOptions) (*Update, error) {
+ return u.update, u.findErr
+}
+
+type testConfig struct {
+ auto bool
+ autoSet bool
+ autoOverride bool
+ installID string
+ err error
+}
+
+func (c testConfig) GetUpdateAuto() (bool, bool) {
+ return c.auto, c.autoSet
+}
+
+func (c *testConfig) SetUpdateAuto(b bool) error {
+ c.auto = b
+ c.autoSet = true
+ return c.err
+}
+
+func (c *testConfig) IsLastUpdateCheckTimeRecent(d time.Duration) bool {
+ return true
+}
+
+func (c *testConfig) SetLastUpdateCheckTime() {
+
+}
+
+// For overriding the current Auto setting
+func (c testConfig) GetUpdateAutoOverride() bool {
+ return c.autoOverride
+}
+
+func (c *testConfig) SetUpdateAutoOverride(auto bool) error {
+ c.autoOverride = auto
+ return nil
+}
+
+func (c testConfig) GetInstallID() string {
+ return c.installID
+}
+
+func (c *testConfig) SetInstallID(s string) error {
+ c.installID = s
+ return c.err
+}
+
+func (c testConfig) GetLastAppliedVersion() string {
+ return ""
+}
+
+func (c *testConfig) SetLastAppliedVersion(version string) error {
+ return nil
+}
+
+func newDefaultTestUpdateOptions() UpdateOptions {
+ return UpdateOptions{
+ Version: "1.0.0",
+ Platform: runtime.GOOS,
+ DestinationPath: filepath.Join(os.TempDir(), "Test"),
+ }
+}
+
+func testServerForUpdateFile(t *testing.T, path string) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ f, err := os.Open(path)
+ require.NoError(t, err)
+ w.Header().Set("Content-Type", "application/zip")
+ _, err = io.Copy(w, f)
+ require.NoError(t, err)
+ }))
+}
+
+func testServerForError(t *testing.T, err error) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, err.Error(), 500)
+ }))
+}
+
+func testServerNotFound(t *testing.T) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "Not Found", 404)
+ }))
+}
+
+func TestUpdaterApply(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, err := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{})
+ assert.NoError(t, err)
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionApply, AutoUpdate: true})
+ update, err := upr.Update(ctx)
+ require.NoError(t, err)
+ require.NotNil(t, update)
+ t.Logf("Update: %#v\n", *update)
+ require.NotNil(t, update.Asset)
+ t.Logf("Asset: %#v\n", *update.Asset)
+
+ auto, autoSet := upr.config.GetUpdateAuto()
+ assert.True(t, auto)
+ assert.True(t, autoSet)
+ assert.Equal(t, "deadbeef", upr.config.GetInstallID())
+
+ assert.Nil(t, ctx.errReported)
+ assert.Equal(t, ctx.actionReported, UpdateActionApply)
+ assert.True(t, ctx.autoUpdateReported)
+
+ require.NotNil(t, ctx.updateReported)
+ assert.Equal(t, "deadbeef", ctx.updateReported.InstallID)
+ assert.Equal(t, "cafedead", ctx.updateReported.RequestID)
+ assert.True(t, ctx.successReported)
+
+ assert.Equal(t, "apply", UpdateActionApply.String())
+}
+
+func TestUpdaterDownloadError(t *testing.T) {
+ testServer := testServerForError(t, fmt.Errorf("bad response"))
+ defer testServer.Close()
+
+ upr, err := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{})
+ assert.NoError(t, err)
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionApply, AutoUpdate: true})
+ _, err = upr.Update(ctx)
+ assert.EqualError(t, err, "Update Error (download): Responded with 500 Internal Server Error")
+
+ require.NotNil(t, ctx.errReported)
+ assert.Equal(t, ctx.errReported.(Error).errorType, DownloadError)
+ assert.False(t, ctx.successReported)
+}
+
+func TestUpdaterCancel(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, err := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{})
+ assert.NoError(t, err)
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionCancel, AutoUpdate: true})
+ _, err = upr.Update(ctx)
+ assert.EqualError(t, err, "Update Error (cancel): Canceled")
+
+ // Don't report error on user cancel
+ assert.NoError(t, ctx.errReported)
+}
+
+func TestUpdaterSnooze(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, err := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{})
+ assert.NoError(t, err)
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionSnooze, AutoUpdate: true})
+ _, err = upr.Update(ctx)
+ assert.EqualError(t, err, "Update Error (cancel): Snoozed update")
+
+ // Don't report error on user snooze
+ assert.NoError(t, ctx.errReported)
+}
+
+func TestUpdaterContinue(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, err := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{})
+ assert.NoError(t, err)
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionContinue})
+ update, err := upr.Update(ctx)
+ require.NoError(t, err)
+ require.NotNil(t, update)
+ require.NotNil(t, update.Asset)
+
+ auto, autoSet := upr.config.GetUpdateAuto()
+ assert.False(t, auto)
+ assert.False(t, autoSet)
+ assert.Equal(t, "deadbeef", upr.config.GetInstallID())
+
+ assert.Nil(t, ctx.errReported)
+ assert.Empty(t, string(ctx.actionReported))
+ assert.False(t, ctx.autoUpdateReported)
+
+ require.NotNil(t, ctx.updateReported)
+ assert.Equal(t, "deadbeef", ctx.updateReported.InstallID)
+ assert.Equal(t, "cafedead", ctx.updateReported.RequestID)
+ assert.True(t, ctx.successReported)
+}
+
+func TestUpdateNoResponse(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, err := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{})
+ assert.NoError(t, err)
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, nil)
+ _, err = upr.Update(ctx)
+ assert.EqualError(t, err, "Update Error (prompt): No response")
+
+ require.NotNil(t, ctx.errReported)
+ assert.Equal(t, ctx.errReported.(Error).errorType, PromptError)
+ assert.False(t, ctx.successReported)
+}
+
+func TestUpdateNoAsset(t *testing.T) {
+ testServer := testServerNotFound(t)
+ defer testServer.Close()
+
+ upr, err := newTestUpdaterWithServer(t, testServer, testUpdate(""), &testConfig{})
+ assert.NoError(t, err)
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionApply, AutoUpdate: true})
+ update, err := upr.Update(ctx)
+ assert.NoError(t, err)
+ assert.Nil(t, update.Asset)
+}
+
+func testUpdaterError(t *testing.T, errorType ErrorType) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, _ := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{})
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionApply, AutoUpdate: true})
+ testErr := fmt.Errorf("Test error")
+ switch errorType {
+ case PromptError:
+ ctx.promptErr = testErr
+ case VerifyError:
+ ctx.verifyErr = testErr
+ }
+
+ _, err := upr.Update(ctx)
+ assert.EqualError(t, err, fmt.Sprintf("Update Error (%s): Test error", errorType.String()))
+
+ require.NotNil(t, ctx.errReported)
+ assert.Equal(t, ctx.errReported.(Error).errorType, errorType)
+}
+
+func TestUpdaterErrors(t *testing.T) {
+ testUpdaterError(t, PromptError)
+ testUpdaterError(t, VerifyError)
+}
+
+func TestUpdaterConfigError(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, _ := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{err: fmt.Errorf("Test config error")})
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionApply, AutoUpdate: true})
+
+ _, err := upr.Update(ctx)
+ assert.NoError(t, err)
+
+ require.NotNil(t, ctx.errReported)
+ assert.Equal(t, ConfigError, ctx.errReported.(Error).errorType)
+}
+
+func TestUpdaterAuto(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, _ := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{auto: true, autoSet: true})
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionApply, AutoUpdate: true})
+
+ _, err := upr.Update(ctx)
+ assert.NoError(t, err)
+ assert.Equal(t, UpdateActionAuto, ctx.actionReported)
+}
+
+func TestUpdaterDownloadNil(t *testing.T) {
+ upr, err := newTestUpdater(t)
+ require.NoError(t, err)
+ tmpDir, err := util.MakeTempDir("TestUpdaterDownloadNil", 0700)
+ defer util.RemoveFileAtPath(tmpDir)
+ require.NoError(t, err)
+ err = upr.downloadAsset(nil, tmpDir, UpdateOptions{})
+ assert.EqualError(t, err, "No asset to download")
+}
+
+func TestUpdaterApplyError(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, _ := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{auto: true, autoSet: true})
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionApply, AutoUpdate: true})
+
+ ctx.beforeApplyErr = fmt.Errorf("Test before error")
+ _, err := upr.Update(ctx)
+ assert.EqualError(t, err, "Update Error (apply): Test before error")
+ ctx.beforeApplyErr = nil
+
+ ctx.afterApplyErr = fmt.Errorf("Test after error")
+ _, err = upr.Update(ctx)
+ assert.EqualError(t, err, "Update Error (apply): Test after error")
+}
+
+func TestUpdaterNotNeeded(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, err := newTestUpdaterWithServer(t, testServer, newTestUpdate(testServer.URL, false), &testConfig{})
+ assert.NoError(t, err)
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionSnooze, AutoUpdate: true})
+ update, err := upr.Update(ctx)
+ assert.NoError(t, err)
+ assert.Nil(t, update)
+
+ assert.False(t, ctx.successReported)
+ assert.Equal(t, "deadbeef", upr.config.GetInstallID())
+}
+
+func TestUpdaterCheckAndUpdate(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ testUpdate := newTestUpdate(testServer.URL, false)
+ upr, err := newTestUpdaterWithServer(t, testServer, testUpdate, &testConfig{})
+ assert.NoError(t, err)
+ defer func() {
+ err = upr.CleanupPreviousUpdates()
+ assert.NoError(t, err)
+ }()
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionSnooze, AutoUpdate: true})
+
+ // 1.No update from the server
+ // Need update = false
+ // FindDownloadedAsset = false
+ // return updateAvailable = false, updateWasDownloaded = false
+ updateAvailable, updateWasDownloaded, err := upr.CheckAndDownload(ctx)
+ assert.NoError(t, err)
+ assert.False(t, updateAvailable)
+ assert.False(t, updateWasDownloaded)
+ assert.False(t, ctx.successReported)
+ assert.Equal(t, "deadbeef", upr.config.GetInstallID())
+
+ // 2. Download asset from URL
+ // Need update = true
+ testUpdate.NeedUpdate = true
+ updateAvailable, updateWasDownloaded, err = upr.CheckAndDownload(ctx)
+ assert.NoError(t, err)
+ assert.True(t, updateAvailable)
+ assert.True(t, updateWasDownloaded)
+ assert.False(t, ctx.successReported)
+ assert.Equal(t, "deadbeef", upr.config.GetInstallID())
+
+ // 3.Find existing downloaded asset
+ // Need update = true
+ // FindDownloadedAsset = true
+ // return updateAvailable = true, updateWasDownloaded = true
+ tmpDir := makeKeybaseUpdateTempDir(t, upr, testUpdate.Asset)
+ updateAvailable, updateWasDownloaded, err = upr.CheckAndDownload(ctx)
+ assert.NoError(t, err)
+ assert.True(t, updateAvailable)
+ assert.False(t, updateWasDownloaded)
+ assert.False(t, ctx.successReported)
+ assert.Equal(t, "deadbeef", upr.config.GetInstallID())
+
+ // Run it again to ensure we don't accidentally download again
+ updateAvailable, updateWasDownloaded, err = upr.CheckAndDownload(ctx)
+ assert.NoError(t, err)
+ assert.True(t, updateAvailable)
+ assert.False(t, updateWasDownloaded)
+ assert.False(t, ctx.successReported)
+ assert.Equal(t, "deadbeef", upr.config.GetInstallID())
+
+ util.RemoveFileAtPath(tmpDir)
+
+ // 4.Verify fails b.c. bit flip
+ // Need update = true
+ // FindDownloadedAsset = true
+ // return updateAvailable = false, updateWasDownloaded = false
+ tmpDir = makeKeybaseUpdateTempDir(t, upr, testUpdate.Asset)
+ testUpdate.Asset.Signature = invalidSignature
+
+ updateAvailable, updateWasDownloaded, err = upr.CheckAndDownload(ctx)
+ assert.EqualError(t, err, "Update Error (verify): error verifying signature: failed to read header bytes")
+ assert.False(t, updateAvailable)
+ assert.False(t, updateWasDownloaded)
+ assert.False(t, ctx.successReported)
+ assert.Equal(t, "deadbeef", upr.config.GetInstallID())
+
+ util.RemoveFileAtPath(tmpDir)
+ testUpdate.Asset.Signature = validSignature
+
+ // 5.Digest fails b.c. bit flip
+ // Need update = true
+ // FindDownloadedAsset = true
+ // return updateAvailable = false, updateWasDownloaded = false
+ tmpDir = makeKeybaseUpdateTempDir(t, upr, testUpdate.Asset)
+ testUpdate.Asset.Digest = invalidDigest
+
+ updateAvailable, updateWasDownloaded, err = upr.CheckAndDownload(ctx)
+ assert.EqualError(t, err, fmt.Sprintf("Update Error (verify): Invalid digest: 54970995e4d02da631e0634162ef66e2663e0eee7d018e816ac48ed6f7811c84 != 74970995e4d02da631e0634162ef66e2663e0eee7d018e816ac48ed6f7811c84 (%s)", filepath.Join(tmpDir, testUpdate.Asset.Name)))
+ assert.False(t, updateAvailable)
+ assert.False(t, updateWasDownloaded)
+ assert.False(t, ctx.successReported)
+ assert.Equal(t, "deadbeef", upr.config.GetInstallID())
+
+ util.RemoveFileAtPath(tmpDir)
+ testUpdate.Asset.Digest = validDigest
+}
+
+func TestApplyDownloaded(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ testUpdate := newTestUpdate(testServer.URL, false)
+ testAsset := *testUpdate.Asset
+ upr, err := newTestUpdaterWithServer(t, testServer, testUpdate, &testConfig{})
+ assert.NoError(t, err)
+ defer func() {
+ err = upr.CleanupPreviousUpdates()
+ assert.NoError(t, err)
+ }()
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionSnooze, AutoUpdate: true})
+ resetCtxErr := func() {
+ ctx.promptErr = nil
+ ctx.verifyErr = nil
+ ctx.beforeApplyErr = nil
+ ctx.afterApplyErr = nil
+ ctx.errReported = nil
+ }
+
+ // 1. NeedUpdate = false -> return nil
+ applied, err := upr.ApplyDownloaded(ctx)
+ assert.EqualError(t, err, "No previously downloaded update to apply since client is update to date")
+ assert.False(t, applied)
+ assert.NotNil(t, ctx.errReported)
+ assert.Nil(t, ctx.updateReported)
+ assert.False(t, ctx.successReported)
+
+ resetCtxErr()
+
+ // 2. Update missing asset
+ testUpdate.NeedUpdate = true
+ testUpdate.Asset = nil
+
+ applied, err = upr.ApplyDownloaded(ctx)
+ assert.EqualError(t, err, "Update contained no asset to apply. Update version: 1.0.1")
+ assert.False(t, applied)
+ assert.NotNil(t, ctx.errReported)
+ assert.Nil(t, ctx.updateReported)
+ assert.False(t, ctx.successReported)
+
+ resetCtxErr()
+ testUpdate.Asset = &testAsset
+ tempURL := testUpdate.Asset.URL
+ testUpdate.Asset.URL = ""
+
+ applied, err = upr.ApplyDownloaded(ctx)
+ assert.EqualError(t, err, "Update contained no asset to apply. Update version: 1.0.1")
+ assert.False(t, applied)
+ assert.NotNil(t, ctx.errReported)
+ assert.Nil(t, ctx.updateReported)
+ assert.False(t, ctx.successReported)
+
+ resetCtxErr()
+ testUpdate.Asset.URL = tempURL
+
+ // 3. FindDownloadedAsset = false -> return nil
+ applied, err = upr.ApplyDownloaded(ctx)
+ assert.EqualError(t, err, "No downloaded asset found for version: 1.0.1")
+ assert.False(t, applied)
+ assert.NotNil(t, ctx.errReported)
+ assert.Nil(t, ctx.updateReported)
+ assert.False(t, ctx.successReported)
+
+ resetCtxErr()
+
+ // 4. FindDownloadedAsset = true -> digest fails
+ tmpDir := makeKeybaseUpdateTempDir(t, upr, testUpdate.Asset)
+ testUpdate.Asset.Digest = invalidDigest
+
+ applied, err = upr.ApplyDownloaded(ctx)
+ assert.EqualError(t, err, fmt.Sprintf("Update Error (verify): Invalid digest: 54970995e4d02da631e0634162ef66e2663e0eee7d018e816ac48ed6f7811c84 != 74970995e4d02da631e0634162ef66e2663e0eee7d018e816ac48ed6f7811c84 (%s)", filepath.Join(tmpDir, testUpdate.Asset.Name)))
+ assert.False(t, applied)
+ assert.NotNil(t, ctx.errReported)
+ assert.Nil(t, ctx.updateReported)
+ assert.False(t, ctx.successReported)
+
+ resetCtxErr()
+ testUpdate.Asset.Digest = validDigest
+ util.RemoveFileAtPath(tmpDir)
+
+ // 5. FindDownloadedAsset = true -> verify fails
+ tmpDir = makeKeybaseUpdateTempDir(t, upr, testUpdate.Asset)
+ testUpdate.Asset.Signature = invalidSignature
+
+ applied, err = upr.ApplyDownloaded(ctx)
+ assert.EqualError(t, err, "Update Error (verify): error verifying signature: failed to read header bytes")
+ assert.False(t, applied)
+ assert.NotNil(t, ctx.errReported)
+ assert.Nil(t, ctx.updateReported)
+ assert.False(t, ctx.successReported)
+
+ resetCtxErr()
+ testUpdate.Asset.Signature = validSignature
+ util.RemoveFileAtPath(tmpDir)
+
+ // 6. FindDownloadedAsset = true -> no error success
+ tmpDir = makeKeybaseUpdateTempDir(t, upr, testUpdate.Asset)
+
+ applied, err = upr.ApplyDownloaded(ctx)
+ assert.NoError(t, err)
+ assert.True(t, applied)
+ assert.Nil(t, ctx.errReported)
+ assert.NotNil(t, ctx.updateReported)
+ assert.True(t, ctx.successReported)
+
+ resetCtxErr()
+ util.RemoveFileAtPath(tmpDir)
+}
+
+func TestFindDownloadedAsset(t *testing.T) {
+ upr, err := newTestUpdater(t)
+ assert.NoError(t, err)
+ defer func() {
+ err = upr.CleanupPreviousUpdates()
+ assert.NoError(t, err)
+ }()
+
+ // 1. empty asset
+ matchingAssetPath, err := upr.FindDownloadedAsset("")
+ assert.EqualError(t, err, "No asset name provided")
+ assert.Equal(t, "", matchingAssetPath)
+
+ // 2. assset given -> did not create KeybaseUpdate.
+ matchingAssetPath, err = upr.FindDownloadedAsset("temp")
+ assert.NoError(t, err)
+ assert.Equal(t, "", matchingAssetPath)
+
+ // 3. asset given -> created KeybaseUpdate. -> directory empty
+ tmpDir, err := util.MakeTempDir("KeybaseUpdater.", 0700)
+ assert.NoError(t, err)
+ require.NoError(t, err)
+
+ matchingAssetPath, err = upr.FindDownloadedAsset("temp")
+ assert.NoError(t, err)
+ assert.Equal(t, "", matchingAssetPath)
+
+ util.RemoveFileAtPath(tmpDir)
+
+ // 4. asset given -> created KeybaseUpdate. -> file exists but no match
+ tmpDir, err = util.MakeTempDir("KeybaseUpdater.", 0700)
+ assert.NoError(t, err)
+ tmpFile := filepath.Join(tmpDir, "nottemp")
+ err = os.WriteFile(tmpFile, []byte("Contents of temp file"), 0700)
+ require.NoError(t, err)
+
+ matchingAssetPath, err = upr.FindDownloadedAsset("temp")
+ assert.NoError(t, err)
+ assert.Equal(t, "", matchingAssetPath)
+
+ util.RemoveFileAtPath(tmpDir)
+
+ // 5. asset given -> created KeybaseUpdate. -> file exixst and matches
+ tmpDir, err = util.MakeTempDir("KeybaseUpdater.", 0700)
+ tmpFile = filepath.Join(tmpDir, "temp")
+ err = os.WriteFile(tmpFile, []byte("Contents of temp file"), 0700)
+ require.NoError(t, err)
+
+ matchingAssetPath, err = upr.FindDownloadedAsset("temp")
+ assert.NoError(t, err)
+ assert.Equal(t, tmpFile, matchingAssetPath)
+
+ util.RemoveFileAtPath(tmpDir)
+
+}
+
+func TestUpdaterGuiBusy(t *testing.T) {
+ testServer := testServerForUpdateFile(t, testZipPath)
+ defer testServer.Close()
+
+ upr, err := newTestUpdaterWithServer(t, testServer, testUpdate(testServer.URL), &testConfig{auto: true, autoSet: true})
+ assert.NoError(t, err)
+ ctx := newTestContext(newDefaultTestUpdateOptions(), upr.config, &UpdatePromptResponse{Action: UpdateActionApply, AutoUpdate: true})
+ // Expect no error when the app state config is not found, allowing auto update to continue
+ _, err = upr.Update(ctx)
+ assert.NoError(t, err)
+
+ // Now put the config file there and make sure the right error is returned
+ now := time.Now().Unix() * 1000
+ err = os.WriteFile(testAppStatePath, []byte(fmt.Sprintf(`{"isUserActive":true, "changedAtMs":%d}`, now)), 0644)
+ assert.NoError(t, err)
+ defer util.RemoveFileAtPath(testAppStatePath)
+ _, err = upr.Update(ctx)
+ assert.EqualError(t, err, "Update Error (guiBusy): User active, retrying later")
+
+ // If the user was recently active, they are still considered busy.
+ err = os.WriteFile(testAppStatePath, []byte(fmt.Sprintf(`{"isUserActive":false, "changedAtMs":%d}`, now)), 0644)
+ assert.NoError(t, err)
+ _, err = upr.Update(ctx)
+ assert.EqualError(t, err, "Update Error (guiBusy): User active, retrying later")
+
+ // Make sure check command doesn't skip update on active UI
+ ctx.isCheckCommand = true
+ _, err = upr.Update(ctx)
+ assert.NoError(t, err)
+
+ // If the user wasn't recently active, they are not considered busy
+ ctx.isCheckCommand = false
+ later := time.Now().Add(-5*time.Minute).Unix() * 1000
+ err = os.WriteFile(testAppStatePath, []byte(fmt.Sprintf(`{"isUserActive":false, "changedAtMs":%d}`, later)), 0644)
+ assert.NoError(t, err)
+ _, err = upr.Update(ctx)
+ assert.NoError(t, err)
+}
diff --git a/go/updater/util/README.md b/go/updater/util/README.md
new file mode 100644
index 000000000000..c8374f200182
--- /dev/null
+++ b/go/updater/util/README.md
@@ -0,0 +1,3 @@
+## Util
+
+Utility and core functions used by the updater.
diff --git a/go/updater/util/digest.go b/go/updater/util/digest.go
new file mode 100644
index 000000000000..4690f2526926
--- /dev/null
+++ b/go/updater/util/digest.go
@@ -0,0 +1,48 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "os"
+)
+
+// CheckDigest returns no error if digest matches file
+func CheckDigest(digest string, path string, log Log) error {
+ if digest == "" {
+ return fmt.Errorf("Missing digest")
+ }
+ calcDigest, err := DigestForFileAtPath(path)
+ if err != nil {
+ return err
+ }
+ if calcDigest != digest {
+ return fmt.Errorf("Invalid digest: %s != %s (%s)", calcDigest, digest, path)
+ }
+ log.Infof("Verified digest: %s (%s)", digest, path)
+ return nil
+}
+
+// DigestForFileAtPath returns a SHA256 digest for file at specified path
+func DigestForFileAtPath(path string) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer Close(f)
+ return Digest(f)
+}
+
+// Digest returns a SHA256 digest
+func Digest(r io.Reader) (string, error) {
+ hasher := sha256.New()
+ if _, err := io.Copy(hasher, r); err != nil {
+ return "", err
+ }
+ digest := hex.EncodeToString(hasher.Sum(nil))
+ return digest, nil
+}
diff --git a/go/updater/util/digest_test.go b/go/updater/util/digest_test.go
new file mode 100644
index 000000000000..788f032e7ead
--- /dev/null
+++ b/go/updater/util/digest_test.go
@@ -0,0 +1,32 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDigest(t *testing.T) {
+ data := []byte("test data\n")
+ path, err := WriteTempFile("TestDigest", data, 0644)
+ assert.NoError(t, err)
+ defer RemoveFileAtPath(path)
+
+ err = CheckDigest("0c15e883dee85bb2f3540a47ec58f617a2547117f9096417ba5422268029f501", path, testLog)
+ assert.NoError(t, err)
+
+ err = CheckDigest("bad", path, testLog)
+ assert.Error(t, err)
+
+ err = CheckDigest("", path, testLog)
+ assert.Error(t, err)
+}
+
+func TestDigestInvalidPath(t *testing.T) {
+ err := CheckDigest("0c15e883dee85bb2f3540a47ec58f617a2547117f9096417ba5422268029f501", "/tmp/invalidpath", testLog)
+ t.Logf("Error: %#v", err)
+ assert.Error(t, err)
+}
diff --git a/go/updater/util/env.go b/go/updater/util/env.go
new file mode 100644
index 000000000000..3c4d53fa59fc
--- /dev/null
+++ b/go/updater/util/env.go
@@ -0,0 +1,48 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "os"
+ "strconv"
+ "time"
+)
+
+type envFn func(e string) string
+
+// EnvDuration returns a duration from an environment variable or default if
+// invalid or not specified
+func EnvDuration(envVar string, defaultValue time.Duration) time.Duration {
+ return envDuration(os.Getenv, envVar, defaultValue)
+}
+
+func envDuration(fn envFn, envVar string, defaultValue time.Duration) time.Duration {
+ envVal := fn(envVar)
+ if envVal == "" {
+ return defaultValue
+ }
+ duration, err := time.ParseDuration(envVal)
+ if err != nil {
+ return defaultValue
+ }
+ return duration
+}
+
+// EnvBool returns a bool from an environment variable or default if invalid or
+// not specified
+func EnvBool(envVar string, defaultValue bool) bool {
+ return envBool(os.Getenv, envVar, defaultValue)
+}
+
+func envBool(fn envFn, envVar string, defaultValue bool) bool {
+ envVal := fn(envVar)
+ if envVal == "" {
+ return defaultValue
+ }
+ b, err := strconv.ParseBool(envVal)
+ if err != nil {
+ return defaultValue
+ }
+ return b
+}
diff --git a/go/updater/util/env_test.go b/go/updater/util/env_test.go
new file mode 100644
index 000000000000..b1e323cca655
--- /dev/null
+++ b/go/updater/util/env_test.go
@@ -0,0 +1,49 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// testEnvFn returns value
+func testEnvFn(k, v string) func(e string) string {
+ return func(e string) string {
+ if k == e {
+ return v
+ }
+ return ""
+ }
+}
+
+func TestEnvDuration(t *testing.T) {
+ duration := envDuration(testEnvFn("TEST", "1s"), "TEST", time.Minute)
+ assert.Equal(t, time.Second, duration)
+ duration = envDuration(testEnvFn("TEST", ""), "TEST", time.Minute)
+ assert.Equal(t, time.Minute, duration)
+ duration = envDuration(testEnvFn("TEST", "invalid"), "TEST", time.Minute)
+ assert.Equal(t, time.Minute, duration)
+ duration = EnvDuration("TEST", time.Hour)
+ assert.Equal(t, time.Hour, duration)
+}
+
+func TestEnvBool(t *testing.T) {
+ b := envBool(testEnvFn("TEST", "true"), "TEST", false)
+ assert.True(t, b)
+ b = envBool(testEnvFn("TEST", "1"), "TEST", false)
+ assert.True(t, b)
+ b = envBool(testEnvFn("TEST", "false"), "TEST", true)
+ assert.False(t, b)
+ b = envBool(testEnvFn("TEST", "0"), "TEST", false)
+ assert.False(t, b)
+ b = envBool(testEnvFn("TEST", ""), "TEST", false)
+ assert.False(t, b)
+ b = envBool(testEnvFn("TEST", ""), "TEST", true)
+ assert.True(t, b)
+ b = EnvBool("TEST", true)
+ assert.True(t, b)
+}
diff --git a/go/updater/util/error.go b/go/updater/util/error.go
new file mode 100644
index 000000000000..cf8ad4b4bcbe
--- /dev/null
+++ b/go/updater/util/error.go
@@ -0,0 +1,40 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "fmt"
+ "strings"
+)
+
+// removeNilErrors returns error slice with nil errors removed
+func removeNilErrors(errs []error) []error {
+ if len(errs) == 0 {
+ return nil
+ }
+ var r []error
+ for _, err := range errs {
+ if err != nil {
+ r = append(r, err)
+ }
+ }
+ return r
+}
+
+// CombineErrors returns a single error for multiple errors, or nil if none
+func CombineErrors(errs ...error) error {
+ errs = removeNilErrors(errs)
+ if len(errs) == 0 {
+ return nil
+ } else if len(errs) == 1 {
+ return errs[0]
+ }
+
+ // Combine multiple errors
+ msgs := []string{}
+ for _, err := range errs {
+ msgs = append(msgs, err.Error())
+ }
+ return fmt.Errorf("There were multiple errors: %s", strings.Join(msgs, "; "))
+}
diff --git a/go/updater/util/error_test.go b/go/updater/util/error_test.go
new file mode 100644
index 000000000000..96d58d52f35c
--- /dev/null
+++ b/go/updater/util/error_test.go
@@ -0,0 +1,21 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCombineErrors(t *testing.T) {
+ assert.Equal(t, nil, CombineErrors(nil))
+ assert.Equal(t, nil, CombineErrors(nil, nil))
+ assert.Equal(t, "1 error", CombineErrors(errors.New("1 error"), nil).Error())
+ assert.Equal(t, "1 error", CombineErrors(nil, errors.New("1 error")).Error())
+ assert.Equal(t, "There were multiple errors: 1 error; 2 error", CombineErrors(nil, errors.New("1 error"), errors.New("2 error")).Error())
+ assert.Equal(t, "There were multiple errors: 1 error; 2 error", CombineErrors(nil, errors.New("1 error"), errors.New("2 error"), nil).Error())
+ assert.Equal(t, "There were multiple errors: 1 error; 2 error", CombineErrors(nil, errors.New("1 error"), nil, errors.New("2 error"), nil).Error())
+}
diff --git a/go/updater/util/etag.go b/go/updater/util/etag.go
new file mode 100644
index 000000000000..35eb959fe49d
--- /dev/null
+++ b/go/updater/util/etag.go
@@ -0,0 +1,28 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "crypto/md5"
+ "encoding/hex"
+ "io"
+ "os"
+)
+
+// ComputeEtag returns etag for a file at path
+func ComputeEtag(path string) (string, error) {
+ var result []byte
+ file, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer Close(file)
+
+ hash := md5.New()
+ if _, err := io.Copy(hash, file); err != nil {
+ return "", err
+ }
+
+ return hex.EncodeToString(hash.Sum(result)), nil
+}
diff --git a/go/updater/util/etag_test.go b/go/updater/util/etag_test.go
new file mode 100644
index 000000000000..44d78bf2ec1e
--- /dev/null
+++ b/go/updater/util/etag_test.go
@@ -0,0 +1,39 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestEtag(t *testing.T) {
+ data := []byte("test data\n")
+ path, err := WriteTempFile("TestEtag", data, 0644)
+ assert.NoError(t, err)
+ defer RemoveFileAtPath(path)
+
+ etag, err := ComputeEtag(path)
+ assert.NoError(t, err)
+ assert.Equal(t, "39a870a194a787550b6b5d1f49629236", etag)
+}
+
+func TestEtagNoData(t *testing.T) {
+ var data []byte
+ path, err := WriteTempFile("TestEtag", data, 0644)
+ assert.NoError(t, err)
+ defer RemoveFileAtPath(path)
+
+ etag, err := ComputeEtag(path)
+ assert.NoError(t, err)
+ assert.Equal(t, "d41d8cd98f00b204e9800998ecf8427e", etag)
+}
+
+func TestEtagInvalidPath(t *testing.T) {
+ etag, err := ComputeEtag("/tmp/invalidpath")
+ t.Logf("Error: %#v", err)
+ assert.Error(t, err)
+ assert.Equal(t, "", etag)
+}
diff --git a/go/updater/util/file.go b/go/updater/util/file.go
new file mode 100644
index 000000000000..ec11600a65ea
--- /dev/null
+++ b/go/updater/util/file.go
@@ -0,0 +1,358 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+)
+
+// File uses a safer file API
+type File struct {
+ name string
+ data []byte
+ perm os.FileMode
+}
+
+// SafeWriter defines a writer that is safer (atomic)
+type SafeWriter interface {
+ GetFilename() string
+ WriteTo(io.Writer) (int64, error)
+}
+
+// NewFile returns a File
+func NewFile(name string, data []byte, perm os.FileMode) File {
+ return File{name, data, perm}
+}
+
+// Save file
+func (f File) Save(log Log) error {
+ return safeWriteToFile(f, f.perm, log)
+}
+
+// GetFilename returns the file name for SafeWriter
+func (f File) GetFilename() string {
+ return f.name
+}
+
+// WriteTo is for SafeWriter
+func (f File) WriteTo(w io.Writer) (int64, error) {
+ n, err := w.Write(f.data)
+ return int64(n), err
+}
+
+// safeWriteToFile to safely write to a file
+func safeWriteToFile(t SafeWriter, mode os.FileMode, log Log) error {
+ filename := t.GetFilename()
+ if filename == "" {
+ return fmt.Errorf("No filename")
+ }
+ log.Debugf("Writing to %s", filename)
+ tempFilename, tempFile, err := openTempFile(filename+"-", "", mode)
+ log.Debugf("Temporary file generated: %s", tempFilename)
+ if err != nil {
+ return err
+ }
+ _, err = t.WriteTo(tempFile)
+ if err != nil {
+ log.Errorf("Error writing temporary file %s: %s", tempFilename, err)
+ _ = tempFile.Close()
+ _ = os.Remove(tempFilename)
+ return err
+ }
+ err = tempFile.Close()
+ if err != nil {
+ log.Errorf("Error closing temporary file %s: %s", tempFilename, err)
+ _ = os.Remove(tempFilename)
+ return err
+ }
+ err = os.Rename(tempFilename, filename)
+ if err != nil {
+ log.Errorf("Error renaming temporary file %s to %s: %s", tempFilename, filename, err)
+ _ = os.Remove(tempFilename)
+ return err
+ }
+ log.Debugf("Wrote to %s", filename)
+ return nil
+}
+
+// Close closes a file and ignores the error.
+// This satisfies lint checks when using with defer and you don't care if there
+// is an error, so instead of:
+//
+// defer func() { _ = f.Close() }()
+// defer Close(f)
+func Close(f io.Closer) {
+ if f == nil {
+ return
+ }
+ _ = f.Close()
+}
+
+// RemoveFileAtPath removes a file at path (and any children) ignoring any error.
+// We do nothing if path == "".
+// This satisfies lint checks when using with defer and you don't care if there
+// is an error, so instead of:
+//
+// defer func() { _ = os.Remove(path) }()
+// defer RemoveFileAtPath(path)
+func RemoveFileAtPath(path string) {
+ if path == "" {
+ return
+ }
+ _ = os.RemoveAll(path)
+}
+
+// openTempFile creates an opened temporary file.
+//
+// openTempFile("foo", ".zip", 0755) => "foo.RCG2KUSCGYOO3PCKNWQHBOXBKACOPIKL.zip"
+// openTempFile(path.Join(os.TempDir(), "foo"), "", 0600) => "/tmp/foo.RCG2KUSCGYOO3PCKNWQHBOXBKACOPIKL"
+func openTempFile(prefix string, suffix string, mode os.FileMode) (string, *os.File, error) {
+ filename, err := RandomID(prefix)
+ if err != nil {
+ return "", nil, err
+ }
+ if suffix != "" {
+ filename += suffix
+ }
+ flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL
+ if mode == 0 {
+ mode = 0600
+ }
+ file, err := os.OpenFile(filename, flags, mode)
+ return filename, file, err
+}
+
+// FileExists returns whether the given file or directory exists or not
+func FileExists(path string) (bool, error) {
+ _, err := os.Stat(path)
+ if err == nil {
+ return true, nil
+ }
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, err
+}
+
+// MakeParentDirs ensures parent directory exist for path
+func MakeParentDirs(path string, mode os.FileMode, log Log) error {
+ // 2nd return value here is filename (not an error), which is not needed
+ dir, _ := filepath.Split(path)
+ if dir == "" {
+ return fmt.Errorf("No base directory")
+ }
+ return MakeDirs(dir, mode, log)
+}
+
+// MakeDirs ensures directory exists for path
+func MakeDirs(dir string, mode os.FileMode, log Log) error {
+ exists, err := FileExists(dir)
+ if err != nil {
+ return err
+ }
+
+ if !exists {
+ log.Debugf("Creating: %s\n", dir)
+ err = os.MkdirAll(dir, mode)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// TempPath returns a temporary unique file path.
+// If for some reason we can't obtain random data, we still return a valid
+// path, which may not be as unique.
+// If tempDir is "", then os.TempDir() is used.
+func TempPath(tempDir string, prefix string) string {
+ if tempDir == "" {
+ tempDir = os.TempDir()
+ }
+ filename, err := RandomID(prefix)
+ if err != nil {
+ // We had an error getting random bytes, we'll use current nanoseconds
+ filename = fmt.Sprintf("%s%d", prefix, time.Now().UnixNano())
+ }
+ path := filepath.Join(tempDir, filename)
+ return path
+}
+
+// WriteTempFile creates a unique temp file with data.
+//
+// For example:
+//
+// WriteTempFile("Test.", byte[]("test data"), 0600)
+func WriteTempFile(prefix string, data []byte, mode os.FileMode) (string, error) {
+ path := TempPath("", prefix)
+ if err := os.WriteFile(path, data, mode); err != nil {
+ return "", err
+ }
+ return path, nil
+}
+
+// MakeTempDir creates a unique temp directory.
+//
+// For example:
+//
+// MakeTempDir("Test.", 0700)
+func MakeTempDir(prefix string, mode os.FileMode) (string, error) {
+ path := TempPath("", prefix)
+ if err := os.MkdirAll(path, mode); err != nil {
+ return "", err
+ }
+ return path, nil
+}
+
+// IsDirReal returns true if directory exists and is a real directory (not a symlink).
+// If it returns false, an error will be set explaining why.
+func IsDirReal(path string) (bool, error) {
+ fileInfo, err := os.Lstat(path)
+ if err != nil {
+ return false, err
+ }
+ // Check if symlink
+ if fileInfo.Mode()&os.ModeSymlink != 0 {
+ return false, fmt.Errorf("Path is a symlink")
+ }
+ if !fileInfo.Mode().IsDir() {
+ return false, fmt.Errorf("Path is not a directory")
+ }
+ return true, nil
+}
+
+// MoveFile moves a file safely.
+// It will create parent directories for destinationPath if they don't exist.
+// If the destination already exists and you specify a tmpDir, it will move
+// it there, otherwise it will be removed.
+func MoveFile(sourcePath string, destinationPath string, tmpDir string, log Log) error {
+ if _, statErr := os.Stat(destinationPath); statErr == nil {
+ if tmpDir == "" {
+ log.Infof("Removing existing destination path: %s", destinationPath)
+ if removeErr := os.RemoveAll(destinationPath); removeErr != nil {
+ return removeErr
+ }
+ } else {
+ tmpPath := filepath.Join(tmpDir, filepath.Base(destinationPath))
+ log.Infof("Moving existing destination %q to %q", destinationPath, tmpPath)
+ if tmpMoveErr := os.Rename(destinationPath, tmpPath); tmpMoveErr != nil {
+ return tmpMoveErr
+ }
+ }
+ }
+
+ if err := MakeParentDirs(destinationPath, 0700, log); err != nil {
+ return err
+ }
+
+ log.Infof("Moving %s to %s", sourcePath, destinationPath)
+ // Rename will copy over an existing destination
+ return os.Rename(sourcePath, destinationPath)
+}
+
+// CopyFile copies a file safely.
+// It will create parent directories for destinationPath if they don't exist.
+// It will overwrite an existing destinationPath.
+func CopyFile(sourcePath string, destinationPath string, log Log) error {
+ log.Infof("Copying %s to %s", sourcePath, destinationPath)
+ in, err := os.Open(sourcePath)
+ if err != nil {
+ return err
+ }
+ defer Close(in)
+
+ if _, statErr := os.Stat(destinationPath); statErr == nil {
+ log.Infof("Removing existing destination path: %s", destinationPath)
+ if removeErr := os.RemoveAll(destinationPath); removeErr != nil {
+ return removeErr
+ }
+ }
+
+ if makeDirErr := MakeParentDirs(destinationPath, 0700, log); makeDirErr != nil {
+ return makeDirErr
+ }
+
+ out, err := os.Create(destinationPath)
+ if err != nil {
+ return err
+ }
+ defer Close(out)
+ _, err = io.Copy(out, in)
+ closeErr := out.Close()
+ if err != nil {
+ return err
+ }
+ return closeErr
+}
+
+// ReadFile returns data for file at path
+func ReadFile(path string) ([]byte, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer Close(file)
+ data, err := io.ReadAll(file)
+ if err != nil {
+ return nil, err
+ }
+ return data, nil
+}
+
+func convertPathForWindows(path string) string {
+ return "/" + strings.ReplaceAll(path, `\`, `/`)
+}
+
+// URLStringForPath returns an URL as string with file scheme for path.
+// For example,
+//
+// /usr/local/go/bin => file:///usr/local/go/bin
+// C:\Go\bin => file:///C:/Go/bin
+func URLStringForPath(path string) string {
+ if runtime.GOOS == "windows" {
+ path = convertPathForWindows(path)
+ }
+ u := &url.URL{Path: path}
+ encodedPath := u.String()
+ return fmt.Sprintf("%s://%s", fileScheme, encodedPath)
+}
+
+// PathFromURL returns path for file URL scheme
+// For example,
+//
+// file:///usr/local/go/bin => /usr/local/go/bin
+// file:///C:/Go/bin => C:\Go\bin
+func PathFromURL(u *url.URL) string {
+ path := u.Path
+ if runtime.GOOS == "windows" && u.Scheme == fileScheme {
+ // Remove leading slash for Windows
+ path = strings.TrimPrefix(path, "/")
+ path = filepath.FromSlash(path)
+ }
+ return path
+}
+
+// Touch a file, updating its modification time
+func Touch(path string) error {
+ f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE|os.O_TRUNC, 0600)
+ Close(f)
+ return err
+}
+
+// FileModTime returns modification time for file.
+// If file doesn't exist returns error.
+func FileModTime(path string) (time.Time, error) {
+ info, err := os.Stat(path)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return info.ModTime(), nil
+}
diff --git a/go/updater/util/file_test.go b/go/updater/util/file_test.go
new file mode 100644
index 000000000000..e8b726740b93
--- /dev/null
+++ b/go/updater/util/file_test.go
@@ -0,0 +1,357 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewFile(t *testing.T) {
+ filename := filepath.Join(os.TempDir(), "TestNewFile")
+ defer RemoveFileAtPath(filename)
+
+ f := NewFile(filename, []byte("somedata"), 0600)
+ err := f.Save(testLog)
+ assert.NoError(t, err)
+
+ fileInfo, err := os.Stat(filename)
+ assert.NoError(t, err)
+ assert.False(t, fileInfo.IsDir())
+
+ if runtime.GOOS != "windows" {
+ assert.EqualValues(t, 0600, fileInfo.Mode().Perm())
+ }
+}
+
+func TestMakeParentDirs(t *testing.T) {
+ dir := filepath.Join(os.TempDir(), "TestMakeParentDirs", "TestMakeParentDirs2", "TestMakeParentDirs3")
+ defer RemoveFileAtPath(dir)
+
+ file := filepath.Join(dir, "testfile")
+ defer RemoveFileAtPath(file)
+
+ err := MakeParentDirs(file, 0700, testLog)
+ assert.NoError(t, err)
+
+ exists, err := FileExists(dir)
+ assert.NoError(t, err)
+ assert.True(t, exists, "File doesn't exist")
+
+ fileInfo, err := os.Stat(dir)
+ assert.NoError(t, err)
+ assert.True(t, fileInfo.IsDir())
+ if runtime.GOOS != "windows" {
+ assert.EqualValues(t, 0700, fileInfo.Mode().Perm())
+ }
+
+ // Test making dir that already exists
+ err = MakeParentDirs(file, 0700, testLog)
+ assert.NoError(t, err)
+}
+
+func TestMakeParentDirsInvalid(t *testing.T) {
+ err := MakeParentDirs("\\\\invalid", 0700, testLog)
+ if runtime.GOOS != "windows" {
+ assert.EqualError(t, err, "No base directory")
+ } else {
+ assert.Error(t, err)
+ }
+}
+
+func TestTempPathValid(t *testing.T) {
+ tempPath := TempPath("", "TempPrefix.")
+ t.Logf("Temp path: %s", tempPath)
+ assert.True(t, strings.HasPrefix(filepath.Base(tempPath), "TempPrefix."))
+ assert.Equal(t, len(filepath.Base(tempPath)), 63)
+}
+
+func TestTempPathRandFail(t *testing.T) {
+ // Replace rand.Read with a failing read
+ defaultRandRead := randRead
+ defer func() { randRead = defaultRandRead }()
+ randRead = func(b []byte) (int, error) {
+ return 0, fmt.Errorf("Test rand failure")
+ }
+
+ tempPath := TempPath("", "TempPrefix.")
+ t.Logf("Temp path: %s", tempPath)
+ assert.True(t, strings.HasPrefix(filepath.Base(tempPath), "TempPrefix."))
+ assert.Equal(t, len(filepath.Base(tempPath)), 30)
+}
+
+func TestIsDirReal(t *testing.T) {
+ ok, err := IsDirReal("/invalid")
+ assert.Error(t, err)
+ assert.False(t, ok)
+
+ path := os.Getenv("GOPATH")
+ ok, err = IsDirReal(path)
+ assert.NoError(t, err)
+ assert.True(t, ok)
+
+ _, filename, _, _ := runtime.Caller(0)
+ testFile := filepath.Join(filepath.Dir(filename), "../test/test.zip")
+ ok, err = IsDirReal(testFile)
+ assert.Error(t, err)
+ assert.Equal(t, "Path is not a directory", err.Error())
+ assert.False(t, ok)
+
+ // Windows requires privileges to create symbolic links
+ symLinkPath := TempPath("", "TestIsDirReal")
+ defer RemoveFileAtPath(symLinkPath)
+ target := os.TempDir()
+ if runtime.GOOS == "windows" {
+ err = exec.Command("cmd", "/C", "mklink", "/J", symLinkPath, target).Run()
+ assert.NoError(t, err)
+ } else {
+ err = os.Symlink(target, symLinkPath)
+ assert.NoError(t, err)
+ }
+ ok, err = IsDirReal(symLinkPath)
+ assert.Error(t, err)
+ assert.Equal(t, "Path is a symlink", err.Error())
+ assert.False(t, ok)
+}
+
+func TestMoveFileValid(t *testing.T) {
+ destinationPath := filepath.Join(TempPath("", "TestMoveFileDestination"), "TestMoveFileDestinationSubdir")
+ defer RemoveFileAtPath(destinationPath)
+
+ sourcePath, err := WriteTempFile("TestMoveFile", []byte("test"), 0600)
+ defer RemoveFileAtPath(sourcePath)
+ assert.NoError(t, err)
+
+ err = MoveFile(sourcePath, destinationPath, "", testLog)
+ assert.NoError(t, err)
+ exists, err := FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.True(t, exists)
+ data, err := os.ReadFile(destinationPath)
+ assert.NoError(t, err)
+ assert.Equal(t, []byte("test"), data)
+ srcExists, err := FileExists(sourcePath)
+ assert.NoError(t, err)
+ assert.False(t, srcExists)
+
+ // Move again with different source data, and overwrite
+ sourcePath2, err := WriteTempFile("TestMoveFile", []byte("test2"), 0600)
+ assert.NoError(t, err)
+ err = MoveFile(sourcePath2, destinationPath, "", testLog)
+ assert.NoError(t, err)
+ exists, err = FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.True(t, exists)
+ data2, err := os.ReadFile(destinationPath)
+ assert.NoError(t, err)
+ assert.Equal(t, []byte("test2"), data2)
+ srcExists2, err := FileExists(sourcePath2)
+ assert.NoError(t, err)
+ assert.False(t, srcExists2)
+}
+
+func TestMoveFileDirValid(t *testing.T) {
+ destinationPath := filepath.Join(TempPath("", "TestMoveFileDestination"), "TestMoveFileDestinationSubdir")
+ defer RemoveFileAtPath(destinationPath)
+
+ sourcePath, err := MakeTempDir("TestMoveDir", 0700)
+ defer RemoveFileAtPath(sourcePath)
+ assert.NoError(t, err)
+
+ err = MoveFile(sourcePath, destinationPath, "", testLog)
+ assert.NoError(t, err)
+ exists, err := FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.True(t, exists)
+
+ // Move again with different source data, and overwrite
+ sourcePath2, err := MakeTempDir("TestMoveDir2", 0700)
+ assert.NoError(t, err)
+ defer RemoveFileAtPath(sourcePath2)
+ err = MoveFile(sourcePath2, destinationPath, "", testLog)
+ assert.NoError(t, err)
+ exists, err = FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.True(t, exists)
+}
+
+func TestMoveFileInvalidSource(t *testing.T) {
+ sourcePath := "/invalid"
+ destinationPath := TempPath("", "TestMoveFileDestination")
+ err := MoveFile(sourcePath, destinationPath, "", testLog)
+ assert.Error(t, err)
+
+ exists, err := FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.False(t, exists)
+}
+
+func TestMoveFileInvalidDest(t *testing.T) {
+ sourcePath := "/invalid"
+ destinationPath := TempPath("", "TestMoveFileDestination")
+ err := MoveFile(sourcePath, destinationPath, "", testLog)
+ assert.Error(t, err)
+
+ exists, err := FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.False(t, exists)
+}
+
+func TestCopyFileValid(t *testing.T) {
+ destinationPath := filepath.Join(TempPath("", "TestCopyFileDestination"), "TestCopyFileDestinationSubdir")
+ defer RemoveFileAtPath(destinationPath)
+
+ sourcePath, err := WriteTempFile("TestCopyFile", []byte("test"), 0600)
+ defer RemoveFileAtPath(sourcePath)
+ assert.NoError(t, err)
+
+ err = CopyFile(sourcePath, destinationPath, testLog)
+ assert.NoError(t, err)
+ exists, err := FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.True(t, exists)
+ data, err := os.ReadFile(destinationPath)
+ assert.NoError(t, err)
+ assert.Equal(t, []byte("test"), data)
+
+ // Move again with different source data, and overwrite
+ sourcePath2, err := WriteTempFile("TestCopyFile", []byte("test2"), 0600)
+ assert.NoError(t, err)
+ err = CopyFile(sourcePath2, destinationPath, testLog)
+ assert.NoError(t, err)
+ exists, err = FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.True(t, exists)
+ data2, err := os.ReadFile(destinationPath)
+ assert.NoError(t, err)
+ assert.Equal(t, []byte("test2"), data2)
+}
+
+func TestCopyFileInvalidSource(t *testing.T) {
+ sourcePath := "/invalid"
+ destinationPath := TempPath("", "TestCopyFileDestination")
+ err := CopyFile(sourcePath, destinationPath, testLog)
+ assert.Error(t, err)
+
+ exists, err := FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.False(t, exists)
+}
+
+func TestCopyFileInvalidDest(t *testing.T) {
+ sourcePath := "/invalid"
+ destinationPath := TempPath("", "TestCopyFileDestination")
+ err := CopyFile(sourcePath, destinationPath, testLog)
+ assert.Error(t, err)
+
+ exists, err := FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.False(t, exists)
+}
+
+func TestCloseNil(t *testing.T) {
+ Close(nil)
+}
+
+func TestOpenTempFile(t *testing.T) {
+ path, tempFile, err := openTempFile("prefix", "suffix", 0)
+ defer Close(tempFile)
+ defer RemoveFileAtPath(path)
+ require.NoError(t, err)
+ require.NotNil(t, tempFile)
+
+ basePath := filepath.Base(path)
+ assert.True(t, strings.HasPrefix(basePath, "prefix"))
+ assert.True(t, strings.HasSuffix(basePath, "suffix"))
+}
+
+func TestFileExists(t *testing.T) {
+ exists, err := FileExists("/nope")
+ assert.NoError(t, err)
+ assert.False(t, exists)
+}
+
+func TestReadFile(t *testing.T) {
+ dataIn := []byte("test")
+ sourcePath, err := WriteTempFile("TestReadFile", dataIn, 0600)
+ require.NoError(t, err)
+
+ dataOut, err := ReadFile(sourcePath)
+ require.NoError(t, err)
+ assert.Equal(t, dataIn, dataOut)
+
+ _, err = ReadFile("/invalid")
+ assert.Error(t, err)
+ require.True(t, strings.HasPrefix(err.Error(), "open /invalid: "))
+}
+
+func TestURLStringForPathWindows(t *testing.T) {
+ if runtime.GOOS != "windows" {
+ t.Skip("Windows only test")
+ }
+ assert.Equal(t, "file:///C:/Go/bin", URLStringForPath(`C:\Go\bin`))
+ assert.Equal(t, "file:///C:/Program%20Files", URLStringForPath(`C:\Program Files`))
+ assert.Equal(t, "file:///C:/test%20%E2%9C%93%E2%9C%93", URLStringForPath(`C:\test ✓✓`))
+}
+
+func TestURLStringForPath(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("See TestURLStringForPathWindows")
+ }
+ assert.Equal(t, "file:///usr/local/go/bin", URLStringForPath("/usr/local/go/bin"))
+ assert.Equal(t, "file:///Applications/System%20Preferences.app", URLStringForPath("/Applications/System Preferences.app"))
+ assert.Equal(t, "file:///test%20%E2%9C%93%E2%9C%93", URLStringForPath("/test ✓✓"))
+}
+
+func TestPathFromURLWindows(t *testing.T) {
+ if runtime.GOOS != "windows" {
+ t.Skip("Windows only test")
+ }
+ url, err := url.Parse("file:///C:/Go/bin")
+ require.NoError(t, err)
+ assert.Equal(t, `C:\Go\bin`, PathFromURL(url))
+}
+
+func TestPathFromURL(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("See TestPathFromURLWindows")
+ }
+ url, err := url.Parse("file:///usr/local/go/bin")
+ require.NoError(t, err)
+ assert.Equal(t, "/usr/local/go/bin", PathFromURL(url))
+
+ url, err = url.Parse("file:///Applications/System%20Preferences.app")
+ require.NoError(t, err)
+ assert.Equal(t, "/Applications/System Preferences.app", PathFromURL(url))
+}
+
+func TestTouchModTime(t *testing.T) {
+ path, err := RandomID("TestTouchModTime")
+ defer RemoveFileAtPath(path)
+ require.NoError(t, err)
+ now := time.Now()
+ err = Touch(path)
+ require.NoError(t, err)
+ ti, err := FileModTime(path)
+ require.NoError(t, err)
+ assert.WithinDuration(t, now, ti, time.Second)
+ time.Sleep(1 * time.Second)
+
+ // Touch same path, ensure it updates mod time
+ err = Touch(path)
+ require.NoError(t, err)
+ ti2, err := FileModTime(path)
+ require.NoError(t, err)
+ assert.NotEqual(t, ti, ti2)
+}
diff --git a/go/updater/util/http.go b/go/updater/util/http.go
new file mode 100644
index 000000000000..8fc5806a8083
--- /dev/null
+++ b/go/updater/util/http.go
@@ -0,0 +1,244 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "time"
+)
+
+const fileScheme = "file"
+
+func discardAndClose(rc io.ReadCloser) error {
+ _, _ = io.Copy(io.Discard, rc)
+ return rc.Close()
+}
+
+// DiscardAndCloseBody reads as much as possible from the body of the
+// given response, and then closes it.
+//
+// This is because, in order to free up the current connection for
+// re-use, a response body must be read from before being closed; see
+// http://stackoverflow.com/a/17953506 .
+//
+// Instead of doing:
+//
+// res, _ := ...
+// defer res.Body.Close()
+//
+// do
+//
+// res, _ := ...
+// defer DiscardAndCloseBody(res)
+//
+// instead.
+func DiscardAndCloseBody(resp *http.Response) error {
+ if resp == nil {
+ return fmt.Errorf("Nothing to discard (http.Response was nil)")
+ }
+ return discardAndClose(resp.Body)
+}
+
+// SaveHTTPResponse saves an http.Response to path
+func SaveHTTPResponse(resp *http.Response, savePath string, mode os.FileMode, log Log) error {
+ if resp == nil {
+ return fmt.Errorf("No response")
+ }
+ file, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
+ if err != nil {
+ return err
+ }
+ defer Close(file)
+
+ log.Infof("Downloading to %s", savePath)
+ n, err := io.Copy(file, resp.Body)
+ if err == nil {
+ log.Infof("Downloaded %d bytes", n)
+ }
+ return err
+}
+
+// DiscardAndCloseBodyIgnoreError calls DiscardAndCloseBody.
+// This satisfies lint checks when using with defer and you don't care if there
+// is an error, so instead of:
+//
+// defer func() { _ = DiscardAndCloseBody(resp) }()
+// defer DiscardAndCloseBodyIgnoreError(resp)
+func DiscardAndCloseBodyIgnoreError(resp *http.Response) {
+ _ = DiscardAndCloseBody(resp)
+}
+
+// parseURL ensures error if parse error or no url was returned from url.Parse
+func parseURL(urlString string) (*url.URL, error) {
+ url, parseErr := url.Parse(urlString)
+ if parseErr != nil {
+ return nil, parseErr
+ }
+ if url == nil {
+ return nil, fmt.Errorf("No URL")
+ }
+ return url, nil
+}
+
+// URLExists returns error if URL doesn't exist
+func URLExists(urlString string, timeout time.Duration, log Log) (bool, error) {
+ url, err := parseURL(urlString)
+ if err != nil {
+ return false, err
+ }
+
+ // Handle local files
+ if url.Scheme == "file" {
+ return FileExists(PathFromURL(url))
+ }
+
+ log.Debugf("Checking URL exists: %s", urlString)
+ req, err := http.NewRequest("HEAD", urlString, nil)
+ if err != nil {
+ return false, err
+ }
+ client := &http.Client{
+ Timeout: timeout,
+ }
+ resp, requestErr := client.Do(req)
+ if requestErr != nil {
+ return false, requestErr
+ }
+ if resp == nil {
+ return false, fmt.Errorf("No response")
+ }
+ defer DiscardAndCloseBodyIgnoreError(resp)
+ if resp.StatusCode != http.StatusOK {
+ return false, fmt.Errorf("Invalid status code (%d)", resp.StatusCode)
+ }
+ return true, nil
+}
+
+// DownloadURLOptions are options for DownloadURL
+type DownloadURLOptions struct {
+ Digest string
+ RequireDigest bool
+ UseETag bool
+ Timeout time.Duration
+ Log Log
+}
+
+// DownloadURL downloads a URL to a path.
+func DownloadURL(urlString string, destinationPath string, options DownloadURLOptions) error {
+ _, err := downloadURL(urlString, destinationPath, options)
+ return err
+}
+
+func downloadURL(urlString string, destinationPath string, options DownloadURLOptions) (cached bool, _ error) {
+ log := options.Log
+
+ url, err := parseURL(urlString)
+ if err != nil {
+ return false, err
+ }
+
+ // Handle local files
+ if url.Scheme == fileScheme {
+ return cached, downloadLocal(PathFromURL(url), destinationPath, options)
+ }
+
+ // Compute ETag if the destinationPath already exists
+ etag := ""
+ if options.UseETag {
+ if _, statErr := os.Stat(destinationPath); statErr == nil {
+ computedEtag, etagErr := ComputeEtag(destinationPath)
+ if etagErr != nil {
+ log.Warningf("Error computing etag", etagErr)
+ } else {
+ etag = computedEtag
+ }
+ }
+ }
+
+ req, err := http.NewRequest("GET", url.String(), nil)
+ if err != nil {
+ return cached, err
+ }
+ if etag != "" {
+ log.Infof("Using etag: %s", etag)
+ req.Header.Set("If-None-Match", etag)
+ }
+ var client http.Client
+ if options.Timeout > 0 {
+ client = http.Client{Timeout: options.Timeout}
+ } else {
+ client = http.Client{}
+ }
+ log.Infof("Request %s", url.String())
+ resp, requestErr := client.Do(req)
+ if requestErr != nil {
+ return cached, requestErr
+ }
+ if resp == nil {
+ return cached, fmt.Errorf("No response")
+ }
+ defer DiscardAndCloseBodyIgnoreError(resp)
+ if resp.StatusCode == http.StatusNotModified {
+ cached = true
+ // ETag matched, we already have it
+ log.Infof("Using cached file: %s", destinationPath)
+ return cached, nil
+ }
+ if resp.StatusCode != http.StatusOK {
+ return cached, fmt.Errorf("Responded with %s", resp.Status)
+ }
+
+ savePath := fmt.Sprintf("%s.download", destinationPath)
+ if _, ferr := os.Stat(savePath); ferr == nil {
+ log.Infof("Removing existing partial download: %s", savePath)
+ if rerr := os.Remove(savePath); rerr != nil {
+ return cached, fmt.Errorf("Error removing existing partial download: %s", rerr)
+ }
+ }
+
+ if err := MakeParentDirs(savePath, 0700, log); err != nil {
+ return cached, err
+ }
+
+ if err := SaveHTTPResponse(resp, savePath, 0600, log); err != nil {
+ return cached, err
+ }
+
+ if options.RequireDigest {
+ if err := CheckDigest(options.Digest, savePath, log); err != nil {
+ return cached, err
+ }
+ }
+
+ if err := MoveFile(savePath, destinationPath, "", log); err != nil {
+ return cached, err
+ }
+
+ return cached, nil
+}
+
+func downloadLocal(localPath string, destinationPath string, options DownloadURLOptions) error {
+ if err := CopyFile(localPath, destinationPath, options.Log); err != nil {
+ return err
+ }
+
+ if options.RequireDigest {
+ if err := CheckDigest(options.Digest, destinationPath, options.Log); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// URLValueForBool returns "1" for true, otherwise "0"
+func URLValueForBool(b bool) string {
+ if b {
+ return "1"
+ }
+ return "0"
+}
diff --git a/go/updater/util/http_test.go b/go/updater/util/http_test.go
new file mode 100644
index 000000000000..c3e151816ed6
--- /dev/null
+++ b/go/updater/util/http_test.go
@@ -0,0 +1,255 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDiscardAndCloseBodyNil(t *testing.T) {
+ err := DiscardAndCloseBody(nil)
+ if err == nil {
+ t.Fatal("Should have errored")
+ }
+}
+
+func testServer(t *testing.T, data string, delay time.Duration) *httptest.Server {
+ return testServerWithETag(t, data, delay, "")
+}
+
+func testServerWithETag(t *testing.T, data string, delay time.Duration, etag string) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if delay > 0 {
+ time.Sleep(delay)
+ }
+
+ etagMatch := r.Header.Get("If-None-Match")
+ if etagMatch != "" {
+ t.Logf("Checking etag match: %s == %s", etag, etagMatch)
+ if etag == etagMatch {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintln(w, data)
+ }))
+}
+
+func testServerForError(err error) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, err.Error(), 500)
+ }))
+}
+
+func TestSaveHTTPResponse(t *testing.T) {
+ data := `{"test": true}`
+ server := testServer(t, data, 0)
+ defer server.Close()
+ resp, err := http.Get(server.URL)
+ assert.NoError(t, err)
+
+ savePath := TempPath("", "TestSaveHTTPResponse.")
+ defer RemoveFileAtPath(savePath)
+
+ err = SaveHTTPResponse(resp, savePath, 0600, testLog)
+ assert.NoError(t, err)
+
+ saved, err := os.ReadFile(savePath)
+ assert.NoError(t, err)
+
+ assert.Equal(t, string(saved), data+"\n")
+}
+
+func TestSaveHTTPResponseInvalidPath(t *testing.T) {
+ data := `{"test": true}`
+ server := testServer(t, data, 0)
+ defer server.Close()
+ resp, err := http.Get(server.URL)
+ assert.NoError(t, err)
+
+ savePath := TempPath("", "TestSaveHTTPResponse.")
+ defer RemoveFileAtPath(savePath)
+
+ badPath := "/badpath"
+ if runtime.GOOS == "windows" {
+ badPath = `x:\` // Shouldn't be writable
+ }
+
+ err = SaveHTTPResponse(resp, badPath, 0600, testLog)
+ assert.Error(t, err)
+ err = SaveHTTPResponse(nil, savePath, 0600, testLog)
+ assert.Error(t, err)
+}
+
+func TestURLExistsValid(t *testing.T) {
+ server := testServer(t, "ok", 0)
+ defer server.Close()
+ exists, err := URLExists(server.URL, time.Second, testLog)
+ assert.True(t, exists)
+ assert.NoError(t, err)
+}
+
+func TestURLExistsInvalid(t *testing.T) {
+ exists, err := URLExists("", time.Second, testLog)
+ assert.Error(t, err)
+ assert.False(t, exists)
+
+ exists, err = URLExists("badurl", time.Second, testLog)
+ assert.Error(t, err)
+ assert.False(t, exists)
+
+ exists, err = URLExists("http://n", time.Second, testLog)
+ assert.Error(t, err)
+ assert.False(t, exists)
+}
+
+func TestURLExistsTimeout(t *testing.T) {
+ server := testServer(t, "timeout", time.Second)
+ defer server.Close()
+ exists, err := URLExists(server.URL, time.Millisecond, testLog)
+ t.Logf("Timeout error: %s", err)
+ assert.Error(t, err)
+ assert.False(t, exists)
+}
+
+func TestURLExistsFile(t *testing.T) {
+ path, err := WriteTempFile("TestURLExistsFile", []byte(""), 0600)
+ assert.NoError(t, err)
+ exists, err := URLExists(URLStringForPath(path), 0, testLog)
+ assert.NoError(t, err)
+ assert.True(t, exists)
+
+ exists, err = URLExists(URLStringForPath("/invalid"), 0, testLog)
+ assert.NoError(t, err)
+ assert.False(t, exists)
+}
+
+func TestDownloadURLValid(t *testing.T) {
+ server := testServer(t, "ok", 0)
+ defer server.Close()
+ destinationPath := TempPath("", "TestDownloadURLValid.")
+ digest, err := Digest(bytes.NewReader([]byte("ok\n")))
+ assert.NoError(t, err)
+ err = DownloadURL(server.URL, destinationPath, DownloadURLOptions{Digest: digest, RequireDigest: true, Log: testLog})
+ if assert.NoError(t, err) {
+ // Check file saved and correct data
+ fileExists, fileErr := FileExists(destinationPath)
+ assert.NoError(t, fileErr)
+ assert.True(t, fileExists)
+ data, readErr := os.ReadFile(destinationPath)
+ assert.NoError(t, readErr)
+ assert.Equal(t, []byte("ok\n"), data)
+ }
+
+ // Repeat test, download again, overwriting destination
+ server2 := testServer(t, "ok2", 0)
+ defer server2.Close()
+ digest2, err := Digest(bytes.NewReader([]byte("ok2\n")))
+ assert.NoError(t, err)
+ err = DownloadURL(server2.URL, destinationPath, DownloadURLOptions{Digest: digest2, RequireDigest: true, Log: testLog})
+ if assert.NoError(t, err) {
+ fileExists2, err := FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.True(t, fileExists2)
+ data2, err := os.ReadFile(destinationPath)
+ assert.NoError(t, err)
+ assert.Equal(t, []byte("ok2\n"), data2)
+ }
+}
+
+func TestDownloadURLInvalid(t *testing.T) {
+ destinationPath := TempPath("", "TestDownloadURLInvalid.")
+
+ err := DownloadURL("", destinationPath, DownloadURLOptions{Log: testLog})
+ assert.Error(t, err)
+
+ err = DownloadURL("badurl", destinationPath, DownloadURLOptions{Log: testLog})
+ assert.Error(t, err)
+
+ err = DownloadURL("http://", destinationPath, DownloadURLOptions{Log: testLog})
+ assert.Error(t, err)
+}
+
+func TestDownloadURLTimeout(t *testing.T) {
+ server := testServer(t, "timeout", time.Second)
+ defer server.Close()
+ destinationPath := TempPath("", "TestDownloadURLInvalid.")
+ err := DownloadURL(server.URL, destinationPath, DownloadURLOptions{Timeout: time.Millisecond, Log: testLog})
+ t.Logf("Timeout error: %s", err)
+ assert.Error(t, err)
+}
+
+func TestDownloadURLParseError(t *testing.T) {
+ err := DownloadURL("invalid", "", DownloadURLOptions{Log: testLog})
+ assert.Error(t, err)
+}
+
+func TestDownloadURLError(t *testing.T) {
+ server := testServerForError(fmt.Errorf("Test error"))
+ defer server.Close()
+
+ err := DownloadURL(server.URL, "", DownloadURLOptions{Log: testLog})
+ assert.EqualError(t, err, "Responded with 500 Internal Server Error")
+}
+
+func TestDownloadURLLocal(t *testing.T) {
+ _, filename, _, _ := runtime.Caller(0)
+ testZipPath := filepath.Join(filepath.Dir(filename), "../test/test.zip")
+ destinationPath := TempPath("", "TestDownloadURLLocal.")
+ defer RemoveFileAtPath(destinationPath)
+ err := DownloadURL(URLStringForPath(testZipPath), destinationPath, DownloadURLOptions{Log: testLog})
+ assert.NoError(t, err)
+
+ exists, err := FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.True(t, exists)
+}
+
+func TestDownloadURLETag(t *testing.T) {
+ data := []byte("ok\n")
+ etag := "eff5bc1ef8ec9d03e640fc4370f5eacd"
+ server := testServerWithETag(t, "ok", 0, etag)
+ defer server.Close()
+ destinationPath := TempPath("", "TestDownloadURLETag.")
+ err := os.WriteFile(destinationPath, data, 0600)
+ require.NoError(t, err)
+ digest, err := Digest(bytes.NewReader(data))
+ assert.NoError(t, err)
+ cached, err := downloadURL(server.URL, destinationPath, DownloadURLOptions{Digest: digest, RequireDigest: true, UseETag: true, Log: testLog})
+ require.NoError(t, err)
+ assert.True(t, cached)
+}
+
+func TestURLExistsParseError(t *testing.T) {
+ exists, err := URLExists("invalid", time.Millisecond, testLog)
+ assert.False(t, exists)
+ assert.Error(t, err)
+}
+
+func TestURLExistsError(t *testing.T) {
+ server := testServerForError(fmt.Errorf("Test error"))
+ defer server.Close()
+
+ exists, err := URLExists(server.URL, time.Second, testLog)
+ assert.False(t, exists)
+ assert.EqualError(t, err, "Invalid status code (500)")
+}
+
+func TestURLValueForBool(t *testing.T) {
+ assert.Equal(t, "0", URLValueForBool(false))
+ assert.Equal(t, "1", URLValueForBool(true))
+}
diff --git a/go/updater/util/log.go b/go/updater/util/log.go
new file mode 100644
index 000000000000..ac5d2e006b7c
--- /dev/null
+++ b/go/updater/util/log.go
@@ -0,0 +1,12 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+// Log is the logging interface for the util package
+type Log interface {
+ Debugf(s string, args ...interface{})
+ Infof(s string, args ...interface{})
+ Warningf(s string, args ...interface{})
+ Errorf(s string, args ...interface{})
+}
diff --git a/go/updater/util/rand.go b/go/updater/util/rand.go
new file mode 100644
index 000000000000..61c8a591ebf7
--- /dev/null
+++ b/go/updater/util/rand.go
@@ -0,0 +1,34 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "crypto/rand"
+ "encoding/base32"
+ "strings"
+)
+
+var randRead = rand.Read
+
+// RandomID returns random ID (base32) string with prefix, using 256 bits as
+// recommended by tptacek: https://gist.github.com/tqbf/be58d2d39690c3b366ad
+func RandomID(prefix string) (string, error) {
+ buf, err := RandBytes(32)
+ if err != nil {
+ return "", err
+ }
+ str := base32.StdEncoding.EncodeToString(buf)
+ str = strings.ReplaceAll(str, "=", "")
+ str = prefix + str
+ return str, nil
+}
+
+// RandBytes returns random bytes of length
+func RandBytes(length int) ([]byte, error) {
+ buf := make([]byte, length)
+ if _, err := randRead(buf); err != nil {
+ return nil, err
+ }
+ return buf, nil
+}
diff --git a/go/updater/util/rand_test.go b/go/updater/util/rand_test.go
new file mode 100644
index 000000000000..54a70e00d367
--- /dev/null
+++ b/go/updater/util/rand_test.go
@@ -0,0 +1,23 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRandString(t *testing.T) {
+ s, err := RandomID("prefix=")
+ t.Logf("Rand string: %s", s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(s, "prefix=") {
+ t.Errorf("Invalid prefix: %s", s)
+ }
+ if len(s)-len("prefix.") != 52 {
+ t.Errorf("Invalid length: %s (%d)", s, len(s))
+ }
+}
diff --git a/go/updater/util/semver.go b/go/updater/util/semver.go
new file mode 100644
index 000000000000..e662ba9fd21c
--- /dev/null
+++ b/go/updater/util/semver.go
@@ -0,0 +1,14 @@
+package util
+
+import "github.com/blang/semver"
+
+// Semver outputs the semver in Major.Minor.Patch form for readability.
+func Semver(version string) string {
+ v, err := semver.Parse(version)
+ if err != nil {
+ return version
+ }
+ v.Pre = nil
+ v.Build = nil
+ return v.String()
+}
diff --git a/go/updater/util/strings.go b/go/updater/util/strings.go
new file mode 100644
index 000000000000..b5bcd452b348
--- /dev/null
+++ b/go/updater/util/strings.go
@@ -0,0 +1,17 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import "strings"
+
+// JoinPredicate joins strings with predicate
+func JoinPredicate(arr []string, delimeter string, f func(s string) bool) string {
+ arrNew := make([]string, 0, len(arr))
+ for _, s := range arr {
+ if f(s) {
+ arrNew = append(arrNew, s)
+ }
+ }
+ return strings.Join(arrNew, delimeter)
+}
diff --git a/go/updater/util/strings_test.go b/go/updater/util/strings_test.go
new file mode 100644
index 000000000000..180efad55e2d
--- /dev/null
+++ b/go/updater/util/strings_test.go
@@ -0,0 +1,17 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestJoinPredicate(t *testing.T) {
+ f := func(s string) bool { return strings.HasPrefix(s, "f") }
+ s := JoinPredicate([]string{"foo", "bar", "faa"}, "-", f)
+ if s != "foo-faa" {
+ t.Errorf("Unexpected output: %s", s)
+ }
+}
diff --git a/go/updater/util/unzip.go b/go/updater/util/unzip.go
new file mode 100644
index 000000000000..f1aaadf880ac
--- /dev/null
+++ b/go/updater/util/unzip.go
@@ -0,0 +1,146 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "archive/zip"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+// UnzipOver safely unzips a file and copies it contents to a destination path.
+// If destination path exists, it will be removed first.
+// The filename must have a ".zip" extension.
+// You can specify a check function, which will run before moving the unzipped
+// directory into place.
+// If you specify a tmpDir and destination path exists, it will be moved there
+// instead of being removed.
+//
+// To unzip Keybase-1.2.3.zip and move the contents Keybase.app to /Applications/Keybase.app
+//
+// UnzipOver("/tmp/Keybase-1.2.3.zip", "Keybase.app", "/Applications/Keybase.app", check, "", log)
+func UnzipOver(sourcePath string, path string, destinationPath string, check func(sourcePath, destinationPath string) error, tmpDir string, log Log) error {
+ unzipPath := fmt.Sprintf("%s.unzipped", sourcePath)
+ defer RemoveFileAtPath(unzipPath)
+ err := unzipOver(sourcePath, unzipPath, log)
+ if err != nil {
+ return err
+ }
+
+ contentPath := filepath.Join(unzipPath, path)
+
+ err = check(contentPath, destinationPath)
+ if err != nil {
+ return err
+ }
+
+ return MoveFile(contentPath, destinationPath, tmpDir, log)
+}
+
+// UnzipPath unzips and returns path to unzipped directory
+func UnzipPath(sourcePath string, log Log) (string, error) {
+ unzipPath := fmt.Sprintf("%s.unzipped", sourcePath)
+ err := unzipOver(sourcePath, unzipPath, log)
+ if err != nil {
+ return "", err
+ }
+ return unzipPath, nil
+}
+
+func unzipOver(sourcePath string, destinationPath string, log Log) error {
+ if destinationPath == "" {
+ return fmt.Errorf("Invalid destination %q", destinationPath)
+ }
+
+ if _, ferr := os.Stat(destinationPath); ferr == nil {
+ log.Infof("Removing existing unzip destination path: %s", destinationPath)
+ err := os.RemoveAll(destinationPath)
+ if err != nil {
+ return err
+ }
+ }
+
+ log.Infof("Unzipping %q to %q", sourcePath, destinationPath)
+ return Unzip(sourcePath, destinationPath, log)
+}
+
+// Unzip unpacks a zip file to a destination.
+// This unpacks files using the current user and time (it doesn't preserve).
+// This code was modified from https://stackoverflow.com/questions/20357223/easy-way-to-unzip-file-with-golang/20357902
+func Unzip(sourcePath, destinationPath string, log Log) error {
+ r, err := zip.OpenReader(sourcePath)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if closeErr := r.Close(); closeErr != nil {
+ log.Warningf("Error in unzip closing zip file: %s", closeErr)
+ }
+ }()
+
+ err = os.MkdirAll(destinationPath, 0755)
+ if err != nil {
+ return err
+ }
+
+ // Closure to address file descriptors issue with all the deferred .Close() methods
+ extractAndWriteFile := func(f *zip.File) error {
+ rc, err := f.Open()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := rc.Close(); err != nil {
+ log.Warningf("Error in unzip closing file: %s", err)
+ }
+ }()
+
+ filePath := filepath.Join(destinationPath, f.Name)
+ fileInfo := f.FileInfo()
+
+ if fileInfo.IsDir() {
+ err := os.MkdirAll(filePath, fileInfo.Mode())
+ if err != nil {
+ return err
+ }
+ } else {
+ err := os.MkdirAll(filepath.Dir(filePath), 0755)
+ if err != nil {
+ return err
+ }
+
+ if fileInfo.Mode()&os.ModeSymlink != 0 {
+ linkName, readErr := io.ReadAll(rc)
+ if readErr != nil {
+ return readErr
+ }
+ return os.Symlink(string(linkName), filePath)
+ }
+
+ fileCopy, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileInfo.Mode())
+ if err != nil {
+ return err
+ }
+ defer Close(fileCopy)
+
+ _, err = io.Copy(fileCopy, rc)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }
+
+ for _, f := range r.File {
+ err := extractAndWriteFile(f)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/go/updater/util/unzip_nix_test.go b/go/updater/util/unzip_nix_test.go
new file mode 100644
index 000000000000..2d8ac475dc05
--- /dev/null
+++ b/go/updater/util/unzip_nix_test.go
@@ -0,0 +1,71 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+//go:build !windows
+// +build !windows
+
+package util
+
+import (
+ "os"
+ "os/user"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestUnzipOtherUser checks to make sure that a zip file created from a
+// different uid has the current uid after unpacking.
+func TestUnzipOtherUser(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Unsupported on windows")
+ }
+ _, filename, _, _ := runtime.Caller(0)
+ testZipOtherUserPath := filepath.Join(filepath.Dir(filename), "../test/test-uid-503.zip")
+ destinationPath := TempPath("", "TestUnzipOtherUser.")
+ err := Unzip(testZipOtherUserPath, destinationPath, testLog)
+ require.NoError(t, err)
+
+ // Get uid, gid of current user
+ currentUser, err := user.Current()
+ require.NoError(t, err)
+ uid, err := strconv.Atoi(currentUser.Uid)
+ require.NoError(t, err)
+
+ fileInfo, err := os.Stat(filepath.Join(destinationPath, "test"))
+ require.NoError(t, err)
+ fileUID := fileInfo.Sys().(*syscall.Stat_t).Uid
+ assert.Equal(t, uid, int(fileUID))
+}
+
+// TestUnzipFileModTime checks to make sure after unpacking zip file the file
+// modification time is "now" and not the original file time.
+func TestUnzipFileModTime(t *testing.T) {
+ // Fudge now a bit, since the timestamps below on Linux seem
+ // to happen a bit *before* now.
+ now := time.Now().Add(-time.Second)
+ t.Logf("Now: %s", now)
+ destinationPath := TempPath("", "TestUnzipFileModTime.")
+ err := Unzip(testZipPath, destinationPath, testLog)
+ require.NoError(t, err)
+
+ fileInfo, err := os.Stat(filepath.Join(destinationPath, "test"))
+ require.NoError(t, err)
+ dirMod := fileInfo.ModTime()
+ diffDir := dirMod.Sub(now)
+ t.Logf("Diff (dir): %s", diffDir)
+ assert.True(t, diffDir >= 0, "now=%s, dirtime=%s", now, dirMod)
+
+ fileInfo, err = os.Stat(filepath.Join(destinationPath, "test", "testfile"))
+ require.NoError(t, err)
+ fileMod := fileInfo.ModTime()
+ diffFile := fileMod.Sub(now)
+ t.Logf("Diff (file): %s", diffFile)
+ assert.True(t, diffFile >= 0, "now=%s, filetime=%s", now, fileMod)
+}
diff --git a/go/updater/util/unzip_test.go b/go/updater/util/unzip_test.go
new file mode 100644
index 000000000000..0f98a4c05399
--- /dev/null
+++ b/go/updater/util/unzip_test.go
@@ -0,0 +1,151 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import (
+ "fmt"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testZipPath, testSymZipPath, testCorruptedZipPath, testInvalidZipPath string
+
+func init() {
+ _, filename, _, _ := runtime.Caller(0)
+ // testZipPath is a valid zip file
+ testZipPath = filepath.Join(filepath.Dir(filename), "../test/test.zip")
+ // testSymZipPath is a valid zip file with a symbolic link
+ testSymZipPath = filepath.Join(filepath.Dir(filename), "../test/test-with-sym.zip")
+ // testCorruptedZipPath is a corrupted zip file (flipped a bit)
+ testCorruptedZipPath = filepath.Join(filepath.Dir(filename), "../test/test-corrupted2.zip")
+ // testInvalidZipPath is not a valid zip file
+ testInvalidZipPath = filepath.Join(filepath.Dir(filename), "../test/test-invalid.zip")
+}
+
+func assertFileExists(t *testing.T, path string) {
+ t.Logf("Checking %s", path)
+ fileExists, err := FileExists(path)
+ assert.NoError(t, err)
+ assert.True(t, fileExists)
+}
+
+func testUnzipOverValid(t *testing.T, path string) string {
+ destinationPath := TempPath("", "TestUnzipOver.")
+
+ noCheck := func(sourcePath, destinationPath string) error { return nil }
+
+ err := UnzipOver(path, "test", destinationPath, noCheck, "", testLog)
+ require.NoError(t, err)
+
+ dirExists, err := FileExists(destinationPath)
+ assert.NoError(t, err)
+ assert.True(t, dirExists)
+
+ assertFileExists(t, filepath.Join(destinationPath, "testfile"))
+ assertFileExists(t, filepath.Join(destinationPath, "testfolder"))
+ assertFileExists(t, filepath.Join(destinationPath, "testfolder", "testsubfolder"))
+ assertFileExists(t, filepath.Join(destinationPath, "testfolder", "testsubfolder", "testfile2"))
+
+ // Unzip again over existing path
+ err = UnzipOver(path, "test", destinationPath, noCheck, "", testLog)
+ require.NoError(t, err)
+
+ dirExists2, err := FileExists(destinationPath)
+ require.NoError(t, err)
+ require.True(t, dirExists2)
+
+ fileExists2, err := FileExists(filepath.Join(destinationPath, "testfile"))
+ require.NoError(t, err)
+ require.True(t, fileExists2)
+
+ // Unzip again over existing path, fail check
+ failCheck := func(sourcePath, destinationPath string) error { return fmt.Errorf("Failed check") }
+ err = UnzipOver(testZipPath, "test", destinationPath, failCheck, "", testLog)
+ assert.Error(t, err)
+
+ return destinationPath
+}
+
+func TestUnzipOverValid(t *testing.T) {
+ destinationPath := testUnzipOverValid(t, testZipPath)
+ defer RemoveFileAtPath(destinationPath)
+}
+
+func TestUnzipOverSymlink(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Symlink in zip unsupported on Windows")
+ }
+ destinationPath := testUnzipOverValid(t, testSymZipPath)
+ defer RemoveFileAtPath(destinationPath)
+ assertFileExists(t, filepath.Join(destinationPath, "testfolder", "testlink"))
+}
+
+func TestUnzipOverInvalidPath(t *testing.T) {
+ noCheck := func(sourcePath, destinationPath string) error { return nil }
+ err := UnzipOver(testZipPath, "test", "", noCheck, "", testLog)
+ assert.Error(t, err)
+
+ destinationPath := TempPath("", "TestUnzipOverInvalidPath.")
+ defer RemoveFileAtPath(destinationPath)
+ err = UnzipOver("/badfile.zip", "test", destinationPath, noCheck, "", testLog)
+ assert.Error(t, err)
+
+ err = UnzipOver("", "test", destinationPath, noCheck, "", testLog)
+ assert.Error(t, err)
+
+ err = unzipOver("", "", testLog)
+ assert.Error(t, err)
+}
+
+func TestUnzipOverInvalidZip(t *testing.T) {
+ noCheck := func(sourcePath, destinationPath string) error { return nil }
+ destinationPath := TempPath("", "TestUnzipOverInvalidZip.")
+ defer RemoveFileAtPath(destinationPath)
+ err := UnzipOver(testInvalidZipPath, "test", destinationPath, noCheck, "", testLog)
+ t.Logf("Error: %s", err)
+ assert.Error(t, err)
+}
+
+func TestUnzipOverInvalidContents(t *testing.T) {
+ noCheck := func(sourcePath, destinationPath string) error { return nil }
+ destinationPath := TempPath("", "TestUnzipOverInvalidContents.")
+ defer RemoveFileAtPath(destinationPath)
+ err := UnzipOver(testInvalidZipPath, "invalid", destinationPath, noCheck, "", testLog)
+ t.Logf("Error: %s", err)
+ assert.Error(t, err)
+}
+
+func TestUnzipOverCorrupted(t *testing.T) {
+ noCheck := func(sourcePath, destinationPath string) error { return nil }
+ destinationPath := TempPath("", "TestUnzipOverCorrupted.")
+ defer RemoveFileAtPath(destinationPath)
+ err := UnzipOver(testCorruptedZipPath, "test", destinationPath, noCheck, "", testLog)
+ t.Logf("Error: %s", err)
+ assert.Error(t, err)
+}
+
+func tempDir(t *testing.T) string {
+ tmpDir := TempPath("", "TestUnzipOver")
+ err := MakeDirs(tmpDir, 0700, testLog)
+ require.NoError(t, err)
+ return tmpDir
+}
+
+func TestUnzipOverMoveExisting(t *testing.T) {
+ noCheck := func(sourcePath, destinationPath string) error { return nil }
+ destinationPath := TempPath("", "TestUnzipOverMoveExisting.")
+ defer RemoveFileAtPath(destinationPath)
+ tmpDir := tempDir(t)
+ defer RemoveFileAtPath(tmpDir)
+ err := UnzipOver(testZipPath, "test", destinationPath, noCheck, tmpDir, testLog)
+ assert.NoError(t, err)
+ err = UnzipOver(testZipPath, "test", destinationPath, noCheck, tmpDir, testLog)
+ assert.NoError(t, err)
+
+ assertFileExists(t, filepath.Join(tmpDir, filepath.Base(destinationPath)))
+}
diff --git a/go/updater/util/util_test.go b/go/updater/util/util_test.go
new file mode 100644
index 000000000000..91c8e457d8ce
--- /dev/null
+++ b/go/updater/util/util_test.go
@@ -0,0 +1,8 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package util
+
+import "github.com/keybase/go-logging"
+
+var testLog = &logging.Logger{Module: "test"}
diff --git a/go/updater/watchdog/README.md b/go/updater/watchdog/README.md
new file mode 100644
index 000000000000..60b5f8a34044
--- /dev/null
+++ b/go/updater/watchdog/README.md
@@ -0,0 +1,10 @@
+## Watchdog
+
+The watchdog is in charge of ensuring that a list of programs are always running.
+The watchdog will start these programs (it will be the parent of those processes).
+If the watchdog is terminated, the monitored programs will also be terminated.
+
+When the watchdog starts up, it will kill any programs that it will be monitoring.
+Then it will monitor a list of programs and will restart them if they exit.
+Programs can be configured to always run, or to stop when receiving a particular
+exit status.
diff --git a/go/updater/watchdog/watchdog.go b/go/updater/watchdog/watchdog.go
new file mode 100644
index 000000000000..96e8a958ba6b
--- /dev/null
+++ b/go/updater/watchdog/watchdog.go
@@ -0,0 +1,207 @@
+// Copyright 2016 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package watchdog
+
+import (
+ "os"
+ "os/exec"
+ "time"
+
+ "github.com/keybase/client/go/updater/process"
+)
+
+// ExitOn describes when a program should exit (not-restart)
+type ExitOn string
+
+const (
+ // ExitOnNone means the program should always be restarted
+ ExitOnNone ExitOn = ""
+ // ExitOnSuccess means the program should only restart if errored
+ ExitOnSuccess ExitOn = "success"
+ // ExitAllOnSuccess means the program should only restart if errored,
+ // otherwise exit this watchdog. Intended for Windows
+ ExitAllOnSuccess ExitOn = "all"
+)
+
+const terminationDelay = 200 * time.Millisecond
+const heartbealDelay = 1 * time.Hour
+
+// Program is a program at path with arguments
+type Program struct {
+ Path string
+ Name string
+ Args []string
+ ExitOn ExitOn
+ runningPid int
+}
+
+// Log is the logging interface for the watchdog package
+type Log interface {
+ Debugf(s string, args ...interface{})
+ Infof(s string, args ...interface{})
+ Warningf(s string, args ...interface{})
+ Errorf(s string, args ...interface{})
+}
+
+type Watchdog struct {
+ Programs []Program
+ RestartDelay time.Duration
+ Log Log
+ shutdownCh chan (struct{})
+}
+
+func Watch(programs []Program, restartDelay time.Duration, log Log) error {
+ w := Watchdog{
+ Programs: programs,
+ RestartDelay: restartDelay,
+ Log: log,
+ shutdownCh: make(chan struct{}),
+ }
+ w.Log.Infof("Terminating any existing programs we will be monitoring")
+ w.terminateExistingMatches()
+ // Start monitoring all the programs
+ w.Log.Infof("about to start %+v\n", w.Programs)
+ for idx := range w.Programs {
+ // modifies the underlying
+ go w.startProgram(idx)
+ }
+ go w.heartbeatToLog(heartbealDelay)
+ return nil
+}
+
+func (w *Watchdog) Shutdown() {
+ w.Log.Infof("attempting a graceful exit of all of the watchdog's programs")
+ close(w.shutdownCh)
+ time.Sleep(terminationDelay)
+ for i := 1; i <= 3; i++ {
+ for _, p := range w.Programs {
+ _ = p.dieIfRunning(w.Log)
+ }
+ time.Sleep(terminationDelay)
+ }
+ w.Log.Infof("done terminating all watched programs - exiting process")
+ os.Exit(0)
+}
+
+func (p *Program) dieIfRunning(log Log) bool {
+ if p.runningPid != 0 {
+ log.Infof("%s running at %d is asked to die", p.Name, p.runningPid)
+ _ = process.TerminatePID(p.runningPid, terminationDelay, log)
+ p.runningPid = 0
+ return true
+ }
+ log.Debugf("%s did not appear to be running so it was not terminated", p.Name)
+ return false
+}
+
+func (p *Program) Run(log Log, shutdownCh chan struct{}) (err error) {
+ p.dieIfRunning(log)
+ cmd := exec.Command(p.Path, p.Args...)
+ if err = cmd.Start(); err != nil {
+ log.Errorf("Error starting %#v, err: %s", p, err.Error())
+ return err
+ }
+ p.runningPid = cmd.Process.Pid
+ log.Infof("Started %s at %d", p.Name, cmd.Process.Pid)
+ err = cmd.Wait()
+ p.runningPid = 0
+ return err
+}
+
+type heartbeat struct {
+ name string
+ pid int
+}
+
+func (w *Watchdog) heartbeatToLog(delay time.Duration) {
+ // wait enough time for the first heartbeat so it's actually useful
+ time.Sleep(1 * time.Minute)
+ for {
+ var heartbeats []heartbeat
+ for _, p := range w.Programs {
+ heartbeats = append(heartbeats, heartbeat{p.Name, p.runningPid})
+ }
+ w.Log.Infof("heartbeating programs: %v", heartbeats)
+ select {
+ case <-w.shutdownCh:
+ w.Log.Infof("watchdog is shutting down, stop heartbeating")
+ return
+ case <-time.After(delay):
+ continue
+ }
+ }
+}
+
+// watchProgram will monitor a program and restart it if it exits.
+// This method will run forever.
+func (w *Watchdog) startProgram(idx int) {
+ program := &(w.Programs[idx])
+ for {
+ start := time.Now()
+ err := program.Run(w.Log, w.shutdownCh)
+ if err != nil {
+ w.Log.Errorf("Error running %s: %+v; %s", program.Name, program, err)
+ } else {
+ w.Log.Infof("%s finished: %+v", program.Name, program)
+ if program.ExitOn == ExitOnSuccess {
+ w.Log.Infof("Program %s configured to exit on success, not restarting", program.Name)
+ break
+ } else if program.ExitOn == ExitAllOnSuccess {
+ w.Log.Infof("Program %s configured to trigger full watchdog shutdown", program.Name)
+ w.Shutdown()
+ }
+ }
+ w.Log.Infof("Program %s ran for %s", program.Name, time.Since(start))
+ select {
+ case <-w.shutdownCh:
+ w.Log.Infof("watchdog is shutting down, not restarting %s", program.Name)
+ return
+ default:
+ }
+ if time.Since(start) < w.RestartDelay {
+ w.Log.Infof("Waiting %s before trying to start %s command again", w.RestartDelay, program.Name)
+ time.Sleep(w.RestartDelay)
+ }
+ }
+}
+
+// terminateExistingMatches aggressively kills anything running that looks like similar
+// to what this watchdog will be running. the goal here is to be sure that, if multiple
+// calls attempt to start a watchdog, the last one will be the only one that survives.
+func (w *Watchdog) terminateExistingMatches() {
+ w.Log.Infof("Terminate any existing programs that look like matches")
+ var killedPids []int
+ for i := 1; i <= 3; i++ {
+ killedPids = w.killSimilarRunningPrograms()
+ if !includesARealProcess(killedPids) {
+ w.Log.Infof("none of these programs are running")
+ return
+ }
+ w.Log.Infof("Terminated pids %v", killedPids)
+ time.Sleep(terminationDelay)
+ }
+}
+
+func includesARealProcess(pids []int) bool {
+ for _, p := range pids {
+ if p > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func (w *Watchdog) killSimilarRunningPrograms() (killedPids []int) {
+ // kill any running processes that look like the ones this watchdog wants to watch
+ // this logic also exists in the updater, so if you want to change it, look there too.
+ ospid := os.Getpid()
+ for _, program := range w.Programs {
+ matcher := process.NewMatcher(program.Path, process.PathEqual, w.Log)
+ matcher.ExceptPID(ospid)
+ w.Log.Infof("Terminating %s", program.Name)
+ pids := process.TerminateAll(matcher, time.Second, w.Log)
+ killedPids = append(killedPids, pids...)
+ }
+ return killedPids
+}
diff --git a/go/updater/watchdog/watchdog_test.go b/go/updater/watchdog/watchdog_test.go
new file mode 100644
index 000000000000..c7703f63ae38
--- /dev/null
+++ b/go/updater/watchdog/watchdog_test.go
@@ -0,0 +1,270 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package watchdog
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/updater/process"
+ "github.com/keybase/client/go/updater/util"
+ "github.com/keybase/go-logging"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testLog = &logging.Logger{Module: "test"}
+
+func TestWatchMultiple(t *testing.T) {
+ procProgram1 := procProgram(t, "testWatch1", "sleep")
+ procProgram2 := procProgram(t, "testWatch2", "sleep")
+ defer util.RemoveFileAtPath(procProgram1.Path)
+ defer util.RemoveFileAtPath(procProgram2.Path)
+
+ delay := 10 * time.Millisecond
+
+ err := Watch([]Program{procProgram1, procProgram2}, delay, testLog)
+ require.NoError(t, err)
+
+ matcher1 := process.NewMatcher(procProgram1.Path, process.PathEqual, testLog)
+ procs1, err := process.FindProcesses(matcher1, time.Second, 200*time.Millisecond, testLog)
+ require.NoError(t, err)
+ assert.Equal(t, 1, len(procs1))
+
+ matcher2 := process.NewMatcher(procProgram2.Path, process.PathEqual, testLog)
+ procs2, err := process.FindProcesses(matcher2, time.Second, 200*time.Millisecond, testLog)
+ require.NoError(t, err)
+ require.Equal(t, 1, len(procs2))
+ proc2 := procs2[0]
+
+ err = process.TerminatePID(proc2.Pid(), time.Millisecond, testLog)
+ require.NoError(t, err)
+
+ time.Sleep(2 * delay)
+
+ // Check for restart
+ procs2After, err := process.FindProcesses(matcher2, time.Second, time.Millisecond, testLog)
+ require.NoError(t, err)
+ require.Equal(t, 1, len(procs2After))
+}
+
+// TestTerminateBeforeWatch checks to make sure any existing processes are
+// terminated before a process is monitored.
+func TestTerminateBeforeWatch(t *testing.T) {
+ procProgram := procProgram(t, "testTerminateBeforeWatch", "sleep")
+ defer util.RemoveFileAtPath(procProgram.Path)
+
+ matcher := process.NewMatcher(procProgram.Path, process.PathEqual, testLog)
+
+ // Launch program (so we can test it gets terminated on watch)
+ err := exec.Command(procProgram.Path, procProgram.Args...).Start()
+ require.NoError(t, err)
+
+ procsBefore, err := process.FindProcesses(matcher, time.Second, time.Millisecond, testLog)
+ require.NoError(t, err)
+ require.Equal(t, 1, len(procsBefore))
+ pidBefore := procsBefore[0].Pid()
+ t.Logf("Pid before: %d", pidBefore)
+
+ // Start watching
+ err = Watch([]Program{procProgram}, 10*time.Millisecond, testLog)
+ require.NoError(t, err)
+
+ // Check again, and make sure it's a new process
+ procsAfter, err := process.FindProcesses(matcher, time.Second, time.Millisecond, testLog)
+ require.NoError(t, err)
+ require.Equal(t, 1, len(procsAfter))
+ pidAfter := procsAfter[0].Pid()
+ t.Logf("Pid after: %d", pidAfter)
+
+ assert.NotEqual(t, pidBefore, pidAfter)
+}
+
+// TestTerminateBeforeWatchRace verifies protection from the following scenario:
+//
+// watchdog1 starts up
+// watchdog1 looks up existing processes to terminate and sees none
+// watchdog2 starts up
+// watchdog2 looks up existing processes to terminate and sees watchdog1
+// watchdog1 starts PROGRAM
+// watchdog1 receives kill signal from watchdog2 and dies
+// watchdog2 starts a second PROGRAM
+// PROGRAM has a bad time
+//
+// The test doesn't protect us from the race condition generically, rather only when:
+//
+// (1) PROGRAM is only started by a watchdog, and
+// (2) PROGRAM and watchdog share a path to the same executable. When a
+// watchdog looks up existing processes to terminate, it needs to be able
+// to find another watchdog.
+func TestTerminateBeforeWatchRace(t *testing.T) {
+ var err error
+ // set up a bunch of iterations of the same program
+ programName := "TestTerminateBeforeWatchRace"
+ otherIterations := make([]Program, 6)
+ for i := 0; i < 6; i++ {
+ otherIterations[i] = procProgram(t, programName, "sleep")
+ }
+ mainProgram := procProgram(t, programName, "sleep")
+ defer util.RemoveFileAtPath(mainProgram.Path)
+ blocker := make(chan struct{})
+ go func() {
+ for _, p := range otherIterations[:3] {
+ _ = exec.Command(p.Path, p.Args...).Start()
+ }
+ blocker <- struct{}{}
+ for _, p := range otherIterations[3:] {
+ _ = exec.Command(p.Path, p.Args...).Start()
+ }
+ }()
+
+ // block until we definitely have something to kill
+ <-blocker
+ err = Watch([]Program{mainProgram}, 10*time.Millisecond, testLog)
+ require.NoError(t, err)
+
+ // Check and make sure there's only one of these processes running
+ matcher := process.NewMatcher(mainProgram.Path, process.PathEqual, testLog)
+ procsAfter, err := process.FindProcesses(matcher, time.Second, time.Millisecond, testLog)
+ require.NoError(t, err)
+ require.Equal(t, 1, len(procsAfter))
+}
+
+func TestExitOnSuccess(t *testing.T) {
+ procProgram := procProgram(t, "testExitOnSuccess", "echo")
+ procProgram.ExitOn = ExitOnSuccess
+ defer util.RemoveFileAtPath(procProgram.Path)
+
+ err := Watch([]Program{procProgram}, 0, testLog)
+ require.NoError(t, err)
+
+ time.Sleep(50 * time.Millisecond)
+
+ matcher := process.NewMatcher(procProgram.Path, process.PathEqual, testLog)
+ procsAfter, err := process.WaitForExit(matcher, 500*time.Millisecond, 50*time.Millisecond, testLog)
+ require.NoError(t, err)
+ assert.Equal(t, 0, len(procsAfter))
+}
+
+func procTestPath(name string) (string, string) {
+ // Copy test executable to tmp
+ if runtime.GOOS == "windows" {
+ return filepath.Join(os.Getenv("GOPATH"), "bin", "test.exe"), filepath.Join(os.TempDir(), name+".exe")
+ }
+ return filepath.Join(os.Getenv("GOPATH"), "bin", "test"), filepath.Join(os.TempDir(), name)
+}
+
+// procProgram returns a testable unique program at a temporary location
+func procProgram(t *testing.T, name string, testCommand string) Program {
+ path, procPath := procTestPath(name)
+ err := util.CopyFile(path, procPath, testLog)
+ require.NoError(t, err)
+ err = os.Chmod(procPath, 0777)
+ require.NoError(t, err)
+ // Temp dir might have symlinks in which case we need the eval'ed path
+ procPath, err = filepath.EvalSymlinks(procPath)
+ require.NoError(t, err)
+ return Program{
+ Path: procPath,
+ Args: []string{testCommand},
+ Name: name,
+ }
+}
+
+// TestExitAllOnSuccess verifies that a program with ExitAllOnSuccess that exits cleanly
+// will also cause a clean exit on another program which has been restarted by the watchdog.
+func TestExitAllOnSuccess(t *testing.T) {
+ // This test is slow and I'm sorry about that.
+ sleepTimeInTest := 10000 // 10 seconds
+ exiter := procProgram(t, "testExitAllOnSuccess", "sleep")
+ defer util.RemoveFileAtPath(exiter.Path)
+ exiter.ExitOn = ExitAllOnSuccess
+ testProgram := procProgram(t, "alice", "sleep")
+ defer util.RemoveFileAtPath(testProgram.Path)
+
+ getProgramPID := func(program Program) int {
+ matcher := process.NewMatcher(program.Path, process.PathEqual, testLog)
+ procs, err := process.FindProcesses(matcher, time.Second, 100*time.Millisecond, testLog)
+ require.NoError(t, err)
+ require.Equal(t, 1, len(procs))
+ proc := procs[0]
+ return proc.Pid()
+ }
+
+ // watch two programs, 1 that will exit cleanly and 1 that won't
+ programs := []Program{exiter, testProgram}
+ err := Watch(programs, 0, testLog)
+ require.NoError(t, err)
+
+ // bounce the testProgram halfway through the sleep interval
+ firstPid := getProgramPID(testProgram)
+ require.NotEqual(t, 0, firstPid)
+ time.Sleep(time.Duration(sleepTimeInTest/2) * time.Second)
+ err = process.TerminatePID(firstPid, time.Millisecond, testLog)
+ require.NoError(t, err)
+ time.Sleep(100 * time.Millisecond)
+ secondPid := getProgramPID(testProgram)
+ require.NotEqual(t, 0, secondPid)
+ require.NotEqual(t, firstPid, secondPid)
+
+ // sleep until the exiter program should have exited cleanly
+ // and triggered the exit of everything else
+ time.Sleep(time.Duration((sleepTimeInTest/2)+1) * time.Second)
+
+ assertProgramEnded := func(program Program) {
+ matcher := process.NewMatcher(program.Path, process.PathEqual, testLog)
+ procs, err := process.FindProcesses(matcher, time.Second, 100*time.Millisecond, testLog)
+ require.NoError(t, err)
+ require.Equal(t, 0, len(procs))
+ }
+
+ assertProgramEnded(exiter)
+ assertProgramEnded(testProgram)
+}
+
+func TestWatchdogExitAllRace(t *testing.T) {
+ exiter := procProgram(t, "TestWatchdogExitAllRace", "sleep")
+ defer util.RemoveFileAtPath(exiter.Path)
+ exiter.ExitOn = ExitAllOnSuccess
+ procProgram1 := procProgram(t, "alice", "sleep")
+ defer util.RemoveFileAtPath(procProgram1.Path)
+ procProgram2 := procProgram(t, "bob", "sleep")
+ defer util.RemoveFileAtPath(procProgram2.Path)
+
+ assertOneProcessIsRunning := func(p Program) {
+ matcher := process.NewMatcher(p.Path, process.PathEqual, testLog)
+ procs, err := process.FindProcesses(matcher, time.Second, 200*time.Millisecond, testLog)
+ require.NoError(t, err)
+ assert.Equal(t, 1, len(procs))
+ }
+ assertOneProcessOfEachProgramIsRunning := func() {
+ assertOneProcessIsRunning(exiter)
+ assertOneProcessIsRunning(procProgram1)
+ assertOneProcessIsRunning(procProgram2)
+ }
+
+ // spin up three watchdogs at the same time withe the same three programs
+ var wg sync.WaitGroup
+ for i := 0; i < 3; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ err := Watch([]Program{exiter, procProgram1, procProgram2}, 0, testLog)
+ require.NoError(t, err)
+ }()
+ }
+ wg.Wait()
+ assertOneProcessOfEachProgramIsRunning()
+ time.Sleep(500 * time.Millisecond)
+ assertOneProcessOfEachProgramIsRunning()
+
+ err := Watch([]Program{exiter, procProgram1, procProgram2}, 0, testLog)
+ require.NoError(t, err)
+ assertOneProcessOfEachProgramIsRunning()
+}
diff --git a/go/updater/windows/README.md b/go/updater/windows/README.md
new file mode 100644
index 000000000000..aef9d99ee2de
--- /dev/null
+++ b/go/updater/windows/README.md
@@ -0,0 +1,3 @@
+## Windows
+
+The updater expects to find prompter.exe in the same directory as the updater executable (upd.exe).
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/App.config b/go/updater/windows/WpfPrompter/WpfApplication1/App.config
new file mode 100644
index 000000000000..72a71af99a7c
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/App.config
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/App.xaml b/go/updater/windows/WpfPrompter/WpfApplication1/App.xaml
new file mode 100644
index 000000000000..7d80047df111
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/App.xaml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/App.xaml.cs b/go/updater/windows/WpfPrompter/WpfApplication1/App.xaml.cs
new file mode 100644
index 000000000000..2d14e854c3c7
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/App.xaml.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+using System.Configuration;
+using System.Data;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+
+namespace WpfApplication1
+{
+ ///
+ /// Interaction logic for App.xaml
+ ///
+ public partial class App : Application
+ {
+ }
+}
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/ClassDiagram1.cd b/go/updater/windows/WpfPrompter/WpfApplication1/ClassDiagram1.cd
new file mode 100644
index 000000000000..93db90223b1b
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/ClassDiagram1.cd
@@ -0,0 +1,26 @@
+
+
+
+
+
+ AAAAAAAAQAAAAAAAIAAAAAAAAAAAAAAAoAAAAAAAACA=
+ MainWindow.xaml.cs
+
+
+
+
+
+ AAAABAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAA=
+ MainWindow.xaml.cs
+
+
+
+
+
+ AAAAACAAAAAAAAACJAAAAAIJAAAAABAAAAAAEAAAAAA=
+ MainWindow.xaml.cs
+
+
+
+
+
\ No newline at end of file
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/MainWindow.xaml b/go/updater/windows/WpfPrompter/WpfApplication1/MainWindow.xaml
new file mode 100644
index 000000000000..71b879db0b1e
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/MainWindow.xaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/MainWindow.xaml.cs b/go/updater/windows/WpfPrompter/WpfApplication1/MainWindow.xaml.cs
new file mode 100644
index 000000000000..a4730f74a367
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/MainWindow.xaml.cs
@@ -0,0 +1,107 @@
+using System;
+using System.IO;
+using System.Windows;
+using System.Windows.Interop;
+using System.Web.Script.Serialization;
+using System.Runtime.InteropServices;
+
+public class Input
+{
+ public string title { get; set; }
+ public string message { get; set; }
+ public string description { get; set; }
+ public string outPath { get; set; }
+ public bool auto { get; set; }
+}
+
+public class Result
+{
+ public string action { get; set; }
+ public bool autoUpdate { get; set; }
+ public int snooze_duration { get; set; }
+}
+
+namespace WpfApplication1
+{
+ ///
+ /// Interaction logic for MainWindow.xaml
+ ///
+ public partial class MainWindow : Window
+ {
+ Input input;
+ Result result;
+
+ private const int GWL_STYLE = -16;
+ private const int WS_SYSMENU = 0x80000;
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
+ [DllImport("user32.dll")]
+ private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
+
+ private const int snoozeDay = 60 * 60 * 24; // seconds per day
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ result = new Result();
+ string[] args = Environment.GetCommandLineArgs();
+ if (args != null && args.Length > 1)
+ {
+ JavaScriptSerializer serializer = new JavaScriptSerializer();
+ input = serializer.Deserialize(args[1]);
+ } else
+ {
+ input = new global::Input();
+ }
+ if (input.title != null && input.title.Length > 0)
+ {
+ title.Text = input.title;
+ }
+ if (input.message != null && input.message.Length > 0)
+ {
+ message.Text = input.message;
+ }
+ if (input.description != null && input.description.Length > 0)
+ {
+ description.Text = input.description;
+ }
+ if (input.outPath == null || input.outPath.Length <= 0)
+ {
+ input.outPath = "updaterPromptResult.txt";
+ }
+ silent.IsChecked = input.auto;
+
+ }
+
+ private void apply_Click(object sender, RoutedEventArgs e)
+ {
+ result.action = "apply";
+ result.autoUpdate = (bool) silent.IsChecked;
+ writeResult();
+ }
+
+ private void writeResult()
+ {
+ JavaScriptSerializer serializer = new JavaScriptSerializer();
+ File.WriteAllText(input.outPath, serializer.Serialize(result));
+ Application.Current.Shutdown();
+ }
+
+ private void Window_Loaded(object sender, RoutedEventArgs e)
+ {
+ var hwnd = new WindowInteropHelper(this).Handle;
+ SetWindowLong(hwnd, GWL_STYLE, GetWindowLong(hwnd, GWL_STYLE) & ~WS_SYSMENU);
+ }
+
+ private void snoozeDuration_DropDownClosed(object sender, EventArgs e)
+ {
+ var snoozeVal = (System.Windows.Controls.ComboBoxItem) snoozeDuration.SelectedItem;
+ if (snoozeVal != null && snoozeDuration.SelectedIndex > 0)
+ {
+ result.action = "snooze";
+ result.snooze_duration = (snoozeVal.Name == "snooze7") ? snoozeDay * 7 : snoozeDay;
+ writeResult();
+ }
+ }
+ }
+}
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/Properties/AssemblyInfo.cs b/go/updater/windows/WpfPrompter/WpfApplication1/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000000..4a4107feb823
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/Properties/AssemblyInfo.cs
@@ -0,0 +1,55 @@
+using System.Reflection;
+using System.Resources;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Windows;
+
+// General Information about an assembly is controlled through the following
+// set of attributes. Change these attribute values to modify the information
+// associated with an assembly.
+[assembly: AssemblyTitle("Keybase Update Prompter")]
+[assembly: AssemblyDescription("GUI for prompting the user to update")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("Keybase, Inc.")]
+[assembly: AssemblyProduct("Keybase")]
+[assembly: AssemblyCopyright("Copyright © 2017")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+// Setting ComVisible to false makes the types in this assembly not visible
+// to COM components. If you need to access a type in this assembly from
+// COM, set the ComVisible attribute to true on that type.
+[assembly: ComVisible(false)]
+
+//In order to begin building localizable applications, set
+//CultureYouAreCodingWith in your .csproj file
+//inside a . For example, if you are using US english
+//in your source files, set the to en-US. Then uncomment
+//the NeutralResourceLanguage attribute below. Update the "en-US" in
+//the line below to match the UICulture setting in the project file.
+
+//[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)]
+
+
+[assembly: ThemeInfo(
+ ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
+ //(used if a resource is not found in the page,
+ // or application resource dictionaries)
+ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
+ //(used if a resource is not found in the page,
+ // app, or any theme specific resource dictionaries)
+)]
+
+
+// Version information for an assembly consists of the following four values:
+//
+// Major Version
+// Minor Version
+// Build Number
+// Revision
+//
+// You can specify all the values or you can default the Build and Revision Numbers
+// by using the '*' as shown below:
+// [assembly: AssemblyVersion("1.0.*")]
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Resources.Designer.cs b/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Resources.Designer.cs
new file mode 100644
index 000000000000..eb9962708f90
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Resources.Designer.cs
@@ -0,0 +1,83 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace WpfPrompterApp.Properties {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class Resources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("WpfPrompterApp.Properties.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized resource of type System.Drawing.Bitmap.
+ ///
+ internal static System.Drawing.Bitmap icon_128x128 {
+ get {
+ object obj = ResourceManager.GetObject("icon_128x128", resourceCulture);
+ return ((System.Drawing.Bitmap)(obj));
+ }
+ }
+
+ ///
+ /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon).
+ ///
+ internal static System.Drawing.Icon keybase {
+ get {
+ object obj = ResourceManager.GetObject("keybase", resourceCulture);
+ return ((System.Drawing.Icon)(obj));
+ }
+ }
+ }
+}
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Resources.resx b/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Resources.resx
new file mode 100644
index 000000000000..625f75100361
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Resources.resx
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ ..\Resources\icon_128x128.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
+
+
+ ..\Resources\keybase.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
+
+
\ No newline at end of file
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Settings.Designer.cs b/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Settings.Designer.cs
new file mode 100644
index 000000000000..af39f893e4da
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Settings.Designer.cs
@@ -0,0 +1,26 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace WpfPrompterApp.Properties {
+
+
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "14.0.0.0")]
+ internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
+
+ private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
+
+ public static Settings Default {
+ get {
+ return defaultInstance;
+ }
+ }
+ }
+}
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Settings.settings b/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Settings.settings
new file mode 100644
index 000000000000..033d7a5e9e22
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/Properties/Settings.settings
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/Resources/icon_128x128.png b/go/updater/windows/WpfPrompter/WpfApplication1/Resources/icon_128x128.png
new file mode 100644
index 000000000000..3d8aac1cc0a1
Binary files /dev/null and b/go/updater/windows/WpfPrompter/WpfApplication1/Resources/icon_128x128.png differ
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/Resources/keybase.ico b/go/updater/windows/WpfPrompter/WpfApplication1/Resources/keybase.ico
new file mode 100644
index 000000000000..b89878d7a18f
Binary files /dev/null and b/go/updater/windows/WpfPrompter/WpfApplication1/Resources/keybase.ico differ
diff --git a/go/updater/windows/WpfPrompter/WpfApplication1/WpfPropmpterApp.csproj b/go/updater/windows/WpfPrompter/WpfApplication1/WpfPropmpterApp.csproj
new file mode 100644
index 000000000000..bccee71928d5
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfApplication1/WpfPropmpterApp.csproj
@@ -0,0 +1,121 @@
+
+
+
+
+ Debug
+ AnyCPU
+ {B15C3D30-4A9E-4524-8079-4B0ADF6D05B7}
+ WinExe
+ Properties
+ WpfPrompterApp
+ prompter
+ v4.0
+ 512
+ {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}
+ 4
+ true
+
+
+
+
+ AnyCPU
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ AnyCPU
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+ Resources\keybase.ico
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 4.0
+
+
+
+
+
+
+
+ MSBuild:Compile
+ Designer
+
+
+ MSBuild:Compile
+ Designer
+
+
+ App.xaml
+ Code
+
+
+ MainWindow.xaml
+ Code
+
+
+
+
+ Code
+
+
+ True
+ True
+ Resources.resx
+
+
+ True
+ Settings.settings
+ True
+
+
+ ResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+
+
+ SettingsSingleFileGenerator
+ Settings.Designer.cs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/go/updater/windows/WpfPrompter/WpfPrompter.sln b/go/updater/windows/WpfPrompter/WpfPrompter.sln
new file mode 100644
index 000000000000..4c6dea17eaba
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/WpfPrompter.sln
@@ -0,0 +1,22 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio 14
+VisualStudioVersion = 14.0.25420.1
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WpfPropmpterApp", "WpfApplication1\WpfPropmpterApp.csproj", "{B15C3D30-4A9E-4524-8079-4B0ADF6D05B7}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {B15C3D30-4A9E-4524-8079-4B0ADF6D05B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B15C3D30-4A9E-4524-8079-4B0ADF6D05B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B15C3D30-4A9E-4524-8079-4B0ADF6D05B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B15C3D30-4A9E-4524-8079-4B0ADF6D05B7}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/go/updater/windows/WpfPrompter/run.go b/go/updater/windows/WpfPrompter/run.go
new file mode 100644
index 000000000000..68f975d9fb8c
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/run.go
@@ -0,0 +1,65 @@
+// Copyright 2015 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+//go:build windows
+// +build windows
+
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/kardianos/osext"
+ "github.com/keybase/client/go/updater/command"
+ "github.com/keybase/go-logging"
+)
+
+// Copied here since it is not exported from go-updater/keybase
+type updaterPromptInput struct {
+ Title string `json:"title"`
+ Message string `json:"message"`
+ Description string `json:"description"`
+ AutoUpdate bool `json:"autoUpdate"`
+ OutPath string `json:"outPath"` // Used for windows instead of stdout
+}
+
+func main() {
+ var testLog = &logging.Logger{Module: "test"}
+
+ exePathName, _ := osext.Executable()
+ pathName, _ := filepath.Split(exePathName)
+ outPathName := filepath.Join(pathName, "out.txt")
+
+ promptJSONInput, err := json.Marshal(updaterPromptInput{
+ Title: "Keybase Update: Version Foobar",
+ Message: "The version you are currently running (0.0) is outdated.",
+ Description: "Recent changes:\n\n---------------\n\n- Introducing the main GUI screen - this is where you'll look people up, manage your folders, and perform other actions. A lot of the features are stubbed out, but you can start playing with it.\n\n- Sharing before signup - go ahead and put data in /keybase/private/you,friend@twitter/ . If you have any invite codes, this will pop up a window with a link to DM them. It also works for end-to-end encryption with Reddit, Coinbase, Github, and Hacker News users.\n\nWhat we are currently working on:\n\n---------------------------------\n\n- KBFS performance, including delayed writes\n\n",
+ AutoUpdate: true,
+ OutPath: outPathName,
+ })
+ if err != nil {
+ testLog.Errorf("Error generating input: %s", err)
+ return
+ }
+
+ path := filepath.Join(pathName, "WpfApplication1\\bin\\Release\\prompter.exe")
+
+ testLog.Debugf("Executing: %s %s", path, string(string(promptJSONInput)))
+
+ _, err = command.Exec(path, []string{string(promptJSONInput)}, 100*time.Second, testLog)
+ if err != nil {
+ testLog.Errorf("Error: %v", err)
+ return
+ }
+
+ result, err := os.ReadFile(outPathName)
+ if err != nil {
+ testLog.Errorf("Error opening result file: %v", err)
+ return
+ }
+
+ testLog.Debugf("Result: %s", string(result))
+}
diff --git a/go/updater/windows/WpfPrompter/test.bat b/go/updater/windows/WpfPrompter/test.bat
new file mode 100644
index 000000000000..4660f466d543
--- /dev/null
+++ b/go/updater/windows/WpfPrompter/test.bat
@@ -0,0 +1 @@
+%GOPATH%src\github.com\keybase\client\go\updater\windows\WpfPrompter\WpfApplication1\bin\Release\prompter.exe "{\"title\":\"Keybase Update: Version Foobar\",\"message\":\"The version you are currently running (0.0) is outdated.\",\"description\":\"Recent changes:\n\n---------------\n\n- Introducing the main GUI screen - this is where you'll look people up, manage your folders, and perform other actions. A lot of the features are stubbed out, but you can start playing with it.\n\n- Sharing before signup - go ahead and put data in /keybase/private/you,friend@twitter/ . If you have any invite codes, this will pop up a window with a link to DM them. It also works for end-to-end encryption with Reddit, Coinbase, Github, and Hacker News users.\n\nWhat we are currently working on:\n\n---------------------------------\n\n- KBFS performance, including delayed writes\n\n\",\"autoUpdate\":true,\"outPath\":\"c:\\work\\src\\github.com\\keybase\\client\\go\\updater\\windows\\prompter\\out.txt\"}"
\ No newline at end of file
diff --git a/osx/Scripts/build.sh b/osx/Scripts/build.sh
index 772f22936d0a..66247c7560ff 100755
--- a/osx/Scripts/build.sh
+++ b/osx/Scripts/build.sh
@@ -15,7 +15,7 @@ mkdir -p $build_dest
# Flirting with custom configuration but xcodebuild archive will only do Release
# configuration.
xcode_configuration="Release"
-code_sign_identity="9FC3A5BC09FA2EE307C04060C918486411869B65" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
+code_sign_identity="90524F7BEAEACD94C7B473787F4949582F904104" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
echo "Plist: $plist"
app_version="`/usr/libexec/plistBuddy -c "Print :CFBundleShortVersionString" $plist`"
diff --git a/packaging/desktop/package_darwin.sh b/packaging/desktop/package_darwin.sh
index 54e069728af8..5b29f5ac751d 100755
--- a/packaging/desktop/package_darwin.sh
+++ b/packaging/desktop/package_darwin.sh
@@ -52,7 +52,7 @@ icon_path="$client_dir/media/icons/Keybase.icns"
saltpack_icon="$client_dir/media/icons/saltpack.icns"
echo "Loading release tool"
-(cd "$client_dir/go/buildtools"; go install "github.com/keybase/release")
+(cd "$client_dir/go/buildtools"; go install "github.com/keybase/client/go/release")
release_bin="$GOPATH/bin/release"
echo "$(go version)"
@@ -205,7 +205,7 @@ package_electron() {(
yarn install --pure-lockfile --ignore-engines
yarn run package -- --appVersion="$app_version" --comment="$comment" --icon="$icon_path" --saltpackIcon="$saltpack_icon" --outDir="$build_dir" --arch="$electron_arch"
- # Create symlink for Electron to overcome Gatekeeper bug https://github.com/keybase/go-updater/pull/4
+ # Create symlink for Electron to overcome Gatekeeper bug https://github.com/keybase/client/go/updater/pull/4
cd "$out_dir/$app_name.app/Contents/MacOS"
ln -s "Keybase" "Electron"
@@ -245,7 +245,7 @@ update_plist() {(
sign() {(
cd "$out_dir"
- code_sign_identity="9FC3A5BC09FA2EE307C04060C918486411869B65" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
+ code_sign_identity="90524F7BEAEACD94C7B473787F4949582F904104" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
# need to sign some stuff from electron that doesn't get picked up for some reason
codesign --verbose --force --deep --timestamp --options runtime --sign "$code_sign_identity" "$app_name.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Libraries/libffmpeg.dylib"
codesign --verbose --force --deep --timestamp --options runtime --sign "$code_sign_identity" "$app_name.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Libraries/libEGL.dylib"
diff --git a/packaging/github/kbfs.sh b/packaging/github/kbfs.sh
index d62474175b4f..06a3b31889d8 100755
--- a/packaging/github/kbfs.sh
+++ b/packaging/github/kbfs.sh
@@ -27,7 +27,7 @@ tag="v$version"
tgz="kbfs-$version.tgz"
echo "Loading release tool"
-(cd "$client_dir/go/buildtools"; go install "github.com/keybase/release")
+(cd "$client_dir/go/buildtools"; go install "github.com/keybase/client/go/release")
release_bin="$GOPATH/bin/release"
echo "$(go version)"
diff --git a/packaging/github/keybase.sh b/packaging/github/keybase.sh
index e58a14663f3a..136cbc9af53b 100755
--- a/packaging/github/keybase.sh
+++ b/packaging/github/keybase.sh
@@ -27,7 +27,7 @@ tag="v$version"
tgz="keybase-$version.tgz"
echo "Loading release tool"
-(cd "$client_dir/go" && go install "github.com/keybase/release")
+(cd "$client_dir/go" && go install "github.com/keybase/client/go/release")
release_bin="$GOPATH/bin/release"
echo "$(go version)"
diff --git a/packaging/linux/build_and_push_packages.sh b/packaging/linux/build_and_push_packages.sh
index 3dfd8761b8c4..7414e746eedc 100755
--- a/packaging/linux/build_and_push_packages.sh
+++ b/packaging/linux/build_and_push_packages.sh
@@ -49,7 +49,7 @@ mkdir -p "$build_dir"
echo "Loading release tool"
release_gopath="$HOME/release_gopath"
-(cd "$client_dir/go/buildtools"; GOPATH="$release_gopath" go install "github.com/keybase/release")
+(cd "$client_dir/go/buildtools"; GOPATH="$release_gopath" go install "github.com/keybase/client/go/release")
release_bin="$release_gopath/bin/release"
echo "$(go version)"
diff --git a/packaging/prerelease/build_app.sh b/packaging/prerelease/build_app.sh
index 8af0ac3a3885..e752abd7f0fa 100755
--- a/packaging/prerelease/build_app.sh
+++ b/packaging/prerelease/build_app.sh
@@ -45,14 +45,13 @@ build_dir_kbnm="/tmp/build_kbnm"
build_dir_updater="/tmp/build_updater"
client_dir=${CLIENT_DIR:-"$gopath/src/github.com/keybase/client"}
kbfs_dir="$client_dir/go/kbfs"
-updater_dir=${UPDATER_DIR:-"$gopath/src/github.com/keybase/go-updater"}
-
-if [ ! "$nopull" = "1" ]; then
- "$client_dir/packaging/check_status_and_pull.sh" "$updater_dir"
-fi
+updater_dir=${UPDATER_DIR:-"$gopath/src/github.com/keybase/client/go/updater"}
+echo "client_dir: $client_dir"
+echo "kbfs_dir: $kbfs_dir"
+echo "updater_dir: $updater_dir"
echo "Loading release tool"
-(cd "$client_dir/go/buildtools"; go install "github.com/keybase/release")
+(cd "$client_dir/go/buildtools"; go install "github.com/keybase/client/go/release")
release_bin="$GOPATH/bin/release"
echo "$(go version)"
@@ -75,8 +74,6 @@ fi
if [ ! "$nowait" = "1" ]; then
echo "Checking client CI"
"$release_bin" wait-ci --repo="client" --commit=$(git -C "$client_dir" log -1 --pretty=format:%h) --context="continuous-integration/jenkins/branch" --context="ci/circleci"
- echo "Checking updater CI"
- "$release_bin" wait-ci --repo="go-updater" --commit=$(git -C "$updater_dir" log -1 --pretty=format:%h) --context="continuous-integration/travis-ci/push"
"$client_dir/packaging/slack/send.sh" "CI tests passed! Starting build for $platform."
fi
diff --git a/packaging/prerelease/build_kbfs.sh b/packaging/prerelease/build_kbfs.sh
index 5273c744b926..8e0e15dcdcd0 100755
--- a/packaging/prerelease/build_kbfs.sh
+++ b/packaging/prerelease/build_kbfs.sh
@@ -38,7 +38,7 @@ GOARCH="$arch" go build -a -tags "$tags" -ldflags "$ldflags" -o "$build_dir/keyb
if [ "$PLATFORM" = "darwin" ] || [ "$PLATFORM" = "darwin-arm64" ]; then
echo "Signing binaries..."
- code_sign_identity="9FC3A5BC09FA2EE307C04060C918486411869B65" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
+ code_sign_identity="90524F7BEAEACD94C7B473787F4949582F904104" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
codesign --verbose --force --deep --timestamp --options runtime --sign "$code_sign_identity" "$build_dir"/kbfs
codesign --verbose --force --deep --timestamp --options runtime --sign "$code_sign_identity" "$build_dir"/git-remote-keybase
codesign --verbose --force --deep --timestamp --options runtime --sign "$code_sign_identity" "$build_dir"/keybase-redirector
diff --git a/packaging/prerelease/build_kbnm.sh b/packaging/prerelease/build_kbnm.sh
index d03f9d9f3faf..b56fda21cce1 100755
--- a/packaging/prerelease/build_kbnm.sh
+++ b/packaging/prerelease/build_kbnm.sh
@@ -24,7 +24,7 @@ echo "Building $build_dir/kbnm ($kbnm_build) with $(go version) on arch: $arch"
if [ "$PLATFORM" = "darwin" ] || [ "$PLATFORM" = "darwin-arm64" ]; then
echo "Signing binary..."
- code_sign_identity="9FC3A5BC09FA2EE307C04060C918486411869B65" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
+ code_sign_identity="90524F7BEAEACD94C7B473787F4949582F904104" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
codesign --verbose --force --deep --timestamp --options runtime --sign "$code_sign_identity" "$build_dir"/kbnm
elif [ "$PLATFORM" = "linux" ]; then
echo "No codesigning for Linux"
diff --git a/packaging/prerelease/build_keybase.sh b/packaging/prerelease/build_keybase.sh
index eefd8f496693..03f99d1294e1 100755
--- a/packaging/prerelease/build_keybase.sh
+++ b/packaging/prerelease/build_keybase.sh
@@ -23,7 +23,7 @@ echo "Building $build_dir/keybase ($keybase_build) with $(go version) on arch: $
if [ "$PLATFORM" = "darwin" ] || [ "$PLATFORM" = "darwin-arm64" ]; then
echo "Signing binary..."
- code_sign_identity="9FC3A5BC09FA2EE307C04060C918486411869B65" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
+ code_sign_identity="90524F7BEAEACD94C7B473787F4949582F904104" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
codesign --verbose --force --deep --timestamp --options runtime --sign "$code_sign_identity" "$build_dir/keybase"
elif [ "$PLATFORM" = "linux" ]; then
echo "No codesigning for Linux"
diff --git a/packaging/prerelease/build_updater.sh b/packaging/prerelease/build_updater.sh
index f81f965de6e1..8a5bded41ff4 100755
--- a/packaging/prerelease/build_updater.sh
+++ b/packaging/prerelease/build_updater.sh
@@ -7,7 +7,7 @@ cd "$dir"
build_dir=${BUILD_DIR:-/tmp/keybase}
gopath=${GOPATH:-}
-package="github.com/keybase/go-updater/service"
+package="github.com/keybase/client/go/updater/service"
dest="$build_dir/updater"
arch=${ARCH:-"amd64"}
@@ -21,7 +21,7 @@ GOARCH="$arch" go build -a -o "$dest" "$package"
if [ "$PLATFORM" = "darwin" ] || [ "$PLATFORM" = "darwin-arm64" ]; then
echo "Signing binary..."
- code_sign_identity="9FC3A5BC09FA2EE307C04060C918486411869B65" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
+ code_sign_identity="90524F7BEAEACD94C7B473787F4949582F904104" # "Developer ID Application: Keybase, Inc. (99229SGT5K)"
codesign --verbose --force --deep --timestamp --options runtime --sign "$code_sign_identity" "$dest"
elif [ "$PLATFORM" = "linux" ]; then
echo "No codesigning for Linux"
diff --git a/packaging/prerelease/report.sh b/packaging/prerelease/report.sh
index 385e933f3026..50092afa95d6 100755
--- a/packaging/prerelease/report.sh
+++ b/packaging/prerelease/report.sh
@@ -9,7 +9,7 @@ client_dir="$dir/../.."
bucket_name=${BUCKET_NAME:-}
echo "Loading release tool"
-(cd "$client_dir/go" && go install "github.com/keybase/release")
+(cd "$client_dir/go" && go install "github.com/keybase/client/go/release")
release_bin="$GOPATH/bin/release"
echo "$(go version)"
diff --git a/packaging/prerelease/s3_index.sh b/packaging/prerelease/s3_index.sh
index 4a25cddc3802..6aa683e8930e 100755
--- a/packaging/prerelease/s3_index.sh
+++ b/packaging/prerelease/s3_index.sh
@@ -23,7 +23,7 @@ if [ "$platform" = "" ]; then
fi
echo "Loading release tool"
-(cd "$client_dir/go/buildtools"; go install "github.com/keybase/release")
+(cd "$client_dir/go/buildtools"; go install "github.com/keybase/client/go/release")
release_bin="$GOPATH/bin/release"
echo "$(go version)"
diff --git a/packaging/windows/WIXInstallers/KeybaseApps/KeybaseApps.wxs b/packaging/windows/WIXInstallers/KeybaseApps/KeybaseApps.wxs
index c2911aa80816..61426f8fb1b4 100644
--- a/packaging/windows/WIXInstallers/KeybaseApps/KeybaseApps.wxs
+++ b/packaging/windows/WIXInstallers/KeybaseApps/KeybaseApps.wxs
@@ -111,7 +111,7 @@
-
+
@@ -119,7 +119,7 @@
-
+
diff --git a/packaging/windows/build_prerelease.cmd b/packaging/windows/build_prerelease.cmd
index 1a3dc935e7bd..09110f204571 100644
--- a/packaging/windows/build_prerelease.cmd
+++ b/packaging/windows/build_prerelease.cmd
@@ -51,7 +51,7 @@ IF %ERRORLEVEL% NEQ 0 (
popd
:: Updater
-pushd %GOPATH%\src\github.com\keybase\go-updater\service
+pushd %GOPATH%\src\github.com\keybase\client\go\updater\service
del upd.exe
go build -a -o upd.exe
IF %ERRORLEVEL% NEQ 0 (
diff --git a/packaging/windows/doinstaller_wix.cmd b/packaging/windows/doinstaller_wix.cmd
index 3b312494d8c3..d82a1f92443d 100644
--- a/packaging/windows/doinstaller_wix.cmd
+++ b/packaging/windows/doinstaller_wix.cmd
@@ -38,7 +38,7 @@ echo KEYBASE_VERSION %KEYBASE_VERSION%
popd
:: prompter
-pushd %GOPATH%\src\github.com\keybase\go-updater\windows\WpfPrompter
+pushd %GOPATH%\src\github.com\keybase\client\go\updater\windows\WpfPrompter
msbuild WpfPrompter.sln /t:Clean
msbuild WpfPrompter.sln /p:Configuration=Release /t:Build
IF %ERRORLEVEL% NEQ 0 (
@@ -49,12 +49,12 @@ popd
call:dosignexe %PathName%
call:dosignexe %GOPATH%\src\github.com\keybase\client\go\kbfs\kbfsdokan\kbfsdokan.exe
call:dosignexe %GOPATH%\src\github.com\keybase\client\go\kbfs\kbfsgit\git-remote-keybase\git-remote-keybase.exe
-call:dosignexe %GOPATH%\src\github.com\keybase\go-updater\service\upd.exe
+call:dosignexe %GOPATH%\src\github.com\keybase\client\go\updater\service\upd.exe
call:dosignexe %GOPATH%\src\github.com\keybase\client\shared\desktop\release\win32-x64\Keybase-win32-x64\Keybase.exe
:: Browser Extension
call:dosignexe %GOPATH%\src\github.com\keybase\client\go\kbnm\kbnm.exe
:: prompter
-call:dosignexe %GOPATH%\src\github.com\keybase\go-updater\windows\WpfPrompter\WpfApplication1\bin\Release\prompter.exe
+call:dosignexe %GOPATH%\src\github.com\keybase\client\go\updater\windows\WpfPrompter\WpfApplication1\bin\Release\prompter.exe
:: Double check that keybase is codesigned
%SIGNTOOL% verify /pa %PathName%
@@ -75,7 +75,7 @@ IF %ERRORLEVEL% NEQ 0 (
)
:: Double check that updater is codesigned
-%SIGNTOOL% verify /pa %GOPATH%\src\github.com\keybase\go-updater\service\upd.exe
+%SIGNTOOL% verify /pa %GOPATH%\src\github.com\keybase\client\go\updater\service\upd.exe
IF %ERRORLEVEL% NEQ 0 (
EXIT /B 1
)
@@ -93,7 +93,7 @@ IF %ERRORLEVEL% NEQ 0 (
)
:: Double check that the prompter exe is codesigned
-%SIGNTOOL% verify /pa %GOPATH%\src\github.com\keybase\go-updater\windows\WpfPrompter\WpfApplication1\bin\Release\prompter.exe
+%SIGNTOOL% verify /pa %GOPATH%\src\github.com\keybase\client\go\updater\windows\WpfPrompter\WpfApplication1\bin\Release\prompter.exe
IF %ERRORLEVEL% NEQ 0 (
EXIT /B 1
)
@@ -112,7 +112,7 @@ IF "%CONFIGURATION%"=="Debug" (
)
:: Here we rely on the previous steps checking out and building release.exe
-set ReleaseBin=%GOPATH%\src\github.com\keybase\release\release.exe
+set ReleaseBin=%GOPATH%\src\github.com\keybase\client\go\release\release.exe
if not EXIST %GOPATH%\src\github.com\keybase\client\packaging\windows\%BUILD_TAG% mkdir %GOPATH%\src\github.com\keybase\client\packaging\windows\%BUILD_TAG%
pushd %GOPATH%\src\github.com\keybase\client\packaging\windows\%BUILD_TAG%
diff --git a/packaging/windows/dorelease.cmd b/packaging/windows/dorelease.cmd
index 5fe5a7f0427f..a036d9e00cd5 100644
--- a/packaging/windows/dorelease.cmd
+++ b/packaging/windows/dorelease.cmd
@@ -21,19 +21,12 @@ if NOT DEFINED DevEnvDir call "%ProgramFiles(x86)%\\Microsoft Visual Studio 14.0
:: don't bother with ci or checking out source, etc. for smoke2 build
IF [%UpdateChannel%] == [Smoke2] goto:done_ci
-:: NOTE: We depend on the bot or caller to checkout client first
-call:checkout_keybase go-updater, %UpdaterRevision% || goto:build_error || EXIT /B 1
-call:checkout_keybase release, %ReleaseRevision% || goto:build_error || EXIT /B 1
-
-::wait for CI
-if [%UpdateChannel%] == [SmokeCI] call:check_ci || EXIT /B 1
-
:done_ci
for /F delims^=^"^ tokens^=2 %%x in ('findstr /C:"Version = " %GOPATH%\src\github.com\keybase\client\go\libkb\version.go') do set LIBKB_VER=%%x
:: release
-pushd %GOPATH%\src\github.com\keybase\release
+pushd %GOPATH%\src\github.com\keybase\client\go\release
del release.exe
go build
IF %ERRORLEVEL% NEQ 0 (
@@ -69,7 +62,7 @@ if %UpdateChannel% NEQ "None" (
:: Test channel json
s3browser-con upload prerelease.keybase.io %GOPATH%\src\github.com\keybase\client\packaging\windows\%BUILD_TAG%\update-windows-prod-test-v2.json prerelease.keybase.io || goto:build_error || EXIT /B 1
echo "Creating index files"
- %GOPATH%\src\github.com\keybase\release\release index-html --bucket-name=prerelease.keybase.io --prefixes="windows/" --upload="windows/index.html"
+ %GOPATH%\src\github.com\keybase\client\go\release\release index-html --bucket-name=prerelease.keybase.io --prefixes="windows/" --upload="windows/index.html"
) else (
echo "No update channel"
)
@@ -111,7 +104,7 @@ if [%UpdateChannel%] NEQ [Smoke2] (
::Smoke B json
s3browser-con upload prerelease.keybase.io %GOPATH%\src\github.com\keybase\client\packaging\windows\%BUILD_TAG%\*.json prerelease.keybase.io/windows-support || goto:build_error || EXIT /B 1
set smokeBSemVer=%KEYBASE_VERSION%
-%GOPATH%\src\github.com\keybase\release\release announce-build --build-a="%SmokeASemVer%" --build-b="%smokeBSemVer%" --platform="windows" || goto:build_error || EXIT /B 1
+%GOPATH%\src\github.com\keybase\client\go\release\release announce-build --build-a="%SmokeASemVer%" --build-b="%smokeBSemVer%" --platform="windows" || goto:build_error || EXIT /B 1
%OUTPUT% "Successfully built Windows: --build-a=%SmokeASemVer% --build-b=%smokeBSemVer%
%OUTPUT% "https://prerelease.keybase.io/windows/"
:no_smokeb
@@ -149,7 +142,7 @@ EXIT /B 1
for /f %%i in ('git -C %GOPATH%\src\github.com\keybase\client rev-parse --short^=8 HEAD') do set clientCommit=%%i
echo [%clientCommit%]
:: need GITHUB_TOKEN
-pushd %GOPATH%\src\github.com\keybase\release
+pushd %GOPATH%\src\github.com\keybase\client\go\release
go build || goto:build_error || EXIT /B 1
release wait-ci --repo="client" --commit="%clientCommit%" --context="continuous-integration/jenkins/branch" --context="ci/circleci" || goto:ci_error
popd
diff --git a/packaging/windows/readme.md b/packaging/windows/readme.md
index 87280ac4b6cf..7ac87246c352 100644
--- a/packaging/windows/readme.md
+++ b/packaging/windows/readme.md
@@ -19,7 +19,6 @@
```
git clone https://github.com/keybase/client.git c:\work\src\github.com\keybase\client
-git clone https://github.com/keybase/go-updater.git c:\work\src\github.com\keybase\go-updater
```
### Build Service, Etc
diff --git a/packaging/windows/s3_prerelease.cmd b/packaging/windows/s3_prerelease.cmd
index 33013498576d..c38c64921957 100644
--- a/packaging/windows/s3_prerelease.cmd
+++ b/packaging/windows/s3_prerelease.cmd
@@ -6,7 +6,7 @@ IF [%BUCKET_NAME%]==[] (
)
echo "Loading release tool"
-(cd %GOPATH%\src/github.com/keybase/client/go && go install github.com/keybase/release)
+(cd %GOPATH%\src/github.com/keybase/client/go && go install github.com/keybase/client/go/release)
set release_bin=%GOPATH%\bin\release.exe
echo "Creating index files"
diff --git a/shared/android/app/build.gradle b/shared/android/app/build.gradle
index c30f6141868c..459862e6968f 100644
--- a/shared/android/app/build.gradle
+++ b/shared/android/app/build.gradle
@@ -5,7 +5,7 @@ import com.android.build.OutputFile
import org.apache.tools.ant.taskdefs.condition.Os
// KB: app version
-def VERSION_NAME = "6.2.7"
+def VERSION_NAME = "6.2.8"
/**
* The react.gradle file registers a task for each build variant (e.g. bundleDebugJsAndAssets
@@ -142,7 +142,7 @@ Integer getVersionCode() {
commandLine 'git', 'rev-list', 'HEAD', '--count'
standardOutput = stdout
}
- return Integer.parseInt(stdout.toString().trim()) + 10516799 // plus bump it so its above the old version code
+ return Integer.parseInt(stdout.toString().trim()) + 10517780 // plus bump it so its above the old version code
}
project.logger.lifecycle('Version code: ' + getVersionCode().toString())
diff --git a/shared/desktop/CHANGELOG.txt b/shared/desktop/CHANGELOG.txt
index a9b971c6ced7..8e5f8542227b 100644
--- a/shared/desktop/CHANGELOG.txt
+++ b/shared/desktop/CHANGELOG.txt
@@ -1 +1,2 @@
-• Fix connection errors due to expired CA certificate
+• Fix link previews to some web sites.
+• Bug fixes and improvements
diff --git a/shared/ios/Keybase/Info.plist b/shared/ios/Keybase/Info.plist
index 6b4a5e37e644..81f31bdb357a 100644
--- a/shared/ios/Keybase/Info.plist
+++ b/shared/ios/Keybase/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 6.2.7
+ 6.2.8
CFBundleSignature
????
CFBundleURLTypes
diff --git a/shared/ios/KeybaseShare/Info.plist b/shared/ios/KeybaseShare/Info.plist
index a15e5eb8a164..68d47237881c 100644
--- a/shared/ios/KeybaseShare/Info.plist
+++ b/shared/ios/KeybaseShare/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 6.2.7
+ 6.2.8
CFBundleVersion
1
NSExtension
diff --git a/shared/react-native/gobuild.sh b/shared/react-native/gobuild.sh
index 2177eb03b076..94cf2483843a 100755
--- a/shared/react-native/gobuild.sh
+++ b/shared/react-native/gobuild.sh
@@ -42,7 +42,7 @@ PATH="$GOPATH/bin:$PATH"
export CGO_CFLAGS_ALLOW="-fmodules|-fblocks"
if [ "$check_ci" = "1" ]; then
- (cd "$client_dir/go/buildtools"; go install "github.com/keybase/release")
+ (cd "$client_dir/go/buildtools"; go install "github.com/keybase/client/go/release")
release wait-ci --repo="client" --commit="$(git rev-parse HEAD)" --context="continuous-integration/jenkins/branch" --context="ci/circleci"
fi