diff --git a/README.md b/README.md
index 94061c7..95e7b77 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,16 @@ on first boot it will:
after that, local admin happens over the unix socket at `~/.local/share/imsg-bridge/imsg-bridge.sock`.
-## pairing a client
+## how to use it
+
+there are really two client modes today:
+
+- direct clients: cli tools, native apps, and anything that can live with the bridge's self-signed tls plus the pairing fingerprint
+- browser clients: the web app shell, which needs a browser-trusted https host and should go through tailscale serve
+
+### direct clients
+
+direct clients can talk to the bridge itself on `:8443`.
the normal path is:
@@ -69,7 +78,100 @@ imsg-bridge-cli clients
imsg-bridge-cli revoke c_01example
```
-web clients use a delegated session flow instead of qr pairing:
+for local bridge work, you can also point the daemon at a patched `imsg` checkout:
+
+```sh
+IMSGBRIDGE_IMSG_BIN=/path/to/imsg make run
+```
+
+or:
+
+```sh
+./bin/imsg-bridge -imsg-bin /path/to/imsg
+```
+
+### browser clients
+
+the browser should not connect to the bridge's direct `:8443` listener.
+
+that listener uses a self-signed cert, which is fine for pinned native flows but not for a normal browser.
+
+for browsers, use:
+
+- cloudflare pages for the static shell
+- tailscale serve for the bridge traffic
+- the `*.ts.net` hostname as the saved browser host
+
+#### 1. run the bridge locally
+
+```sh
+make run
+```
+
+or your built binary:
+
+```sh
+./bin/imsg-bridge
+```
+
+#### 2. put tailscale serve in front of it
+
+if your bridge is listening on `127.0.0.1:8443` or `:8443` with its current self-signed cert, this is the simplest setup:
+
+```sh
+tailscale serve --https=443 https+insecure://127.0.0.1:8443
+tailscale serve status
+```
+
+that gives you a browser-trusted tailscale https endpoint, usually something like:
+
+```text
+https://bridge-name.your-tailnet.ts.net
+```
+
+#### 3. deploy the web shell
+
+the web app is just a static frontend:
+
+```sh
+cd web
+npm install
+npm run build
+```
+
+then deploy `web/dist` to cloudflare pages.
+
+cloudflare is only hosting the shell. it is not proxying message traffic.
+
+#### 4. pair from the browser
+
+open the cloudflare-hosted web app and use either:
+
+- `pair with qr`
+- `approval code`
+
+for the browser host, use the plain `*.ts.net` host:
+
+```text
+bridge-name.your-tailnet.ts.net
+```
+
+not:
+
+```text
+bridge-name.your-tailnet.ts.net:8443
+100.x.y.z:8443
+https://100.x.y.z:8443
+```
+
+the web app stores:
+
+- `https://bridge-name.your-tailnet.ts.net` for api requests
+- `wss://bridge-name.your-tailnet.ts.net` for live events
+
+### delegated browser login
+
+web clients can also use the delegated session flow instead of qr pairing:
```sh
curl -sk https://127.0.0.1:8443/v1/sessions \
@@ -79,6 +181,14 @@ curl -sk https://127.0.0.1:8443/v1/sessions \
the browser polls the returned `session_id`, and an already-paired client approves it.
+## pairing a client
+
+the short version:
+
+- use `imsg-bridge-cli pair` for direct/native clients
+- use the cloudflare-hosted web shell plus your `*.ts.net` host for browser clients
+- do not use `:8443` as the browser host
+
## api shape
main endpoints:
@@ -127,10 +237,33 @@ curl -sk https://127.0.0.1:8443/v1/attachments \
webhook targets must use `https://` and cannot resolve to loopback, private, link-local, or metadata addresses.
+## browser troubleshooting
+
+if desktop works but mobile chrome fails, check these first:
+
+- the saved browser host must be your `*.ts.net` hostname with no `:8443`
+- `tailscale serve status` should show port `443` serving the bridge
+- if you just deployed the web shell, do one hard refresh so the browser picks up the latest service worker and bundle
+
+if the bridge log shows:
+
+```text
+tls: unknown certificate
+```
+
+or:
+
+```text
+tls handshake error ... EOF
+```
+
+the browser is almost always talking to the bridge's direct self-signed tls listener instead of the tailscale serve host.
+
## project notes
- the daemon keeps its state in `~/.local/share/imsg-bridge/`
- the api is meant for tailscale clients, not direct public exposure
- websocket auth uses `?token=` because browser websocket clients cannot set custom auth headers
+- for now, the browser story assumes tailscale serve and a `*.ts.net` hostname. direct self-signed tls is for native/direct clients.
if you want the deeper architecture and rollout plan, that lives in the repo notes rather than this README.
diff --git a/web/public/sw.js b/web/public/sw.js
index 3bebb08..a313fe1 100644
--- a/web/public/sw.js
+++ b/web/public/sw.js
@@ -1,4 +1,4 @@
-const cacheName = 'imsg-bridge-web-shell-v1';
+const cacheName = 'imsg-bridge-web-shell-v2';
const shellAssets = ['/', '/manifest.webmanifest', '/icon.svg'];
self.addEventListener('install', (event) => {
@@ -33,21 +33,44 @@ self.addEventListener('fetch', (event) => {
return;
}
- event.respondWith(
- caches.match(event.request).then((cached) => {
- if (cached) {
- return cached;
- }
-
- return fetch(event.request).then((response) => {
- if (!response.ok || response.type === 'opaque') {
- return response;
- }
-
- const clone = response.clone();
- caches.open(cacheName).then((cache) => cache.put(event.request, clone));
- return response;
- });
- }),
- );
+ const isShellRequest =
+ event.request.mode === 'navigate' || shellAssets.includes(requestUrl.pathname);
+
+ if (isShellRequest) {
+ event.respondWith(networkFirst(event.request));
+ return;
+ }
+
+ event.respondWith(cacheFirst(event.request));
});
+
+async function networkFirst(request) {
+ try {
+ const response = await fetch(request);
+ if (response.ok && response.type !== 'opaque') {
+ const cache = await caches.open(cacheName);
+ await cache.put(request, response.clone());
+ }
+ return response;
+ } catch (error) {
+ const cached = await caches.match(request);
+ if (cached) {
+ return cached;
+ }
+ throw error;
+ }
+}
+
+async function cacheFirst(request) {
+ const cached = await caches.match(request);
+ if (cached) {
+ return cached;
+ }
+
+ const response = await fetch(request);
+ if (response.ok && response.type !== 'opaque') {
+ const cache = await caches.open(cacheName);
+ await cache.put(request, response.clone());
+ }
+ return response;
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index fcbd961..252ed39 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -27,6 +27,7 @@ import {
buildProfileDraft,
deriveApiBaseUrl,
deriveBrowserPairTarget,
+ requireBrowserSafeHost,
} from './lib/connection';
import { parsePairPayload } from './lib/qr';
import type {
@@ -343,7 +344,8 @@ function PairPage(props: {
try {
const payload = parsePairPayload(payloadText);
- const targetHost = browserHost.trim() || deriveBrowserPairTarget(payload.h).suggestedBrowserHost;
+ const fallbackHost = deriveBrowserPairTarget(payload.h).suggestedBrowserHost;
+ const targetHost = requireBrowserSafeHost(browserHost.trim() || fallbackHost);
const apiBaseUrl = deriveApiBaseUrl(targetHost);
const pairResult = await pairClient(apiBaseUrl, {
code: payload.c,
@@ -413,7 +415,7 @@ function PairPage(props: {
placeholder="bridge-name.your-tailnet.ts.net"
/>
- prefer the *.ts.net serve host for browser connections.
+ use a browser-trusted https hostname here, usually your *.ts.net serve host.
@@ -437,7 +439,7 @@ function PairPage(props: {
host: {parsedPayload.h}