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>
342 lines
12 KiB
Go
342 lines
12 KiB
Go
// Package shell is what the desktop app's webview loads: the web client,
|
||
// served through the same host package as warp-web, plus the few pages and
|
||
// endpoints only the desktop app has.
|
||
//
|
||
// Unlike warp-web, the desktop app isn't installed next to one homeserver,
|
||
// so nothing is proxied: the client calls the homeserver directly, as any
|
||
// Matrix client does (homeservers answer CORS for every origin). Which
|
||
// homeserver new sign-ins go to is asked on first run and kept in the
|
||
// settings file. A session already signed in keeps the base URL it signed
|
||
// in with, so changing the homeserver never sends one server's access
|
||
// token to another.
|
||
package shell
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"html/template"
|
||
"io/fs"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/dariuszjaniak/warp/apps/web/host"
|
||
)
|
||
|
||
const (
|
||
// SetupPath is the first-run page, also where "Change homeserver…"
|
||
// goes.
|
||
SetupPath = "/_desktop/setup"
|
||
// openPath takes a URL to open in the system browser: the page's links
|
||
// to other sites go there instead of navigating the app's window.
|
||
openPath = "/_desktop/open"
|
||
)
|
||
|
||
// Options configures the shell.
|
||
type Options struct {
|
||
// Store keeps the chosen homeserver.
|
||
Store *Store
|
||
// Homeserver, when set, is used for this run instead of the saved
|
||
// one, and isn't saved.
|
||
Homeserver string
|
||
// Client is the built web client; nil serves host's "not built" page.
|
||
Client fs.FS
|
||
// Version is reported in /config.json.
|
||
Version string
|
||
// Logger defaults to slog.Default().
|
||
Logger *slog.Logger
|
||
// OpenURL opens a URL in the system browser.
|
||
OpenURL func(string) error
|
||
// 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.
|
||
type Shell struct {
|
||
opts Options
|
||
app atomic.Pointer[app]
|
||
}
|
||
|
||
// app is the web client configured for one homeserver.
|
||
type app struct {
|
||
homeserver string
|
||
handler http.Handler
|
||
}
|
||
|
||
// New returns the shell, serving the client for the saved homeserver, or
|
||
// the setup page when there is none yet.
|
||
func New(opts Options) (*Shell, error) {
|
||
if opts.Store == nil {
|
||
return nil, errors.New("shell: Store is required")
|
||
}
|
||
if opts.Logger == nil {
|
||
opts.Logger = slog.Default()
|
||
}
|
||
if opts.HTTPClient == nil {
|
||
opts.HTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||
}
|
||
if opts.Client != nil {
|
||
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 will open in the app's window, and there are no notifications")
|
||
}
|
||
opts.Client = client
|
||
}
|
||
s := &Shell{opts: opts}
|
||
|
||
if opts.Homeserver != "" {
|
||
if err := s.use(opts.Homeserver); err != nil {
|
||
return nil, err
|
||
}
|
||
return s, nil
|
||
}
|
||
// A damaged settings file only costs the choice of server: the setup
|
||
// page asks again and overwrites it.
|
||
st, err := opts.Store.Load()
|
||
if err != nil {
|
||
opts.Logger.Warn("can't read the settings; asking for the homeserver again", "err", err)
|
||
}
|
||
if st.Homeserver != "" {
|
||
if err := s.use(st.Homeserver); err != nil {
|
||
opts.Logger.Warn("the saved homeserver isn't valid; asking again", "err", err)
|
||
}
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// Homeserver returns the base URL new sign-ins go to, or "" before setup.
|
||
func (s *Shell) Homeserver() string {
|
||
if a := s.app.Load(); a != nil {
|
||
return a.homeserver
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (s *Shell) use(homeserver string) error {
|
||
u, err := parseBase(homeserver)
|
||
if err != nil {
|
||
return errors.New("shell: the homeserver must be an absolute http(s) URL, got " + homeserver)
|
||
}
|
||
h, err := host.New(host.Options{
|
||
PublicHomeserver: u,
|
||
Client: s.opts.Client,
|
||
Version: s.opts.Version,
|
||
Logger: s.opts.Logger,
|
||
AnyCallOrigin: true,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
s.app.Store(&app{homeserver: u.String(), handler: h})
|
||
return nil
|
||
}
|
||
|
||
func (s *Shell) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case SetupPath:
|
||
s.serveSetup(w, r)
|
||
return
|
||
case openPath:
|
||
s.serveOpen(w, r)
|
||
return
|
||
case notifyPath:
|
||
s.serveNotify(w, r)
|
||
return
|
||
case titlePath:
|
||
s.serveTitle(w, r)
|
||
return
|
||
case linkScriptPath:
|
||
writeScript(w, linkScript)
|
||
return
|
||
case bridgeScriptPath:
|
||
writeScript(w, bridgeScript)
|
||
return
|
||
}
|
||
|
||
a := s.app.Load()
|
||
if a == nil {
|
||
// Before setup every page is the setup page; there is no
|
||
// homeserver to configure the client with yet.
|
||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||
s.renderSetup(w, setupPage{})
|
||
return
|
||
}
|
||
http.Error(w, "the homeserver isn't set up yet", http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
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")
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
var body struct {
|
||
URL string `json:"url"`
|
||
}
|
||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&body); err != nil {
|
||
http.Error(w, "bad request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
u, err := url.Parse(body.URL)
|
||
if err != nil || !externalScheme(u) {
|
||
http.Error(w, "only http, https and mailto links are opened", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if s.opts.OpenURL == nil {
|
||
http.Error(w, "can't open links", http.StatusNotImplemented)
|
||
return
|
||
}
|
||
if err := s.opts.OpenURL(u.String()); err != nil {
|
||
s.opts.Logger.Warn("can't open a link in the browser", "err", err)
|
||
http.Error(w, "can't open the link", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func externalScheme(u *url.URL) bool {
|
||
switch u.Scheme {
|
||
case "http", "https":
|
||
return u.Host != ""
|
||
case "mailto":
|
||
return u.Opaque != ""
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *Shell) serveSetup(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case http.MethodGet, http.MethodHead:
|
||
cur := s.Homeserver()
|
||
s.renderSetup(w, setupPage{Server: cur, Current: cur})
|
||
case http.MethodPost:
|
||
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
|
||
if err := r.ParseForm(); err != nil {
|
||
http.Error(w, "bad request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
input := r.PostForm.Get("server")
|
||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||
defer cancel()
|
||
base, err := Discover(ctx, s.opts.HTTPClient, input)
|
||
if err == nil {
|
||
err = s.use(base)
|
||
}
|
||
if err != nil {
|
||
s.renderSetup(w, setupPage{Server: input, Current: s.Homeserver(), Error: err.Error()})
|
||
return
|
||
}
|
||
// 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)
|
||
}
|
||
s.opts.Logger.Info("homeserver set", "homeserver", base)
|
||
// A meta refresh rather than a redirect: not every webview
|
||
// follows a redirect from the app's own scheme handler.
|
||
writePage(w, doneTemplate, nil)
|
||
default:
|
||
w.Header().Set("Allow", "GET, HEAD, POST")
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
type setupPage struct {
|
||
Server string // the input's value
|
||
Current string // the homeserver in use, if any
|
||
Error string
|
||
}
|
||
|
||
func (s *Shell) renderSetup(w http.ResponseWriter, p setupPage) {
|
||
writePage(w, setupTemplate, p)
|
||
}
|
||
|
||
func writePage(w http.ResponseWriter, t *template.Template, data any) {
|
||
h := w.Header()
|
||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||
h.Set("Cache-Control", "no-store")
|
||
h.Set("X-Content-Type-Options", "nosniff")
|
||
h.Set("Content-Security-Policy",
|
||
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
|
||
var b strings.Builder
|
||
if err := t.Execute(&b, data); err != nil {
|
||
http.Error(w, "template: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
_, _ = w.Write([]byte(b.String()))
|
||
}
|
||
|
||
// The pages use the design tokens from @warp/ui (apps/ui/src/styles/tokens.css).
|
||
const pageStyle = `
|
||
:root{--surface:#fff;--sunken:#f3f5f4;--line:#e1e6e4;--line-strong:#7b8682;--ink:#111114;--muted:#5b6461;
|
||
--teal:#00806a;--on-teal:#fff;--danger:#b42318;--danger-soft:#fde8e6;--ring:0 0 0 2px #fff,0 0 0 4px #00806a;color-scheme:light}
|
||
@media (prefers-color-scheme:dark){:root{--surface:#0f1213;--sunken:#161a1c;--line:#2a3134;--line-strong:#6e7976;
|
||
--ink:#eceeee;--muted:#9aa5a1;--teal:#22c3a6;--on-teal:#062a23;--danger:#f97066;--danger-soft:#3a1512;
|
||
--ring:0 0 0 2px #0f1213,0 0 0 4px #3ed6b8;color-scheme:dark}}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--sunken);color:var(--ink);
|
||
font:14px/20px Figtree,system-ui,-apple-system,"Segoe UI",sans-serif;padding:16px}
|
||
main{width:100%;max-width:420px;background:var(--surface);border:1px solid var(--line);border-radius:18px;padding:32px}
|
||
h1{font:600 22px/28px Outfit,Figtree,system-ui,sans-serif;margin:0 0 8px}
|
||
p{margin:0 0 16px;color:var(--muted)}
|
||
label{display:block;font-weight:600;margin:0 0 6px}
|
||
input{width:100%;height:40px;padding:0 12px;border:1px solid var(--line-strong);border-radius:8px;
|
||
background:var(--surface);color:var(--ink);font:inherit}
|
||
input:focus{outline:none;box-shadow:var(--ring)}
|
||
.hint{font-size:13px;margin:6px 0 0}
|
||
.error{background:var(--danger-soft);color:var(--danger);border-radius:8px;padding:8px 12px;margin:0 0 16px}
|
||
.actions{display:flex;gap:8px;justify-content:flex-end;margin-top:24px}
|
||
button,a.button{height:40px;padding:0 16px;border-radius:9999px;border:1px solid transparent;font-family:inherit;font-size:14px;font-weight:600;line-height:38px;
|
||
cursor:pointer;text-decoration:none;display:inline-block}
|
||
button{background:var(--teal);color:var(--on-teal)}
|
||
a.button{color:var(--ink);border-color:var(--line)}
|
||
button:focus-visible,a.button:focus-visible{outline:none;box-shadow:var(--ring)}
|
||
code{font:13px/20px "JetBrains Mono",ui-monospace,monospace}
|
||
`
|
||
|
||
var setupTemplate = template.Must(template.New("setup").Parse(`<!doctype html>
|
||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Choose a homeserver · Warp</title><style>` + pageStyle + `</style></head>
|
||
<body><main>
|
||
<h1>{{if .Current}}Change homeserver{{else}}Welcome to Warp{{end}}</h1>
|
||
<p>{{if .Current}}New sign-ins will go to this server. If you’re signed in, you stay signed in to <code>{{.Current}}</code> until you sign out.{{else}}Which Matrix homeserver is your account on?{{end}}</p>
|
||
{{if .Error}}<div class="error" role="alert">{{.Error}}</div>{{end}}
|
||
<form method="post" action="/_desktop/setup">
|
||
<label for="server">Homeserver</label>
|
||
<input id="server" name="server" value="{{.Server}}" placeholder="example.org" autocomplete="url" autocapitalize="off" spellcheck="false" required autofocus>
|
||
<p class="hint">A server name like <code>example.org</code>, or an address like <code>https://matrix.example.org</code>.</p>
|
||
<div class="actions">{{if .Current}}<a class="button" href="/">Cancel</a>{{end}}<button type="submit">Continue</button></div>
|
||
</form>
|
||
</main></body></html>
|
||
`))
|
||
|
||
var doneTemplate = template.Must(template.New("done").Parse(`<!doctype html>
|
||
<html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=/">
|
||
<title>Warp</title><style>` + pageStyle + `</style></head><body></body></html>
|
||
`))
|