Desktop: notifications, tray, auto-update, installer; web: sliding sync, voice messages, notifications
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
CI / Desktop client (ubuntu-24.04) (push) Canceled after 0s
CI / Desktop client (windows-latest) (push) Canceled after 0s

Web client (also what the desktop app runs):

- Simplified Sliding Sync (MSC4186) when the homeserver advertises
  org.matrix.simplified_msc3575. src/matrix/sliding.ts answers the SDK's
  GET /sync with a sliding sync request and rewrites the response into
  the /sync shape, as the Android app does; the SDK's own sliding sync
  mode doesn't sort thread replies into threads. 20 rooms first, then
  every room (up to 1000); to_device, e2ee, account data, receipts and
  typing extensions; M_UNKNOWN_POS starts a new connection. The SDK's
  /members?at=<sync token> drops the token, which here is ours. Settings
  can switch back to /sync.
- Notifications for new messages and incoming calls while Warp isn't in
  front, by the account's push rules, evaluated after decryption. The
  browser's Notification API once turned on in Settings, or the desktop
  app's bridge (window.warpDesktop). One per room; a click opens it.
- Voice messages: record from the composer, sent as m.audio with
  MSC3245's voice marker and MSC1767's duration and waveform (encrypted
  in encrypted rooms); a waveform player in the timeline.
- @warp/ui: a slot for extra composer buttons; bell-off, play and pause
  icons.

Desktop app:

- System notifications through Wails' notifications service, fed by
  /_desktop/bridge.js (/_desktop/notify, JSON only); clicking one shows
  the window and opens the room.
- Tray icon with Open and Quit; closing the window hides it (a one-time
  notification says so), File > Quit ends it. The page title goes to
  the window and the tray tooltip, the unread count to the taskbar or
  Dock badge.
- Auto-update from a Wails update manifest at DESKTOP_UPDATE_URL: a quiet
  check a minute after start and every six hours, a notification when a
  newer version exists, Help > Check for updates opens the update window.
  Optional signature key (DESKTOP_UPDATE_KEY). Linux package installs
  leave updates to the package manager. build/manifest writes the
  manifest.
- Per-user NSIS installer (make desktop-windows-installer), the macOS
  bundle zipped for the updater, a desktop CI job, and a Desktop release
  workflow that builds all three platforms and the manifest.

Tested on Windows against a local homeserver: sliding sync with E2EE,
threads and a server restart; notifications (toast history), close to
tray, relaunch restoring the window; voice messages recorded, sent and
played, encrypted too; the update check finding a newer version. Not
tested: installing an update, the NSIS installer, CI, macOS and Linux.
Code signing needs certificates and isn't done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 14:01:02 +02:00
1 parent 8ff2730676
commit 829b236efe
33 files changed
+2067 -51

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
package shell
import (
"encoding/json"
"mime"
"net/http"
"strings"
"unicode/utf8"
)
// bridgeScriptPath serves bridgeScript, which withDesktopScripts adds to
// the client's index.html next to the link script.
const bridgeScriptPath = "/_desktop/bridge.js"
const (
// notifyPath shows a system notification: the client's notifications
// store calls window.warpDesktop.notify for new messages and calls.
notifyPath = "/_desktop/notify"
// titlePath takes the page's title, which counts unread rooms, for the
// window title and the tray icon's tooltip.
titlePath = "/_desktop/title"
)
// bridgeScript is what the client finds of the desktop app: window.warpDesktop
// (see apps/web/src/stores/notifications.ts), and the page title sent along
// whenever it changes. A click on a notification comes back as a
// "warp-desktop:open-room" event, dispatched by the app.
const bridgeScript = `(() => {
if (window.warpDesktop) return
const post = (path, body) => fetch(path, {
method: 'POST', credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).catch(() => {})
window.warpDesktop = {
notify: (n) => post('/_desktop/notify', { id: n.id, title: n.title, body: n.body, roomId: n.roomId }),
}
let last = null
const sendTitle = () => {
if (document.title === last) return
last = document.title
post('/_desktop/title', { title: last })
}
new MutationObserver(sendTitle).observe(document.head, { subtree: true, childList: true, characterData: true })
document.addEventListener('DOMContentLoaded', sendTitle)
})()
`
// Notification is a system notification the client asks for.
type Notification struct {
// ID replaces an earlier notification with the same ID: one per room.
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
RoomID string `json:"roomId"`
}
// Longest title and body shown; the system cuts them shorter anyway.
const (
maxTitle = 200
maxBody = 1000
)
// readJSON decodes a small JSON body. It insists on the JSON content type,
// which a page of another origin can't send without a CORS preflight that
// the shell never answers.
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return false
}
if ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); ct != "application/json" {
http.Error(w, "expected application/json", http.StatusUnsupportedMediaType)
return false
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(v); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return false
}
return true
}
func (s *Shell) serveNotify(w http.ResponseWriter, r *http.Request) {
var n Notification
if !readJSON(w, r, &n) {
return
}
n.Title = clip(n.Title, maxTitle)
n.Body = clip(n.Body, maxBody)
if n.ID == "" || n.Title == "" {
http.Error(w, "id and title are required", http.StatusBadRequest)
return
}
if s.opts.Notify == nil {
w.WriteHeader(http.StatusNoContent)
return
}
if err := s.opts.Notify(n); err != nil {
s.opts.Logger.Warn("can't show a notification", "err", err)
http.Error(w, "can't show the notification", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Shell) serveTitle(w http.ResponseWriter, r *http.Request) {
var body struct {
Title string `json:"title"`
}
if !readJSON(w, r, &body) {
return
}
if s.opts.SetTitle != nil {
s.opts.SetTitle(clip(body.Title, maxTitle))
}
w.WriteHeader(http.StatusNoContent)
}
// clip trims s and cuts it to at most n runes, without control characters.
func clip(s string, n int) string {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' {
return ' '
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, strings.TrimSpace(s))
if utf8.RuneCountInString(s) <= n {
return s
}
return string([]rune(s)[:n-1]) + "…"
}
@@ -0,0 +1,60 @@
package shell
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func postJSON(h http.Handler, target, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestNotify(t *testing.T) {
var got []Notification
s, _ := newShell(t, Options{Notify: func(n Notification) error { got = append(got, n); return nil }})
rec := postJSON(s, notifyPath, `{"id":"!a:x","title":" Room\u0007 ","body":"Ann:\nhi","roomId":"!a:x"}`)
if rec.Code != http.StatusNoContent || len(got) != 1 {
t.Fatalf("notify: %d, %v", rec.Code, got)
}
if want := (Notification{ID: "!a:x", Title: "Room", Body: "Ann: hi", RoomID: "!a:x"}); got[0] != want {
t.Errorf("got %+v, want %+v", got[0], want)
}
// Missing fields, the wrong method and a body that isn't JSON are refused.
if rec := postJSON(s, notifyPath, `{"id":"","title":"x"}`); rec.Code != http.StatusBadRequest {
t.Errorf("no id: %d", rec.Code)
}
if rec := do(s, http.MethodGet, notifyPath, ""); rec.Code != http.StatusMethodNotAllowed {
t.Errorf("GET: %d", rec.Code)
}
if rec := do(s, http.MethodPost, notifyPath, `{"id":"a","title":"b"}`); rec.Code != http.StatusUnsupportedMediaType {
t.Errorf("no content type: %d", rec.Code)
}
if len(got) != 1 {
t.Errorf("refused requests notified: %v", got)
}
}
func TestTitle(t *testing.T) {
var title string
s, _ := newShell(t, Options{SetTitle: func(v string) { title = v }})
if rec := postJSON(s, titlePath, `{"title":"(2) Warp"}`); rec.Code != http.StatusNoContent || title != "(2) Warp" {
t.Errorf("title: %d %q", rec.Code, title)
}
}
func TestClip(t *testing.T) {
if got := clip(" abcdef ", 4); got != "abc…" {
t.Errorf("clip: %q", got)
}
if got := clip("żółw", 4); got != "żółw" {
t.Errorf("clip: %q", got)
}
}
+9 -6
View File
@@ -6,7 +6,7 @@ import (
"time"
)
// linkScriptPath serves linkScript, which withLinkScript adds to the
// linkScriptPath serves linkScript, which withDesktopScripts adds to the
// client's index.html. A script of the page's own, rather than one the
// webview injects, runs before the app on every platform and is allowed by
// the client's Content-Security-Policy (script-src 'self').
@@ -51,15 +51,18 @@ const linkScript = `(() => {
})()
`
// withLinkScript returns the client with a script tag for linkScript at the
// top of index.html's head. ok is false when index.html has no <head> to
// add it to; the client is then returned unchanged.
func withLinkScript(client fs.FS) (_ fs.FS, ok bool, _ error) {
// withDesktopScripts returns the client with script tags for linkScript and
// bridgeScript at the top of index.html's head, so both run before the app.
// ok is false when index.html has no <head> to add them to; the client is
// then returned unchanged.
func withDesktopScripts(client fs.FS) (_ fs.FS, ok bool, _ error) {
index, err := fs.ReadFile(client, "index.html")
if err != nil {
return nil, false, err
}
tag := []byte(`<head>` + "\n " + `<script src="` + linkScriptPath + `"></script>`)
tag := []byte(`<head>` +
"\n " + `<script src="` + linkScriptPath + `"></script>` +
"\n " + `<script src="` + bridgeScriptPath + `"></script>`)
patched := bytes.Replace(index, []byte("<head>"), tag, 1)
if bytes.Equal(patched, index) {
return client, false, nil
+3
View File
@@ -15,6 +15,9 @@ type Settings struct {
// Homeserver is the base URL new sign-ins go to, e.g.
// https://matrix.example.org. Empty until the first-run page has asked.
Homeserver string `json:"homeserver,omitempty"`
// TrayHintShown is set once the app has said, the first time its
// window was closed, that it keeps running in the tray.
TrayHintShown bool `json:"tray_hint_shown,omitempty"`
}
// Store reads and writes Settings as a JSON file.
+28 -8
View File
@@ -54,6 +54,10 @@ type Options struct {
// HTTPClient is used to find and check a homeserver on the setup
// page. Defaults to a client with a 15 second timeout.
HTTPClient *http.Client
// Notify shows a system notification; nil drops them.
Notify func(Notification) error
// SetTitle gets the page's title whenever it changes.
SetTitle func(string)
}
// Shell is the webview's http.Handler.
@@ -81,12 +85,12 @@ func New(opts Options) (*Shell, error) {
opts.HTTPClient = &http.Client{Timeout: 15 * time.Second}
}
if opts.Client != nil {
client, ok, err := withLinkScript(opts.Client)
client, ok, err := withDesktopScripts(opts.Client)
if err != nil {
return nil, err
}
if !ok {
opts.Logger.Warn("the client's index.html has no <head>; links to other sites will open in the app's window")
opts.Logger.Warn("the client's index.html has no <head>; links will open in the app's window, and there are no notifications")
}
opts.Client = client
}
@@ -147,12 +151,17 @@ func (s *Shell) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case openPath:
s.serveOpen(w, r)
return
case notifyPath:
s.serveNotify(w, r)
return
case titlePath:
s.serveTitle(w, r)
return
case linkScriptPath:
h := w.Header()
h.Set("Content-Type", "text/javascript; charset=utf-8")
h.Set("Cache-Control", "no-cache")
h.Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write([]byte(linkScript))
writeScript(w, linkScript)
return
case bridgeScriptPath:
writeScript(w, bridgeScript)
return
}
@@ -170,6 +179,14 @@ func (s *Shell) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.handler.ServeHTTP(w, r)
}
func writeScript(w http.ResponseWriter, script string) {
h := w.Header()
h.Set("Content-Type", "text/javascript; charset=utf-8")
h.Set("Cache-Control", "no-cache")
h.Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write([]byte(script))
}
func (s *Shell) serveOpen(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
@@ -232,7 +249,10 @@ func (s *Shell) serveSetup(w http.ResponseWriter, r *http.Request) {
s.renderSetup(w, setupPage{Server: input, Current: s.Homeserver(), Error: err.Error()})
return
}
if err := s.opts.Store.Save(Settings{Homeserver: base}); err != nil {
// The rest of the settings stay; a damaged file is replaced.
st, _ := s.opts.Store.Load()
st.Homeserver = base
if err := s.opts.Store.Save(st); err != nil {
// The choice still holds for this run.
s.opts.Logger.Warn("can't save the settings", "err", err)
}
+8 -2
View File
@@ -174,13 +174,15 @@ func TestCSPAllowsCallBackends(t *testing.T) {
}
}
func TestLinkScriptIsAddedToTheClient(t *testing.T) {
func TestDesktopScriptsAreAddedToTheClient(t *testing.T) {
s, _ := newShell(t, Options{Homeserver: "http://localhost:8008", Client: fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><html><head><title>Warp</title></head></html>")},
}})
want := `<head>` + "\n " + `<script src="/_desktop/links.js"></script>` +
"\n " + `<script src="/_desktop/bridge.js"></script><title>`
for _, p := range []string{"/", "/room/!abc:warp.example"} {
body := do(s, http.MethodGet, p, "").Body.String()
if !strings.Contains(body, `<head>`+"\n "+`<script src="/_desktop/links.js"></script><title>`) {
if !strings.Contains(body, want) {
t.Errorf("GET %s: %q", p, body)
}
}
@@ -189,6 +191,10 @@ func TestLinkScriptIsAddedToTheClient(t *testing.T) {
!strings.Contains(rec.Body.String(), "/_desktop/open") {
t.Errorf("links.js: %d %q", rec.Code, rec.Header().Get("Content-Type"))
}
rec = do(s, http.MethodGet, bridgeScriptPath, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "window.warpDesktop") {
t.Errorf("bridge.js: %d %q", rec.Code, rec.Body.String())
}
}
func TestOpenURL(t *testing.T) {