diff --git a/pkg/gomuks/buffer.go b/pkg/gomuks/buffer.go
index 5171fec..a6233b6 100644
--- a/pkg/gomuks/buffer.go
+++ b/pkg/gomuks/buffer.go
@@ -54,7 +54,7 @@ func NewEventBuffer(maxSize int) *EventBuffer {
}
}
-func (eb *EventBuffer) HicliEventHandler(evt any) {
+func (eb *EventBuffer) Push(evt any) {
data, err := json.Marshal(evt)
if err != nil {
panic(fmt.Errorf("failed to marshal event %T: %w", evt, err))
diff --git a/pkg/gomuks/config.go b/pkg/gomuks/config.go
index 7479cc6..3d51e00 100644
--- a/pkg/gomuks/config.go
+++ b/pkg/gomuks/config.go
@@ -34,6 +34,7 @@ import (
type Config struct {
Web WebConfig `yaml:"web"`
Matrix MatrixConfig `yaml:"matrix"`
+ Push PushConfig `yaml:"push"`
Logging zeroconfig.Config `yaml:"logging"`
}
@@ -41,6 +42,10 @@ type MatrixConfig struct {
DisableHTTP2 bool `yaml:"disable_http2"`
}
+type PushConfig struct {
+ FCMGateway string `yaml:"fcm_gateway"`
+}
+
type WebConfig struct {
ListenAddress string `yaml:"listen_address"`
Username string `yaml:"username"`
@@ -121,6 +126,10 @@ func (gmx *Gomuks) LoadConfig() error {
gmx.Config.Web.EventBufferSize = 512
changed = true
}
+ if gmx.Config.Push.FCMGateway == "" {
+ gmx.Config.Push.FCMGateway = "https://push.gomuks.app"
+ changed = true
+ }
if len(gmx.Config.Web.OriginPatterns) == 0 {
gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"}
changed = true
diff --git a/pkg/gomuks/gomuks.go b/pkg/gomuks/gomuks.go
index 3758eb6..0b382d3 100644
--- a/pkg/gomuks/gomuks.go
+++ b/pkg/gomuks/gomuks.go
@@ -34,6 +34,7 @@ import (
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog"
+ "go.mau.fi/util/ptr"
"golang.org/x/net/http2"
"go.mau.fi/gomuks/pkg/hicli"
@@ -171,7 +172,7 @@ func (gmx *Gomuks) StartClient() {
nil,
gmx.Log.With().Str("component", "hicli").Logger(),
[]byte("meow"),
- gmx.EventBuffer.HicliEventHandler,
+ gmx.HandleEvent,
)
gmx.Client.LogoutFunc = gmx.Logout
httpClient := gmx.Client.Client.Client
@@ -197,6 +198,14 @@ func (gmx *Gomuks) StartClient() {
gmx.Log.Info().Stringer("user_id", userID).Msg("Client started")
}
+func (gmx *Gomuks) HandleEvent(evt any) {
+ gmx.EventBuffer.Push(evt)
+ syncComplete, ok := evt.(*hicli.SyncComplete)
+ if ok && ptr.Val(syncComplete.Since) != "" {
+ go gmx.SendPushNotifications(syncComplete)
+ }
+}
+
func (gmx *Gomuks) Stop() {
gmx.stopOnce.Do(func() {
close(gmx.stopChan)
diff --git a/pkg/gomuks/push.go b/pkg/gomuks/push.go
new file mode 100644
index 0000000..6e3347e
--- /dev/null
+++ b/pkg/gomuks/push.go
@@ -0,0 +1,252 @@
+// 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 (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "time"
+
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/jsontime"
+ "go.mau.fi/util/ptr"
+ "go.mau.fi/util/random"
+ "maunium.net/go/mautrix/id"
+
+ "go.mau.fi/gomuks/pkg/hicli"
+ "go.mau.fi/gomuks/pkg/hicli/database"
+)
+
+type PushNotification struct {
+ Dismiss []PushDismiss `json:"dismiss,omitempty"`
+ OrigMessages []*PushNewMessage `json:"-"`
+ RawMessages []json.RawMessage `json:"messages,omitempty"`
+ ImageAuth string `json:"image_auth,omitempty"`
+ ImageAuthExpiry *jsontime.UnixMilli `json:"image_auth_expiry,omitempty"`
+ HasImportant bool `json:"-"`
+}
+
+type PushDismiss struct {
+ RoomID id.RoomID `json:"room_id"`
+}
+
+var pushClient = &http.Client{
+ Transport: &http.Transport{
+ DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
+ ResponseHeaderTimeout: 10 * time.Second,
+ Proxy: http.ProxyFromEnvironment,
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: 5,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ },
+ Timeout: 60 * time.Second,
+}
+
+func (gmx *Gomuks) SendPushNotifications(sync *hicli.SyncComplete) {
+ var ctx context.Context
+ var push PushNotification
+ for _, room := range sync.Rooms {
+ if room.DismissNotifications && len(push.Dismiss) < 10 {
+ push.Dismiss = append(push.Dismiss, PushDismiss{RoomID: room.Meta.ID})
+ }
+ for _, notif := range room.Notifications {
+ if ctx == nil {
+ ctx = gmx.Log.With().
+ Str("action", "send push notification").
+ Logger().WithContext(context.Background())
+ }
+ msg := gmx.formatPushNotificationMessage(ctx, notif)
+ if msg == nil {
+ continue
+ }
+ msgJSON, err := json.Marshal(msg)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).
+ Int64("event_rowid", int64(notif.RowID)).
+ Stringer("event_id", notif.Event.ID).
+ Msg("Failed to marshal push notification")
+ continue
+ } else if len(msgJSON) > 1500 {
+ // This should not happen as long as formatPushNotificationMessage doesn't return too long messages
+ zerolog.Ctx(ctx).Error().
+ Int64("event_rowid", int64(notif.RowID)).
+ Stringer("event_id", notif.Event.ID).
+ Msg("Push notification too long")
+ continue
+ }
+ push.RawMessages = append(push.RawMessages, msgJSON)
+ push.OrigMessages = append(push.OrigMessages, msg)
+ }
+ }
+ if len(push.Dismiss) == 0 && len(push.RawMessages) == 0 {
+ return
+ }
+ if ctx == nil {
+ ctx = gmx.Log.With().
+ Str("action", "send push notification").
+ Logger().WithContext(context.Background())
+ }
+ pushRegs, err := gmx.Client.DB.PushRegistration.GetAll(ctx)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to get push registrations")
+ return
+ }
+ if len(push.RawMessages) > 0 {
+ exp := time.Now().Add(24 * time.Hour)
+ push.ImageAuth = gmx.generateImageToken(24 * time.Hour)
+ push.ImageAuthExpiry = ptr.Ptr(jsontime.UM(exp))
+ }
+ for notif := range push.Split {
+ gmx.SendPushNotification(ctx, pushRegs, notif)
+ }
+}
+
+func (pn *PushNotification) Split(yield func(*PushNotification) bool) {
+ const maxSize = 2000
+ currentSize := 0
+ offset := 0
+ hasSound := false
+ for i, msg := range pn.RawMessages {
+ if len(msg) >= maxSize {
+ // This is already checked in SendPushNotifications, so this should never happen
+ panic("push notification message too long")
+ }
+ if currentSize+len(msg) > maxSize {
+ yield(&PushNotification{
+ Dismiss: pn.Dismiss,
+ RawMessages: pn.RawMessages[offset:i],
+ ImageAuth: pn.ImageAuth,
+ HasImportant: hasSound,
+ })
+ offset = i
+ currentSize = 0
+ hasSound = false
+ }
+ currentSize += len(msg)
+ hasSound = hasSound || pn.OrigMessages[i].Sound
+ }
+ yield(&PushNotification{
+ Dismiss: pn.Dismiss,
+ RawMessages: pn.RawMessages[offset:],
+ ImageAuth: pn.ImageAuth,
+ HasImportant: hasSound,
+ })
+}
+
+func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*database.PushRegistration, notif *PushNotification) {
+ rawPayload, err := json.Marshal(notif)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to marshal push notification")
+ return
+ } else if base64.StdEncoding.EncodedLen(len(rawPayload)) >= 4000 {
+ zerolog.Ctx(ctx).Error().Msg("Generated push payload too long")
+ return
+ }
+ for _, reg := range pushRegs {
+ devicePayload := rawPayload
+ encrypted := false
+ if reg.Encryption.Key != nil {
+ var err error
+ devicePayload, err = encryptPush(rawPayload, reg.Encryption.Key)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Str("device_id", reg.DeviceID).Msg("Failed to encrypt push payload")
+ continue
+ }
+ encrypted = true
+ }
+ switch reg.Type {
+ case database.PushTypeFCM:
+ if !encrypted {
+ zerolog.Ctx(ctx).Warn().
+ Str("device_id", reg.DeviceID).
+ Msg("FCM push registration doesn't have encryption key")
+ continue
+ }
+ var token string
+ err = json.Unmarshal(reg.Data, &token)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Str("device_id", reg.DeviceID).Msg("Failed to unmarshal FCM token")
+ continue
+ }
+ gmx.SendFCMPush(ctx, token, devicePayload, notif.HasImportant)
+ }
+ }
+}
+
+func encryptPush(payload, key []byte) ([]byte, error) {
+ if len(key) != 32 {
+ return nil, fmt.Errorf("encryption key must be 32 bytes long")
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create AES cipher: %w", err)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create GCM cipher: %w", err)
+ }
+ iv := random.Bytes(12)
+ encrypted := make([]byte, 12, 12+len(payload))
+ copy(encrypted, iv)
+ return gcm.Seal(encrypted, iv, payload, nil), nil
+}
+
+type PushRequest struct {
+ Token string `json:"token"`
+ Payload []byte `json:"payload"`
+ HighPriority bool `json:"high_priority"`
+}
+
+func (gmx *Gomuks) SendFCMPush(ctx context.Context, token string, payload []byte, highPriority bool) {
+ wrappedPayload, _ := json.Marshal(&PushRequest{
+ Token: token,
+ Payload: payload,
+ HighPriority: highPriority,
+ })
+ url := fmt.Sprintf("%s/_gomuks/push/fcm", gmx.Config.Push.FCMGateway)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wrappedPayload))
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to create push request")
+ return
+ }
+ resp, err := pushClient.Do(req)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Str("push_token", token).Msg("Failed to send push request")
+ } else if resp.StatusCode != http.StatusOK {
+ zerolog.Ctx(ctx).Error().
+ Int("status", resp.StatusCode).
+ Str("push_token", token).
+ Msg("Non-200 status while sending push request")
+ } else {
+ zerolog.Ctx(ctx).Trace().
+ Int("status", resp.StatusCode).
+ Str("push_token", token).
+ Msg("Sent push request")
+ }
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+}
diff --git a/pkg/gomuks/pushmessage.go b/pkg/gomuks/pushmessage.go
new file mode 100644
index 0000000..a4d3d1e
--- /dev/null
+++ b/pkg/gomuks/pushmessage.go
@@ -0,0 +1,169 @@
+// 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 (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "unicode/utf8"
+
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/jsontime"
+ "go.mau.fi/util/ptr"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+ "go.mau.fi/gomuks/pkg/hicli"
+ "go.mau.fi/gomuks/pkg/hicli/database"
+)
+
+type PushNewMessage struct {
+ Timestamp jsontime.UnixMilli `json:"timestamp"`
+ EventID id.EventID `json:"event_id"`
+ EventRowID database.EventRowID `json:"event_rowid"`
+
+ RoomID id.RoomID `json:"room_id"`
+ RoomName string `json:"room_name"`
+ RoomAvatar string `json:"room_avatar,omitempty"`
+ Sender NotificationUser `json:"sender"`
+ Self NotificationUser `json:"self"`
+
+ Text string `json:"text"`
+ Image string `json:"image,omitempty"`
+ Mention bool `json:"mention,omitempty"`
+ Reply bool `json:"reply,omitempty"`
+ Sound bool `json:"sound,omitempty"`
+}
+
+type NotificationUser struct {
+ ID id.UserID `json:"id"`
+ Name string `json:"name"`
+ Avatar string `json:"avatar,omitempty"`
+}
+
+func getAvatarLinkForNotification(name, ident string, uri id.ContentURIString) string {
+ parsed := uri.ParseOrIgnore()
+ if !parsed.IsValid() {
+ return ""
+ }
+ var fallbackChar rune
+ if name == "" {
+ fallbackChar, _ = utf8.DecodeRuneInString(ident[1:])
+ } else {
+ fallbackChar, _ = utf8.DecodeRuneInString(name)
+ }
+ return fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false&fallback=%s", parsed.Homeserver, parsed.FileID, url.QueryEscape(string(fallbackChar)))
+}
+
+func (gmx *Gomuks) getNotificationUser(ctx context.Context, roomID id.RoomID, userID id.UserID) (user NotificationUser) {
+ user = NotificationUser{ID: userID, Name: userID.Localpart()}
+ memberEvt, err := gmx.Client.DB.CurrentState.Get(ctx, roomID, event.StateMember, userID.String())
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Stringer("of_user_id", userID).Msg("Failed to get member event")
+ return
+ }
+ var memberContent event.MemberEventContent
+ _ = json.Unmarshal(memberEvt.Content, &memberContent)
+ if memberContent.Displayname != "" {
+ user.Name = memberContent.Displayname
+ }
+ if len(user.Name) > 50 {
+ user.Name = user.Name[:50] + "…"
+ }
+ if memberContent.AvatarURL != "" {
+ user.Avatar = getAvatarLinkForNotification(memberContent.Displayname, userID.String(), memberContent.AvatarURL)
+ }
+ return
+}
+
+func (gmx *Gomuks) formatPushNotificationMessage(ctx context.Context, notif hicli.SyncNotification) *PushNewMessage {
+ evtType := notif.Event.Type
+ rawContent := notif.Event.Content
+ if evtType == event.EventEncrypted.Type {
+ evtType = notif.Event.DecryptedType
+ rawContent = notif.Event.Decrypted
+ }
+ if evtType != event.EventMessage.Type && evtType != event.EventSticker.Type {
+ return nil
+ }
+ var content event.MessageEventContent
+ err := json.Unmarshal(rawContent, &content)
+ if err != nil {
+ zerolog.Ctx(ctx).Warn().Err(err).
+ Stringer("event_id", notif.Event.ID).
+ Msg("Failed to unmarshal message content to format push notification")
+ return nil
+ }
+ var roomAvatar, image string
+ if notif.Room.Avatar != nil {
+ avatarIdent := notif.Room.ID.String()
+ if ptr.Val(notif.Room.DMUserID) != "" {
+ avatarIdent = notif.Room.DMUserID.String()
+ }
+ roomAvatar = getAvatarLinkForNotification(ptr.Val(notif.Room.Name), avatarIdent, notif.Room.Avatar.CUString())
+ }
+ roomName := ptr.Val(notif.Room.Name)
+ if roomName == "" {
+ roomName = "Unnamed room"
+ }
+ if len(roomName) > 50 {
+ roomName = roomName[:50] + "…"
+ }
+ text := content.Body
+ if len(text) > 400 {
+ text = text[:350] + "[…]"
+ }
+ if content.MsgType == event.MsgImage || evtType == event.EventSticker.Type {
+ if content.File != nil && content.File.URL != "" {
+ parsed := content.File.URL.ParseOrIgnore()
+ if len(content.File.URL) < 255 && parsed.IsValid() {
+ image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=true", parsed.Homeserver, parsed.FileID)
+ }
+ } else if content.URL != "" {
+ parsed := content.URL.ParseOrIgnore()
+ if len(content.URL) < 255 && parsed.IsValid() {
+ image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false", parsed.Homeserver, parsed.FileID)
+ }
+ }
+ if content.FileName == "" || content.FileName == content.Body {
+ text = "Sent a photo"
+ }
+ } else if content.MsgType.IsMedia() {
+ if content.FileName == "" || content.FileName == content.Body {
+ text = "Sent a file: " + text
+ }
+ }
+ return &PushNewMessage{
+ Timestamp: notif.Event.Timestamp,
+ EventID: notif.Event.ID,
+ EventRowID: notif.Event.RowID,
+
+ RoomID: notif.Room.ID,
+ RoomName: roomName,
+ RoomAvatar: roomAvatar,
+ Sender: gmx.getNotificationUser(ctx, notif.Room.ID, notif.Event.Sender),
+ Self: gmx.getNotificationUser(ctx, notif.Room.ID, gmx.Client.Account.UserID),
+
+ Text: text,
+ Image: image,
+ Mention: content.Mentions.Has(gmx.Client.Account.UserID),
+ Reply: content.RelatesTo.GetNonFallbackReplyTo() != "",
+ Sound: notif.Sound,
+ }
+}
diff --git a/pkg/gomuks/server.go b/pkg/gomuks/server.go
index 7c2c8f2..73cc8f3 100644
--- a/pkg/gomuks/server.go
+++ b/pkg/gomuks/server.go
@@ -190,10 +190,10 @@ func (gmx *Gomuks) generateToken() (string, time.Time) {
}), expiry
}
-func (gmx *Gomuks) generateImageToken() string {
+func (gmx *Gomuks) generateImageToken(expiry time.Duration) string {
return gmx.signToken(tokenData{
Username: gmx.Config.Web.Username,
- Expiry: jsontime.U(time.Now().Add(1 * time.Hour)),
+ Expiry: jsontime.U(time.Now().Add(expiry)),
ImageOnly: true,
})
}
@@ -206,16 +206,26 @@ func (gmx *Gomuks) signToken(td any) string {
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum)
}
-func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) {
+func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter, created, jsonOutput bool) {
token, expiry := gmx.generateToken()
- http.SetCookie(w, &http.Cookie{
- Name: "gomuks_auth",
- Value: token,
- Expires: expiry,
- HttpOnly: true,
- Secure: true,
- SameSite: http.SameSiteLaxMode,
- })
+ if !jsonOutput {
+ http.SetCookie(w, &http.Cookie{
+ Name: "gomuks_auth",
+ Value: token,
+ Expires: expiry,
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+ }
+ if created {
+ w.WriteHeader(http.StatusCreated)
+ } else {
+ w.WriteHeader(http.StatusOK)
+ }
+ if jsonOutput {
+ _ = json.NewEncoder(w).Encode(map[string]string{"token": token})
+ }
}
func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
@@ -223,14 +233,17 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
return
}
+ jsonOutput := r.URL.Query().Get("output") == "json"
+ allowPrompt := r.URL.Query().Get("no_prompt") != "true"
authCookie, err := r.Cookie("gomuks_auth")
if err == nil && gmx.validateAuth(authCookie.Value, false) {
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
- gmx.writeTokenCookie(w)
- w.WriteHeader(http.StatusOK)
+ gmx.writeTokenCookie(w, false, jsonOutput)
} else if username, password, ok := r.BasicAuth(); !ok {
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
- w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
+ if allowPrompt {
+ w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
+ }
w.WriteHeader(http.StatusUnauthorized)
} else {
usernameHash := sha256.Sum256([]byte(username))
@@ -239,11 +252,12 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
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)
- w.WriteHeader(http.StatusCreated)
+ gmx.writeTokenCookie(w, true, jsonOutput)
} else {
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials")
- w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
+ if allowPrompt {
+ w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
+ }
w.WriteHeader(http.StatusUnauthorized)
}
}
diff --git a/pkg/gomuks/websocket.go b/pkg/gomuks/websocket.go
index b8c00f4..dda65d2 100644
--- a/pkg/gomuks/websocket.go
+++ b/pkg/gomuks/websocket.go
@@ -148,7 +148,7 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
sendImageAuthToken := func() {
err := writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "image_auth_token",
- Data: exerrors.Must(json.Marshal(gmx.generateImageToken())),
+ Data: exerrors.Must(json.Marshal(gmx.generateImageToken(1 * time.Hour))),
})
if err != nil {
log.Err(err).Msg("Failed to write image auth token message")
diff --git a/pkg/hicli/database/database.go b/pkg/hicli/database/database.go
index ed1a1b4..a3b1840 100644
--- a/pkg/hicli/database/database.go
+++ b/pkg/hicli/database/database.go
@@ -17,17 +17,18 @@ import (
type Database struct {
*dbutil.Database
- Account *AccountQuery
- AccountData *AccountDataQuery
- Room *RoomQuery
- InvitedRoom *InvitedRoomQuery
- Event *EventQuery
- CurrentState *CurrentStateQuery
- Timeline *TimelineQuery
- SessionRequest *SessionRequestQuery
- Receipt *ReceiptQuery
- Media *MediaQuery
- SpaceEdge *SpaceEdgeQuery
+ Account *AccountQuery
+ AccountData *AccountDataQuery
+ Room *RoomQuery
+ InvitedRoom *InvitedRoomQuery
+ Event *EventQuery
+ CurrentState *CurrentStateQuery
+ Timeline *TimelineQuery
+ SessionRequest *SessionRequestQuery
+ Receipt *ReceiptQuery
+ Media *MediaQuery
+ SpaceEdge *SpaceEdgeQuery
+ PushRegistration *PushRegistrationQuery
}
func New(rawDB *dbutil.Database) *Database {
@@ -36,17 +37,18 @@ func New(rawDB *dbutil.Database) *Database {
return &Database{
Database: rawDB,
- Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
- AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
- Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
- InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
- Event: &EventQuery{QueryHelper: eventQH},
- CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
- Timeline: &TimelineQuery{QueryHelper: eventQH},
- SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
- Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
- Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
- SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)},
+ Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
+ AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
+ Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
+ InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
+ Event: &EventQuery{QueryHelper: eventQH},
+ CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
+ Timeline: &TimelineQuery{QueryHelper: eventQH},
+ SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
+ Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
+ Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
+ SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)},
+ PushRegistration: &PushRegistrationQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newPushRegistration)},
}
}
@@ -85,3 +87,7 @@ func newAccount(_ *dbutil.QueryHelper[*Account]) *Account {
func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
return &SpaceEdge{}
}
+
+func newPushRegistration(_ *dbutil.QueryHelper[*PushRegistration]) *PushRegistration {
+ return &PushRegistration{}
+}
diff --git a/pkg/hicli/database/pushregistration.go b/pkg/hicli/database/pushregistration.go
new file mode 100644
index 0000000..a89e7a1
--- /dev/null
+++ b/pkg/hicli/database/pushregistration.go
@@ -0,0 +1,78 @@
+// Copyright (c) 2025 Tulir Asokan
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package database
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ "go.mau.fi/util/dbutil"
+ "go.mau.fi/util/jsontime"
+)
+
+const (
+ getNonExpiredPushTargets = `
+ SELECT device_id, type, data, encryption, expiration
+ FROM push_registration
+ WHERE expiration > $1
+ `
+ putPushRegistration = `
+ INSERT INTO push_registration (device_id, type, data, encryption, expiration)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (device_id) DO UPDATE SET
+ type = EXCLUDED.type,
+ data = EXCLUDED.data,
+ encryption = EXCLUDED.encryption,
+ expiration = EXCLUDED.expiration
+ `
+)
+
+type PushRegistrationQuery struct {
+ *dbutil.QueryHelper[*PushRegistration]
+}
+
+func (prq *PushRegistrationQuery) Put(ctx context.Context, reg *PushRegistration) error {
+ return prq.Exec(ctx, putPushRegistration, reg.sqlVariables()...)
+}
+
+func (seq *PushRegistrationQuery) GetAll(ctx context.Context) ([]*PushRegistration, error) {
+ return seq.QueryMany(ctx, getNonExpiredPushTargets, time.Now().Unix())
+}
+
+type PushType string
+
+const (
+ PushTypeFCM PushType = "fcm"
+)
+
+type EncryptionKey struct {
+ Key []byte `json:"key,omitempty"`
+}
+
+type PushRegistration struct {
+ DeviceID string `json:"device_id"`
+ Type PushType `json:"type"`
+ Data json.RawMessage `json:"data"`
+ Encryption EncryptionKey `json:"encryption"`
+ Expiration jsontime.Unix `json:"expiration"`
+}
+
+func (pe *PushRegistration) Scan(row dbutil.Scannable) (*PushRegistration, error) {
+ err := row.Scan(&pe.DeviceID, &pe.Type, (*[]byte)(&pe.Data), dbutil.JSON{Data: &pe.Encryption}, &pe.Expiration)
+ if err != nil {
+ return nil, err
+ }
+ return pe, nil
+}
+
+func (pe *PushRegistration) sqlVariables() []any {
+ if pe.Expiration.IsZero() {
+ pe.Expiration = jsontime.U(time.Now().Add(7 * 24 * time.Hour))
+ }
+ return []interface{}{pe.DeviceID, pe.Type, unsafeJSONString(pe.Data), dbutil.JSON{Data: &pe.Encryption}, pe.Expiration}
+}
diff --git a/pkg/hicli/database/upgrades/00-latest-revision.sql b/pkg/hicli/database/upgrades/00-latest-revision.sql
index 97f98d4..847afae 100644
--- a/pkg/hicli/database/upgrades/00-latest-revision.sql
+++ b/pkg/hicli/database/upgrades/00-latest-revision.sql
@@ -1,4 +1,4 @@
--- v0 -> v11 (compatible with v10+): Latest revision
+-- v0 -> v12 (compatible with v10+): Latest revision
CREATE TABLE account (
user_id TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
@@ -301,3 +301,13 @@ CREATE TABLE space_edge (
CONSTRAINT space_edge_parent_event_unique UNIQUE (parent_event_rowid)
) STRICT;
CREATE INDEX space_edge_child_idx ON space_edge (child_id);
+
+CREATE TABLE push_registration (
+ device_id TEXT NOT NULL,
+ type TEXT NOT NULL,
+ data TEXT NOT NULL,
+ encryption TEXT NOT NULL,
+ expiration INTEGER NOT NULL,
+
+ PRIMARY KEY (device_id)
+) STRICT;
diff --git a/pkg/hicli/database/upgrades/12-push-registrations.sql b/pkg/hicli/database/upgrades/12-push-registrations.sql
new file mode 100644
index 0000000..07c03eb
--- /dev/null
+++ b/pkg/hicli/database/upgrades/12-push-registrations.sql
@@ -0,0 +1,10 @@
+-- v12 (compatible with v10+): Add table for push registrations
+CREATE TABLE push_registration (
+ device_id TEXT NOT NULL,
+ type TEXT NOT NULL,
+ data TEXT NOT NULL,
+ encryption TEXT NOT NULL,
+ expiration INTEGER NOT NULL,
+
+ PRIMARY KEY (device_id)
+) STRICT;
diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go
index e45a4e8..31cb9bb 100644
--- a/pkg/hicli/events.go
+++ b/pkg/hicli/events.go
@@ -15,19 +15,24 @@ import (
)
type SyncRoom struct {
- Meta *database.Room `json:"meta"`
- Timeline []database.TimelineRowTuple `json:"timeline"`
- State map[event.Type]map[string]database.EventRowID `json:"state"`
- AccountData map[event.Type]*database.AccountData `json:"account_data"`
- Events []*database.Event `json:"events"`
- Reset bool `json:"reset"`
- Notifications []SyncNotification `json:"notifications"`
- Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
+ Meta *database.Room `json:"meta"`
+ Timeline []database.TimelineRowTuple `json:"timeline"`
+ State map[event.Type]map[string]database.EventRowID `json:"state"`
+ AccountData map[event.Type]*database.AccountData `json:"account_data"`
+ Events []*database.Event `json:"events"`
+ Reset bool `json:"reset"`
+ Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
+
+ DismissNotifications bool `json:"dismiss_notifications"`
+ Notifications []SyncNotification `json:"notifications"`
}
type SyncNotification struct {
- RowID database.EventRowID `json:"event_rowid"`
- Sound bool `json:"sound"`
+ RowID database.EventRowID `json:"event_rowid"`
+ Sound bool `json:"sound"`
+ Highlight bool `json:"highlight"`
+ Event *database.Event `json:"-"`
+ Room *database.Room `json:"-"`
}
type SyncComplete struct {
@@ -41,6 +46,16 @@ type SyncComplete struct {
TopLevelSpaces []id.RoomID `json:"top_level_spaces"`
}
+func (c *SyncComplete) Notifications(yield func(SyncNotification) bool) {
+ for _, room := range c.Rooms {
+ for _, notif := range room.Notifications {
+ if !yield(notif) {
+ return
+ }
+ }
+ }
+}
+
func (c *SyncComplete) IsEmpty() bool {
return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.InvitedRooms) == 0 && len(c.AccountData) == 0
}
diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go
index 2ef0ab4..dca1ea7 100644
--- a/pkg/hicli/json-commands.go
+++ b/pkg/hicli/json-commands.go
@@ -201,6 +201,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
}
return cli.GetLoginFlows(ctx)
})
+ case "register_push":
+ return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
+ return true, h.DB.PushRegistration.Put(ctx, params)
+ })
default:
return nil, fmt.Errorf("unknown command %q", req.Command)
}
diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go
index 316da7e..ad515de 100644
--- a/pkg/hicli/sync.go
+++ b/pkg/hicli/sync.go
@@ -724,8 +724,11 @@ func (h *HiClient) processStateAndTimeline(
if isUnread {
if dbEvt.UnreadType.Is(database.UnreadTypeNotify) && h.firstSyncReceived {
newNotifications = append(newNotifications, SyncNotification{
- RowID: dbEvt.RowID,
- Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound),
+ RowID: dbEvt.RowID,
+ Sound: dbEvt.UnreadType.Is(database.UnreadTypeSound),
+ Highlight: dbEvt.UnreadType.Is(database.UnreadTypeHighlight),
+ Event: dbEvt,
+ Room: room,
})
}
newUnreadCounts.AddOne(dbEvt.UnreadType)
@@ -924,6 +927,7 @@ func (h *HiClient) processStateAndTimeline(
} else {
updatedRoom.UnreadCounts.Add(newUnreadCounts)
}
+ dismissNotifications := room.UnreadNotifications > 0 && updatedRoom.UnreadNotifications == 0 && len(newNotifications) == 0
if timeline.PrevBatch != "" && (room.PrevBatch == "" || timeline.Limited) {
updatedRoom.PrevBatch = timeline.PrevBatch
}
@@ -944,14 +948,16 @@ func (h *HiClient) processStateAndTimeline(
receipt.RoomID = ""
}
ctx.Value(syncContextKey).(*syncContext).evt.Rooms[room.ID] = &SyncRoom{
- Meta: room,
- Timeline: timelineRowTuples,
- AccountData: accountData,
- State: changedState,
- Reset: timeline.Limited,
- Events: allNewEvents,
- Notifications: newNotifications,
- Receipts: receiptMap,
+ Meta: room,
+ Timeline: timelineRowTuples,
+ AccountData: accountData,
+ State: changedState,
+ Reset: timeline.Limited,
+ Events: allNewEvents,
+ Receipts: receiptMap,
+
+ Notifications: newNotifications,
+ DismissNotifications: dismissNotifications,
}
}
return nil
diff --git a/web/index.html b/web/index.html
index 7a59ab1..6be7b75 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 89e78a0..4f26ef4 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -22,6 +22,7 @@ import type {
ElementRecentEmoji,
EventID,
EventType,
+ GomuksAndroidMessageToWeb,
ImagePackRooms,
RPCEvent,
RawDBEvent,
@@ -71,6 +72,74 @@ export default class Client {
this.requestNotificationPermission()
}
+ async #reallyStartAndroid(signal: AbortSignal) {
+ const androidListener = async (evt: CustomEventInit) => {
+ const evtData = JSON.parse(evt.detail ?? "{}") as GomuksAndroidMessageToWeb
+ switch (evtData.type) {
+ case "register_push":
+ await this.rpc.registerPush({
+ type: "fcm",
+ device_id: evtData.device_id,
+ data: evtData.token,
+ encryption: evtData.encryption,
+ expiration: evtData.expiration,
+ })
+ return
+ case "auth":
+ try {
+ const resp = await fetch("_gomuks/auth?no_prompt=true", {
+ method: "POST",
+ headers: {
+ Authorization: evtData.authorization,
+ },
+ signal,
+ })
+ if (!resp.ok && !signal.aborted) {
+ console.error("Failed to authenticate:", resp.status, resp.statusText)
+ window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
+ detail: {
+ event: "auth_fail",
+ error: `${resp.statusText || resp.status}`,
+ },
+ }))
+ return
+ }
+ } catch (err) {
+ console.error("Failed to authenticate:", err)
+ window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
+ detail: {
+ event: "auth_fail",
+ error: `${err}`.replace(/^Error: /, ""),
+ },
+ }))
+ return
+ }
+ if (signal.aborted) {
+ return
+ }
+ console.log("Successfully authenticated, connecting to websocket")
+ this.rpc.start()
+ return
+ }
+ }
+ const unsubscribeConnect = this.rpc.connect.listen(evt => {
+ if (!evt.connected) {
+ return
+ }
+ window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
+ detail: { event: "connected" },
+ }))
+ })
+ window.addEventListener("GomuksAndroidMessageToWeb", androidListener)
+ signal.addEventListener("abort", () => {
+ unsubscribeConnect()
+ window.removeEventListener("GomuksAndroidMessageToWeb", androidListener)
+ })
+ window.dispatchEvent(new CustomEvent("GomuksWebMessageToAndroid", {
+ detail: { event: "ready" },
+ }))
+ }
+
requestNotificationPermission = (evt?: MouseEvent) => {
window.Notification?.requestPermission().then(permission => {
console.log("Notification permission:", permission)
@@ -86,7 +155,11 @@ export default class Client {
start(): () => void {
const abort = new AbortController()
- this.#reallyStart(abort.signal)
+ if (window.gomuksAndroid) {
+ this.#reallyStartAndroid(abort.signal)
+ } else {
+ this.#reallyStart(abort.signal)
+ }
this.#gcInterval = setInterval(() => {
console.log("Garbage collection completed:", this.store.doGarbageCollection())
}, window.gcSettings.interval)
diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts
index 083ca75..95fb019 100644
--- a/web/src/api/rpc.ts
+++ b/web/src/api/rpc.ts
@@ -17,6 +17,7 @@ import { CachedEventDispatcher, EventDispatcher } from "../util/eventdispatcher.
import { CancellablePromise } from "../util/promise.ts"
import type {
ClientWellKnown,
+ DBPushRegistration,
EventID,
EventRowID,
EventType,
@@ -267,4 +268,8 @@ export default abstract class RPCClient {
requestOpenIDToken(): Promise {
return this.request("request_openid_token", {})
}
+
+ registerPush(reg: DBPushRegistration): Promise {
+ return this.request("register_push", reg)
+ }
}
diff --git a/web/src/api/types/android.ts b/web/src/api/types/android.ts
new file mode 100644
index 0000000..8f62f84
--- /dev/null
+++ b/web/src/api/types/android.ts
@@ -0,0 +1,30 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 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 .
+
+export interface AndroidRegisterPushEvent {
+ type: "register_push"
+ device_id: string
+ token: string
+ encryption: { key: string }
+ expiration?: number
+}
+
+export interface AndroidAuthEvent {
+ type: "auth"
+ authorization: `Bearer ${string}`
+}
+
+export type GomuksAndroidMessageToWeb = AndroidRegisterPushEvent | AndroidAuthEvent
diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts
index 637f38a..c7489cd 100644
--- a/web/src/api/types/hitypes.ts
+++ b/web/src/api/types/hitypes.ts
@@ -284,3 +284,11 @@ export interface ProfileEncryptionInfo {
user_trusted: boolean
errors: string[]
}
+
+export interface DBPushRegistration {
+ device_id: string
+ type: "fcm"
+ data: unknown
+ encryption: { key: string }
+ expiration?: number
+}
diff --git a/web/src/api/types/index.ts b/web/src/api/types/index.ts
index 88930fa..693c67a 100644
--- a/web/src/api/types/index.ts
+++ b/web/src/api/types/index.ts
@@ -1,3 +1,4 @@
export * from "./mxtypes.ts"
export * from "./hitypes.ts"
export * from "./hievents.ts"
+export * from "./android.ts"
diff --git a/web/src/ui/settings/SettingsView.tsx b/web/src/ui/settings/SettingsView.tsx
index daf5c44..67ec3c7 100644
--- a/web/src/ui/settings/SettingsView.tsx
+++ b/web/src/ui/settings/SettingsView.tsx
@@ -394,10 +394,12 @@ const SettingsView = ({ room }: SettingsViewProps) => {
- {window.Notification &&
>
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
index 9cef2d8..ae9d0a8 100644
--- a/web/src/vite-env.d.ts
+++ b/web/src/vite-env.d.ts
@@ -16,5 +16,6 @@ declare global {
gcSettings: GCSettings
hackyOpenEventContextMenu?: string
closeModal: () => void
+ gomuksAndroid?: true
}
}