eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– lchat β€” iOS & macOS client

A native SwiftUI client for lchat, 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 (brew install xcodegen). The .xcodeproj is generated, not committed.

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 **.

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):

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 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:

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.

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.