diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 191c183..5ab3291 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -375,3 +375,58 @@ jobs: status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/_warp/admin/v1/stats \ -H "Authorization: Bearer $token")" test "$status" = "404" + + desktop: + name: Desktop client (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-latest] + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: apps/desktop/go.sum + + # Wails builds against GTK 4 and WebKitGTK 6.0 on Linux, with cgo. + - name: Install the Linux webview + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Build the web client + run: pnpm --filter @warp/web build + + - name: Verify formatting + if: runner.os == 'Linux' + working-directory: apps/desktop + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: Vet and test + working-directory: apps/desktop + run: | + go vet ./... + go test -count=1 ./... + + - name: Build + working-directory: apps/desktop + shell: bash + run: go build -tags production -o warp-desktop ./cmd/warp-desktop diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..2c3cee0 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,140 @@ +name: Desktop release + +# Builds the desktop client for Windows, Linux and macOS, the Windows +# installer, the Linux packages and the update manifest, and keeps them as +# one workflow artifact. Publishing is up to you: put the files and +# warp-desktop.json in one directory on a web server, at the URL in the +# DESKTOP_UPDATE_URL repository variable, which the builds check for +# updates. +# +# Start it by pushing a tag desktop-v1.2.3, or by hand with a version. +# Nothing is signed yet: the Windows build shows SmartScreen's warning and +# the macOS one opens only after "Open anyway" (see apps/desktop/README.md). + +on: + push: + tags: ["desktop-v*"] + workflow_dispatch: + inputs: + version: + description: Version, e.g. 1.2.3 + required: true + +permissions: + contents: read + +env: + GO_VERSION: "1.26" + DESKTOP_VERSION: ${{ inputs.version || github.ref_name }} + DESKTOP_UPDATE_URL: ${{ vars.DESKTOP_UPDATE_URL }} + +jobs: + build: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: windows-latest + goos: windows + - os: ubuntu-24.04 + goos: linux + - os: macos-latest + goos: darwin + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: apps/desktop/go.sum + + - name: Version + run: | + v="${DESKTOP_VERSION#desktop-v}" + echo "DESKTOP_VERSION=${v#v}" >> "$GITHUB_ENV" + + - name: Install the Linux webview and packaging tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Windows binary and installer + if: runner.os == 'Windows' + # The same steps as `make desktop-windows-installer`; the runner has + # NSIS but no make. + run: | + pnpm --filter @warp/web build + (cd apps/desktop && go build -tags production -o ../../bin/warp-desktop.exe \ + -ldflags "-X main.version=$DESKTOP_VERSION -X main.appVersion=$DESKTOP_VERSION -X main.updateURL=$DESKTOP_UPDATE_URL -H windowsgui" \ + ./cmd/warp-desktop) + (cd apps/desktop/build/windows && makensis -V2 -DVERSION="$DESKTOP_VERSION" \ + -DEXE=../../../../bin/warp-desktop.exe -DOUT=../../../../bin/warp-desktop-setup.exe installer.nsi) + mkdir -p dist + cp bin/warp-desktop.exe dist/warp-desktop-windows-amd64.exe + cp bin/warp-desktop-setup.exe "dist/warp-desktop-$DESKTOP_VERSION-setup.exe" + + - name: Linux binary and packages + if: runner.os == 'Linux' + run: | + make desktop-linux DESKTOP_VERSION="$DESKTOP_VERSION" DESKTOP_UPDATE_URL="$DESKTOP_UPDATE_URL" + mkdir -p dist + cp bin/warp-desktop dist/warp-desktop-linux-amd64 + cp bin/*.deb bin/*.rpm bin/*.pkg.tar.zst dist/ + + - name: macOS app + if: runner.os == 'macOS' + run: | + make desktop-macos DESKTOP_VERSION="$DESKTOP_VERSION" DESKTOP_UPDATE_URL="$DESKTOP_UPDATE_URL" + mkdir -p dist + cp bin/Warp.app.zip dist/Warp-darwin-arm64.app.zip + + - uses: actions/upload-artifact@v4 + with: + name: desktop-${{ matrix.goos }} + path: dist/ + if-no-files-found: error + + manifest: + name: Update manifest + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: apps/desktop/go.sum + + - uses: actions/download-artifact@v4 + with: + pattern: desktop-* + path: dist + merge-multiple: true + + - name: Write warp-desktop.json + run: | + v="${DESKTOP_VERSION#desktop-v}" + go -C apps/desktop run ./build/manifest -version "${v#v}" -out "$PWD/dist/warp-desktop.json" \ + windows/amd64="$PWD/dist/warp-desktop-windows-amd64.exe" \ + linux/amd64="$PWD/dist/warp-desktop-linux-amd64" \ + darwin/arm64="$PWD/dist/Warp-darwin-arm64.app.zip" + cat dist/warp-desktop.json + + - uses: actions/upload-artifact@v4 + with: + name: warp-desktop-release + path: dist/ diff --git a/Makefile b/Makefile index 4b6362d..aa65bf8 100644 --- a/Makefile +++ b/Makefile @@ -173,9 +173,18 @@ else DESKTOP_LDFLAGS := endif +# A build checks for updates when DESKTOP_UPDATE_URL names its update +# manifest (see desktop-manifest); DESKTOP_VERSION is the version it +# compares. DESKTOP_UPDATE_KEY, a PEM public key, makes it insist on signed +# releases. +DESKTOP_UPDATE_URL ?= +DESKTOP_UPDATE_KEY ?= +DESKTOP_X := -X main.version=$(VERSION) -X main.appVersion=$(DESKTOP_VERSION) \ + -X 'main.updateURL=$(DESKTOP_UPDATE_URL)' -X 'main.updateKey=$(DESKTOP_UPDATE_KEY)' + .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 + cd $(DESKTOP_DIR) && $(GO) build -tags production -ldflags "$(DESKTOP_X) $(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 @@ -225,6 +234,8 @@ desktop-macos: desktop ## Build bin/Warp.app (on 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) + # The updater's macOS artifact: the bundle, zipped. + cd $(BIN_DIR) && rm -f Warp.app.zip && ditto -c -k --keepParent Warp.app Warp.app.zip # Run on Linux: the binary needs cgo. Packages land in bin/. .PHONY: desktop-linux @@ -236,6 +247,30 @@ desktop-linux: desktop ## Build Linux packages of the desktop client (on Linux) -config build/linux/nfpm.yaml -out ../../$(BIN_DIR) || exit 1; \ done +# Run on Windows with NSIS (makensis) on the PATH: a per-user installer, +# bin/warp-desktop-setup.exe. DESKTOP_SIGN, when set, signs the installer +# and uninstaller: a command with %1 for the file. +MAKENSIS ?= makensis +DESKTOP_SIGN ?= + +.PHONY: desktop-windows-installer +desktop-windows-installer: desktop ## Build the Windows installer, bin/warp-desktop-setup.exe + cd $(DESKTOP_DIR)/build/windows && $(MAKENSIS) -V2 -DVERSION=$(DESKTOP_VERSION) \ + -DEXE=../../../../$(BIN_DIR)/warp-desktop.exe -DOUT=../../../../$(BIN_DIR)/warp-desktop-setup.exe \ + $(if $(DESKTOP_SIGN),"-DSIGN=$(DESKTOP_SIGN)") installer.nsi + +# The update manifest for the binaries in DESKTOP_ARTIFACTS (os/arch=file, +# space separated), written to bin/warp-desktop.json. Host it with the +# files, at DESKTOP_UPDATE_URL. +DESKTOP_ARTIFACTS ?= $(GOOS_HOST)/$(GOARCH_HOST)=$(BIN_DIR)/warp-desktop$(EXE) +GOOS_HOST = $(shell $(GO) env GOOS) +GOARCH_HOST = $(shell $(GO) env GOARCH) + +.PHONY: desktop-manifest +desktop-manifest: ## Write the desktop update manifest, bin/warp-desktop.json + $(GO) -C $(DESKTOP_DIR) run ./build/manifest -version $(DESKTOP_VERSION) -out $(CURDIR)/$(BIN_DIR)/warp-desktop.json \ + $(foreach a,$(DESKTOP_ARTIFACTS),$(firstword $(subst =, ,$(a)))=$(CURDIR)/$(lastword $(subst =, ,$(a)))) + # The Android app. Needs Flutter, the Android SDK, and Rust with the Android # targets (flutter_vodozemac compiles the crypto from source); see # apps/mobile/README.md. The APKs land in bin/. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1bcd152..cfdf9c3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -38,12 +38,34 @@ learned from the homeserver at run time and can't be listed in advance. 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. +- **System notifications.** New messages and incoming calls show as the + system's own notifications (Windows toasts, Notification Center, the + freedesktop daemon), one per room; clicking one opens the room. The + client decides what notifies, by the account's push rules + (`apps/web/src/stores/notifications.ts`), and hands it to the shell + through `window.warpDesktop`, which `/_desktop/bridge.js` adds; the shell + shows it with Wails' notifications service. On macOS they need the + bundle (`make desktop-macos`), not the bare binary. +- **The tray and the unread count.** A tray icon (menu-bar icon on macOS) + with Open and Quit. Closing the window hides it and the app keeps + running there, so notifications still arrive; the first time, a + notification says so. **File → Quit** (Ctrl+Q) ends it. The window + title and the tray's tooltip follow the page title, and the number of + unread rooms goes on the taskbar button (Windows) or the Dock icon. On a + Linux desktop with no tray (GNOME without an AppIndicator extension), + starting the app again brings the hidden window back. +- **Updates.** A build that knows its update manifest (see + [Updates](#updates)) checks a minute after it starts and every six hours, + quietly; a newer version brings a notification, and clicking it, or + **Help → Check for updates…**, opens Wails' update window, which + downloads the new binary, checks its SHA-256 (and signature, when the + build pins a key), swaps it in and restarts. ## 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) | +| The chosen homeserver, whether the tray hint was shown | `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 @@ -75,6 +97,8 @@ make desktop # builds the web client, then bin/warp-desktop | `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 | +| `make desktop-windows-installer` | on Windows, with NSIS: `bin/warp-desktop-setup.exe` | +| `make desktop-manifest` | the update manifest, `bin/warp-desktop.json` | A development build (one without `-tags production`) also opens the Chrome DevTools Protocol when `WARP_DESKTOP_REMOTE_DEBUGGING_PORT` is set, on @@ -92,6 +116,15 @@ Linux packaging; the Makefile runs it with `go run` at the 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 installer.** `make desktop-windows-installer` turns the + binary into `bin/warp-desktop-setup.exe` with NSIS + (`build/windows/installer.nsi`). It installs for the current user, in + `%LocalAppData%\Programs\Warp` with a Start menu entry and an entry in + Installed apps, so it needs no administrator rights and the updater can + replace the executable. It points at the WebView2 download when the + runtime is missing. Uninstalling keeps your settings and the webview's + storage, so a reinstall stays signed in. `DESKTOP_SIGN` signs the + installer and uninstaller, given a signing command with `%1` for the file. - **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` @@ -103,7 +136,8 @@ package; `-version` prints the build's git version. `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` + signs the bundle ad hoc, and zips it as `bin/Warp.app.zip` for the + updater. 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 @@ -121,24 +155,52 @@ package; `-version` prints the build's git version. brand kit (`DESIGN/warp-brand/logo-kit`, not in git): `symbol/warp-symbol-app-icon-1024.png` and `web/favicon.ico`. +## Updates + +The app updates itself from a Wails update manifest: one JSON document +with the latest version and, per platform, the file to download and its +SHA-256. Any static web server can host it, next to the files it lists. + +- Build with `DESKTOP_UPDATE_URL` (the manifest's URL) and + `DESKTOP_VERSION` (the version this build is, `1.2.3`). A build without + an update URL never checks, and has no **Help** menu. +- `make desktop-manifest DESKTOP_VERSION=1.2.3 + DESKTOP_ARTIFACTS="windows/amd64=bin/warp-desktop.exe ..."` writes + `bin/warp-desktop.json`; the artifact URLs are the files' names, + relative to the manifest. +- Artifacts are the bare binaries for Windows and Linux, and + `Warp.app.zip` for macOS. The Windows one is `warp-desktop.exe`, not the + installer: the updater swaps the executable in place, which the per-user + install allows. A Linux install from a package (in `/usr`) doesn't + update itself; the package manager does that. +- `DESKTOP_UPDATE_KEY`, a PEM public key (Ed25519 or ECDSA P-256), makes + the build insist on releases signed with its private key. Without one, a + release is checked by its digest only, so the manifest's host is trusted. + +The **Desktop release** workflow (`.github/workflows/desktop-release.yml`, +on a `desktop-v1.2.3` tag or by hand) builds Windows, Linux and macOS, +the installer and the Linux packages, and writes the manifest; set the +`DESKTOP_UPDATE_URL` repository variable to where you will publish them. +It keeps everything as one workflow artifact; publishing is yours to do. + ## 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 | +| `cmd/warp-desktop/` | the binary: the Wails app, window, menu and single-instance lock; notifications, the tray and the badge (`desktop.go`); updates (`update.go`) | +| `internal/shell/` | the webview's `http.Handler`: the client through `apps/web/host`, the first-run page and discovery, the settings file, links, the bridge the client's notifications and title go through | +| `build/` | packaging inputs: icons, the Windows manifest and version info, the NSIS installer script, `Info.plist`, the nfpm config; `build/manifest`, the update manifest writer | ## 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. +- Signing, which needs certificates: an Authenticode-signed executable + and installer (without it SmartScreen warns on first run), a Developer + ID signed and notarized macOS `.app` in a `.dmg` (ad-hoc signed, it + opens elsewhere only after "Open anyway"), and a signing key for update + releases. The hooks are there: `DESKTOP_SIGN`, `DESKTOP_UPDATE_KEY`. +- A universal (arm64 + amd64) macOS build, an AppImage or Flatpak. - 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 diff --git a/apps/desktop/build/manifest/main.go b/apps/desktop/build/manifest/main.go new file mode 100644 index 0000000..b9172a9 --- /dev/null +++ b/apps/desktop/build/manifest/main.go @@ -0,0 +1,116 @@ +// Command manifest writes the update manifest the desktop app checks +// (the Wails endpoint provider's format, schemaVersion 1): the version and +// one artifact per platform, with its SHA-256. +// +// go run ./build/manifest -version 1.2.3 -out bin/warp-desktop.json \ +// windows/amd64=bin/warp-desktop.exe linux/amd64=bin/warp-desktop-linux-amd64 +// +// Artifact URLs are the files' names, which the app resolves against the +// manifest's own URL: host the manifest and the files in one directory. +// -base-url makes them absolute instead. +package main + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +type artifact struct { + URL string `json:"url"` + Filename string `json:"filename"` + Size int64 `json:"size"` + Platform string `json:"platform"` + Arch string `json:"arch"` + DigestAlgo string `json:"digestAlgo"` + Digest string `json:"digest"` +} + +type manifest struct { + SchemaVersion int `json:"schemaVersion"` + Version string `json:"version"` + Name string `json:"name,omitempty"` + Notes string `json:"notes,omitempty"` + PublishedAt string `json:"publishedAt"` + Artifacts []artifact `json:"artifacts"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "manifest:", err) + os.Exit(1) + } +} + +func run() error { + version := flag.String("version", "", "the release's version, e.g. 1.2.3 (required)") + notes := flag.String("notes", "", "release notes shown in the update window") + baseURL := flag.String("base-url", "", "make artifact URLs absolute under this URL") + out := flag.String("out", "", "where to write the manifest (default: standard output)") + flag.Parse() + if *version == "" || flag.NArg() == 0 { + return errors.New("usage: manifest -version X [-notes T] [-base-url U] [-out F] os/arch=file ...") + } + m := manifest{ + SchemaVersion: 1, + Version: strings.TrimPrefix(*version, "v"), + Name: "Warp " + strings.TrimPrefix(*version, "v"), + Notes: *notes, + PublishedAt: time.Now().UTC().Format(time.RFC3339), + } + for _, arg := range flag.Args() { + target, path, ok := strings.Cut(arg, "=") + goos, goarch, ok2 := strings.Cut(target, "/") + if !ok || !ok2 || path == "" { + return fmt.Errorf("%q: want os/arch=file", arg) + } + a, err := describe(path) + if err != nil { + return err + } + a.Platform, a.Arch = goos, goarch + if *baseURL != "" { + a.URL = strings.TrimRight(*baseURL, "/") + "/" + a.Filename + } + m.Artifacts = append(m.Artifacts, a) + } + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + if *out == "" { + _, err = os.Stdout.Write(b) + return err + } + return os.WriteFile(*out, b, 0o644) +} + +func describe(path string) (artifact, error) { + f, err := os.Open(path) + if err != nil { + return artifact{}, err + } + defer f.Close() + h := sha256.New() + n, err := io.Copy(h, f) + if err != nil { + return artifact{}, err + } + name := filepath.Base(path) + return artifact{ + URL: name, + Filename: name, + Size: n, + DigestAlgo: "sha256", + Digest: base64.StdEncoding.EncodeToString(h.Sum(nil)), + }, nil +} diff --git a/apps/desktop/build/windows/installer.nsi b/apps/desktop/build/windows/installer.nsi new file mode 100644 index 0000000..240b1d3 --- /dev/null +++ b/apps/desktop/build/windows/installer.nsi @@ -0,0 +1,103 @@ +; The Windows installer for warp-desktop: `make desktop-windows-installer` +; (needs NSIS's makensis). Installs for the current user, under +; %LocalAppData%\Programs\Warp, so it needs no administrator rights and the +; app's updater can replace its own executable. +; +; makensis -DVERSION=1.2.3 -DEXE=..\..\..\..\bin\warp-desktop.exe -DOUT=..\..\..\..\bin\warp-desktop-setup.exe installer.nsi +; +; Signing: set SIGN to a command that signs the file named by %1, e.g. +; -DSIGN="signtool sign /fd sha256 /tr http://timestamp.digicert.com /td sha256 /a %1" + +Unicode true +ManifestDPIAware true +RequestExecutionLevel user +SetCompressor /SOLID lzma + +!ifndef VERSION + !define VERSION "0.0.0" +!endif +!ifndef EXE + !error "pass -DEXE=path\to\warp-desktop.exe" +!endif +!ifndef OUT + !define OUT "warp-desktop-setup.exe" +!endif + +!define NAME "Warp" +!define APPEXE "warp-desktop.exe" +!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Warp" + +!ifdef SIGN + !finalize '${SIGN}' + !uninstfinalize '${SIGN}' +!endif + +Name "${NAME}" +OutFile "${OUT}" +InstallDir "$LOCALAPPDATA\Programs\${NAME}" +InstallDirRegKey HKCU "${UNINSTKEY}" "InstallLocation" + +VIProductVersion "${VERSION}.0" +VIFileVersion "${VERSION}.0" +VIAddVersionKey "ProductName" "${NAME}" +VIAddVersionKey "FileDescription" "${NAME} installer" +VIAddVersionKey "ProductVersion" "${VERSION}" +VIAddVersionKey "FileVersion" "${VERSION}" + +!include "MUI2.nsh" +!define MUI_ICON "icon.ico" +!define MUI_UNICON "icon.ico" +!define MUI_ABORTWARNING +!define MUI_FINISHPAGE_RUN "$INSTDIR\${APPEXE}" +!define MUI_FINISHPAGE_RUN_TEXT "Open Warp" + +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "English" + +; The app runs in WebView2, which Windows 11 has. On an older Windows +; without it, the installer says where to get it rather than bundling it. +Function .onInit + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + StrCmp $0 "" 0 found + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + StrCmp $0 "" 0 found + MessageBox MB_YESNO|MB_ICONEXCLAMATION "Warp needs the Microsoft Edge WebView2 Runtime, which isn't installed.$\n$\nOpen the download page?" IDNO found + ExecShell "open" "https://developer.microsoft.com/microsoft-edge/webview2/" + found: +FunctionEnd + +Section "Warp" + ; A running Warp holds its executable open. + nsExec::Exec 'taskkill /IM ${APPEXE} /F' + SetOutPath "$INSTDIR" + File "/oname=${APPEXE}" "${EXE}" + WriteUninstaller "$INSTDIR\uninstall.exe" + + CreateShortcut "$SMPROGRAMS\${NAME}.lnk" "$INSTDIR\${APPEXE}" + + WriteRegStr HKCU "${UNINSTKEY}" "DisplayName" "${NAME}" + WriteRegStr HKCU "${UNINSTKEY}" "DisplayVersion" "${VERSION}" + WriteRegStr HKCU "${UNINSTKEY}" "DisplayIcon" "$INSTDIR\${APPEXE}" + WriteRegStr HKCU "${UNINSTKEY}" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "${UNINSTKEY}" "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegStr HKCU "${UNINSTKEY}" "Publisher" "${NAME}" + WriteRegDWORD HKCU "${UNINSTKEY}" "NoModify" 1 + WriteRegDWORD HKCU "${UNINSTKEY}" "NoRepair" 1 +SectionEnd + +; Uninstalling leaves %AppData%\Warp (the chosen homeserver) and the +; webview's storage in %LocalAppData%\Warp\WebView2 (sessions and +; encryption keys), so reinstalling keeps you signed in. +Section "Uninstall" + nsExec::Exec 'taskkill /IM ${APPEXE} /F' + Delete "$SMPROGRAMS\${NAME}.lnk" + Delete "$INSTDIR\${APPEXE}" + Delete "$INSTDIR\uninstall.exe" + RMDir "$INSTDIR" + DeleteRegKey HKCU "${UNINSTKEY}" +SectionEnd diff --git a/apps/desktop/cmd/warp-desktop/desktop.go b/apps/desktop/cmd/warp-desktop/desktop.go new file mode 100644 index 0000000..275022f --- /dev/null +++ b/apps/desktop/cmd/warp-desktop/desktop.go @@ -0,0 +1,211 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "regexp" + "strconv" + "sync" + "sync/atomic" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/wailsapp/wails/v3/pkg/services/dock" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/dariuszjaniak/warp/apps/desktop/internal/shell" +) + +// desktop is what the app adds around the window: system notifications, +// the tray icon, the unread count on the taskbar or Dock, and closing to +// the tray. A chat app that quits when its window closes misses the +// messages it was meant to tell you about. +type desktop struct { + app *application.App + window *application.WebviewWindow + tray *application.SystemTray + notes *notifications.NotificationService + dock *dock.DockService + store *shell.Store + logger *slog.Logger + + quitting atomic.Bool + // onUpdate installs an update: an update notification was clicked. + onUpdate func() + + mu sync.Mutex + title string + unread int +} + +func newDesktop(store *shell.Store, logger *slog.Logger) *desktop { + return &desktop{ + notes: notifications.New(), + dock: dock.New(), + store: store, + logger: logger, + } +} + +// services are registered with the app; they start and stop with it. +func (d *desktop) services() []application.Service { + return []application.Service{application.NewService(d.notes), application.NewService(d.dock)} +} + +// shouldQuit is the app's ShouldQuit: every way of quitting (the menus, +// Cmd+Q, signing out of the OS) passes here, and from then on closing the +// window closes it. +func (d *desktop) shouldQuit() bool { + d.quitting.Store(true) + return true +} + +func (d *desktop) quit() { + d.quitting.Store(true) + d.app.Quit() +} + +// attach sets up the tray and window behaviour, once both exist. +func (d *desktop) attach(app *application.App, window *application.WebviewWindow, icon []byte) { + d.app = app + d.window = window + + menu := app.NewMenu() + menu.Add("Open Warp").OnClick(func(*application.Context) { d.show() }) + menu.AddSeparator() + menu.Add("Quit Warp").OnClick(func(*application.Context) { d.quit() }) + d.tray = app.SystemTray.New().SetIcon(icon).SetMenu(menu).OnClick(d.show) + d.tray.SetTooltip("Warp") + + window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + if d.quitting.Load() { + return + } + e.Cancel() + window.Hide() + d.hintOnce() + }) + // The Dock icon of a running app whose window is hidden. + app.Event.OnApplicationEvent(events.Mac.ApplicationShouldHandleReopen, func(*application.ApplicationEvent) { d.show() }) + + d.notes.OnNotificationResponse(d.onNotificationResponse) +} + +func (d *desktop) show() { + d.window.Show() + d.window.Restore() + d.window.Focus() +} + +// hintOnce says, the first time the window is closed, where the app went. +func (d *desktop) hintOnce() { + st, err := d.store.Load() + if err != nil || st.TrayHintShown { + return + } + st.TrayHintShown = true + if err := d.store.Save(st); err != nil { + d.logger.Warn("can't save the settings", "err", err) + } + err = d.notes.SendNotification(notifications.NotificationOptions{ + ID: "warp-tray-hint", + Title: "Warp is still running", + Body: "It keeps checking for messages. Open it or quit from the tray icon.", + Sound: ¬ifications.NotificationSound{Silent: true}, + }) + if err != nil { + d.logger.Warn("can't show a notification", "err", err) + } +} + +// notify is the shell's Notify: one notification per room, replacing the +// room's last one where the system can. No ThreadID: Windows shows it as a +// header, and a room ID is no title. +func (d *desktop) notify(n shell.Notification) error { + return d.notes.SendNotification(notifications.NotificationOptions{ + ID: n.ID, + Title: n.Title, + Body: n.Body, + Data: map[string]any{"roomId": n.RoomID}, + }) +} + +// onNotificationResponse brings the window forward and opens the room of +// the notification that was clicked. +func (d *desktop) onNotificationResponse(r notifications.NotificationResult) { + if r.Error != nil { + d.logger.Warn("notification response", "err", r.Error) + return + } + if action, _ := r.Response.UserInfo["action"].(string); action == "update" && d.onUpdate != nil { + d.onUpdate() + return + } + d.show() + roomID, _ := r.Response.UserInfo["roomId"].(string) + if roomID == "" { + return + } + detail, _ := json.Marshal(roomID) + d.window.ExecJS(fmt.Sprintf(`window.dispatchEvent(new CustomEvent('warp-desktop:open-room', { detail: %s }))`, detail)) +} + +// notifyUpdate says that a newer version is ready to install. +func (d *desktop) notifyUpdate(version string) { + err := d.notes.SendNotification(notifications.NotificationOptions{ + ID: "warp-update", + Title: "Warp " + version + " is available", + Body: "Click to install it, or use Help → Check for updates.", + Data: map[string]any{"action": "update"}, + }) + if err != nil { + d.logger.Warn("can't show a notification", "err", err) + } +} + +// unreadPrefix is how the client's page title counts unread rooms: "(3) Room · Warp". +var unreadPrefix = regexp.MustCompile(`^\((\d+)\) `) + +// unreadIn returns the unread count in a page title, 0 when it has none. +func unreadIn(title string) int { + m := unreadPrefix.FindStringSubmatch(title) + if m == nil { + return 0 + } + n, _ := strconv.Atoi(m[1]) + return n +} + +// setTitle is the shell's SetTitle: the page's title goes to the window +// and the tray, its unread count to the taskbar or Dock. +func (d *desktop) setTitle(title string) { + if title == "" { + title = "Warp" + } + n := unreadIn(title) + d.mu.Lock() + changed, countChanged := title != d.title, n != d.unread + d.title, d.unread = title, n + d.mu.Unlock() + if changed { + d.window.SetTitle(title) + d.tray.SetTooltip(title) + } + if !countChanged { + return + } + var err error + if n == 0 { + err = d.dock.RemoveBadge() + } else { + label := strconv.Itoa(n) + if n > 99 { + label = "99+" + } + err = d.dock.SetBadge(label) + } + if err != nil { + d.logger.Debug("can't set the badge", "err", err) + } +} diff --git a/apps/desktop/cmd/warp-desktop/main.go b/apps/desktop/cmd/warp-desktop/main.go index 123f0d1..2249e4f 100644 --- a/apps/desktop/cmd/warp-desktop/main.go +++ b/apps/desktop/cmd/warp-desktop/main.go @@ -61,14 +61,18 @@ func run() error { files = nil } + store := shell.NewStore(filepath.Join(dirs.config, "desktop.json")) + d := newDesktop(store, logger) var app *application.App sh, err := shell.New(shell.Options{ - Store: shell.NewStore(filepath.Join(dirs.config, "desktop.json")), + Store: store, Homeserver: *homeserver, Client: files, Version: version, Logger: logger, OpenURL: func(u string) error { return app.Browser.OpenURL(u) }, + Notify: d.notify, + SetTitle: d.setTitle, }) if err != nil { return err @@ -81,6 +85,8 @@ func run() error { Icon: icon, Logger: logger, Assets: application.AssetOptions{Handler: sh}, + Services: d.services(), + ShouldQuit: d.shouldQuit, // 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. @@ -113,12 +119,18 @@ func run() error { URL: "/", }) - app.Menu.SetApplicationMenu(appMenu(app, window)) + d.attach(app, window, icon) + updates := setupUpdates(app, logger) + if updates { + d.onUpdate = func() { checkForUpdates(app, logger) } + watchUpdates(app, d, logger) + } + app.Menu.SetApplicationMenu(appMenu(app, window, d, updates, logger)) return app.Run() } -func appMenu(app *application.App, window *application.WebviewWindow) *application.Menu { +func appMenu(app *application.App, window *application.WebviewWindow, d *desktop, updates bool, logger *slog.Logger) *application.Menu { menu := app.NewMenu() if runtime.GOOS == "darwin" { menu.AddRole(application.AppMenu) @@ -131,11 +143,16 @@ func appMenu(app *application.App, window *application.WebviewWindow) *applicati if runtime.GOOS == "darwin" { file.AddRole(application.CloseWindow) } else { - file.AddRole(application.Quit) + // Closing the window keeps the app in the tray; this ends it. + file.Add("Quit").SetAccelerator("CmdOrCtrl+Q").OnClick(func(*application.Context) { d.quit() }) } menu.AddRole(application.EditMenu) menu.AddRole(application.ViewMenu) menu.AddRole(application.WindowMenu) + if updates { + help := menu.AddSubmenu("Help") + help.Add("Check for updates…").OnClick(func(*application.Context) { checkForUpdates(app, logger) }) + } return menu } diff --git a/apps/desktop/cmd/warp-desktop/update.go b/apps/desktop/cmd/warp-desktop/update.go new file mode 100644 index 0000000..eae6f9e --- /dev/null +++ b/apps/desktop/cmd/warp-desktop/update.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "log/slog" + "os" + "runtime" + "strings" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/updater" + "github.com/wailsapp/wails/v3/pkg/updater/providers/endpoint" +) + +// Updates come from a Wails update manifest (the endpoint provider): one +// JSON document listing the latest version and a binary per platform, +// each with its SHA-256. `make desktop-manifest` writes it next to the +// binaries; any static web server can host the lot. +// +// A build checks for updates only when it is told where to look, with +// -ldflags "-X main.updateURL=https://…/warp-desktop.json -X main.appVersion=1.2.3" +// (the Makefile's DESKTOP_UPDATE_URL and DESKTOP_VERSION). updateKey, when +// set, is the PEM public key that release signatures must verify against; +// without one a release is checked by its digest only. +var ( + updateURL string + appVersion string + updateKey string +) + +// A running app checks a minute after it starts, then this often. +const ( + updateDelay = time.Minute + updateInterval = 6 * time.Hour +) + +// setupUpdates configures app.Updater; false when this build has no update +// source, and there is nothing to check. +func setupUpdates(app *application.App, logger *slog.Logger) bool { + if updateURL == "" || appVersion == "" { + return false + } + // A Linux package's binary is the package manager's to replace. + if exe, err := os.Executable(); err == nil && runtime.GOOS == "linux" && strings.HasPrefix(exe, "/usr/") { + logger.Info("updates come from the package manager", "path", exe) + return false + } + p, err := endpoint.New(endpoint.Config{URL: updateURL}) + if err != nil { + logger.Warn("updates are off", "err", err) + return false + } + // No CheckInterval: the updater's own polling opens its window on every + // check, up to date or not. watchUpdates checks quietly instead. + cfg := updater.Config{ + CurrentVersion: appVersion, + Providers: []updater.Provider{p}, + } + if updateKey != "" { + cfg.PublicKey = []byte(updateKey) + } + if err := app.Updater.Init(cfg); err != nil { + logger.Warn("updates are off", "err", err) + return false + } + app.Event.On(updater.EventError, func(e *application.CustomEvent) { + logger.Warn("update", "err", e.Data) + }) + return true +} + +// checkForUpdates is the menu's "Check for updates…": the updater's own +// window says whether there is one and installs it. +func checkForUpdates(app *application.App, logger *slog.Logger) { + go func() { + if err := app.Updater.CheckAndInstall(context.Background()); err != nil { + logger.Warn("update", "err", err) + } + }() +} + +// watchUpdates checks for a newer version in the background, and says so +// with a notification once per version; clicking it starts the install. +func watchUpdates(app *application.App, d *desktop, logger *slog.Logger) { + go func() { + time.Sleep(updateDelay) + told := "" + for { + rel, err := app.Updater.Check(context.Background()) + if err != nil { + logger.Warn("can't check for updates", "err", err) + } else if rel != nil && rel.Version != told { + told = rel.Version + d.notifyUpdate(rel.Version) + } + time.Sleep(updateInterval) + } + }() +} diff --git a/apps/desktop/internal/shell/bridge.go b/apps/desktop/internal/shell/bridge.go new file mode 100644 index 0000000..e59c7fc --- /dev/null +++ b/apps/desktop/internal/shell/bridge.go @@ -0,0 +1,135 @@ +package shell + +import ( + "encoding/json" + "mime" + "net/http" + "strings" + "unicode/utf8" +) + +// bridgeScriptPath serves bridgeScript, which withDesktopScripts adds to +// the client's index.html next to the link script. +const bridgeScriptPath = "/_desktop/bridge.js" + +const ( + // notifyPath shows a system notification: the client's notifications + // store calls window.warpDesktop.notify for new messages and calls. + notifyPath = "/_desktop/notify" + // titlePath takes the page's title, which counts unread rooms, for the + // window title and the tray icon's tooltip. + titlePath = "/_desktop/title" +) + +// bridgeScript is what the client finds of the desktop app: window.warpDesktop +// (see apps/web/src/stores/notifications.ts), and the page title sent along +// whenever it changes. A click on a notification comes back as a +// "warp-desktop:open-room" event, dispatched by the app. +const bridgeScript = `(() => { + if (window.warpDesktop) return + const post = (path, body) => fetch(path, { + method: 'POST', credentials: 'omit', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => {}) + window.warpDesktop = { + notify: (n) => post('/_desktop/notify', { id: n.id, title: n.title, body: n.body, roomId: n.roomId }), + } + let last = null + const sendTitle = () => { + if (document.title === last) return + last = document.title + post('/_desktop/title', { title: last }) + } + new MutationObserver(sendTitle).observe(document.head, { subtree: true, childList: true, characterData: true }) + document.addEventListener('DOMContentLoaded', sendTitle) +})() +` + +// Notification is a system notification the client asks for. +type Notification struct { + // ID replaces an earlier notification with the same ID: one per room. + ID string `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + RoomID string `json:"roomId"` +} + +// Longest title and body shown; the system cuts them shorter anyway. +const ( + maxTitle = 200 + maxBody = 1000 +) + +// readJSON decodes a small JSON body. It insists on the JSON content type, +// which a page of another origin can't send without a CORS preflight that +// the shell never answers. +func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return false + } + if ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); ct != "application/json" { + http.Error(w, "expected application/json", http.StatusUnsupportedMediaType) + return false + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(v); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return false + } + return true +} + +func (s *Shell) serveNotify(w http.ResponseWriter, r *http.Request) { + var n Notification + if !readJSON(w, r, &n) { + return + } + n.Title = clip(n.Title, maxTitle) + n.Body = clip(n.Body, maxBody) + if n.ID == "" || n.Title == "" { + http.Error(w, "id and title are required", http.StatusBadRequest) + return + } + if s.opts.Notify == nil { + w.WriteHeader(http.StatusNoContent) + return + } + if err := s.opts.Notify(n); err != nil { + s.opts.Logger.Warn("can't show a notification", "err", err) + http.Error(w, "can't show the notification", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Shell) serveTitle(w http.ResponseWriter, r *http.Request) { + var body struct { + Title string `json:"title"` + } + if !readJSON(w, r, &body) { + return + } + if s.opts.SetTitle != nil { + s.opts.SetTitle(clip(body.Title, maxTitle)) + } + w.WriteHeader(http.StatusNoContent) +} + +// clip trims s and cuts it to at most n runes, without control characters. +func clip(s string, n int) string { + s = strings.Map(func(r rune) rune { + if r == '\n' || r == '\t' { + return ' ' + } + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, strings.TrimSpace(s)) + if utf8.RuneCountInString(s) <= n { + return s + } + return string([]rune(s)[:n-1]) + "…" +} diff --git a/apps/desktop/internal/shell/bridge_test.go b/apps/desktop/internal/shell/bridge_test.go new file mode 100644 index 0000000..edd58ce --- /dev/null +++ b/apps/desktop/internal/shell/bridge_test.go @@ -0,0 +1,60 @@ +package shell + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func postJSON(h http.Handler, target, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestNotify(t *testing.T) { + var got []Notification + s, _ := newShell(t, Options{Notify: func(n Notification) error { got = append(got, n); return nil }}) + + rec := postJSON(s, notifyPath, `{"id":"!a:x","title":" Room\u0007 ","body":"Ann:\nhi","roomId":"!a:x"}`) + if rec.Code != http.StatusNoContent || len(got) != 1 { + t.Fatalf("notify: %d, %v", rec.Code, got) + } + if want := (Notification{ID: "!a:x", Title: "Room", Body: "Ann: hi", RoomID: "!a:x"}); got[0] != want { + t.Errorf("got %+v, want %+v", got[0], want) + } + + // Missing fields, the wrong method and a body that isn't JSON are refused. + if rec := postJSON(s, notifyPath, `{"id":"","title":"x"}`); rec.Code != http.StatusBadRequest { + t.Errorf("no id: %d", rec.Code) + } + if rec := do(s, http.MethodGet, notifyPath, ""); rec.Code != http.StatusMethodNotAllowed { + t.Errorf("GET: %d", rec.Code) + } + if rec := do(s, http.MethodPost, notifyPath, `{"id":"a","title":"b"}`); rec.Code != http.StatusUnsupportedMediaType { + t.Errorf("no content type: %d", rec.Code) + } + if len(got) != 1 { + t.Errorf("refused requests notified: %v", got) + } +} + +func TestTitle(t *testing.T) { + var title string + s, _ := newShell(t, Options{SetTitle: func(v string) { title = v }}) + if rec := postJSON(s, titlePath, `{"title":"(2) Warp"}`); rec.Code != http.StatusNoContent || title != "(2) Warp" { + t.Errorf("title: %d %q", rec.Code, title) + } +} + +func TestClip(t *testing.T) { + if got := clip(" abcdef ", 4); got != "abc…" { + t.Errorf("clip: %q", got) + } + if got := clip("żółw", 4); got != "żółw" { + t.Errorf("clip: %q", got) + } +} diff --git a/apps/desktop/internal/shell/links.go b/apps/desktop/internal/shell/links.go index e563f4e..983b050 100644 --- a/apps/desktop/internal/shell/links.go +++ b/apps/desktop/internal/shell/links.go @@ -6,7 +6,7 @@ import ( "time" ) -// linkScriptPath serves linkScript, which withLinkScript adds to the +// linkScriptPath serves linkScript, which withDesktopScripts 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'). @@ -51,15 +51,18 @@ const linkScript = `(() => { })() ` -// 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
to -// add it to; the client is then returned unchanged. -func withLinkScript(client fs.FS) (_ fs.FS, ok bool, _ error) { +// withDesktopScripts returns the client with script tags for linkScript and +// bridgeScript at the top of index.html's head, so both run before the app. +// ok is false when index.html has no to add them to; the client is +// then returned unchanged. +func withDesktopScripts(client fs.FS) (_ fs.FS, ok bool, _ error) { index, err := fs.ReadFile(client, "index.html") if err != nil { return nil, false, err } - tag := []byte(`` + "\n " + ``) + tag := []byte(`` + + "\n " + `` + + "\n " + ``) patched := bytes.Replace(index, []byte(""), tag, 1) if bytes.Equal(patched, index) { return client, false, nil diff --git a/apps/desktop/internal/shell/settings.go b/apps/desktop/internal/shell/settings.go index 46753af..78706e5 100644 --- a/apps/desktop/internal/shell/settings.go +++ b/apps/desktop/internal/shell/settings.go @@ -15,6 +15,9 @@ 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"` + // TrayHintShown is set once the app has said, the first time its + // window was closed, that it keeps running in the tray. + TrayHintShown bool `json:"tray_hint_shown,omitempty"` } // Store reads and writes Settings as a JSON file. diff --git a/apps/desktop/internal/shell/shell.go b/apps/desktop/internal/shell/shell.go index 1342f7e..5e7ec91 100644 --- a/apps/desktop/internal/shell/shell.go +++ b/apps/desktop/internal/shell/shell.go @@ -54,6 +54,10 @@ type Options struct { // HTTPClient is used to find and check a homeserver on the setup // page. Defaults to a client with a 15 second timeout. HTTPClient *http.Client + // Notify shows a system notification; nil drops them. + Notify func(Notification) error + // SetTitle gets the page's title whenever it changes. + SetTitle func(string) } // Shell is the webview's http.Handler. @@ -81,12 +85,12 @@ func New(opts Options) (*Shell, error) { opts.HTTPClient = &http.Client{Timeout: 15 * time.Second} } if opts.Client != nil { - client, ok, err := withLinkScript(opts.Client) + client, ok, err := withDesktopScripts(opts.Client) if err != nil { return nil, err } if !ok { - opts.Logger.Warn("the client's index.html has no ; links to other sites will open in the app's window") + opts.Logger.Warn("the client's index.html has no ; links will open in the app's window, and there are no notifications") } opts.Client = client } @@ -147,12 +151,17 @@ func (s *Shell) ServeHTTP(w http.ResponseWriter, r *http.Request) { case openPath: s.serveOpen(w, r) return + case notifyPath: + s.serveNotify(w, r) + return + case titlePath: + s.serveTitle(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)) + writeScript(w, linkScript) + return + case bridgeScriptPath: + writeScript(w, bridgeScript) return } @@ -170,6 +179,14 @@ func (s *Shell) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.handler.ServeHTTP(w, r) } +func writeScript(w http.ResponseWriter, script string) { + h := w.Header() + h.Set("Content-Type", "text/javascript; charset=utf-8") + h.Set("Cache-Control", "no-cache") + h.Set("X-Content-Type-Options", "nosniff") + _, _ = w.Write([]byte(script)) +} + func (s *Shell) serveOpen(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") @@ -232,7 +249,10 @@ func (s *Shell) serveSetup(w http.ResponseWriter, r *http.Request) { 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 rest of the settings stay; a damaged file is replaced. + st, _ := s.opts.Store.Load() + st.Homeserver = base + if err := s.opts.Store.Save(st); err != nil { // The choice still holds for this run. s.opts.Logger.Warn("can't save the settings", "err", err) } diff --git a/apps/desktop/internal/shell/shell_test.go b/apps/desktop/internal/shell/shell_test.go index f16a7e9..20a0d3a 100644 --- a/apps/desktop/internal/shell/shell_test.go +++ b/apps/desktop/internal/shell/shell_test.go @@ -174,13 +174,15 @@ func TestCSPAllowsCallBackends(t *testing.T) { } } -func TestLinkScriptIsAddedToTheClient(t *testing.T) { +func TestDesktopScriptsAreAddedToTheClient(t *testing.T) { s, _ := newShell(t, Options{Homeserver: "http://localhost:8008", Client: fstest.MapFS{ "index.html": {Data: []byte("Notifications
+{{ notifications.active ? 'Notifications are on' : 'Notifications are off' }}
++ This browser can’t show notifications. + The browser blocks notifications from this site. Allow them in its site settings. + New messages and calls, when Warp isn’t in front. Your push rules decide which messages. +
+Encryption
This session
Session ID {{ session.deviceId }}
Warp web {{ config.version }}
++ Sync: {{ usingSlidingSync() ? 'Simplified Sliding Sync' : '/sync' }} + +