From 5638adf6bcf025b10d1c4f23bcf3d2f039282d5d Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 7 Dec 2024 16:12:47 +0200 Subject: [PATCH] web/main: automatically reload page if version changes --- desktop/main.go | 3 +++ pkg/gomuks/gomuks.go | 4 +++- pkg/gomuks/server.go | 40 +++++++++++++++++++++++++++++------ pkg/gomuks/websocket.go | 15 +++++++++++-- pkg/hicli/init.go | 9 ++++++++ web/index.html | 1 + web/src/api/types/hievents.ts | 7 +++++- web/src/api/wsclient.ts | 33 ++++++++++++++++++++++++++++- web/src/ui/MainScreen.tsx | 29 ++++++++++++++++++++----- 9 files changed, 124 insertions(+), 17 deletions(-) diff --git a/desktop/main.go b/desktop/main.go index d13d358..c841ca7 100644 --- a/desktop/main.go +++ b/desktop/main.go @@ -81,6 +81,9 @@ func (c *CommandHandler) Init() { Data: marshaledPayload, }) } + if ctx.Err() != nil { + return + } c.App.EmitEvent("hicli_event", &hicli.JSONCommand{ Command: "init_complete", RequestID: 0, diff --git a/pkg/gomuks/gomuks.go b/pkg/gomuks/gomuks.go index 8abba28..264f964 100644 --- a/pkg/gomuks/gomuks.go +++ b/pkg/gomuks/gomuks.go @@ -55,7 +55,9 @@ type Gomuks struct { TempDir string LogDir string - FrontendFS embed.FS + FrontendFS embed.FS + indexWithETag []byte + frontendETag string Config Config DisableAuth bool diff --git a/pkg/gomuks/server.go b/pkg/gomuks/server.go index a317020..106124d 100644 --- a/pkg/gomuks/server.go +++ b/pkg/gomuks/server.go @@ -17,15 +17,19 @@ package gomuks import ( + "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" + "html" + "io" "io/fs" "net/http" _ "net/http/pprof" + "strconv" "strings" "time" @@ -73,6 +77,25 @@ func (gmx *Gomuks) StartServer() { gmx.Log.Warn().Msg("Frontend not found") } else { router.Handle("/", gmx.FrontendCacheMiddleware(http.FileServerFS(frontend))) + if gmx.Commit != "unknown" && !gmx.BuildTime.IsZero() { + gmx.frontendETag = fmt.Sprintf(`"%s-%s"`, gmx.Commit, gmx.BuildTime.Format(time.RFC3339)) + + indexFile, err := frontend.Open("index.html") + if err != nil { + gmx.Log.Err(err).Msg("Failed to open index.html") + } else { + data, err := io.ReadAll(indexFile) + _ = indexFile.Close() + if err == nil { + gmx.indexWithETag = bytes.Replace( + data, + []byte(""), + []byte(fmt.Sprintf(``, html.EscapeString(gmx.frontendETag))), + 1, + ) + } + } + } } gmx.Server = &http.Server{ Addr: gmx.Config.Web.ListenAddress, @@ -88,20 +111,23 @@ func (gmx *Gomuks) StartServer() { } func (gmx *Gomuks) FrontendCacheMiddleware(next http.Handler) http.Handler { - var frontendCacheETag string - if gmx.Commit != "unknown" && !gmx.BuildTime.IsZero() { - frontendCacheETag = fmt.Sprintf(`"%s-%s"`, gmx.Commit, gmx.BuildTime.Format(time.RFC3339)) - } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("If-None-Match") == frontendCacheETag { + if gmx.frontendETag != "" && r.Header.Get("If-None-Match") == gmx.frontendETag { w.WriteHeader(http.StatusNotModified) return } if strings.HasPrefix(r.URL.Path, "/assets/") { w.Header().Set("Cache-Control", "max-age=604800, immutable") } - if frontendCacheETag != "" { - w.Header().Set("ETag", frontendCacheETag) + if gmx.frontendETag != "" { + w.Header().Set("ETag", gmx.frontendETag) + if r.URL.Path == "/" && gmx.indexWithETag != nil { + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Length", strconv.Itoa(len(gmx.indexWithETag))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(gmx.indexWithETag) + return + } } next.ServeHTTP(w, r) }) diff --git a/pkg/gomuks/websocket.go b/pkg/gomuks/websocket.go index 0a78446..bec91dc 100644 --- a/pkg/gomuks/websocket.go +++ b/pkg/gomuks/websocket.go @@ -59,6 +59,11 @@ type PingRequestData struct { var runID = time.Now().UnixNano() +type RunData struct { + RunID string `json:"run_id"` + ETag string `json:"etag"` +} + func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) { var conn *websocket.Conn log := zerolog.Ctx(r.Context()) @@ -233,9 +238,12 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) { log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent response to command") } } - initErr := writeCmd(ctx, conn, &hicli.JSONCommandCustom[string]{ + initErr := writeCmd(ctx, conn, &hicli.JSONCommandCustom[*RunData]{ Command: "run_id", - Data: strconv.FormatInt(runID, 10), + Data: &RunData{ + RunID: strconv.FormatInt(runID, 10), + ETag: gmx.frontendETag, + }, }) if initErr != nil { log.Err(initErr).Msg("Failed to write init client state message") @@ -315,6 +323,9 @@ func (gmx *Gomuks) sendInitialData(ctx context.Context, conn *websocket.Conn) { return } } + if ctx.Err() != nil { + return + } err := writeCmd(ctx, conn, &hicli.JSONCommand{ Command: "init_complete", RequestID: 0, diff --git a/pkg/hicli/init.go b/pkg/hicli/init.go index 6b6f7a0..7b44f07 100644 --- a/pkg/hicli/init.go +++ b/pkg/hicli/init.go @@ -23,6 +23,9 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID) if err != nil { zerolog.Ctx(ctx).Err(err).Stringer("room_id", room.ID).Msg("Failed to get room account data") + if ctx.Err() != nil { + return nil + } syncRoom.AccountData = make(map[event.Type]*database.AccountData) } else { syncRoom.AccountData = make(map[event.Type]*database.AccountData, len(ad)) @@ -34,6 +37,9 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room) previewEvent, err := h.DB.Event.GetByRowID(ctx, room.PreviewEventRowID) if err != nil { zerolog.Ctx(ctx).Err(err).Stringer("room_id", room.ID).Msg("Failed to get preview event for room") + if ctx.Err() != nil { + return nil + } } else if previewEvent != nil { h.ReprocessExistingEvent(ctx, previewEvent) previewMember, err := h.DB.CurrentState.Get(ctx, room.ID, event.StateMember, previewEvent.Sender.String()) @@ -85,6 +91,9 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[* } maxTS = room.SortingTimestamp.Time payload.Rooms[room.ID] = h.getInitialSyncRoom(ctx, room) + if ctx.Err() != nil { + return + } } if !yield(&payload) || len(rooms) < batchSize { break diff --git a/web/index.html b/web/index.html index 07cd4b7..739246e 100644 --- a/web/index.html +++ b/web/index.html @@ -5,6 +5,7 @@ gomuks web +
diff --git a/web/src/api/types/hievents.ts b/web/src/api/types/hievents.ts index acaf973..f4bc4ec 100644 --- a/web/src/api/types/hievents.ts +++ b/web/src/api/types/hievents.ts @@ -124,7 +124,12 @@ export interface InitCompleteEvent extends BaseRPCCommand { command: "init_complete" } -export interface RunIDEvent extends BaseRPCCommand { +export interface RunData { + run_id: string + etag: string +} + +export interface RunIDEvent extends BaseRPCCommand { command: "run_id" } diff --git a/web/src/api/wsclient.ts b/web/src/api/wsclient.ts index ca8140d..83d844d 100644 --- a/web/src/api/wsclient.ts +++ b/web/src/api/wsclient.ts @@ -19,6 +19,35 @@ import type { RPCCommand } from "./types" const PING_INTERVAL = 15_000 const RECV_TIMEOUT = 4 * PING_INTERVAL +function checkUpdate(etag: string) { + if (!import.meta.env.PROD) { + return + } else if (!etag) { + console.log("Not checking for update, frontend etag not found in websocket init") + return + } + const currentETag = ( + document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement + )?.content + if (!currentETag) { + console.log("Not checking for update, frontend etag not found in head") + } else if (currentETag === etag) { + console.log("Frontend is up to date") + } else if (localStorage.lastUpdateTo === etag) { + console.warn( + `Frontend etag mismatch ${currentETag} !== ${etag}, `, + "but localstorage says an update was already attempted", + ) + } else { + console.info(`Frontend etag mismatch ${currentETag} !== ${etag}, reloading`) + localStorage.lastUpdateTo = etag + location.search = "?" + new URLSearchParams({ + updateTo: etag, + state: JSON.stringify(history.state), + }) + } +} + export default class WSClient extends RPCClient { #conn: WebSocket | null = null #lastMessage: number = 0 @@ -105,7 +134,9 @@ export default class WSClient extends RPCClient { if (parsed.request_id < 0) { this.#lastReceivedEvt = parsed.request_id } else if (parsed.command === "run_id") { - this.#resumeRunID = parsed.data + console.log("Received run ID", parsed.data) + this.#resumeRunID = parsed.data.run_id + checkUpdate(parsed.data.etag) } this.onCommand(parsed) } diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 676ba41..f3e7bc8 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -58,7 +58,7 @@ class ContextFields implements MainScreenContextFields { } get currentRightPanel(): RightPanelProps | null { - return this.rightPanelStack.length ? this.rightPanelStack[this.rightPanelStack.length-1] : null + return this.rightPanelStack.length ? this.rightPanelStack[this.rightPanelStack.length - 1] : null } setRightPanel = (props: RightPanelProps | null, pushState = true) => { @@ -151,17 +151,35 @@ const SYNC_ERROR_HIDE_DELAY = 30 * 1000 const handleURLHash = (client: Client, context: ContextFields) => { if (!location.hash.startsWith("#/uri/")) { - return + if (location.search) { + const currentETag = ( + document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement + )?.content + const newURL = new URL(location.href) + const updateTo = newURL.searchParams.get("updateTo") + if (updateTo === currentETag) { + console.info("Update to etag", updateTo, "successful") + } else { + console.warn("Update to etag", updateTo, "failed, got", currentETag) + } + const state = JSON.parse(newURL.searchParams.get("state") || "{}") + newURL.search = "" + history.replaceState(state, "", newURL.toString()) + return state + } + return history.state } + const decodedURI = decodeURIComponent(location.hash.slice("#/uri/".length)) const uri = parseMatrixURI(decodedURI) if (!uri) { console.error("Invalid matrix URI", decodedURI) - return + return history.state } console.log("Handling URI", uri) const newURL = new URL(location.href) newURL.hash = "" + newURL.search = "" if (uri.identifier.startsWith("@")) { const right_panel = { type: "user", @@ -184,6 +202,7 @@ const handleURLHash = (client: Client, context: ContextFields) => { } else { console.error("Invalid matrix URI", uri) } + return null } const MainScreen = () => { @@ -208,8 +227,8 @@ const MainScreen = () => { } window.addEventListener("popstate", listener) const initHandle = () => { - listener({ state: history.state } as PopStateEvent) - handleURLHash(client, context) + const state = handleURLHash(client, context) + listener({ state } as PopStateEvent) } let cancel = () => {} if (client.initComplete.current) {