Desktop client (warp-desktop) with Wails v3
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 / 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
apps/desktop is the web client in a native window. It embeds the same build warp-web does and serves it through the same Go package, so every screen is shared. Unlike warp-web it proxies nothing: the client calls the homeserver directly, and the CSP allows the homeserver plus any https: and wss: origin for the call backend. - First run asks for the homeserver: a server name or address, with .well-known discovery and a /versions check; File > Change homeserver asks again. Signed-in sessions keep their own base URL, so a change never sends one server's token to another. - Links to other sites open in the system browser (/_desktop/links.js, added to index.html); only http, https and mailto are opened. - One instance at a time, since sessions and keys live in the webview's storage. - Dev builds open the DevTools Protocol on Windows when WARP_DESKTOP_REMOTE_DEBUGGING_PORT is set, for scripted testing. - Packaging: icon, version info and manifest in the Windows exe, a macOS .app with camera and microphone descriptions, Linux packages via nfpm. Make targets: desktop, desktop-run, desktop-test, desktop-resources, desktop-macos, desktop-linux. Web client: host and client move out of internal/ so the desktop app can import them, and host gains AnyCallOrigin. The Dockerfile, .dockerignore and the vite output path follow the move. Pinned to Wails v3.0.0-beta.27. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
76dfc98a86
commit
b6bf3ce741
38 files changed
+1616
-17
No files matched your search
@@ -0,0 +1,128 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Discover turns what someone typed on the first-run page into a homeserver
|
||||
// base URL, the way Matrix clients do (spec: client-server API, server
|
||||
// discovery). The input is a server name ("example.org") or a URL; a
|
||||
// /.well-known/matrix/client on that host may delegate to another base URL;
|
||||
// and whatever comes out has to answer /_matrix/client/versions.
|
||||
//
|
||||
// The errors are written for the person who typed the input.
|
||||
func Discover(ctx context.Context, c *http.Client, input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", errors.New("Enter a server name, like example.org, or its address.")
|
||||
}
|
||||
raw := input
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := parseBase(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("“%s” isn’t a server name or address.", input)
|
||||
}
|
||||
|
||||
delegated, err := wellKnown(ctx, c, u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if delegated == nil || *delegated == *u {
|
||||
if err := checkVersions(ctx, c, u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
err = checkVersions(ctx, c, delegated)
|
||||
if err == nil {
|
||||
return delegated.String(), nil
|
||||
}
|
||||
// A server whose well-known names an address that doesn't work (a
|
||||
// development server advertising its public name, say) is still
|
||||
// usable at the address that was typed.
|
||||
if checkVersions(ctx, c, u) == nil {
|
||||
return u.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("%s points to %s. %w", u.Host, delegated, err)
|
||||
}
|
||||
|
||||
// parseBase accepts an absolute http(s) URL with no query or fragment and
|
||||
// returns it without a trailing slash.
|
||||
func parseBase(raw string) (*url.URL, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil ||
|
||||
u.RawQuery != "" || u.Fragment != "" {
|
||||
return nil, errors.New("not an http(s) base URL")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/")
|
||||
u.RawPath = ""
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// wellKnown returns the base URL the host delegates to, or nil when it
|
||||
// doesn't say (no document, or one without m.homeserver). A host that can't
|
||||
// be reached at all is not an error here: the versions check reports it.
|
||||
func wellKnown(ctx context.Context, c *http.Client, u *url.URL) (*url.URL, error) {
|
||||
wk := url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/.well-known/matrix/client"}
|
||||
body, status, err := get(ctx, c, wk.String())
|
||||
if err != nil || status != http.StatusOK {
|
||||
return nil, nil
|
||||
}
|
||||
var doc struct {
|
||||
Homeserver *struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
} `json:"m.homeserver"`
|
||||
}
|
||||
if json.Unmarshal(body, &doc) != nil || doc.Homeserver == nil {
|
||||
return nil, nil
|
||||
}
|
||||
base, err := parseBase(doc.Homeserver.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s points to “%s”, which isn’t a valid homeserver address.", u.Host, doc.Homeserver.BaseURL)
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func checkVersions(ctx context.Context, c *http.Client, base *url.URL) error {
|
||||
body, status, err := get(ctx, c, base.String()+"/_matrix/client/versions")
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("%s took too long to answer.", base.Host)
|
||||
}
|
||||
return fmt.Errorf("Can’t reach %s.", base.Host)
|
||||
}
|
||||
var v struct {
|
||||
Versions []string `json:"versions"`
|
||||
}
|
||||
if status != http.StatusOK || json.Unmarshal(body, &v) != nil || len(v.Versions) == 0 {
|
||||
return fmt.Errorf("%s doesn’t look like a Matrix homeserver.", base.Host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get(ctx context.Context, c *http.Client, u string) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(res.Body, 64<<10))
|
||||
return body, res.StatusCode, err
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"time"
|
||||
)
|
||||
|
||||
// linkScriptPath serves linkScript, which withLinkScript 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').
|
||||
const linkScriptPath = "/_desktop/links.js"
|
||||
|
||||
// linkScript makes links to other sites open in the system browser. A URL
|
||||
// in a message would otherwise replace the app in its own window, or open a
|
||||
// bare webview window for target="_blank"; this sends those to openPath
|
||||
// instead. Links within the app, and blob: URLs for downloads, are left
|
||||
// alone.
|
||||
const linkScript = `(() => {
|
||||
if (window.__warpDesktopLinks) return
|
||||
window.__warpDesktopLinks = true
|
||||
const external = (href) => {
|
||||
try {
|
||||
const u = new URL(href, location.href)
|
||||
return u.origin !== location.origin && ['http:', 'https:', 'mailto:'].includes(u.protocol) ? u.href : null
|
||||
} catch { return null }
|
||||
}
|
||||
const open = (href) => fetch('/_desktop/open', {
|
||||
method: 'POST', credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: href }),
|
||||
}).catch(() => {})
|
||||
const onClick = (e) => {
|
||||
if (e.defaultPrevented || e.button > 1) return
|
||||
const a = e.target instanceof Element ? e.target.closest('a[href]') : null
|
||||
const href = a && external(a.href)
|
||||
if (!href) return
|
||||
e.preventDefault()
|
||||
open(href)
|
||||
}
|
||||
// auxclick is the middle button, which would open a new window.
|
||||
document.addEventListener('click', onClick, true)
|
||||
document.addEventListener('auxclick', onClick, true)
|
||||
const windowOpen = window.open
|
||||
window.open = function (url, ...rest) {
|
||||
const href = url == null ? null : external(String(url))
|
||||
if (href) { open(href); return null }
|
||||
return windowOpen.call(window, url, ...rest)
|
||||
}
|
||||
})()
|
||||
`
|
||||
|
||||
// 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) {
|
||||
index, err := fs.ReadFile(client, "index.html")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
tag := []byte(`<head>` + "\n " + `<script src="` + linkScriptPath + `"></script>`)
|
||||
patched := bytes.Replace(index, []byte("<head>"), 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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <head>; 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(`<!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>
|
||||
`))
|
||||
@@ -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("<!doctype html><title>Warp</title>")},
|
||||
"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(), "<title>Warp</title>") {
|
||||
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("<!doctype html><html><head><title>Warp</title></head></html>")},
|
||||
}})
|
||||
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>`) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user