# 🤖 lchat — iOS & macOS client

A native SwiftUI client for [lchat](../README.md), running on **iPhone/iPad** and
on **macOS via Mac Catalyst** (same target, same code). Same end-to-end
encryption contract as the web app, in the multi-thread model: each instance is a
**workspace of threads** (DMs, groups, Notes); identity is a per-instance **ECDH
P-256 keypair**; and every thread has its own AES key **wrapped to each member's
public key** (ECIES). The user **private key never leaves the device** (Keychain),
and the server only ever sees opaque ciphertext and wrapped key blobs it can't open.

## Shape: native sidebar, webview feed

The app is a hybrid. The **shell is native** — a workspace switcher → a thread
sidebar (decrypted names + last-message previews) → a feed with a native composer.
The **message feed is the web client** (`chat.js`) running in a `WKWebView` in embed
mode, so rendering, reactions, edits, replies, typing and attachments ship from one
codebase.

Crucially, native does the **identity crypto** (`Crypto.swift`): it unwraps each
thread's key with the Keychain private key (ECIES) and decrypts just the sidebar's
names/previews. It then hands the webview **only the active thread's key** — never
the private key, never another thread's key — so a single thread's page can't reach
any other thread's content. Message-content crypto stays single-sourced in `chat.js`,
reached through `window.__lchatEmbed` (seed in) and `window.lchatNative` (send/attach).

## What it does

- **Add a workspace** by pasting a new-format invite link
  (`https://host/join/<slug>#<T>`). The keypair setup + claim runs in a one-shot
  join webview (`join.js`), which hands the resolved identity back to native; the
  private key is stored in the Keychain. The host is taken from the link.
- **Browse threads** natively: DMs, groups and Notes, each with a decrypted title
  and last-message preview, ordered by recency.
- **Read + send** in a thread: history and live messages over `/ws`, text and
  **photos** (downscaled + re-encoded on-device, stripping EXIF/GPS). Reactions,
  edits, replies and typing render in the webview feed.

Out of scope for this cut (deferred follow-ups): creating threads / managing members
from native (do it in the web app for now), native reply/edit banners above the
composer, profile/group avatar editing, and push notifications (needs APNs + server
work; unread refreshes on foreground for now).

## Build & run

Requires Xcode 16+ and [`xcodegen`](https://github.com/yonsm/XcodeGen)
(`brew install xcodegen`). The `.xcodeproj` is generated, not committed.

```sh
cd ios
xcodegen generate        # writes LChat.xcodeproj from project.yml
open LChat.xcodeproj      # then pick a destination and Run
```

Pick the destination in Xcode's run-destination menu:

- **iPhone/iPad** — a Simulator or a connected device.
- **Mac** — choose "My Mac (Mac Catalyst)".

### Build without launching Xcode

Both variants build straight from the command line — useful for CI or a quick
compile check. `CODE_SIGNING_ALLOWED=NO` skips the need for a signing team; a
successful build ends in `** BUILD SUCCEEDED **`.

```sh
cd ios
xcodegen generate        # if you haven't already

# iOS — compiles against the Simulator SDK (no device or signing needed):
xcodebuild -project LChat.xcodeproj -scheme LChat \
  -destination 'generic/platform=iOS Simulator' \
  -derivedDataPath build CODE_SIGNING_ALLOWED=NO build

# macOS (Mac Catalyst) — a native build that doesn't touch the iOS Simulator:
xcodebuild -project LChat.xcodeproj -scheme LChat \
  -destination 'platform=macOS,variant=Mac Catalyst' \
  -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
```

`-derivedDataPath build` puts the products under the gitignored `ios/build/`
instead of a hashed folder in `~/Library/Developer/Xcode/DerivedData/`:

- `ios/build/Build/Products/Debug-iphonesimulator/LChat.app`
- `ios/build/Build/Products/Debug-maccatalyst/LChat.app` (runnable Mac app)

Symlink the deep Mac path somewhere handy to launch it (the `LChat-*.app`
symlink names are gitignored):

```sh
ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app
open LChat-mac.app
```

If a Simulator destination can't be resolved (e.g. an out-of-date
CoreSimulator), target the SDK directly instead — swap the `-destination` flag
for `-sdk iphonesimulator`. The Mac Catalyst build is the most reliable headless
gate since it never touches the Simulator service.

To run on a physical iOS device or on the Mac, sign in with an Apple ID that's
a member of the Eleven Messenger team (`J5NDF5PCD8`, set as `DEVELOPMENT_TEAM`
in `project.yml`) — the bundle id is `com.elevenmessenger.messenger`. Not a
team member? See the personal-team fallback at the bottom of this file.

### macOS (Mac Catalyst) notes

The Mac app is the same target with `SUPPORTS_MACCATALYST = YES`. Because
Catalyst apps are sandboxed, `LChat/LChat.entitlements` grants
`com.apple.security.network.client` — **without it the Mac app cannot connect to
the server at all** (REST or WebSocket). The Keychain, photo picker, and share
sheet all work under the sandbox as-is.

The Mac variant is also the most reliable headless build gate — see
[Build without launching Xcode](#build-without-launching-xcode) above.

Composer keys on the Mac: **Return sends** (matching the web app on desktop) and
**Shift+Return inserts a newline**. The field keeps focus after each send, so you
can fire off several messages without clicking back in.

### Testing against a local server

`NSAllowsLocalNetworking` is enabled so you can point the app at a `go run .`
instance over plain HTTP on the LAN/loopback. In production, instances are HTTPS
and no ATS exception is needed. Mint a key-invite from the admin console, then paste
that new-format `…/join/<slug>#<T>` link into "Add workspace".

> The webview loads the `web/` assets from the server (`go:embed`), so the embed
> mode + bridge changes only take effect against a server **built from this branch**.
> Rebuild/redeploy the binary before testing the app, or the feed will run the old
> single-thread code and the bridge will be missing.

## How it maps to the server

| iOS file | Responsibility | Web counterpart |
|---|---|---|
| `Crypto.swift` | AES-GCM + **ECIES key-unwrap** via CryptoKit | `crypto.js` |
| `LChatAPI.swift` | REST: pubkey, roster, threads, instance-key, messages, group | `fetch` in `chat.js` |
| `ThreadStore.swift` | unwrap thread keys, decrypt sidebar titles/previews, unread | `loadThreads`/`displayName` in `chat.js` |
| `LChatApp.swift` | `AppState` (instances) + `NavModel` + the three-pane shell | — |
| `Views/ChatView.swift` | the embed `WKWebView` + native composer + `WebBridge` | embed hooks in `chat.js` |
| `Views/JoinView.swift` | add-workspace; delegates join to a webview, captures keypair | `join.js` |
| `Models.swift` | `SavedInstance`, server JSON, sidebar `Thread`, `previewText` | `db.go` JSON, `chat.js` |

The wire format is byte-identical to the web client (CryptoKit's `SealedBox.combined`
is exactly `iv‖ciphertext‖tag`; the ECIES wrap is `ephPub(65)‖iv(12)‖ct`, HKDF-SHA256
salt-empty info `lchat-thread-wrap`). A cross-implementation test vector
(`LChatTests/thread-key-vector.json`, generated by `test/threadkey.test.mjs`) gates
the native ECIES against drift — run it with:

```sh
cd ios && swiftc -parse-as-library LChat/Crypto.swift \
  LChatTests/ECIESVectorTest.swift -o /tmp/eciesvec \
  && /tmp/eciesvec LChatTests/thread-key-vector.json
```

## Push notifications (APNs) — built, gated on paid signing

The pipeline is E2EE-preserving end to end: the server pushes only the
ciphertext blob + thread id (`apns.go`); the app parks each thread's key in the
shared keychain (`SharedThreadKeys`); the notification extension (`LChatNSE`)
decrypts on-device and rewrites the banner. Registration + routing live in
`PushManager.swift`.

Verified: server handler tests (`TestAPNsSubscribe`), the cross-implementation
decrypt gate (`LChatTests/PushBlobDecryptTest.swift` — run like the ECIES
vector), banner delivery + permission flow in the simulator. **The simulator
does NOT run notification service extensions for `simctl push`** — banners show
the placeholder there; the decrypted rewrite can only be seen on a real device
with real APNs.

To go live (needs a paid Apple Developer membership):

1. developer.apple.com → Keys → new key with the APNs service → download the
   `.p8`, note the Key ID and Team ID.
2. Deploy each instance with the extra env (base64 the key to survive env
   quoting): `LCHAT_APNS_KEY` (p8 PEM or its base64), `LCHAT_APNS_KEY_ID`,
   `LCHAT_APNS_TEAM_ID`, `LCHAT_APNS_TOPIC` (the app bundle id).
3. In the app project (already done for the Eleven Messenger team
   `J5NDF5PCD8` — redo only if the team or bundle id changes): the
   `aps-environment` entitlement on LChat, the paid team as `DEVELOPMENT_TEAM`
   in `project.yml`, and the same keychain access group on BOTH targets
   (`<TeamID>.<bundle id>.shared`, mirrored by the constant in
   `SharedThreadKeys.swift`).
4. Dev builds register with `sandbox: true` (`#if DEBUG`) and are delivered via
   Apple's sandbox gateway; TestFlight/App Store builds use production.

## Releasing

Both apps are one product and mint together, from the same version and build
number, off `main` (never a feature branch — a shipped build must match the
repo). Each has one committed script; neither takes a secret on argv:

```sh
export LCHAT_ASC_KEY_PATH=... LCHAT_ASC_KEY_ID=... LCHAT_ASC_ISSUER_ID=...
export LCHAT_RELEASE_NOTES_MD=changelog/<the entry for this build>.md

ios/release-ios.sh 0.9.0 1 --external   # archive -> TestFlight -> review
LCHAT_SPARKLE_KEY_FILE=... ios/release-mac.sh 0.9.0 1   # notarize -> DMG -> appcast
```

The iPhone script's App Store Connect half (processing, export compliance,
what-to-test, tester groups, beta review) is `ios/asc-testflight.mjs`, and
every step of it is idempotent — if the upload lands but a later step fails,
re-run that script alone with the same version and build.

The changelog entry is part of the mint, not of the feature PRs: a native
change ships *without* one, and the PR that mints the release adds a single
entry covering everything in the build (CLAUDE.md § Conventions).

## Building on a device as a team member (not the account holder)

Once the account holder invites you to their Apple Developer team (App Store
Connect → Users and Access, **Developer** or Admin role), you build with **your
own Apple ID** — no shared credentials:

1. Xcode → Settings → Accounts → add your Apple ID (the one that was invited).
2. Build with the repo defaults — `project.yml` already sets the team
   (`J5NDF5PCD8`) and bundle id (`com.elevenmessenger.messenger`), whose App ID
   carries the Push capability + the shared keychain group. Easiest is the
   command line — automatic signing mints *your* development cert and registers
   your device under the team:

   ```sh
   cd ios && xcodegen generate
   xcodebuild -project LChat.xcodeproj -scheme LChat -destination 'generic/platform=iOS' \
     -derivedDataPath build -allowProvisioningUpdates \
     CODE_SIGN_STYLE=Automatic build
   xcrun devicectl device install app --device <YOUR_UDID> \
     build/Build/Products/Debug-iphoneos/LChat.app
   ```

   In Xcode's UI instead: just ⌘R — the team and bundle id come from the
   generated project.

If you'd rather not join the team, build with your **own** free personal team
(`DEVELOPMENT_TEAM=<yours>`) and a unique `LCHAT_APP_BUNDLE_ID` — but first
remove `aps-environment` and the `keychain-access-groups` from
`LChat/LChat.entitlements` (a free team can't provision them); you'll lose push
locally but everything else runs.
