diff --git a/.dockerignore b/.dockerignore
index 6c7c49c..7a47277 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,7 +2,7 @@
**/node_modules
**/dist
!apps/admin/internal/panel/build
-!apps/web/internal/client/build
+!apps/web/client/build
bin
coverage.*
.complement
diff --git a/Makefile b/Makefile
index 52eb390..733fa9f 100644
--- a/Makefile
+++ b/Makefile
@@ -136,7 +136,7 @@ WEB_DIR := apps/web
WEB_IMAGE ?= warp-web
.PHONY: web-web
-web-web: ## Build the web client frontend into apps/web/internal/client/build/dist
+web-web: ## Build the web client frontend into apps/web/client/build/dist
$(PNPM) --filter @warp/web build
.PHONY: web
@@ -160,6 +160,82 @@ web-test: ## Typecheck the frontend and test warp-web
web-docker: ## Build the warp-web container image
docker build -t $(WEB_IMAGE):$(VERSION) -f $(WEB_DIR)/Dockerfile .
+# --- Desktop client (apps/desktop) ----------------------------------------
+# The web client in a native window (Wails v3). It embeds the same build as
+# warp-web, so `make desktop` builds the web frontend first.
+
+DESKTOP_DIR := apps/desktop
+
+# Windows builds are GUI programs: no console window behind the app.
+ifeq ($(OS),Windows_NT)
+DESKTOP_LDFLAGS := -H windowsgui
+else
+DESKTOP_LDFLAGS :=
+endif
+
+.PHONY: desktop
+desktop: web-web ## Build bin/warp-desktop with the web client embedded
+ cd $(DESKTOP_DIR) && $(GO) build -tags production -ldflags "-X main.version=$(VERSION) $(DESKTOP_LDFLAGS)" -o ../../$(BIN_DIR)/warp-desktop$(EXE) ./cmd/warp-desktop
+
+.PHONY: desktop-run
+desktop-run: web-web ## Run the desktop client with the developer tools on
+ cd $(DESKTOP_DIR) && $(GO) run ./cmd/warp-desktop $(ARGS)
+
+.PHONY: desktop-test
+desktop-test: ## Vet and test the desktop client's Go code
+ cd $(DESKTOP_DIR) && $(GO) vet ./... && $(GO) test ./...
+
+# Packaging. The inputs are in apps/desktop/build. The wails3 CLI, at the
+# version go.mod pins, makes the icons and the Windows .syso and builds the
+# Linux packages; `go run` fetches it, so there is nothing to install.
+# DESKTOP_VERSION is the numeric version the OS sees (Windows version info,
+# Info.plist, package version); `-version` still prints $(VERSION).
+WAILS3 ?= $(GO) run github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.27
+DESKTOP_VERSION ?= 0.1.0
+DESKTOP_MAINTAINER ?= $(shell git config user.name) <$(shell git config user.email)>
+DESKTOP_FORMATS ?= deb rpm archlinux
+DESKTOP_GEN := $(BIN_DIR)/desktop-build
+DESKTOP_APP := $(BIN_DIR)/Warp.app
+
+# The .syso files are committed, so `make desktop` needs none of this; run
+# it after changing the icon, the manifest or DESKTOP_VERSION.
+.PHONY: desktop-resources
+desktop-resources: ## Regenerate the desktop icons and the Windows .syso files
+ mkdir -p $(DESKTOP_GEN)
+ cd $(DESKTOP_DIR) && $(WAILS3) generate icons -input build/appicon.png -sizes 256,128,64 \
+ -windowsfilename ../../$(DESKTOP_GEN)/tile.ico -macfilename build/darwin/icons.icns
+ cd $(DESKTOP_DIR) && $(GO) run build/windows/mkico.go -small build/windows/favicon.ico \
+ -large ../../$(DESKTOP_GEN)/tile.ico -out build/windows/icon.ico
+ sed 's/{{VERSION}}/$(DESKTOP_VERSION)/g' $(DESKTOP_DIR)/build/windows/info.json > $(DESKTOP_GEN)/info.json
+ sed 's/{{VERSION}}/$(DESKTOP_VERSION)/g' $(DESKTOP_DIR)/build/windows/warp-desktop.exe.manifest > $(DESKTOP_GEN)/warp-desktop.exe.manifest
+ cd $(DESKTOP_DIR) && for arch in amd64 arm64; do \
+ $(WAILS3) generate syso -arch $$arch -icon build/windows/icon.ico \
+ -manifest ../../$(DESKTOP_GEN)/warp-desktop.exe.manifest -info ../../$(DESKTOP_GEN)/info.json \
+ -out cmd/warp-desktop/rsrc_windows_$$arch.syso || exit 1; \
+ done
+
+# Run on a Mac: the binary needs cgo. Ad-hoc signed, so it runs on the
+# machine that built it; distributing it needs a Developer ID and notarizing.
+.PHONY: desktop-macos
+desktop-macos: desktop ## Build bin/Warp.app (on macOS)
+ @test "$$(uname -s)" = Darwin || { echo "desktop-macos runs on macOS"; exit 1; }
+ rm -rf $(DESKTOP_APP)
+ mkdir -p $(DESKTOP_APP)/Contents/MacOS $(DESKTOP_APP)/Contents/Resources
+ cp $(BIN_DIR)/warp-desktop $(DESKTOP_APP)/Contents/MacOS/
+ cp $(DESKTOP_DIR)/build/darwin/icons.icns $(DESKTOP_APP)/Contents/Resources/
+ sed 's/{{VERSION}}/$(DESKTOP_VERSION)/g' $(DESKTOP_DIR)/build/darwin/Info.plist > $(DESKTOP_APP)/Contents/Info.plist
+ codesign --force --deep --sign - $(DESKTOP_APP)
+
+# Run on Linux: the binary needs cgo. Packages land in bin/.
+.PHONY: desktop-linux
+desktop-linux: desktop ## Build Linux packages of the desktop client (on Linux)
+ @test "$$(uname -s)" = Linux || { echo "desktop-linux runs on Linux"; exit 1; }
+ cd $(DESKTOP_DIR) && for format in $(DESKTOP_FORMATS); do \
+ VERSION=$(DESKTOP_VERSION) GOARCH=$$($(GO) env GOARCH) MAINTAINER="$(DESKTOP_MAINTAINER)" \
+ $(WAILS3) tool package -name warp-desktop -format $$format \
+ -config build/linux/nfpm.yaml -out ../../$(BIN_DIR) || exit 1; \
+ done
+
.PHONY: complement-image
complement-image: ## Build the Complement homeserver image
docker build -t $(COMPLEMENT_IMAGE) -f $(SERVER_DIR)/test/complement/Dockerfile .
diff --git a/README.md b/README.md
index 96bd2f2..a31805a 100644
--- a/README.md
+++ b/README.md
@@ -208,6 +208,9 @@ own process, `warp-admin`, next to the homeserver:
The web client is a Matrix client for the browser. It is optional in the
same way, a process of its own, `warp-web`:
`docker compose --profile web up -d`, or see [apps/web](apps/web/README.md).
+The desktop client, `warp-desktop`, is the same client in a native window
+(Wails v3) that signs in to any homeserver: `make desktop`, or see
+[apps/desktop](apps/desktop/README.md).
Also in place: argon2id passwords, hashed access and refresh tokens,
per-endpoint rate limiting, YAML + environment configuration, structured
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
new file mode 100644
index 0000000..1bcd152
--- /dev/null
+++ b/apps/desktop/README.md
@@ -0,0 +1,145 @@
+# Warp desktop client
+
+The [web client](../web/README.md) in a native window, built with
+[Wails v3](https://v3alpha.wails.io). Every screen is the web client's:
+`warp-desktop` embeds the same build `warp-web` does and serves it through
+the same Go package (`apps/web/host`), so web and desktop don't drift
+apart. It runs on Windows (WebView2), macOS (WKWebView) and Linux
+(WebKitGTK).
+
+```
+window ──► wails.localhost ──► shell (this app) ── the web client, /config.json
+ │ first-run page, links
+ └──────────────────────────────────────────────► homeserver /_matrix/*
+```
+
+Unlike `warp-web`, the desktop app isn't installed next to one homeserver,
+so it proxies nothing. The client calls the homeserver directly, the way
+any Matrix client does; homeservers answer CORS for every origin. The
+Content-Security-Policy allows the homeserver plus any `https:` and `wss:`
+origin, because the call backend (the LiveKit JWT service and SFU) is
+learned from the homeserver at run time and can't be listed in advance.
+
+## What it adds to the web client
+
+- **First run asks for the homeserver.** A server name (`example.org`) or
+ an address. Discovery follows `/.well-known/matrix/client` and checks
+ `/_matrix/client/versions`. If the well-known document names an address
+ that doesn't answer, the typed address is used when that one works.
+ **File → Change homeserver…** asks again. A session already signed in
+ keeps the server it signed in to until you sign out, so changing the
+ homeserver never sends one server's access token to another.
+- **Links to other sites open in the system browser.** That covers a URL in
+ a message, `target="_blank"` and `window.open`. Without this they would
+ replace the app in its window or open a bare webview window.
+ `/_desktop/links.js` does it; the shell adds a ``)
+ patched := bytes.Replace(index, []byte("
"), tag, 1)
+ if bytes.Equal(patched, index) {
+ return client, false, nil
+ }
+ return indexFS{FS: client, index: patched}, true, nil
+}
+
+// indexFS is a client whose index.html is replaced.
+type indexFS struct {
+ fs.FS
+ index []byte
+}
+
+func (f indexFS) Open(name string) (fs.File, error) {
+ if name != "index.html" {
+ return f.FS.Open(name)
+ }
+ return &memFile{Reader: bytes.NewReader(f.index), size: int64(len(f.index))}, nil
+}
+
+func (f indexFS) ReadFile(name string) ([]byte, error) {
+ if name != "index.html" {
+ return fs.ReadFile(f.FS, name)
+ }
+ return bytes.Clone(f.index), nil
+}
+
+type memFile struct {
+ *bytes.Reader
+ size int64
+}
+
+func (m *memFile) Stat() (fs.FileInfo, error) { return memInfo{m.size}, nil }
+func (m *memFile) Close() error { return nil }
+
+type memInfo struct{ size int64 }
+
+func (i memInfo) Name() string { return "index.html" }
+func (i memInfo) Size() int64 { return i.size }
+func (i memInfo) Mode() fs.FileMode { return 0o444 }
+func (i memInfo) ModTime() time.Time { return time.Time{} }
+func (i memInfo) IsDir() bool { return false }
+func (i memInfo) Sys() any { return nil }
diff --git a/apps/desktop/internal/shell/settings.go b/apps/desktop/internal/shell/settings.go
new file mode 100644
index 0000000..46753af
--- /dev/null
+++ b/apps/desktop/internal/shell/settings.go
@@ -0,0 +1,61 @@
+package shell
+
+import (
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sync"
+)
+
+// Settings is what the desktop app keeps between runs. The client's own
+// state (sessions, keys) lives in the webview's storage, not here.
+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"`
+}
+
+// Store reads and writes Settings as a JSON file.
+type Store struct {
+ path string
+ mu sync.Mutex
+}
+
+// NewStore returns a store backed by the file at path. The file and its
+// directory are created on the first Save.
+func NewStore(path string) *Store { return &Store{path: path} }
+
+// Load returns the saved settings, or the zero value when there are none.
+func (s *Store) Load() (Settings, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var st Settings
+ b, err := os.ReadFile(s.path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return st, nil
+ }
+ if err != nil {
+ return st, err
+ }
+ return st, json.Unmarshal(b, &st)
+}
+
+// Save writes the settings, replacing the file atomically.
+func (s *Store) Save(st Settings) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ b, err := json.MarshalIndent(st, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
+ return err
+ }
+ tmp := s.path + ".tmp"
+ if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, s.path)
+}
diff --git a/apps/desktop/internal/shell/shell.go b/apps/desktop/internal/shell/shell.go
new file mode 100644
index 0000000..1342f7e
--- /dev/null
+++ b/apps/desktop/internal/shell/shell.go
@@ -0,0 +1,321 @@
+// 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
+}
+
+// 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 := withLinkScript(opts.Client)
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ opts.Logger.Warn("the client's index.html has no ; links to other sites will open in the app's window")
+ }
+ 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 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))
+ 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 (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
+ }
+ if err := s.opts.Store.Save(Settings{Homeserver: base}); 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(`
+
+Choose a homeserver · Warp
+
+{{if .Current}}Change homeserver{{else}}Welcome to Warp{{end}}
+{{if .Current}}New sign-ins will go to this server. If you’re signed in, you stay signed in to {{.Current}} until you sign out.{{else}}Which Matrix homeserver is your account on?{{end}}
+{{if .Error}}{{.Error}}
{{end}}
+
+
+`))
+
+var doneTemplate = template.Must(template.New("done").Parse(`
+
+Warp
+`))
diff --git a/apps/desktop/internal/shell/shell_test.go b/apps/desktop/internal/shell/shell_test.go
new file mode 100644
index 0000000..f16a7e9
--- /dev/null
+++ b/apps/desktop/internal/shell/shell_test.go
@@ -0,0 +1,257 @@
+package shell
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+ "testing/fstest"
+)
+
+var clientFS = fstest.MapFS{
+ "index.html": {Data: []byte("Warp")},
+ "assets/app-1.js": {Data: []byte("console.log(1)")},
+}
+
+// homeserver is a fake homeserver: /versions, and a well-known document
+// when delegate is set.
+func homeserver(t *testing.T, delegate string) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /_matrix/client/versions", func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"versions":["v1.11"]}`))
+ })
+ if delegate != "" {
+ mux.HandleFunc("GET /.well-known/matrix/client", func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"m.homeserver":{"base_url":"` + delegate + `/"}}`))
+ })
+ }
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func newShell(t *testing.T, opts Options) (*Shell, *Store) {
+ t.Helper()
+ if opts.Store == nil {
+ opts.Store = NewStore(filepath.Join(t.TempDir(), "desktop.json"))
+ }
+ if opts.Client == nil {
+ opts.Client = clientFS
+ }
+ s, err := New(opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s, opts.Store
+}
+
+func do(h http.Handler, method, target, body string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, target, strings.NewReader(body))
+ if method == http.MethodPost && strings.HasPrefix(target, SetupPath) {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestFirstRunAsksForTheHomeserver(t *testing.T) {
+ s, _ := newShell(t, Options{})
+ for _, p := range []string{"/", "/room/!abc:warp.example", SetupPath} {
+ rec := do(s, http.MethodGet, p, "")
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Welcome to Warp") {
+ t.Errorf("GET %s: %d %q", p, rec.Code, rec.Body.String())
+ }
+ }
+ if rec := do(s, http.MethodPost, "/_matrix/client/v3/login", "{}"); rec.Code != http.StatusServiceUnavailable {
+ t.Errorf("POST before setup: %d", rec.Code)
+ }
+}
+
+func TestSetupSavesTheHomeserverAndServesTheClient(t *testing.T) {
+ hs := homeserver(t, "")
+ s, store := newShell(t, Options{})
+
+ rec := do(s, http.MethodPost, SetupPath, url.Values{"server": {hs.URL + "/"}}.Encode())
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `http-equiv="refresh"`) {
+ t.Fatalf("setup: %d %q", rec.Code, rec.Body.String())
+ }
+ if got := s.Homeserver(); got != hs.URL {
+ t.Errorf("Homeserver() = %q, want %q", got, hs.URL)
+ }
+ if st, _ := store.Load(); st.Homeserver != hs.URL {
+ t.Errorf("saved %q", st.Homeserver)
+ }
+
+ // The client is served now, told to call the homeserver directly.
+ if rec := do(s, http.MethodGet, "/", ""); !strings.Contains(rec.Body.String(), "Warp") {
+ t.Errorf("GET / after setup: %q", rec.Body.String())
+ }
+ rec = do(s, http.MethodGet, "/config.json", "")
+ var cfg struct {
+ HomeserverURL string `json:"homeserver_url"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil || cfg.HomeserverURL != hs.URL {
+ t.Errorf("config.json %q", rec.Body.String())
+ }
+ // Nothing is proxied.
+ if rec := do(s, http.MethodGet, "/_matrix/client/versions", ""); rec.Code != http.StatusNotFound {
+ t.Errorf("/_matrix proxied: %d", rec.Code)
+ }
+
+ // The next run starts with it.
+ s2, _ := newShell(t, Options{Store: store})
+ if s2.Homeserver() != hs.URL {
+ t.Errorf("next run: %q", s2.Homeserver())
+ }
+ if rec := do(s2, http.MethodGet, SetupPath, ""); !strings.Contains(rec.Body.String(), "Change homeserver") {
+ t.Errorf("setup page once configured: %q", rec.Body.String())
+ }
+}
+
+func TestSetupRejectsWhatIsntAHomeserver(t *testing.T) {
+ notMatrix := httptest.NewServer(http.NotFoundHandler())
+ t.Cleanup(notMatrix.Close)
+ s, store := newShell(t, Options{})
+
+ rec := do(s, http.MethodPost, SetupPath, url.Values{"server": {notMatrix.URL}}.Encode())
+ if !strings.Contains(rec.Body.String(), "doesn’t look like a Matrix homeserver") {
+ t.Errorf("body %q", rec.Body.String())
+ }
+ // What was typed stays in the field.
+ if !strings.Contains(rec.Body.String(), `value="`+notMatrix.URL+`"`) {
+ t.Errorf("input not kept: %q", rec.Body.String())
+ }
+ if s.Homeserver() != "" {
+ t.Errorf("homeserver set to %q", s.Homeserver())
+ }
+ if st, _ := store.Load(); st.Homeserver != "" {
+ t.Errorf("saved %q", st.Homeserver)
+ }
+}
+
+func TestDamagedSettingsAskAgain(t *testing.T) {
+ store := NewStore(filepath.Join(t.TempDir(), "desktop.json"))
+ if err := store.Save(Settings{Homeserver: "matrix.example"}); err != nil {
+ t.Fatal(err)
+ }
+ s, _ := newShell(t, Options{Store: store})
+ if s.Homeserver() != "" {
+ t.Errorf("Homeserver() = %q", s.Homeserver())
+ }
+ if rec := do(s, http.MethodGet, "/", ""); !strings.Contains(rec.Body.String(), "Welcome to Warp") {
+ t.Errorf("GET /: %q", rec.Body.String())
+ }
+}
+
+func TestOverrideIsNotSaved(t *testing.T) {
+ store := NewStore(filepath.Join(t.TempDir(), "desktop.json"))
+ if err := store.Save(Settings{Homeserver: "https://saved.example"}); err != nil {
+ t.Fatal(err)
+ }
+ s, _ := newShell(t, Options{Store: store, Homeserver: "http://localhost:8008"})
+ if s.Homeserver() != "http://localhost:8008" {
+ t.Errorf("Homeserver() = %q", s.Homeserver())
+ }
+ if st, _ := store.Load(); st.Homeserver != "https://saved.example" {
+ t.Errorf("saved %q", st.Homeserver)
+ }
+ if _, err := New(Options{Store: store, Homeserver: "matrix.example"}); err == nil {
+ t.Error("a homeserver without a scheme was accepted")
+ }
+}
+
+func TestCSPAllowsCallBackends(t *testing.T) {
+ s, _ := newShell(t, Options{Homeserver: "http://localhost:8008"})
+ csp := do(s, http.MethodGet, "/", "").Header().Get("Content-Security-Policy")
+ if !strings.Contains(csp, "connect-src 'self' http://localhost:8008 https: wss:;") {
+ t.Errorf("CSP %q", csp)
+ }
+}
+
+func TestLinkScriptIsAddedToTheClient(t *testing.T) {
+ s, _ := newShell(t, Options{Homeserver: "http://localhost:8008", Client: fstest.MapFS{
+ "index.html": {Data: []byte("Warp")},
+ }})
+ for _, p := range []string{"/", "/room/!abc:warp.example"} {
+ body := do(s, http.MethodGet, p, "").Body.String()
+ if !strings.Contains(body, ``+"\n "+``) {
+ t.Errorf("GET %s: %q", p, body)
+ }
+ }
+ rec := do(s, http.MethodGet, linkScriptPath, "")
+ if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") ||
+ !strings.Contains(rec.Body.String(), "/_desktop/open") {
+ t.Errorf("links.js: %d %q", rec.Code, rec.Header().Get("Content-Type"))
+ }
+}
+
+func TestOpenURL(t *testing.T) {
+ var opened []string
+ s, _ := newShell(t, Options{OpenURL: func(u string) error { opened = append(opened, u); return nil }})
+
+ for _, u := range []string{"https://example.org/a?b=c", "mailto:alice@example.org"} {
+ if rec := do(s, http.MethodPost, openPath, `{"url":"`+u+`"}`); rec.Code != http.StatusNoContent {
+ t.Errorf("open %s: %d", u, rec.Code)
+ }
+ }
+ for _, u := range []string{"file:///etc/passwd", "javascript:alert(1)", "https://", "ms-settings:"} {
+ if rec := do(s, http.MethodPost, openPath, `{"url":"`+u+`"}`); rec.Code != http.StatusBadRequest {
+ t.Errorf("open %s: %d", u, rec.Code)
+ }
+ }
+ if rec := do(s, http.MethodGet, openPath, ""); rec.Code != http.StatusMethodNotAllowed {
+ t.Errorf("GET open: %d", rec.Code)
+ }
+ if len(opened) != 2 {
+ t.Errorf("opened %q", opened)
+ }
+}
+
+func TestDiscover(t *testing.T) {
+ target := homeserver(t, "")
+ delegating := homeserver(t, target.URL)
+ badDelegate := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"m.homeserver":{"base_url":"ftp://x"}}`))
+ }))
+ t.Cleanup(badDelegate.Close)
+ c := http.DefaultClient
+ ctx := context.Background()
+
+ if got, err := Discover(ctx, c, " "+target.URL+"/ "); err != nil || got != target.URL {
+ t.Errorf("plain: %q %v", got, err)
+ }
+ if got, err := Discover(ctx, c, delegating.URL); err != nil || got != target.URL {
+ t.Errorf("delegated: %q %v", got, err)
+ }
+ if _, err := Discover(ctx, c, badDelegate.URL); err == nil || !strings.Contains(err.Error(), "isn’t a valid homeserver address") {
+ t.Errorf("bad delegation: %v", err)
+ }
+ // Delegation to an address that doesn't answer falls back to the one
+ // typed, when that one is a homeserver itself.
+ deadDelegation := homeserver(t, "http://127.0.0.1:1")
+ if got, err := Discover(ctx, c, deadDelegation.URL); err != nil || got != deadDelegation.URL {
+ t.Errorf("dead delegation: %q %v", got, err)
+ }
+ nowhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"m.homeserver":{"base_url":"http://127.0.0.1:1"}}`))
+ }))
+ t.Cleanup(nowhere.Close)
+ if _, err := Discover(ctx, c, nowhere.URL); err == nil || !strings.Contains(err.Error(), "points to http://127.0.0.1:1. Can’t reach 127.0.0.1:1.") {
+ t.Errorf("delegation to nowhere: %v", err)
+ }
+ for _, in := range []string{"", "https://x.example/?a=b", "ftp://x.example"} {
+ if _, err := Discover(ctx, c, in); err == nil {
+ t.Errorf("%q accepted", in)
+ }
+ }
+ // A bare name gets https://; nothing listens there, so it can't be reached.
+ if _, err := Discover(ctx, c, "127.0.0.1:1"); err == nil || !strings.Contains(err.Error(), "Can’t reach 127.0.0.1:1") {
+ t.Errorf("unreachable: %v", err)
+ }
+}
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 5406213..26317b1 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -26,8 +26,9 @@ FROM golang:1.26-alpine AS build
WORKDIR /src/apps/web
COPY apps/web/go.mod ./
COPY apps/web/cmd ./cmd
-COPY apps/web/internal ./internal
-COPY --from=web /src/apps/web/internal/client/build/dist ./internal/client/build/dist
+COPY apps/web/host ./host
+COPY apps/web/client/client.go ./client/
+COPY --from=web /src/apps/web/client/build/dist ./client/build/dist
ARG VERSION=0.0.0-dev
ENV CGO_ENABLED=0 GOWORK=off
diff --git a/apps/web/README.md b/apps/web/README.md
index 1de27b6..b81b1d1 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -171,7 +171,7 @@ proxies the same paths warp-web does, so the app code is the same in both.
| Command | What |
|---|---|
-| `make web-web` | build the frontend into `internal/client/build/dist` |
+| `make web-web` | build the frontend into `client/build/dist` |
| `make web` | build the frontend, then `bin/warp-web` with it embedded |
| `make web-test` | typecheck the frontend, vet and test the Go host |
| `make web-docker` | build the container image |
@@ -188,8 +188,8 @@ page saying the client is missing.
| `src/views/` | sign-in, sign-up, home, room |
| `src/components/` | timeline, message body, room info, dialogs |
| `cmd/warp-web/` | the binary |
-| `internal/host/` | static serving, `/config.json` and the allow-listed proxy |
-| `internal/client/` | the embedded build |
+| `host/` | static serving, `/config.json` and the allow-listed proxy (shared with [the desktop app](../desktop/README.md)) |
+| `client/` | the embedded build (shared with the desktop app) |
The SDK's objects change in place and emit events. The stores turn those
events into a version counter (bumped at most once a frame), and the views
diff --git a/apps/web/client/build/README.md b/apps/web/client/build/README.md
new file mode 100644
index 0000000..d3d1632
--- /dev/null
+++ b/apps/web/client/build/README.md
@@ -0,0 +1,4 @@
+`pnpm build` in `apps/web` writes the client to `dist/` here, and package
+`client` embeds it into the `warp-web` and `warp-desktop` binaries. This file
+keeps the directory in git so the Go code compiles before the frontend is
+built; such a binary serves a page saying the client has not been built.
diff --git a/apps/web/internal/client/client.go b/apps/web/client/client.go
similarity index 100%
rename from apps/web/internal/client/client.go
rename to apps/web/client/client.go
diff --git a/apps/web/cmd/warp-web/main.go b/apps/web/cmd/warp-web/main.go
index e0e56f8..8d998af 100644
--- a/apps/web/cmd/warp-web/main.go
+++ b/apps/web/cmd/warp-web/main.go
@@ -19,8 +19,8 @@ import (
"syscall"
"time"
- "github.com/dariuszjaniak/warp/apps/web/internal/client"
- "github.com/dariuszjaniak/warp/apps/web/internal/host"
+ "github.com/dariuszjaniak/warp/apps/web/client"
+ "github.com/dariuszjaniak/warp/apps/web/host"
)
// Set with -ldflags "-X main.version=...".
diff --git a/apps/web/internal/host/host.go b/apps/web/host/host.go
similarity index 97%
rename from apps/web/internal/host/host.go
rename to apps/web/host/host.go
index ec1aba4..512b096 100644
--- a/apps/web/internal/host/host.go
+++ b/apps/web/host/host.go
@@ -46,6 +46,10 @@ type Options struct {
// SFU's WebSocket, e.g. https://livekit-jwt.example and
// wss://livekit.example. They are added to the CSP's connect-src.
CallOrigins []string
+ // AnyCallOrigin lets the page connect to any https: or wss: origin.
+ // The desktop app sets it: it can be pointed at any homeserver, so it
+ // learns the call backend at run time and can't list it in advance.
+ AnyCallOrigin bool
}
// proxiedPrefixes are the parts of the homeserver a Matrix client uses: the
@@ -103,6 +107,9 @@ func New(opts Options) (http.Handler, error) {
connect += " https://" + u.Host
}
}
+ if opts.AnyCallOrigin {
+ connect += " https: wss:"
+ }
body, err := json.Marshal(cfg)
if err != nil {
return nil, err
diff --git a/apps/web/internal/host/host_test.go b/apps/web/host/host_test.go
similarity index 96%
rename from apps/web/internal/host/host_test.go
rename to apps/web/host/host_test.go
index 211764e..e43160a 100644
--- a/apps/web/internal/host/host_test.go
+++ b/apps/web/host/host_test.go
@@ -336,3 +336,16 @@ func TestCallOriginsJoinConnectSrc(t *testing.T) {
t.Error("a call origin with a path was accepted")
}
}
+
+func TestAnyCallOrigin(t *testing.T) {
+ public, _ := url.Parse("http://localhost:8008")
+ h, err := New(Options{PublicHomeserver: public, Client: clientFS, AnyCallOrigin: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := do(h, http.MethodGet, "/", nil)
+ want := "connect-src 'self' http://localhost:8008 https: wss:;"
+ if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, want) {
+ t.Errorf("CSP %q", csp)
+ }
+}
diff --git a/apps/web/internal/client/build/README.md b/apps/web/internal/client/build/README.md
deleted file mode 100644
index ea0cdf4..0000000
--- a/apps/web/internal/client/build/README.md
+++ /dev/null
@@ -1,4 +0,0 @@
-`pnpm build` in `apps/web` writes the client to `dist/` here, and
-`internal/client` embeds it into the `warp-web` binary. This file keeps the
-directory in git so the Go code compiles before the frontend is built; such a
-binary serves a page saying the client has not been built.
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 4ed93fa..1804c63 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -14,8 +14,8 @@ export default defineConfig({
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
build: {
- // Embedded into the warp-web binary by internal/client.
- outDir: 'internal/client/build/dist',
+ // Embedded into the warp-web binary by client/ (and the desktop app).
+ outDir: 'client/build/dist',
emptyOutDir: true,
assetsInlineLimit: 0,
// matrix-js-sdk is most of the bundle; it is one chunk on purpose.
diff --git a/go.work b/go.work
index a0020ce..6623e26 100644
--- a/go.work
+++ b/go.work
@@ -2,9 +2,8 @@ go 1.26.4
use (
./apps/admin
+ ./apps/desktop
./apps/web
./server
)
-// The other client sub-projects join the workspace as they are created:
-// use ./apps/desktop (Wails v3 shell, Phase 8.3)