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

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:
tajniak81andClaude Opus 5.5 committed 2026-10-18 21:43:54 +02:00
1 parent 76dfc98a86
commit b6bf3ce741
38 files changed
+1616 -17

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
**/node_modules
**/dist
!apps/admin/internal/panel/build
!apps/web/internal/client/build
!apps/web/client/build
bin
coverage.*
.complement
+77 -1
View File
@@ -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 .
+3
View File
@@ -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
+145
View File
@@ -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 `<script>` tag for it to
`index.html`. Only `http`, `https` and `mailto` URLs are opened.
- **One instance at a time.** Sessions and encryption keys live in the
webview's storage, and two instances on one store would both sync and
use the same one-time keys. Starting the app again brings the open window
forward.
## Where things are kept
| What | Where |
|---|---|
| The chosen homeserver | `desktop.json` in the user config directory, under `Warp` (`%AppData%\Warp` on Windows, `~/Library/Application Support/Warp` on macOS, `~/.config/Warp` on Linux) |
| Sessions, encryption keys, cache | the webview's storage: `%LocalAppData%\Warp\WebView2` on Windows; the platform webview's default location elsewhere |
Signing out deletes a session's keys, the same as in the browser (see
[Encryption keys in the browser](../web/README.md#encryption-keys-in-the-browser)).
## Build and run
Requires Go 1.26, Node 22 and pnpm. On Windows the WebView2 runtime, which
Windows 11 ships with, and no C compiler. On macOS, Xcode's command line
tools. On Linux, a C compiler with `libgtk-4-dev` and `libwebkitgtk-6.0-dev`
(Wails needs cgo there, and builds against GTK 4 by default).
```bash
pnpm install
make desktop # builds the web client, then bin/warp-desktop
./bin/warp-desktop
```
| Flag | Environment | |
|---|---|---|
| `-homeserver` | `WARP_DESKTOP_HOMESERVER` | use this homeserver for this run without saving it |
| `-version` | | print the version |
| Command | What |
|---|---|
| `make desktop` | a release build: `-tags production` (no developer tools), and on Windows a GUI program with no console |
| `make desktop-run` | build the web client and run the app with the developer tools (View → Open Developer Tools) |
| `make desktop-test` | vet and test the Go code |
| `make desktop-macos` | on macOS: `bin/Warp.app` |
| `make desktop-linux` | on Linux: `bin/warp-desktop.deb`, `.rpm` and `.pkg.tar.zst` |
| `make desktop-resources` | regenerate the icons and the Windows `.syso` files |
A development build (one without `-tags production`) also opens the Chrome
DevTools Protocol when `WARP_DESKTOP_REMOTE_DEBUGGING_PORT` is set, on
Windows. A script or Playwright can then drive the real window:
```bash
WARP_DESKTOP_REMOTE_DEBUGGING_PORT=9229 go run ./cmd/warp-desktop
```
## Packaging
The inputs are in `build/`. The `wails3` CLI does the generating and the
Linux packaging; the Makefile runs it with `go run` at the version
`go.mod` pins (`v3.0.0-beta.27`), so it needs no install. `DESKTOP_VERSION`
in the Makefile (default `0.1.0`) is the version the OS sees in each
package; `-version` prints the build's git version.
- **Windows.** `cmd/warp-desktop/rsrc_windows_{amd64,arm64}.syso` carry the
icon, the version info and the application manifest (per-monitor DPI,
common controls 6, `asInvoker`). They are committed, so `make desktop`
(and a plain `go build`) links them in with no extra step. The icon,
`build/windows/icon.ico`, takes 16–48 px from the brand favicon (the
symbol's small cut) and 64–256 px from the app icon tile;
`build/windows/mkico.go` merges the two. After changing the icon,
`build/windows/info.json`, the manifest or `DESKTOP_VERSION`, run
`make desktop-resources` and commit the `.syso` files.
- **macOS.** `make desktop-macos`, on a Mac, builds the binary and puts it
in `bin/Warp.app` with `build/darwin/Info.plist` and `icons.icns`, then
signs the bundle ad hoc. The `Info.plist` has `NSCameraUsageDescription`
and `NSMicrophoneUsageDescription`: without them macOS ends the app the
moment a call or the QR scanner asks for the camera or microphone. The
bundle is for the CPU it was built on, and ad-hoc signed it opens only on
the machine that built it; to hand it out, sign it with a Developer ID
and notarize it.
- **Linux.** `make desktop-linux`, on Linux, builds the binary and turns it
into `.deb`, `.rpm` and Arch packages with nfpm (through `wails3 tool
package`, configured by `build/linux/nfpm.yaml`). They install
`/usr/bin/warp-desktop`, a menu entry and the icon, and depend on GTK 4
and WebKitGTK 6.0 (Ubuntu 24.04+, Debian 13+, Fedora 40+).
`DESKTOP_FORMATS=deb` builds one format; `DESKTOP_MAINTAINER` defaults
to your git name and email.
`build/appicon.png` and `build/windows/favicon.ico` are copies from the
brand kit (`DESIGN/warp-brand/logo-kit`, not in git):
`symbol/warp-symbol-app-icon-1024.png` and `web/favicon.ico`.
## Layout
| Path | What |
|---|---|
| `cmd/warp-desktop/` | the binary: the Wails app, window, menu and single-instance lock |
| `internal/shell/` | the webview's `http.Handler`: the client through `apps/web/host`, the first-run page and discovery, the settings file, links |
| `build/` | packaging inputs: icons, the Windows manifest and version info, `Info.plist`, the nfpm config |
## Not yet
These follow from [PLAN.md §8.3](../../PLAN.md):
- Installers and signing: a Windows installer (NSIS or MSIX) and a signed
executable, a universal (arm64 + amd64), Developer ID signed and
notarized macOS `.app` in a `.dmg`, an AppImage or Flatpak. `wails3`
has tasks for most of these. None of the packages above is built in CI
yet.
- Native notifications, a tray icon, auto-update.
- Sync and encryption in Go (`mautrix-go`) behind the same Pinia stores,
with the access token in the OS keychain. Today the desktop app runs
matrix-js-sdk in the webview exactly as the browser does. Whether to move
this to Go is open question 5 in the plan.
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>Warp</string>
<key>CFBundleDisplayName</key>
<string>Warp</string>
<key>CFBundleExecutable</key>
<string>warp-desktop</string>
<key>CFBundleIdentifier</key>
<string>io.github.dariuszjaniak.warp</string>
<key>CFBundleVersion</key>
<string>{{VERSION}}</string>
<key>CFBundleShortVersionString</key>
<string>{{VERSION}}</string>
<key>CFBundleGetInfoString</key>
<string>A Matrix client</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Warp uses the camera for video calls and to scan verification QR codes.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Warp uses the microphone for voice and video calls.</string>
</dict>
</plist>
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
# Linux packages (deb, rpm, Arch) for warp-desktop, built by
# `make desktop-linux` with `wails3 tool package`, which runs nfpm
# (https://nfpm.goreleaser.com). Paths are relative to apps/desktop.
# VERSION, GOARCH and MAINTAINER come from the Makefile.
name: warp-desktop
arch: ${GOARCH}
platform: linux
version: ${VERSION}
release: "1"
section: net
priority: optional
maintainer: ${MAINTAINER}
description: |
Warp, a Matrix client: the Warp web client in a native window.
contents:
- src: ../../bin/warp-desktop
dst: /usr/bin/warp-desktop
file_info:
mode: 0755
- src: ./build/linux/warp-desktop.desktop
dst: /usr/share/applications/warp-desktop.desktop
- src: ./cmd/warp-desktop/appicon.png
dst: /usr/share/icons/hicolor/256x256/apps/warp-desktop.png
- src: ./build/appicon.png
dst: /usr/share/icons/hicolor/1024x1024/apps/warp-desktop.png
scripts:
postinstall: ./build/linux/postinstall.sh
# Wails v3 builds against GTK 4 and WebKitGTK 6.0 by default
# (Ubuntu 24.04+, Debian 13+, Fedora 40+).
depends:
- libgtk-4-1
- libwebkitgtk-6.0-4
overrides:
rpm:
depends:
- gtk4
- webkitgtk6.0
archlinux:
depends:
- gtk4
- webkitgtk-6.0
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Make the menu entry and icon show up without logging out.
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t /usr/share/icons/hicolor || true
fi
exit 0
@@ -0,0 +1,9 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Warp
Comment=A Matrix client
Exec=/usr/bin/warp-desktop
Icon=warp-desktop
Terminal=false
Categories=Network;InstantMessaging;Chat;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"fixed": {
"file_version": "{{VERSION}}",
"product_version": "{{VERSION}}"
},
"info": {
"0409": {
"ProductName": "Warp",
"ProductVersion": "{{VERSION}}",
"FileVersion": "{{VERSION}}",
"FileDescription": "Warp",
"Comments": "A Matrix client",
"InternalName": "warp-desktop",
"OriginalFilename": "warp-desktop.exe"
}
}
}
+108
View File
@@ -0,0 +1,108 @@
//go:build ignore
// Command mkico writes the Windows icon: the small sizes from the brand
// favicon (the symbol's small cut, drawn for 16–48 px) and the large sizes
// from the app icon tile. Both inputs are .ico files whose images are PNGs.
//
// go run build/windows/mkico.go -small favicon.ico -large tile.ico -out icon.ico
package main
import (
"bytes"
"encoding/binary"
"flag"
"fmt"
"os"
"sort"
)
type image struct {
size int // pixels; 256 is stored as 0 in the directory
data []byte
}
func main() {
small := flag.String("small", "", ".ico with the images below -split px")
large := flag.String("large", "", ".ico with the images from -split px up")
split := flag.Int("split", 64, "the first size taken from -large")
out := flag.String("out", "", "output .ico")
flag.Parse()
var images []image
for _, in := range []struct {
path string
keep func(int) bool
}{
{*small, func(s int) bool { return s < *split }},
{*large, func(s int) bool { return s >= *split }},
} {
all, err := read(in.path)
if err != nil {
fail(err)
}
for _, im := range all {
if in.keep(im.size) {
images = append(images, im)
}
}
}
sort.Slice(images, func(i, j int) bool { return images[i].size > images[j].size })
if err := os.WriteFile(*out, write(images), 0o644); err != nil {
fail(err)
}
}
func read(path string) ([]image, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if len(b) < 6 || binary.LittleEndian.Uint16(b[2:]) != 1 {
return nil, fmt.Errorf("%s: not an .ico file", path)
}
n := int(binary.LittleEndian.Uint16(b[4:]))
var images []image
for i := 0; i < n; i++ {
e := b[6+16*i:]
size := int(e[0])
if size == 0 {
size = 256
}
length := binary.LittleEndian.Uint32(e[8:])
offset := binary.LittleEndian.Uint32(e[12:])
data := b[offset : offset+length]
if !bytes.HasPrefix(data, []byte("\x89PNG")) {
return nil, fmt.Errorf("%s: the %d px image is not a PNG", path, size)
}
images = append(images, image{size, data})
}
return images, nil
}
func write(images []image) []byte {
var b bytes.Buffer
le := binary.LittleEndian
binary.Write(&b, le, [3]uint16{0, 1, uint16(len(images))})
offset := 6 + 16*len(images)
for _, im := range images {
dim := byte(im.size)
if im.size >= 256 {
dim = 0
}
binary.Write(&b, le, struct {
W, H, Colors, Reserved byte
Planes, BitCount uint16
Length, Offset uint32
}{dim, dim, 0, 0, 1, 32, uint32(len(im.data)), uint32(offset)})
offset += len(im.data)
}
for _, im := range images {
b.Write(im.data)
}
return b.Bytes()
}
func fail(err error) {
fmt.Fprintln(os.Stderr, "mkico:", err)
os.Exit(1)
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="io.github.dariuszjaniak.warp" version="{{VERSION}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -0,0 +1,18 @@
//go:build !production
package main
import (
"os"
"strconv"
)
// devBrowserArgs opens the Chrome DevTools Protocol on Windows when
// WARP_DESKTOP_REMOTE_DEBUGGING_PORT is set, so a script or Playwright can
// drive the app's webview. Production builds leave it out.
func devBrowserArgs() []string {
if p, err := strconv.Atoi(os.Getenv("WARP_DESKTOP_REMOTE_DEBUGGING_PORT")); err == nil && p > 0 {
return []string{"--remote-debugging-port=" + strconv.Itoa(p)}
}
return nil
}
@@ -0,0 +1,5 @@
//go:build production
package main
func devBrowserArgs() []string { return nil }
+163
View File
@@ -0,0 +1,163 @@
// Command warp-desktop is the Warp desktop client: the web client in a
// native window (Wails v3), talking to the homeserver directly.
//
// warp-desktop [-homeserver https://matrix.example.org]
package main
import (
_ "embed"
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/dariuszjaniak/warp/apps/desktop/internal/shell"
"github.com/dariuszjaniak/warp/apps/web/client"
)
// Set with -ldflags "-X main.version=...".
var version = "0.0.0-dev"
//go:embed appicon.png
var icon []byte
// appID names the app to the OS: the single-instance lock, and the
// directory the settings live in on Linux and macOS.
const appID = "io.github.dariuszjaniak.warp"
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "warp-desktop:", err)
os.Exit(1)
}
}
func run() error {
homeserver := flag.String("homeserver", os.Getenv("WARP_DESKTOP_HOMESERVER"),
"homeserver base URL for this run, instead of the one chosen in the app (WARP_DESKTOP_HOMESERVER)")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Println("warp-desktop", version)
return nil
}
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
slog.SetDefault(logger)
dirs, err := dataDirs()
if err != nil {
return err
}
files, built := client.FS()
if !built {
logger.Warn("the web client was not built into this binary; serving a placeholder page")
files = nil
}
var app *application.App
sh, err := shell.New(shell.Options{
Store: shell.NewStore(filepath.Join(dirs.config, "desktop.json")),
Homeserver: *homeserver,
Client: files,
Version: version,
Logger: logger,
OpenURL: func(u string) error { return app.Browser.OpenURL(u) },
})
if err != nil {
return err
}
var window *application.WebviewWindow
app = application.New(application.Options{
Name: "Warp",
Description: "A Matrix client",
Icon: icon,
Logger: logger,
Assets: application.AssetOptions{Handler: sh},
// The client's sessions and encryption keys are in the webview's
// storage; two instances on one store would both sync and both
// use the same one-time keys.
SingleInstance: &application.SingleInstanceOptions{
UniqueID: appID,
OnSecondInstanceLaunch: func(application.SecondInstanceData) {
if window != nil {
window.Restore()
window.Show()
window.Focus()
}
},
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: true,
},
Windows: application.WindowsOptions{
WebviewUserDataPath: dirs.webview,
AdditionalBrowserArgs: devBrowserArgs(),
},
})
window = app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "Warp",
Width: 1200,
Height: 800,
MinWidth: 360,
MinHeight: 480,
URL: "/",
})
app.Menu.SetApplicationMenu(appMenu(app, window))
return app.Run()
}
func appMenu(app *application.App, window *application.WebviewWindow) *application.Menu {
menu := app.NewMenu()
if runtime.GOOS == "darwin" {
menu.AddRole(application.AppMenu)
}
file := menu.AddSubmenu("File")
file.Add("Change homeserver…").OnClick(func(*application.Context) {
window.SetURL(shell.SetupPath)
})
file.AddSeparator()
if runtime.GOOS == "darwin" {
file.AddRole(application.CloseWindow)
} else {
file.AddRole(application.Quit)
}
menu.AddRole(application.EditMenu)
menu.AddRole(application.ViewMenu)
menu.AddRole(application.WindowMenu)
return menu
}
type dirs struct {
config string // settings
webview string // the webview's storage, on Windows
}
func dataDirs() (dirs, error) {
cfg, err := os.UserConfigDir()
if err != nil {
return dirs{}, err
}
d := dirs{config: filepath.Join(cfg, "Warp")}
if runtime.GOOS == "windows" {
// %LocalAppData%: the webview's storage is large and machine-local,
// so it doesn't belong in the roaming profile with the settings.
local, err := os.UserCacheDir()
if err != nil {
return dirs{}, err
}
d.webview = filepath.Join(local, "Warp", "WebView2")
}
return d, nil
}
+21
View File
@@ -0,0 +1,21 @@
module github.com/dariuszjaniak/warp/apps/desktop
go 1.26.4
require (
github.com/dariuszjaniak/warp/apps/web v0.0.0
github.com/wailsapp/wails/v3 v3.0.0-beta.27
)
require (
github.com/adrg/xdg v0.5.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.46.0 // indirect
)
// The web client and its host package come from this repository.
replace github.com/dariuszjaniak/warp/apps/web => ../web
+32
View File
@@ -0,0 +1,32 @@
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wailsapp/wails/v3 v3.0.0-beta.27 h1:GpjEHi/lutVxBk7QAIqwLnygKyAhg5qBhr6IfOe+Obc=
github.com/wailsapp/wails/v3 v3.0.0-beta.27/go.mod h1:5DR/EG5FHU8+BNV7tQYQ/rJMjoFsVlYpM+0gp1+gcTI=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+128
View File
@@ -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
}
+105
View File
@@ -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 }
+61
View File
@@ -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)
}
+321
View File
@@ -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>
`))
+257
View File
@@ -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)
}
}
+3 -2
View File
@@ -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
+3 -3
View File
@@ -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
+4
View File
@@ -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.
File renamed without changes.
+2 -2
View File
@@ -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=...".
@@ -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
@@ -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)
}
}
-4
View File
@@ -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.
+2 -2
View File
@@ -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.
+1 -2
View File
@@ -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)