Add sidebar banner when new release is available (#147)

* Server impl

* add update check setting

* add commands

* fix capitalization of commands

* apply suggestions

* add migration and fix backend bugs

* Add sidebar banner

* remove installedversion, add 5s timeout

* add icon, capture and log errors from release check

* missing return nil

* remove highlight

* remove commented less

* do not fail releasecheckoncommand if release check operation fails

* remove debug condition

* fix update on auto check, move banner display logic into frontend

* remove unnecessary import

* simplify null check

* clean up the invoking of the releasechecker
This commit is contained in:
Evan Simkowitz 2023-12-15 17:43:54 -08:00 committed by GitHub
parent 4ff5dcf1e0
commit b733724c7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 290 additions and 9 deletions

View File

@ -1,3 +1,4 @@
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@ -11,6 +11,7 @@
"@table-nav/react": "^0.0.7",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.3",
"@types/semver": "^7.5.6",
"autobind-decorator": "^2.4.0",
"classnames": "^2.3.1",
"dayjs": "^1.11.3",

View File

@ -693,6 +693,17 @@ class ClientSettingsModal extends React.Component<{}, {}> {
commandRtnHandler(prtn, this.errorMessage);
}
@boundMethod
handleChangeReleaseCheck(val: boolean): void {
let prtn: Promise<CommandRtnType> = null;
if (val) {
prtn = GlobalCommandRunner.releaseCheckAutoOn(false);
} else {
prtn = GlobalCommandRunner.releaseCheckAutoOff(false);
}
commandRtnHandler(prtn, this.errorMessage);
}
getFontSizes(): any {
let availableFontSizes: { label: string; value: number }[] = [];
for (let s = MinFontSize; s <= MaxFontSize; s++) {
@ -769,6 +780,12 @@ class ClientSettingsModal extends React.Component<{}, {}> {
<Toggle checked={!cdata.clientopts.notelemetry} onChange={this.handleChangeTelemetry} />
</div>
</div>
<div className="settings-field">
<div className="settings-label">Check for Updates Automatically</div>
<div className="settings-input">
<Toggle checked={!cdata.clientopts.noreleasecheck} onChange={this.handleChangeReleaseCheck} />
</div>
</div>
<div className="settings-field">
<div className="settings-label">OpenAI Token</div>
<div className="settings-input">

View File

@ -320,4 +320,13 @@
top: -3px;
}
}
.updateBanner {
font-weight: bold;
.icon {
font-weight: normal;
font-size: 16px;
}
}
}

View File

@ -8,7 +8,8 @@ import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import dayjs from "dayjs";
import type { RemoteType } from "../../types/types";
import { If, For } from "tsx-control-statements/components";
import { If } from "tsx-control-statements/components";
import { compareLoose } from "semver";
import { ReactComponent as LeftChevronIcon } from "../assets/icons/chevron_left.svg";
import { ReactComponent as HelpIcon } from "../assets/icons/help.svg";
@ -22,7 +23,7 @@ import { ReactComponent as AddIcon } from "../assets/icons/add.svg";
import { ReactComponent as ActionsIcon } from "../assets/icons/tab/actions.svg";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Session } from "../../model/model";
import { GlobalModel, GlobalCommandRunner, Session, VERSION } from "../../model/model";
import { sortAndFilterRemotes, isBlank, openLink } from "../../util/util";
import * as constants from "../appconst";
@ -195,6 +196,7 @@ class MainSideBar extends React.Component<{}, {}> {
}
let isCollapsed = this.collapsed.get();
let mainView = GlobalModel.activeMainView.get();
let clientData = GlobalModel.clientData.get();
return (
<div className={cn("main-sidebar", { collapsed: isCollapsed }, { "is-dev": GlobalModel.isDev })}>
<div className="title-bar-drag" />
@ -242,6 +244,15 @@ class MainSideBar extends React.Component<{}, {}> {
</div>
<div className="middle hideScrollbarUntillHover">{this.getSessions()}</div>
<div className="bottom">
<If condition = {!clientData?.clientopts.noreleasecheck && clientData?.releaseinfo && compareLoose(VERSION, clientData.releaseinfo.latestversion) < 0} >
<div
className="item hoverEffect unselectable updateBanner"
onClick={() => openLink("https://www.waveterm.dev/download")}
>
<i className="fa-sharp fa-regular fa-circle-up icon" />
Update Available
</div>
</If>
<If condition={GlobalModel.isDev}>
<div className="item hoverEffect unselectable" onClick={this.handlePluginsClick}>
<AppsIcon className="icon" />

View File

@ -4312,6 +4312,14 @@ class CommandRunner {
return GlobalModel.submitCommand("telemetry", "on", null, { nohist: "1" }, interactive);
}
releaseCheckAutoOff(interactive: boolean): Promise<CommandRtnType> {
return GlobalModel.submitCommand("releasecheck", "autooff", null, { nohist: "1" }, interactive);
}
releaseCheckAutoOn(interactive: boolean): Promise<CommandRtnType> {
return GlobalModel.submitCommand("releasecheck", "autoon", null, { nohist: "1" }, interactive);
}
setTermFontSize(fsize: number, interactive: boolean): Promise<CommandRtnType> {
let kwargs = {
nohist: "1",
@ -4459,5 +4467,6 @@ export {
RemotesModel,
MinFontSize,
MaxFontSize,
VERSION
};
export type { LineContainerModel };

View File

@ -454,9 +454,14 @@ type FeOptsType = {
type ClientOptsType = {
notelemetry: boolean;
noreleasecheck: boolean;
acceptedtos: number;
};
type ReleaseInfoType = {
latestversion: string;
};
type ClientDataType = {
clientid: string;
userid: string;
@ -465,6 +470,7 @@ type ClientDataType = {
cmdstoretype: "session" | "screen";
dbversion: number;
openaiopts?: OpenAIOptsType;
releaseinfo?: ReleaseInfoType;
};
type OpenAIOptsType = {

View File

@ -33,6 +33,7 @@ import (
"github.com/wavetermdev/waveterm/waveshell/pkg/server"
"github.com/wavetermdev/waveterm/wavesrv/pkg/cmdrunner"
"github.com/wavetermdev/waveterm/wavesrv/pkg/pcloud"
"github.com/wavetermdev/waveterm/wavesrv/pkg/releasechecker"
"github.com/wavetermdev/waveterm/wavesrv/pkg/remote"
"github.com/wavetermdev/waveterm/wavesrv/pkg/rtnstate"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
@ -715,7 +716,6 @@ func sendTelemetryWrapper() {
}
log.Printf("[error] in sendTelemetryWrapper: %v\n", r)
debug.PrintStack()
return
}()
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
@ -725,14 +725,35 @@ func sendTelemetryWrapper() {
}
}
func checkNewReleaseWrapper() {
defer func() {
r := recover()
if r == nil {
return
}
log.Printf("[error] in checkNewReleaseWrapper: %v\n", r)
debug.PrintStack()
}()
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
_, err := releasechecker.CheckNewRelease(ctx, false)
if err != nil {
log.Printf("[error] checking for new release: %v\n", err)
return
}
}
func telemetryLoop() {
var lastSent time.Time
time.Sleep(InitialTelemetryWait)
for {
dur := time.Now().Sub(lastSent)
dur := time.Since(lastSent)
if lastSent.IsZero() || dur >= TelemetryInterval {
lastSent = time.Now()
sendTelemetryWrapper()
checkNewReleaseWrapper()
}
time.Sleep(TelemetryTick)
}

View File

@ -0,0 +1 @@
ALTER TABLE client DROP COLUMN releaseinfo;

View File

@ -0,0 +1 @@
ALTER TABLE client ADD COLUMN releaseinfo json NOT NULL DEFAULT '{}';

View File

@ -22,6 +22,7 @@ require (
)
require (
github.com/google/go-github/v57 v57.0.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
go.uber.org/atomic v1.7.0 // indirect

View File

@ -13,6 +13,9 @@ github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB
github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-github/v57 v57.0.0 h1:L+Y3UPTY8ALM8x+TV0lg+IEBI+upibemtBD8Q9u7zHs=
github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85PimQh4oygmLIxHw=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
@ -51,6 +54,7 @@ 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/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=

View File

@ -28,12 +28,14 @@ import (
"github.com/wavetermdev/waveterm/wavesrv/pkg/comp"
"github.com/wavetermdev/waveterm/wavesrv/pkg/dbutil"
"github.com/wavetermdev/waveterm/wavesrv/pkg/pcloud"
"github.com/wavetermdev/waveterm/wavesrv/pkg/releasechecker"
"github.com/wavetermdev/waveterm/wavesrv/pkg/remote"
"github.com/wavetermdev/waveterm/wavesrv/pkg/remote/openai"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket"
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
"github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn"
"golang.org/x/mod/semver"
)
const (
@ -207,6 +209,10 @@ func init() {
registerCmdFn("telemetry:send", TelemetrySendCommand)
registerCmdFn("telemetry:show", TelemetryShowCommand)
registerCmdFn("releasecheck", ReleaseCheckCommand)
registerCmdFn("releasecheck:autoon", ReleaseCheckOnCommand)
registerCmdFn("releasecheck:autooff", ReleaseCheckOffCommand)
registerCmdFn("history", HistoryCommand)
registerCmdFn("history:viewall", HistoryViewAllCommand)
registerCmdFn("history:purge", HistoryPurgeCommand)
@ -3716,6 +3722,7 @@ func ClientShowCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "userid", clientData.UserId))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "clientid", clientData.ClientId))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "telemetry", boolToStr(clientData.ClientOpts.NoTelemetry, "off", "on")))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "release-check", boolToStr(clientData.ClientOpts.NoReleaseCheck, "off", "on")))
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "db-version", dbVersion))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "client-version", clientVersion))
buf.WriteString(fmt.Sprintf(" %-15s %s %s\n", "server-version", scbase.WaveVersion, scbase.BuildTime))
@ -3832,6 +3839,102 @@ func TelemetrySendCommand(ctx context.Context, pk *scpacket.FeCommandPacketType)
return sstore.InfoMsgUpdate("telemetry sent"), nil
}
func runReleaseCheck(ctx context.Context, force bool) error {
rslt, err := releasechecker.CheckNewRelease(ctx, force)
if err != nil {
return fmt.Errorf("error checking for new release: %v", err)
}
if rslt == releasechecker.Failure {
return fmt.Errorf("error checking for new release, see log for details")
}
return nil
}
func setNoReleaseCheck(ctx context.Context, clientData *sstore.ClientData, noReleaseCheckValue bool) error {
clientOpts := clientData.ClientOpts
clientOpts.NoReleaseCheck = noReleaseCheckValue
err := sstore.SetClientOpts(ctx, clientOpts)
if err != nil {
return fmt.Errorf("error trying to update client releaseCheck setting: %v", err)
}
log.Printf("client no-release-check setting updated to %v\n", noReleaseCheckValue)
return nil
}
func ReleaseCheckOnCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve client data: %v", err)
}
if !clientData.ClientOpts.NoReleaseCheck {
return sstore.InfoMsgUpdate("release check is already on"), nil
}
err = setNoReleaseCheck(ctx, clientData, false)
if err != nil {
return nil, err
}
err = runReleaseCheck(ctx, true)
if err != nil {
log.Printf("error checking for new release after enabling auto release check: %v\n", err)
}
clientData, err = sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve updated client data: %v", err)
}
update := sstore.InfoMsgUpdate("automatic release checking is now on")
update.ClientData = clientData
return update, nil
}
func ReleaseCheckOffCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve client data: %v", err)
}
if clientData.ClientOpts.NoReleaseCheck {
return sstore.InfoMsgUpdate("release check is already off"), nil
}
err = setNoReleaseCheck(ctx, clientData, true)
if err != nil {
return nil, err
}
clientData, err = sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve updated client data: %v", err)
}
update := sstore.InfoMsgUpdate("automatic release checking is now off")
update.ClientData = clientData
return update, nil
}
func ReleaseCheckCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
err := runReleaseCheck(ctx, true)
if err != nil {
return nil, err
}
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve updated client data: %v", err)
}
var rsp string
if semver.Compare(scbase.WaveVersion, clientData.ReleaseInfo.LatestVersion) < 0 {
rsp = "new release available to download: https://www.waveterm.dev/download"
} else {
rsp = "no new release available"
}
update := sstore.InfoMsgUpdate(rsp)
update.ClientData = clientData
return update, nil
}
func formatTermOpts(termOpts sstore.TermOpts) string {
if termOpts.Cols == 0 {
return "???"

View File

@ -0,0 +1,75 @@
package releasechecker
import (
"context"
"fmt"
"github.com/google/go-github/v57/github"
"golang.org/x/mod/semver"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
)
type ReleaseCheckResult int
const (
NotNeeded ReleaseCheckResult = 0
Success ReleaseCheckResult = 1
Failure ReleaseCheckResult = 2
Disabled ReleaseCheckResult = 3
)
// CheckNewRelease checks for a new release and updates the release info in the DB.
// If force is true, the release info is updated even if it is fresh or if the automatic release check is disabled.
func CheckNewRelease(ctx context.Context, force bool) (ReleaseCheckResult, error) {
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
return Failure, fmt.Errorf("error getting client data: %w", err)
}
if !force && clientData.ClientOpts.NoReleaseCheck {
return Disabled, nil
}
if !force && semver.Compare(scbase.WaveVersion, clientData.ReleaseInfo.LatestVersion) < 0 {
// We have already notified the frontend about a new release and the record is fresh. There is no need to check again.
return NotNeeded, nil
}
// Initialize an unauthenticated client
client := github.NewClient(nil)
// Get the latest release from the repository
release, rsp, err := client.Repositories.GetLatestRelease(ctx, "wavetermdev", "waveterm")
releaseInfoLatest := sstore.ReleaseInfoType{
LatestVersion: scbase.WaveVersion,
}
if err != nil {
return Failure, fmt.Errorf("error getting latest release: %w", err)
}
if rsp.StatusCode != 200 {
return Failure, fmt.Errorf("response from Github is not success: %v", rsp)
}
releaseInfoLatest.LatestVersion = *release.TagName
// Update the release info in the DB
err = sstore.SetReleaseInfo(ctx, releaseInfoLatest)
if err != nil {
return Failure, fmt.Errorf("error updating release info: %w", err)
}
clientData, err = sstore.EnsureClientData(ctx)
if err != nil {
return Failure, fmt.Errorf("error getting updated client data: %w", err)
}
update := &sstore.ModelUpdate{
ClientData: clientData,
}
sstore.MainBus.SendUpdate(update)
return Success, nil
}

View File

@ -22,7 +22,7 @@ import (
"github.com/golang-migrate/migrate/v4"
)
const MaxMigration = 24
const MaxMigration = 25
const MigratePrimaryScreenVersion = 9
const CmdScreenSpecialMigration = 13
const CmdLineSpecialMigration = 20

View File

@ -261,14 +261,19 @@ func (tdata *TelemetryData) Scan(val interface{}) error {
}
type ClientOptsType struct {
NoTelemetry bool `json:"notelemetry,omitempty"`
AcceptedTos int64 `json:"acceptedtos,omitempty"`
NoTelemetry bool `json:"notelemetry,omitempty"`
NoReleaseCheck bool `json:"noreleasecheck,omitempty"`
AcceptedTos int64 `json:"acceptedtos,omitempty"`
}
type FeOptsType struct {
TermFontSize int `json:"termfontsize,omitempty"`
}
type ReleaseInfoType struct {
LatestVersion string `json:"latestversion,omitempty"`
}
type ClientData struct {
ClientId string `json:"clientid"`
UserId string `json:"userid"`
@ -283,6 +288,7 @@ type ClientData struct {
CmdStoreType string `json:"cmdstoretype"`
DBVersion int `json:"dbversion" dbmap:"-"`
OpenAIOpts *OpenAIOptsType `json:"openaiopts,omitempty" dbmap:"openaiopts"`
ReleaseInfo ReleaseInfoType `json:"releaseinfo"`
}
func (ClientData) UseDBMap() {}
@ -1248,9 +1254,10 @@ func createClientData(tx *TxWrap) error {
ActiveSessionId: "",
WinSize: ClientWinSizeType{},
CmdStoreType: CmdStoreTypeScreen,
ReleaseInfo: ReleaseInfoType{},
}
query := `INSERT INTO client ( clientid, userid, activesessionid, userpublickeybytes, userprivatekeybytes, winsize, cmdstoretype)
VALUES (:clientid,:userid,:activesessionid,:userpublickeybytes,:userprivatekeybytes,:winsize,:cmdstoretype)`
query := `INSERT INTO client ( clientid, userid, activesessionid, userpublickeybytes, userprivatekeybytes, winsize, cmdstoretype, releaseinfo)
VALUES (:clientid,:userid,:activesessionid,:userpublickeybytes,:userprivatekeybytes,:winsize,:cmdstoretype,:releaseinfo)`
tx.NamedExec(query, dbutil.ToDBMap(c, false))
log.Printf("create new clientid[%s] userid[%s] with public/private keypair\n", c.ClientId, c.UserId)
return nil
@ -1310,3 +1317,12 @@ func SetClientOpts(ctx context.Context, clientOpts ClientOptsType) error {
})
return txErr
}
func SetReleaseInfo(ctx context.Context, releaseInfo ReleaseInfoType) error {
txErr := WithTx(ctx, func(tx *TxWrap) error {
query := `UPDATE client SET releaseinfo = ?`
tx.Exec(query, quickJson(releaseInfo))
return nil
})
return txErr
}

View File

@ -2247,6 +2247,11 @@
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff"
integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==
"@types/semver@^7.5.6":
version "7.5.6"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339"
integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==
"@types/send@*":
version "0.17.2"
resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.2.tgz#af78a4495e3c2b79bfbdac3955fdd50e03cc98f2"