From 1e22e62a9a2890869d6b896b1cf2458f59444953 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 4 Feb 2025 00:26:45 +0200 Subject: [PATCH] web/settings: add support for manual key import/export Fixes #593 --- desktop/go.mod | 6 +- desktop/go.sum | 12 +-- go.mod | 6 +- go.sum | 12 +-- pkg/gomuks/keys.go | 110 +++++++++++++++++++++++++++ pkg/gomuks/server.go | 43 ++++++----- web/src/ui/settings/SettingsView.css | 38 +++++++++ web/src/ui/settings/SettingsView.tsx | 46 +++++++++++ 8 files changed, 238 insertions(+), 35 deletions(-) create mode 100644 pkg/gomuks/keys.go diff --git a/desktop/go.mod b/desktop/go.mod index 62024b3..0167bff 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -8,7 +8,7 @@ require github.com/wailsapp/wails/v3 v3.0.0-alpha.9 require ( go.mau.fi/gomuks v0.4.0 - go.mau.fi/util v0.8.4 + go.mau.fi/util v0.8.5-0.20250203220331-1c0d19ea6003 ) require ( @@ -67,7 +67,7 @@ require ( go.mau.fi/webp v0.2.0 // indirect go.mau.fi/zeroconfig v0.1.3 // indirect golang.org/x/crypto v0.32.0 // indirect - golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect + golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect golang.org/x/image v0.23.0 // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/net v0.34.0 // indirect @@ -79,7 +79,7 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - maunium.net/go/mautrix v0.23.0 // indirect + maunium.net/go/mautrix v0.23.1-0.20250203222456-475c4bf39d91 // indirect mvdan.cc/xurls/v2 v2.6.0 // indirect ) diff --git a/desktop/go.sum b/desktop/go.sum index 6c747cd..a80d07f 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -166,8 +166,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -go.mau.fi/util v0.8.4 h1:mVKlJcXWfVo8ZW3f4vqtjGpqtZqJvX4ETekxawt2vnQ= -go.mau.fi/util v0.8.4/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY= +go.mau.fi/util v0.8.5-0.20250203220331-1c0d19ea6003 h1:ye5l+QpYW5CpGVMedb3EHlmflGMQsMtw8mC4K/U8hIw= +go.mau.fi/util v0.8.5-0.20250203220331-1c0d19ea6003/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY= go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg= go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= @@ -179,8 +179,8 @@ golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2Uz golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= -golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= @@ -261,7 +261,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.23.0 h1:HNlR19eew5lvrNSL2muhExaGhYdaGk5FfEiA82QqUP4= -maunium.net/go/mautrix v0.23.0/go.mod h1:AGnnaz3ylGikUo1I1MJVn9QLsl2No1/ZNnGDyO0QD5s= +maunium.net/go/mautrix v0.23.1-0.20250203222456-475c4bf39d91 h1:jbga2dSYVTd3MgAKugiz5+mIYp+qxUOCDokUGZOEWRg= +maunium.net/go/mautrix v0.23.1-0.20250203222456-475c4bf39d91/go.mod h1:q2U2IRLSFpglDhIpSjd8TnCNVzBNrUJBD8pmYCGQiwc= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= diff --git a/go.mod b/go.mod index 2eb7d06..788245b 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.7.8 - go.mau.fi/util v0.8.4 + go.mau.fi/util v0.8.5-0.20250203220331-1c0d19ea6003 go.mau.fi/webp v0.2.0 go.mau.fi/zeroconfig v0.1.3 golang.org/x/crypto v0.32.0 @@ -27,7 +27,7 @@ require ( golang.org/x/text v0.21.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 - maunium.net/go/mautrix v0.23.0 + maunium.net/go/mautrix v0.23.1-0.20250203222456-475c4bf39d91 mvdan.cc/xurls/v2 v2.6.0 ) @@ -41,7 +41,7 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect + golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect golang.org/x/sys v0.29.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index a1b722a..6579c76 100644 --- a/go.sum +++ b/go.sum @@ -68,16 +68,16 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -go.mau.fi/util v0.8.4 h1:mVKlJcXWfVo8ZW3f4vqtjGpqtZqJvX4ETekxawt2vnQ= -go.mau.fi/util v0.8.4/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY= +go.mau.fi/util v0.8.5-0.20250203220331-1c0d19ea6003 h1:ye5l+QpYW5CpGVMedb3EHlmflGMQsMtw8mC4K/U8hIw= +go.mau.fi/util v0.8.5-0.20250203220331-1c0d19ea6003/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY= go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg= go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= -golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= @@ -100,7 +100,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.23.0 h1:HNlR19eew5lvrNSL2muhExaGhYdaGk5FfEiA82QqUP4= -maunium.net/go/mautrix v0.23.0/go.mod h1:AGnnaz3ylGikUo1I1MJVn9QLsl2No1/ZNnGDyO0QD5s= +maunium.net/go/mautrix v0.23.1-0.20250203222456-475c4bf39d91 h1:jbga2dSYVTd3MgAKugiz5+mIYp+qxUOCDokUGZOEWRg= +maunium.net/go/mautrix v0.23.1-0.20250203222456-475c4bf39d91/go.mod h1:q2U2IRLSFpglDhIpSjd8TnCNVzBNrUJBD8pmYCGQiwc= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= diff --git a/pkg/gomuks/keys.go b/pkg/gomuks/keys.go new file mode 100644 index 0000000..8b7d0a3 --- /dev/null +++ b/pkg/gomuks/keys.go @@ -0,0 +1,110 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2025 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package gomuks + +import ( + "errors" + "fmt" + "io" + "mime" + "net/http" + "strconv" + + "github.com/rs/zerolog/hlog" + "go.mau.fi/util/dbutil" + "go.mau.fi/util/exhttp" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/id" +) + +func (gmx *Gomuks) ExportKeys(w http.ResponseWriter, r *http.Request) { + found, correct := gmx.doBasicAuth(r) + if !found || !correct { + hlog.FromRequest(r).Debug().Msg("Requesting credentials for key export request") + w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Cache-Control", "no-store") + err := r.ParseForm() + if err != nil { + hlog.FromRequest(r).Err(err).Msg("Failed to parse form") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("Failed to parse form data\n")) + return + } + roomID := id.RoomID(r.PathValue("room_id")) + var sessions dbutil.RowIter[*crypto.InboundGroupSession] + filename := "gomuks-keys.txt" + if roomID == "" { + sessions = gmx.Client.CryptoStore.GetAllGroupSessions(r.Context()) + } else { + filename = fmt.Sprintf("gomuks-keys-%s.txt", roomID) + sessions = gmx.Client.CryptoStore.GetGroupSessionsForRoom(r.Context(), roomID) + } + export, err := crypto.ExportKeysIter(r.FormValue("passphrase"), sessions) + if errors.Is(err, crypto.ErrNoSessionsForExport) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("No keys found\n")) + return + } else if err != nil { + hlog.FromRequest(r).Err(err).Msg("Failed to export keys") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Failed to export keys (see logs for more details)\n")) + return + } + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) + w.Header().Set("Content-Length", strconv.Itoa(len(export))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(export) +} + +var badMultipartForm = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.BAD_FORM_DATA", Err: "Failed to parse form data", StatusCode: http.StatusBadRequest} + +func (gmx *Gomuks) ImportKeys(w http.ResponseWriter, r *http.Request) { + err := r.ParseMultipartForm(5 * 1024 * 1024) + if err != nil { + badMultipartForm.Write(w) + return + } + export, _, err := r.FormFile("export") + if err != nil { + badMultipartForm.WithMessage("Failed to get export file from form: %w", err).Write(w) + return + } + exportData, err := io.ReadAll(export) + if err != nil { + badMultipartForm.WithMessage("Failed to read export file: %w", err).Write(w) + return + } + importedCount, totalCount, err := gmx.Client.Crypto.ImportKeys(r.Context(), r.FormValue("passphrase"), exportData) + if err != nil { + hlog.FromRequest(r).Err(err).Msg("Failed to import keys") + mautrix.MUnknown.WithMessage("Failed to import keys: %w", err).Write(w) + return + } + hlog.FromRequest(r).Info(). + Int("imported_count", importedCount). + Int("total_count", totalCount). + Msg("Successfully imported keys") + exhttp.WriteJSONResponse(w, http.StatusOK, map[string]int{ + "imported": importedCount, + "total": totalCount, + }) +} diff --git a/pkg/gomuks/server.go b/pkg/gomuks/server.go index 73cc8f3..714b191 100644 --- a/pkg/gomuks/server.go +++ b/pkg/gomuks/server.go @@ -53,6 +53,9 @@ func (gmx *Gomuks) CreateAPIRouter() http.Handler { api.HandleFunc("GET /sso", gmx.HandleSSOComplete) api.HandleFunc("POST /sso", gmx.PrepareSSO) api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia) + api.HandleFunc("POST /keys/export", gmx.ExportKeys) + api.HandleFunc("POST /keys/export/{room_id}", gmx.ExportKeys) + api.HandleFunc("POST /keys/import", gmx.ImportKeys) api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS) return exhttp.ApplyMiddleware( api, @@ -239,30 +242,36 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) { if err == nil && gmx.validateAuth(authCookie.Value, false) { hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie") gmx.writeTokenCookie(w, false, jsonOutput) - } else if username, password, ok := r.BasicAuth(); !ok { - hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request") + } else if found, correct := gmx.doBasicAuth(r); found && correct { + hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password") + gmx.writeTokenCookie(w, true, jsonOutput) + } else { + if !found { + hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request") + } else { + hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials") + } if allowPrompt { w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) } w.WriteHeader(http.StatusUnauthorized) - } else { - usernameHash := sha256.Sum256([]byte(username)) - expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username)) - usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:]) - passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil - if usernameCorrect && passwordCorrect { - hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password") - gmx.writeTokenCookie(w, true, jsonOutput) - } else { - hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials") - if allowPrompt { - w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) - } - w.WriteHeader(http.StatusUnauthorized) - } } } +func (gmx *Gomuks) doBasicAuth(r *http.Request) (found, correct bool) { + var username, password string + username, password, found = r.BasicAuth() + if !found { + return + } + usernameHash := sha256.Sum256([]byte(username)) + expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username)) + usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:]) + passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil + correct = passwordCorrect && usernameCorrect + return +} + func isImageFetch(header http.Header) bool { return header.Get("Sec-Fetch-Site") == "cross-site" && header.Get("Sec-Fetch-Mode") == "no-cors" && diff --git a/web/src/ui/settings/SettingsView.css b/web/src/ui/settings/SettingsView.css index c64eda9..f7d6e36 100644 --- a/web/src/ui/settings/SettingsView.css +++ b/web/src/ui/settings/SettingsView.css @@ -113,6 +113,39 @@ div.settings-view { } } + > div.key-export { + display: flex; + flex-direction: column; + gap: .5rem; + margin: 0 .5rem; + max-width: 25rem; + + button { + padding: .5rem; + box-sizing: border-box; + width: 100%; + } + + input { + border: 1px solid var(--border-color); + padding: .5rem; + border-radius: .5rem; + + &[type="file"] { + padding: .25rem; + } + } + + > div.export-buttons, > form.import-buttons { + display: flex; + gap: .5rem; + + > form { + width: 100%; + } + } + } + > div.misc-buttons > button { padding: .5rem 1rem; display: block; @@ -126,4 +159,9 @@ div.settings-view { } } } + + > hr { + width: 100%; + opacity: .2; + } } diff --git a/web/src/ui/settings/SettingsView.tsx b/web/src/ui/settings/SettingsView.tsx index fb8cc44..c1b9903 100644 --- a/web/src/ui/settings/SettingsView.tsx +++ b/web/src/ui/settings/SettingsView.tsx @@ -284,6 +284,49 @@ const AppliedSettingsView = ({ room }: SettingsViewProps) => { } +const KeyExportView = ({ room }: SettingsViewProps) => { + const [passphrase, setPassphrase] = useState("") + const [hasFile, setHasFile] = useState(false) + return
+

Key export/import

+ setPassphrase(evt.target.value)} + placeholder="Passphrase" + /> +
+ + setHasFile(!!evt.target.files?.length)} + /> + +
+
+
+ + +
+
+ + +
+
+
+} + const SettingsView = ({ room }: SettingsViewProps) => { const roomMeta = useEventAsState(room.meta) const client = use(ClientContext)! @@ -393,6 +436,9 @@ const SettingsView = ({ room }: SettingsViewProps) => { +
+ +
{window.Notification && !window.gomuksAndroid &&