| 1 | # π€ lchat β iOS & macOS client |
| 2 | |
| 3 | A native SwiftUI client for [lchat](../README.md), running on **iPhone/iPad** and |
| 4 | on **macOS via Mac Catalyst** (same target, same code). Same end-to-end |
| 5 | encryption contract as the web app, in the multi-thread model: each instance is a |
| 6 | **workspace of threads** (DMs, groups, Notes); identity is a per-instance **ECDH |
| 7 | P-256 keypair**; and every thread has its own AES key **wrapped to each member's |
| 8 | public key** (ECIES). The user **private key never leaves the device** (Keychain), |
| 9 | and the server only ever sees opaque ciphertext and wrapped key blobs it can't open. |
| 10 | |
| 11 | ## Shape: native sidebar, webview feed |
| 12 | |
| 13 | The app is a hybrid. The **shell is native** β a workspace switcher β a thread |
| 14 | sidebar (decrypted names + last-message previews) β a feed with a native composer. |
| 15 | The **message feed is the web client** (`chat.js`) running in a `WKWebView` in embed |
| 16 | mode, so rendering, reactions, edits, replies, typing and attachments ship from one |
| 17 | codebase. |
| 18 | |
| 19 | Crucially, native does the **identity crypto** (`Crypto.swift`): it unwraps each |
| 20 | thread's key with the Keychain private key (ECIES) and decrypts just the sidebar's |
| 21 | names/previews. It then hands the webview **only the active thread's key** β never |
| 22 | the private key, never another thread's key β so a single thread's page can't reach |
| 23 | any other thread's content. Message-content crypto stays single-sourced in `chat.js`, |
| 24 | reached through `window.__lchatEmbed` (seed in) and `window.lchatNative` (send/attach). |
| 25 | |
| 26 | ## What it does |
| 27 | |
| 28 | - **Add a workspace** by pasting a new-format invite link |
| 29 | (`https://host/join/<slug>#<T>`). The keypair setup + claim runs in a one-shot |
| 30 | join webview (`join.js`), which hands the resolved identity back to native; the |
| 31 | private key is stored in the Keychain. The host is taken from the link. |
| 32 | - **Browse threads** natively: DMs, groups and Notes, each with a decrypted title |
| 33 | and last-message preview, ordered by recency. |
| 34 | - **Read + send** in a thread: history and live messages over `/ws`, text and |
| 35 | **photos** (downscaled + re-encoded on-device, stripping EXIF/GPS). Reactions, |
| 36 | edits, replies and typing render in the webview feed. |
| 37 | |
| 38 | Out of scope for this cut (deferred follow-ups): creating threads / managing members |
| 39 | from native (do it in the web app for now), native reply/edit banners above the |
| 40 | composer, profile/group avatar editing, and push notifications (needs APNs + server |
| 41 | work; unread refreshes on foreground for now). |
| 42 | |
| 43 | ## Build & run |
| 44 | |
| 45 | Requires Xcode 16+ and [`xcodegen`](https://github.com/yonsm/XcodeGen) |
| 46 | (`brew install xcodegen`). The `.xcodeproj` is generated, not committed. |
| 47 | |
| 48 | ```sh |
| 49 | cd ios |
| 50 | xcodegen generate # writes LChat.xcodeproj from project.yml |
| 51 | open LChat.xcodeproj # then pick a destination and Run |
| 52 | ``` |
| 53 | |
| 54 | Pick the destination in Xcode's run-destination menu: |
| 55 | |
| 56 | - **iPhone/iPad** β a Simulator or a connected device. |
| 57 | - **Mac** β choose "My Mac (Mac Catalyst)". |
| 58 | |
| 59 | ### Build without launching Xcode |
| 60 | |
| 61 | Both variants build straight from the command line β useful for CI or a quick |
| 62 | compile check. `CODE_SIGNING_ALLOWED=NO` skips the need for a signing team; a |
| 63 | successful build ends in `** BUILD SUCCEEDED **`. |
| 64 | |
| 65 | ```sh |
| 66 | cd ios |
| 67 | xcodegen generate # if you haven't already |
| 68 | |
| 69 | # iOS β compiles against the Simulator SDK (no device or signing needed): |
| 70 | xcodebuild -project LChat.xcodeproj -scheme LChat \ |
| 71 | -destination 'generic/platform=iOS Simulator' \ |
| 72 | -derivedDataPath build CODE_SIGNING_ALLOWED=NO build |
| 73 | |
| 74 | # macOS (Mac Catalyst) β a native build that doesn't touch the iOS Simulator: |
| 75 | xcodebuild -project LChat.xcodeproj -scheme LChat \ |
| 76 | -destination 'platform=macOS,variant=Mac Catalyst' \ |
| 77 | -derivedDataPath build CODE_SIGNING_ALLOWED=NO build |
| 78 | ``` |
| 79 | |
| 80 | `-derivedDataPath build` puts the products under the gitignored `ios/build/` |
| 81 | instead of a hashed folder in `~/Library/Developer/Xcode/DerivedData/`: |
| 82 | |
| 83 | - `ios/build/Build/Products/Debug-iphonesimulator/LChat.app` |
| 84 | - `ios/build/Build/Products/Debug-maccatalyst/LChat.app` (runnable Mac app) |
| 85 | |
| 86 | Symlink the deep Mac path somewhere handy to launch it (the `LChat-*.app` |
| 87 | symlink names are gitignored): |
| 88 | |
| 89 | ```sh |
| 90 | ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app |
| 91 | open LChat-mac.app |
| 92 | ``` |
| 93 | |
| 94 | If a Simulator destination can't be resolved (e.g. an out-of-date |
| 95 | CoreSimulator), target the SDK directly instead β swap the `-destination` flag |
| 96 | for `-sdk iphonesimulator`. The Mac Catalyst build is the most reliable headless |
| 97 | gate since it never touches the Simulator service. |
| 98 | |
| 99 | To run on a physical iOS device or on the Mac, sign in with an Apple ID that's |
| 100 | a member of the Eleven Messenger team (`J5NDF5PCD8`, set as `DEVELOPMENT_TEAM` |
| 101 | in `project.yml`) β the bundle id is `com.elevenmessenger.messenger`. Not a |
| 102 | team member? See the personal-team fallback at the bottom of this file. |
| 103 | |
| 104 | ### macOS (Mac Catalyst) notes |
| 105 | |
| 106 | The Mac app is the same target with `SUPPORTS_MACCATALYST = YES`. Because |
| 107 | Catalyst apps are sandboxed, `LChat/LChat.entitlements` grants |
| 108 | `com.apple.security.network.client` β **without it the Mac app cannot connect to |
| 109 | the server at all** (REST or WebSocket). The Keychain, photo picker, and share |
| 110 | sheet all work under the sandbox as-is. |
| 111 | |
| 112 | The Mac variant is also the most reliable headless build gate β see |
| 113 | [Build without launching Xcode](#build-without-launching-xcode) above. |
| 114 | |
| 115 | Composer keys on the Mac: **Return sends** (matching the web app on desktop) and |
| 116 | **Shift+Return inserts a newline**. The field keeps focus after each send, so you |
| 117 | can fire off several messages without clicking back in. |
| 118 | |
| 119 | ### Testing against a local server |
| 120 | |
| 121 | `NSAllowsLocalNetworking` is enabled so you can point the app at a `go run .` |
| 122 | instance over plain HTTP on the LAN/loopback. In production, instances are HTTPS |
| 123 | and no ATS exception is needed. Mint a key-invite from the admin console, then paste |
| 124 | that new-format `β¦/join/<slug>#<T>` link into "Add workspace". |
| 125 | |
| 126 | > The webview loads the `web/` assets from the server (`go:embed`), so the embed |
| 127 | > mode + bridge changes only take effect against a server **built from this branch**. |
| 128 | > Rebuild/redeploy the binary before testing the app, or the feed will run the old |
| 129 | > single-thread code and the bridge will be missing. |
| 130 | |
| 131 | ## How it maps to the server |
| 132 | |
| 133 | | iOS file | Responsibility | Web counterpart | |
| 134 | |---|---|---| |
| 135 | | `Crypto.swift` | AES-GCM + **ECIES key-unwrap** via CryptoKit | `crypto.js` | |
| 136 | | `LChatAPI.swift` | REST: pubkey, roster, threads, instance-key, messages, group | `fetch` in `chat.js` | |
| 137 | | `ThreadStore.swift` | unwrap thread keys, decrypt sidebar titles/previews, unread | `loadThreads`/`displayName` in `chat.js` | |
| 138 | | `LChatApp.swift` | `AppState` (instances) + `NavModel` + the three-pane shell | β | |
| 139 | | `Views/ChatView.swift` | the embed `WKWebView` + native composer + `WebBridge` | embed hooks in `chat.js` | |
| 140 | | `Views/JoinView.swift` | add-workspace; delegates join to a webview, captures keypair | `join.js` | |
| 141 | | `Models.swift` | `SavedInstance`, server JSON, sidebar `Thread`, `previewText` | `db.go` JSON, `chat.js` | |
| 142 | |
| 143 | The wire format is byte-identical to the web client (CryptoKit's `SealedBox.combined` |
| 144 | is exactly `ivβciphertextβtag`; the ECIES wrap is `ephPub(65)βiv(12)βct`, HKDF-SHA256 |
| 145 | salt-empty info `lchat-thread-wrap`). A cross-implementation test vector |
| 146 | (`LChatTests/thread-key-vector.json`, generated by `test/threadkey.test.mjs`) gates |
| 147 | the native ECIES against drift β run it with: |
| 148 | |
| 149 | ```sh |
| 150 | cd ios && swiftc -parse-as-library LChat/Crypto.swift \ |
| 151 | LChatTests/ECIESVectorTest.swift -o /tmp/eciesvec \ |
| 152 | && /tmp/eciesvec LChatTests/thread-key-vector.json |
| 153 | ``` |
| 154 | |
| 155 | ## Push notifications (APNs) β built, gated on paid signing |
| 156 | |
| 157 | The pipeline is E2EE-preserving end to end: the server pushes only the |
| 158 | ciphertext blob + thread id (`apns.go`); the app parks each thread's key in the |
| 159 | shared keychain (`SharedThreadKeys`); the notification extension (`LChatNSE`) |
| 160 | decrypts on-device and rewrites the banner. Registration + routing live in |
| 161 | `PushManager.swift`. |
| 162 | |
| 163 | Verified: server handler tests (`TestAPNsSubscribe`), the cross-implementation |
| 164 | decrypt gate (`LChatTests/PushBlobDecryptTest.swift` β run like the ECIES |
| 165 | vector), banner delivery + permission flow in the simulator. **The simulator |
| 166 | does NOT run notification service extensions for `simctl push`** β banners show |
| 167 | the placeholder there; the decrypted rewrite can only be seen on a real device |
| 168 | with real APNs. |
| 169 | |
| 170 | To go live (needs a paid Apple Developer membership): |
| 171 | |
| 172 | 1. developer.apple.com β Keys β new key with the APNs service β download the |
| 173 | `.p8`, note the Key ID and Team ID. |
| 174 | 2. Deploy each instance with the extra env (base64 the key to survive env |
| 175 | quoting): `LCHAT_APNS_KEY` (p8 PEM or its base64), `LCHAT_APNS_KEY_ID`, |
| 176 | `LCHAT_APNS_TEAM_ID`, `LCHAT_APNS_TOPIC` (the app bundle id). |
| 177 | 3. In the app project (already done for the Eleven Messenger team |
| 178 | `J5NDF5PCD8` β redo only if the team or bundle id changes): the |
| 179 | `aps-environment` entitlement on LChat, the paid team as `DEVELOPMENT_TEAM` |
| 180 | in `project.yml`, and the same keychain access group on BOTH targets |
| 181 | (`<TeamID>.<bundle id>.shared`, mirrored by the constant in |
| 182 | `SharedThreadKeys.swift`). |
| 183 | 4. Dev builds register with `sandbox: true` (`#if DEBUG`) and are delivered via |
| 184 | Apple's sandbox gateway; TestFlight/App Store builds use production. |
| 185 | |
| 186 | ## Building on a device as a team member (not the account holder) |
| 187 | |
| 188 | Once the account holder invites you to their Apple Developer team (App Store |
| 189 | Connect β Users and Access, **Developer** or Admin role), you build with **your |
| 190 | own Apple ID** β no shared credentials: |
| 191 | |
| 192 | 1. Xcode β Settings β Accounts β add your Apple ID (the one that was invited). |
| 193 | 2. Build with the repo defaults β `project.yml` already sets the team |
| 194 | (`J5NDF5PCD8`) and bundle id (`com.elevenmessenger.messenger`), whose App ID |
| 195 | carries the Push capability + the shared keychain group. Easiest is the |
| 196 | command line β automatic signing mints *your* development cert and registers |
| 197 | your device under the team: |
| 198 | |
| 199 | ```sh |
| 200 | cd ios && xcodegen generate |
| 201 | xcodebuild -project LChat.xcodeproj -scheme LChat -destination 'generic/platform=iOS' \ |
| 202 | -derivedDataPath build -allowProvisioningUpdates \ |
| 203 | CODE_SIGN_STYLE=Automatic build |
| 204 | xcrun devicectl device install app --device <YOUR_UDID> \ |
| 205 | build/Build/Products/Debug-iphoneos/LChat.app |
| 206 | ``` |
| 207 | |
| 208 | In Xcode's UI instead: just βR β the team and bundle id come from the |
| 209 | generated project. |
| 210 | |
| 211 | If you'd rather not join the team, build with your **own** free personal team |
| 212 | (`DEVELOPMENT_TEAM=<yours>`) and a unique `LCHAT_APP_BUNDLE_ID` β but first |
| 213 | remove `aps-environment` and the `keychain-access-groups` from |
| 214 | `LChat/LChat.entitlements` (a free team can't provision them); you'll lose push |
| 215 | locally but everything else runs. |
| 216 | |