eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– lchat β€” iOS & macOS client
2
3A native SwiftUI client for [lchat](../README.md), running on **iPhone/iPad** and
4on **macOS via Mac Catalyst** (same target, same code). Same end-to-end
5encryption 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
7P-256 keypair**; and every thread has its own AES key **wrapped to each member's
8public key** (ECIES). The user **private key never leaves the device** (Keychain),
9and the server only ever sees opaque ciphertext and wrapped key blobs it can't open.
10
11## Shape: native sidebar, webview feed
12
13The app is a hybrid. The **shell is native** β€” a workspace switcher β†’ a thread
14sidebar (decrypted names + last-message previews) β†’ a feed with a native composer.
15The **message feed is the web client** (`chat.js`) running in a `WKWebView` in embed
16mode, so rendering, reactions, edits, replies, typing and attachments ship from one
17codebase.
18
19Crucially, native does the **identity crypto** (`Crypto.swift`): it unwraps each
20thread's key with the Keychain private key (ECIES) and decrypts just the sidebar's
21names/previews. It then hands the webview **only the active thread's key** β€” never
22the private key, never another thread's key β€” so a single thread's page can't reach
23any other thread's content. Message-content crypto stays single-sourced in `chat.js`,
24reached 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
38Out of scope for this cut (deferred follow-ups): creating threads / managing members
39from native (do it in the web app for now), native reply/edit banners above the
40composer, profile/group avatar editing, and push notifications (needs APNs + server
41work; unread refreshes on foreground for now).
42
43## Build & run
44
45Requires Xcode 16+ and [`xcodegen`](https://github.com/yonsm/XcodeGen)
46(`brew install xcodegen`). The `.xcodeproj` is generated, not committed.
47
48```sh
49cd ios
50xcodegen generate # writes LChat.xcodeproj from project.yml
51open LChat.xcodeproj # then pick a destination and Run
52```
53
54Pick 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
61Both variants build straight from the command line β€” useful for CI or a quick
62compile check. `CODE_SIGNING_ALLOWED=NO` skips the need for a signing team; a
63successful build ends in `** BUILD SUCCEEDED **`.
64
65```sh
66cd ios
67xcodegen generate # if you haven't already
68
69# iOS β€” compiles against the Simulator SDK (no device or signing needed):
70xcodebuild -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:
75xcodebuild -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/`
81instead 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
86Symlink the deep Mac path somewhere handy to launch it (the `LChat-*.app`
87symlink names are gitignored):
88
89```sh
90ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app
91open LChat-mac.app
92```
93
94If a Simulator destination can't be resolved (e.g. an out-of-date
95CoreSimulator), target the SDK directly instead β€” swap the `-destination` flag
96for `-sdk iphonesimulator`. The Mac Catalyst build is the most reliable headless
97gate since it never touches the Simulator service.
98
99To run on a physical iOS device or on the Mac, sign in with an Apple ID that's
100a member of the Eleven Messenger team (`J5NDF5PCD8`, set as `DEVELOPMENT_TEAM`
101in `project.yml`) β€” the bundle id is `com.elevenmessenger.messenger`. Not a
102team member? See the personal-team fallback at the bottom of this file.
103
104### macOS (Mac Catalyst) notes
105
106The Mac app is the same target with `SUPPORTS_MACCATALYST = YES`. Because
107Catalyst apps are sandboxed, `LChat/LChat.entitlements` grants
108`com.apple.security.network.client` β€” **without it the Mac app cannot connect to
109the server at all** (REST or WebSocket). The Keychain, photo picker, and share
110sheet all work under the sandbox as-is.
111
112The Mac variant is also the most reliable headless build gate β€” see
113[Build without launching Xcode](#build-without-launching-xcode) above.
114
115Composer 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
117can 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 .`
122instance over plain HTTP on the LAN/loopback. In production, instances are HTTPS
123and no ATS exception is needed. Mint a key-invite from the admin console, then paste
124that 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
143The wire format is byte-identical to the web client (CryptoKit's `SealedBox.combined`
144is exactly `ivβ€–ciphertextβ€–tag`; the ECIES wrap is `ephPub(65)β€–iv(12)β€–ct`, HKDF-SHA256
145salt-empty info `lchat-thread-wrap`). A cross-implementation test vector
146(`LChatTests/thread-key-vector.json`, generated by `test/threadkey.test.mjs`) gates
147the native ECIES against drift β€” run it with:
148
149```sh
150cd 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
157The pipeline is E2EE-preserving end to end: the server pushes only the
158ciphertext blob + thread id (`apns.go`); the app parks each thread's key in the
159shared keychain (`SharedThreadKeys`); the notification extension (`LChatNSE`)
160decrypts on-device and rewrites the banner. Registration + routing live in
161`PushManager.swift`.
162
163Verified: server handler tests (`TestAPNsSubscribe`), the cross-implementation
164decrypt gate (`LChatTests/PushBlobDecryptTest.swift` β€” run like the ECIES
165vector), banner delivery + permission flow in the simulator. **The simulator
166does NOT run notification service extensions for `simctl push`** β€” banners show
167the placeholder there; the decrypted rewrite can only be seen on a real device
168with real APNs.
169
170To go live (needs a paid Apple Developer membership):
171
1721. developer.apple.com β†’ Keys β†’ new key with the APNs service β†’ download the
173 `.p8`, note the Key ID and Team ID.
1742. 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).
1773. 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`).
1834. 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
188Once the account holder invites you to their Apple Developer team (App Store
189Connect β†’ Users and Access, **Developer** or Admin role), you build with **your
190own Apple ID** β€” no shared credentials:
191
1921. Xcode β†’ Settings β†’ Accounts β†’ add your Apple ID (the one that was invited).
1932. 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
211If 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
213remove `aps-environment` and the `keychain-access-groups` from
214`LChat/LChat.entitlements` (a free team can't provision them); you'll lose push
215locally but everything else runs.
216