Docs: public README, setup guide and architecture, generic deploy files, and the T3 Code notice
This commit is contained in:
1 parent
b08fd9bdb1
commit
f8241e42bb
13 files changed
+345
-301
No files matched your search
+5
-5
@@ -4,7 +4,7 @@ The watch app talks to the gateway over HTTPS with JSON bodies. The TypeScript t
|
||||
[`gateway/src/contract.ts`](../gateway/src/contract.ts) are the source of truth, and the watch
|
||||
mirrors them in `watch/WristCode/API/Models.swift`. Change both together.
|
||||
|
||||
The base URL is `https://wristcode.gbrown.org`. Every route except `GET /v1/health` and
|
||||
The base URL is your gateway's public HTTPS address, such as `https://wristcode.example.com`. Every route except `GET /v1/health` and
|
||||
`POST /v1/pair` needs `Authorization: Bearer <deviceToken>`. Timestamps are ISO 8601 strings in UTC.
|
||||
|
||||
Errors return a non-2xx status with `{ "error": "<message for a person>" }`.
|
||||
@@ -330,11 +330,11 @@ GET /v1/status
|
||||
|
||||
HostStatusDetail {
|
||||
"id": "...", "label": "Laptop", "status": "online", "error": null,
|
||||
"activeUrl": "http://laptopvpn.gib:3773",
|
||||
"activeUrl": "http://laptop-vpn.lan:3773",
|
||||
"routes": [
|
||||
{ "url": "http://laptop.gib:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "..." },
|
||||
{ "url": "http://laptopvpn.gib:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "..." },
|
||||
{ "url": "https://t3laptop.gbrown.org", "kind": "public", "result": "untried", "error": null, "checkedAt": null }
|
||||
{ "url": "http://laptop.lan:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "..." },
|
||||
{ "url": "http://laptop-vpn.lan:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "..." },
|
||||
{ "url": "https://t3-laptop.example.com", "kind": "public", "result": "untried", "error": null, "checkedAt": null }
|
||||
],
|
||||
"onlineSince": "...", "lastOnlineAt": null, "nextRetryAt": null,
|
||||
"tokenExpiresAt": "2026-12-01T00:00:00.000Z", "serverVersion": "0.1.5"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Architecture
|
||||
|
||||
WristCode puts every T3 Code environment on an Apple Watch. From the watch you can see which agents
|
||||
need you, answer their requests, reply, start and browse threads, and get notifications. It runs
|
||||
over the watch's own cellular connection, so no phone is needed.
|
||||
|
||||
How a client talks to a T3 server is in [t3-protocol.md](t3-protocol.md), and the watch's API is in
|
||||
[api.md](api.md).
|
||||
|
||||
## Shape
|
||||
|
||||
```
|
||||
watchOS app --HTTPS + device token--> gateway (Bun) --WebSocket + bearer--> T3 server x N
|
||||
|
|
||||
+--HTTP/2--> APNs --> watch notifications
|
||||
```
|
||||
|
||||
- The watch never talks to T3 directly. watchOS only allows WebSockets for audio apps, and every
|
||||
T3 write goes over a WebSocket.
|
||||
- The gateway keeps one `orchestration.subscribeShell` stream open per T3 server and holds every
|
||||
thread shell in memory. It serves lists from memory, so the watch never waits on a T3 round trip
|
||||
to draw the feed. Thread text comes from each thread's bounded snapshot, read on demand and
|
||||
cached until the thread changes.
|
||||
- The gateway is the only place that holds T3 credentials. The watch holds only its own device
|
||||
token.
|
||||
|
||||
## Gateway
|
||||
|
||||
Bun and TypeScript, with no framework: `Bun.serve` for HTTP, `bun:sqlite` for state, and
|
||||
`node:http2` for APNs. The T3 RPC client is hand-written and needs no Effect runtime or vendored
|
||||
contracts.
|
||||
|
||||
| File | Job |
|
||||
| --- | --- |
|
||||
| `src/t3/rpc.ts` | Effect RPC JSON framing: request ids, pending calls, Ack on every Chunk, pings, and a silence timeout |
|
||||
| `src/t3/host.ts` | One T3 server: route choice, ticket mint, connect, reconnect with backoff, shell state, and the HTTP reads |
|
||||
| `src/t3/types.ts` | Narrow, lenient types for the fields the gateway reads. Unknown fields and values pass through |
|
||||
| `src/hub.ts` | Every host in the database, kept connected and rereading the table for changes |
|
||||
| `src/awareness.ts` | A port of T3's own awareness logic, which maps a thread shell to a phase |
|
||||
| `src/views.ts` | The feed, thread detail, browse, search and summary responses |
|
||||
| `src/notify.ts` | Watches phase changes and decides what to push |
|
||||
| `src/push.ts` | Sends pushes to each paired watch that wants that kind |
|
||||
| `src/apns.ts` | ES256 provider JWT and an HTTP/2 client for APNs |
|
||||
| `src/api.ts` | The watch's REST API |
|
||||
| `src/db.ts` | SQLite schema and queries |
|
||||
| `src/cli.ts` | The operator CLI: `host add`, `host list`, `host remove`, `pair`, `device list`, `device revoke` |
|
||||
|
||||
### T3 credentials
|
||||
|
||||
`host add` takes a T3 pairing credential, not a long-lived token. The gateway exchanges it for a
|
||||
30 day session limited to reading and driving threads, and stores that in SQLite. A pairing
|
||||
credential works once and expires in minutes, so the session token never appears in a terminal.
|
||||
T3 sessions cannot be refreshed, so the gateway checks each one daily and pushes a warning to the
|
||||
watch a week before it expires. `scripts/renew-hosts.sh` renews them all in one go.
|
||||
|
||||
A host can have several routes, such as a LAN address, a VPN address and a public URL. The
|
||||
gateway tries them in order on every connect and stays on the first that answers.
|
||||
|
||||
### Notifications
|
||||
|
||||
The gateway pushes when a thread starts waiting for you, finishes, or fails. On a cold start it
|
||||
loads state without pushing, so a restart never replays old news. Each watch picks which kinds it
|
||||
wants. A subagent's request is pushed under the thread that started it.
|
||||
|
||||
### Storage
|
||||
|
||||
```
|
||||
hosts(id, label, url, alt_urls, environment_id, token, token_expires_at, created_at)
|
||||
devices(id, name, token_hash, apns_token, apns_env, notifications, created_at, last_seen_at, revoked_at)
|
||||
pairing_codes(code_hash, expires_at, used_at)
|
||||
```
|
||||
|
||||
Device tokens and pairing codes are stored as SHA-256 hashes.
|
||||
|
||||
### Testing
|
||||
|
||||
Unit tests cover the parts that carry logic: RPC framing against an in-process fake server, route
|
||||
choice and reconnects, the awareness port, feed ordering and subagent roll-up, notification
|
||||
diffing, push filtering, and the API shapes. Run them with `bun test` in `gateway/`.
|
||||
|
||||
## Watch app
|
||||
|
||||
SwiftUI, watch only (`WKRunsIndependentlyOfCompanionApp`). XcodeGen generates the project from
|
||||
`watch/project.yml`. A widget extension provides the complications and the Smart Stack widget. The
|
||||
watch makes plain `URLSession` calls, and the device token lives in the Keychain, shared with the
|
||||
widget.
|
||||
|
||||
The feed refreshes when it appears, when the app returns to the foreground, every 10 seconds while
|
||||
it is on screen, and when a notification arrives.
|
||||
|
||||
[watch/README.md](../watch/README.md) covers building, the demo mode and the screenshot tests.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Threads started from the watch use the project's default model and the host's default runtime
|
||||
mode, unless the watch picks another model or turns on Ask before acting.
|
||||
- An App Store app has to be an iOS app, so a watch-only app ships inside an empty iOS wrapper.
|
||||
The watch app's own bundle ID is also its APNs topic.
|
||||
- `delegate_task` children are not marked as subagents by T3, so the gateway recognizes them by
|
||||
`delegatedFrom` and treats them as subagents of the thread that started them.
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
# WristCode implementation plan
|
||||
|
||||
WristCode puts every T3 Code environment on an Apple Watch. From the watch you can see which agents
|
||||
need you, approve their requests, reply, start new threads, and get a notification when a turn
|
||||
finishes. It runs over the watch's own cellular connection, so no phone is needed.
|
||||
|
||||
How a client talks to a T3 server is documented in [t3-protocol.md](t3-protocol.md). This plan
|
||||
builds on it.
|
||||
|
||||
## Shape
|
||||
|
||||
```
|
||||
watchOS app --HTTPS + device token--> gateway (Bun, junior.gib) --WebSocket + bearer--> T3 server x N
|
||||
|
|
||||
+--HTTP/2--> APNs --> watch notifications
|
||||
```
|
||||
|
||||
- The watch never talks to T3 directly. watchOS only allows WebSockets for audio apps, and every
|
||||
T3 write goes over a WebSocket.
|
||||
- The gateway keeps one `orchestration.subscribeShell` stream open per T3 server and holds every
|
||||
thread shell in memory. The gateway serves list views from memory, so the watch never waits on a T3
|
||||
round trip to draw the feed.
|
||||
- The gateway is the only place that holds T3 credentials, and the watch only holds its own device
|
||||
token.
|
||||
|
||||
## Gateway
|
||||
|
||||
Bun and TypeScript, with no framework. `Bun.serve` for HTTP, `bun:sqlite` for state, and
|
||||
`node:http2` for APNs. The T3 RPC client is hand-written, about 150 lines, as the protocol doc
|
||||
recommends. It needs no Effect and no vendored contracts.
|
||||
|
||||
### Modules
|
||||
|
||||
| File | Job |
|
||||
| --- | --- |
|
||||
| `src/t3/rpc.ts` | Effect RPC JSON framing: request ids, pending calls, Ack on every Chunk, 5 s Ping, 15 s silence timeout |
|
||||
| `src/t3/host.ts` | One T3 server: well-known check, ticket mint, connect, reconnect with jittered backoff, shell state, resume with `afterSequence` |
|
||||
| `src/t3/types.ts` | Narrow, lenient types for the fields we read. Unknown fields and enum values pass through |
|
||||
| `src/awareness.ts` | Port of upstream `resolveThreadAwarenessPhaseV2`. Maps a thread shell to a phase |
|
||||
| `src/feed.ts` | Builds the watch feed across all hosts: needs you, then working, then recently finished |
|
||||
| `src/notify.ts` | Diffs phases on every `thread.updated` and on resync snapshots, and decides what to push |
|
||||
| `src/apns.ts` | ES256 provider JWT plus an HTTP/2 client for APNs |
|
||||
| `src/api.ts` | The watch REST API |
|
||||
| `src/db.ts` | SQLite schema and queries |
|
||||
| `src/cli.ts` | `wristcode host add`, `host list`, `host remove`, `pair`, `device list`, `device revoke` |
|
||||
|
||||
### T3 credentials
|
||||
|
||||
`host add` takes a T3 pairing credential, not a long-lived token:
|
||||
|
||||
```sh
|
||||
# on the T3 host
|
||||
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
|
||||
# on junior.gib
|
||||
podman exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org <pairing credential>
|
||||
```
|
||||
|
||||
The gateway exchanges the credential at `POST /oauth/token` for a 30 day bearer session and stores
|
||||
it in SQLite. A pairing credential is single use and expires in 5 minutes, so the long-lived token
|
||||
never appears in a terminal, a transcript, or a chat. Renewing works the same way with a fresh
|
||||
pairing credential.
|
||||
|
||||
T3 sessions cannot be refreshed. The gateway checks `GET /api/auth/session` daily, and seven days
|
||||
before a token expires it sends a push to the watch that names the host.
|
||||
|
||||
### Watch API
|
||||
|
||||
Every route except pairing and health takes `Authorization: Bearer <device token>`. The gateway
|
||||
stores device tokens as SHA-256 hashes.
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /v1/health` | Liveness, plus each host's connection state |
|
||||
| `POST /v1/pair` | `{ code, deviceName }` returns `{ deviceToken }`. `wristcode pair` makes the codes. They are 6 digits, last 5 minutes, work once, and are rate limited |
|
||||
| `PUT /v1/device/push` | `{ apnsToken, apnsEnvironment }` |
|
||||
| `GET /v1/feed` | `{ needsYou, working, recent, hosts }`. Each item has host, project, title, phase, the last message trimmed to about 280 characters, the pending request summary, and `updatedAt` |
|
||||
| `GET /v1/threads/:host/:thread` | The last 20 messages from `/bounded`, trimmed, plus the full pending request (prompt, command, options) |
|
||||
| `POST /v1/threads/:host/:thread/messages` | `{ text }` sends `message.dispatch` with `queue_after_active` and `creationSource: "mobile"` |
|
||||
| `POST /v1/threads/:host/:thread/respond` | `{ requestId, decision }` or `{ requestId, answers }` |
|
||||
| `POST /v1/threads/:host/:thread/stop` | `thread.stop`, or `run.interrupt` when the host lacks `threadStop` |
|
||||
| `GET /v1/projects` | Hosts, their projects, and each project's resolved default model, for the new thread screen |
|
||||
| `POST /v1/threads` | `{ host, projectId, text }` calls `launchThread` with the resolved defaults |
|
||||
|
||||
Responses are JSON with stable field names. The watch decodes them with `Codable`, so this API is the
|
||||
contract between the two halves and gets its own tests.
|
||||
|
||||
### Notifications
|
||||
|
||||
The gateway pushes when a top-level thread's phase changes to `completed`, `failed`,
|
||||
`waiting_for_approval` or `waiting_for_input`. Each notification has a category the watch registers
|
||||
actions for:
|
||||
|
||||
| Category | Actions |
|
||||
| --- | --- |
|
||||
| `approval` | Allow, Deny (calls `/respond`) |
|
||||
| `finished` | Reply (text input, calls `/messages`), Open |
|
||||
| `failed` | Open |
|
||||
| `input` | Open |
|
||||
|
||||
On a cold start, the gateway loads state without pushing, so a restart never replays old news. After a
|
||||
disconnect, the gateway diffs the resync snapshot against the last known state, so a turn that
|
||||
finished while the socket was down still notifies once.
|
||||
|
||||
### Storage
|
||||
|
||||
```sql
|
||||
hosts(id, label, url, environment_id, token, token_expires_at, last_sequence)
|
||||
devices(id, name, token_hash, apns_token, apns_env, created_at, last_seen_at, revoked_at)
|
||||
pairing_codes(code_hash, expires_at, used_at)
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Unit tests cover the parts that carry logic. They exercise RPC framing against an in-process fake WebSocket server
|
||||
(including Ack flow and reconnect), the awareness port against upstream's own test cases, feed
|
||||
ordering, notification diffing (cold start, resync, debouncing), and the API contract. The only end
|
||||
to end check is a manual run against server-gib's own T3 before deploying.
|
||||
|
||||
## Container, CI and deploy
|
||||
|
||||
- `gateway/Containerfile` uses the official Bun image and a non-root user, and `/data` holds the
|
||||
SQLite file. It is a plain OCI build, the same under podman and docker.
|
||||
- `.gitea/workflows/gateway.yml` follows `secure-market`. On pushes touching `gateway/`, it runs
|
||||
typecheck and tests on the `build` runner. On `main`, it also builds the image and pushes
|
||||
`git.gbrown.org/gib/wristcode-gateway:<sha>` and `:latest`. It needs the repo secrets
|
||||
`REGISTRY_USER` and `REGISTRY_PASSWORD`.
|
||||
- The stack runs under rootless podman on junior.gib, the VPS, beside Gitea and most other
|
||||
services. Its compose file and systemd user unit live in gib/Panama under
|
||||
`server/vps/Wristcode/` and are symlinked into `~/Server/Wristcode/` and
|
||||
`~/.config/systemd/user/`. It joins `nginx-bridge`, publishes no host ports, and bind mounts
|
||||
`./data`. The APNs key and other config come from a mode 600 `.env`.
|
||||
- junior's Nginx Proxy Manager serves `wristcode.gbrown.org` and forwards to
|
||||
`wristcode-gateway:8080`. The Cloudflare record is proxied, as every VPS hostname must be.
|
||||
- Deploys: after pushing the image, CI SSHes to junior's WireGuard address with a deploy key
|
||||
pinned to `~/Server/ci-deploy.sh`, which pulls and restarts the stack. The nightly
|
||||
`update-containers` timer is the fallback.
|
||||
- Local development: `bun --watch src/main.ts` against real T3 hosts, with no local image builds.
|
||||
|
||||
## Watch app
|
||||
|
||||
SwiftUI, watch only (`WKRunsIndependentlyOfCompanionApp`), with the newest watchOS as the
|
||||
deployment target. XcodeGen generates the project from `watch/project.yml`. The team is
|
||||
`GQJNM5YK2M`. The Mac mini builds it with `xcodebuild` and runs it in the watch simulator, and
|
||||
TestFlight installs it on the watch using the App Store Connect key already on the Mac mini.
|
||||
|
||||
### Screens (mock B)
|
||||
|
||||
| Screen | Content |
|
||||
| --- | --- |
|
||||
| Pair | Gateway URL (prefilled with `https://wristcode.gbrown.org`) and a 6 digit code. Shown until paired |
|
||||
| Feed | Count of threads waiting on you, then cards: needs you (with Allow and Deny on the card), working (with the agent's last line), recent (greyed) |
|
||||
| Thread | The agent's latest message, large. Older messages are one scroll up. Reply by dictation or Scribble. Stop while running |
|
||||
| Approval | Inline on the feed card and on the thread: the command or file, and the server's options |
|
||||
| New thread | Host and project (defaults to the last used), model shown read only, task by dictation |
|
||||
|
||||
The feed refreshes on appear, when the app returns to the foreground, every 10 seconds while it is
|
||||
on screen, and when a notification arrives. The watch makes plain `URLSession` HTTPS calls, and the
|
||||
device token lives in the Keychain.
|
||||
|
||||
### Code layout
|
||||
|
||||
```
|
||||
watch/
|
||||
project.yml
|
||||
WristCode/
|
||||
WristCodeApp.swift app entry, notification registration
|
||||
API/GatewayClient.swift async URLSession calls, Codable models
|
||||
API/Models.swift
|
||||
Auth/DeviceStore.swift Keychain
|
||||
Views/PairView.swift
|
||||
Views/FeedView.swift
|
||||
Views/ThreadView.swift
|
||||
Views/NewThreadView.swift
|
||||
Notifications/NotificationHandler.swift categories and actions
|
||||
```
|
||||
|
||||
## Phases
|
||||
|
||||
Each phase ends with something usable.
|
||||
|
||||
1. **Gateway read path.** RPC client, host connection, shell state, awareness, `host add` through
|
||||
pairing, and `GET /v1/feed`. Checked against server-gib's T3 from the Fedora desktop.
|
||||
2. **Gateway write path and pairing.** Send, respond, stop, launch, the projects route, watch
|
||||
pairing codes, and device tokens. Checked with curl against a real thread.
|
||||
3. **Ship the gateway.** Containerfile, CI, registry, the compose stack on junior.gib, and the NPM
|
||||
proxy host. All four hosts are added.
|
||||
4. **Watch app MVP.** Pair, feed, thread, reply, approvals, and new thread, running in the simulator
|
||||
against the deployed gateway.
|
||||
5. **Notifications and the real watch.** An APNs key in the Apple Developer portal, the gateway's
|
||||
APNs sender, notification actions, and a TestFlight build installed on the watch.
|
||||
6. **Later.** Complications showing the count of threads waiting on you, a Smart Stack widget, token
|
||||
expiry warnings, and multiple gateways.
|
||||
|
||||
Phases 1 and 2 happen on the Fedora desktop. Phase 4 runs on the Mac mini in parallel once the
|
||||
phase 2 API is fixed. The gateway owns `gateway/`, the Mac mini owns `watch/`, and both pull before
|
||||
they push to `docs/`.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Threads started from the watch inherit the host's default runtime mode (`full-access` on these
|
||||
hosts).
|
||||
- The gateway runs on junior.gib, the VPS, which is more reliable than the home server. CI deploys
|
||||
right away through junior's `~/Server/ci-deploy.sh`, with the nightly update timer as the
|
||||
fallback.
|
||||
- The App Store record uses `org.gbrown.wristcode`, an empty iOS wrapper the App Store requires
|
||||
around a watch-only app. The watch app itself is `org.gbrown.wristcode.watchkitapp`, which is also
|
||||
its APNs topic.
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Setting up WristCode
|
||||
|
||||
This guide runs the gateway, connects your T3 Code machines to it, builds the watch app, and pairs
|
||||
your watch. It takes about an hour, most of it in Apple's developer portal.
|
||||
|
||||
## 1. Run the gateway
|
||||
|
||||
The gateway is one container with one SQLite file in `/data`. Run it on a machine that is always
|
||||
on and can reach every T3 Code server.
|
||||
|
||||
```sh
|
||||
cd deploy
|
||||
cp .env.example .env # fill in after step 2, or leave the APNS_ lines empty for now
|
||||
mkdir data
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`compose.yml` builds the image from `gateway/` and listens on `127.0.0.1:8080`. Podman works the
|
||||
same with `podman compose`. The image runs as uid 1000, which needs to be able to write `data/`.
|
||||
|
||||
The watch needs to reach the gateway over cellular, so put it behind a reverse proxy that serves
|
||||
HTTPS on a public name, such as `wristcode.example.com`, and forwards to port 8080. Check it with:
|
||||
|
||||
```sh
|
||||
curl https://wristcode.example.com/v1/health
|
||||
```
|
||||
|
||||
Every route except health and pairing needs a device token, which only pairing hands out.
|
||||
|
||||
## 2. Set up notifications
|
||||
|
||||
The gateway sends notifications through Apple's push service (APNs) with a key from your developer
|
||||
account.
|
||||
|
||||
1. In [Certificates, IDs & Profiles](https://developer.apple.com/account/resources/authkeys/list),
|
||||
make a key with Apple Push Notifications service enabled, and download its `.p8` file.
|
||||
2. Put the file in `deploy/data/apns.p8`.
|
||||
3. Fill in `deploy/.env`: the key's ID, your team ID, and `APNS_TOPIC`, which is the watch app's
|
||||
bundle ID from step 4.
|
||||
4. Restart the gateway with `docker compose up -d`.
|
||||
|
||||
Without these settings the gateway still works. It logs each notification instead of sending it.
|
||||
|
||||
## 3. Connect your T3 Code machines
|
||||
|
||||
The gateway logs in to each T3 Code server with a one-time pairing credential. On the T3 Code
|
||||
machine:
|
||||
|
||||
```sh
|
||||
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
|
||||
```
|
||||
|
||||
Then, within a few minutes, on the gateway machine:
|
||||
|
||||
```sh
|
||||
docker compose exec gateway wristcode host add https://t3-desktop.example.com <credential> --label "Desktop"
|
||||
```
|
||||
|
||||
The gateway trades the credential for a 30 day session that can read and drive threads, and the
|
||||
credential stops working. The label is what the watch shows.
|
||||
|
||||
A machine you can reach several ways can list fallbacks with `--alt-url`, preferred first. The
|
||||
gateway uses the first route that answers, so put a LAN or VPN address first and a public URL
|
||||
last:
|
||||
|
||||
```sh
|
||||
docker compose exec gateway wristcode host add http://desktop.lan:3773 <credential> \
|
||||
--label "Desktop" --alt-url https://t3-desktop.example.com
|
||||
```
|
||||
|
||||
`wristcode host list` shows each host and when its session expires. T3 sessions cannot be renewed,
|
||||
so a week before one expires the watch gets a notification. To renew, run `host add` again with a
|
||||
fresh credential, or let `scripts/renew-hosts.sh` do every host over SSH. Copy
|
||||
`scripts/hosts.conf.example` to `scripts/hosts.conf` and list your machines in it first.
|
||||
|
||||
## 4. Build the watch app
|
||||
|
||||
You need a Mac with Xcode and [XcodeGen](https://github.com/yonaskolb/XcodeGen).
|
||||
|
||||
1. Copy `watch/Local.xcconfig.example` to `watch/Local.xcconfig`, which git ignores, and set your
|
||||
team ID and a bundle ID prefix you own, such as `com.example`. The watch app's bundle ID is
|
||||
`<prefix>.wristcode.watchkitapp`, which is the `APNS_TOPIC` from step 2.
|
||||
2. Generate the project and open it:
|
||||
|
||||
```sh
|
||||
cd watch
|
||||
xcodegen generate
|
||||
open WristCode.xcodeproj
|
||||
```
|
||||
|
||||
3. Run the `WristCode` scheme on your watch. Xcode registers the bundle IDs and the push
|
||||
capability for you with automatic signing.
|
||||
|
||||
To install it without Xcode attached, upload a build to TestFlight with
|
||||
`watch/scripts/testflight.sh`. It needs an App Store Connect API key and an app record for
|
||||
`<prefix>.wristcode`. [watch/README.md](../watch/README.md) has the details, plus a demo mode
|
||||
that runs without a gateway.
|
||||
|
||||
## 5. Pair your watch
|
||||
|
||||
On the gateway machine:
|
||||
|
||||
```sh
|
||||
docker compose exec gateway wristcode pair
|
||||
```
|
||||
|
||||
It prints a 6 digit code that works once, for 5 minutes. On the watch, enter your gateway's URL
|
||||
and the code. The watch then asks to send notifications. Allow it, and send a test notification
|
||||
from the gear button, then Notifications, to check the whole chain.
|
||||
|
||||
`wristcode device list` shows paired watches, and `wristcode device revoke <name>` unpairs one.
|
||||
Reference in new issue
Block a user