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,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>
|
||||
`))
|
||||
Reference in new issue
Block a user