iOS native app: multi-thread rebuild + chat-list-first shell (builds on #ios-multithread-rebuild) (#54)
* ios: rebuild the native client for the multi-thread model
The PR #17 multi-thread rewrite (instances are workspaces of threads; identity
is a per-instance ECDH P-256 keypair; each thread's AES key is wrapped per-member
via ECIES) left the native app — which modelled one instance as one flat chat and
delegated all crypto to a webview — broken: the PR #17 chat.js also dropped the
embed hooks PR #16 had added.
Rebuild it as a native shell (workspace switcher → thread sidebar → feed) over a
webview feed, with native identity crypto:
- crypto: ECIES wrap/unwrap in CryptoKit (P-256 ECDH → HKDF-SHA256 salt-empty
info "lchat-thread-wrap" → AES-GCM), byte-compatible with crypto.js. The user
private key stays in the Keychain; the webview is handed only the active
thread's key, never the private key or other threads' keys — strictly tighter
than the old "seed the key into localStorage". Gated by a cross-implementation
test vector (test/threadkey.test.mjs ↔ ios/LChatTests).
- web: a thread-scoped embed mode in chat.js (window.__lchatEmbed seed +
window.lchatNative bridge), revived .native-embed CSS, and a join handback in
join.js returning {token,name,userPub,userPriv}.
- native: SavedInstance (keypair in Keychain), ThreadStore (native unwrap +
decrypted sidebar titles/previews/unread), the three-pane NavigationSplitView,
add-workspace via a one-shot join webview, and native new-chat (roster picker →
wrap thread key per member → POST /api/threads). Clean break from legacy #k=
links and the single-session migration.
Verified: npm test (incl. the new vector), the ECIES vector both directions
(a native-wrapped key unwraps in the web client), and the Mac Catalyst build.
Not yet verified end-to-end: the live nav/feed/join behaviour needs a deploy from
this branch (web assets are go:embed'd) + a native run.
Co-Authored-By: Claude <noreply@anthropic.com>
* ios: end-to-end XCUITest (join → thread list → send/receive) + Send a11y label
The UI test drives the real app against a live server: paste a member-invite
link, complete the web join form, land in the DM, send from the native
composer, and assert the peer's live reply renders in the webview feed. It
needs TEST_RUNNER_INVITE_LINK (plus a scripted peer replying server-side) and
skips itself otherwise. The send button also gains an accessibility label —
it was unlabelled for VoiceOver.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios: match the Mac app's add-chat language + fix join-webview session leakage
- "Add a chat" everywhere (never "workspace"), Mac-style steps, and the link
initialises as soon as the pasted text is valid — no Continue button
(mirrors LChatMac's AddChatView).
- The one-shot join webview now uses a non-persistent website data store, and
join.js skips the adopt-existing shortcut + session writes in embed mode: a
leftover webview session was silently consuming invites as the previous
identity and dead-ending on a blank page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios: chat-list-first navigation with a slide-in server drawer + persistence fix
Restructure the shell to the Mac app's shape translated to a phone: the main
index is the active server's chat list; the top-left server button slides in a
drawer (the Mac rail) to switch servers or add one; tapping a chat pushes its
feed. Replaces the NavigationSplitView three-pane from the earlier rebuild.
Also fix instances vanishing on relaunch: ad-hoc dev builds can't use the
data-protection keychain (SecItemAdd fails), so a failed keychain save now
falls back to a JSON file in Application Support — the Mac scaffold's
trade-off. Ship builds with real signing keep the keychain-only path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios: iMessage-style New chat picker — tap a person to DM, New group flow, BOT-badged Helpers
The New chat sheet now lists people directly: tapping one starts the DM on the
spot (the server dedupes, so an existing DM just opens). "New group" pushes
the multi-select + optional-name flow. Agent helpers decode is_agent from the
roster and sit in their own Helpers section with a BOT badge, mirroring the
web picker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios: debounce the add-chat auto-init so partial links can't fire a truncated secret
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios: Eleven branding — the Mac app's icon (full-bleed for iOS) + display name
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: James Adam <james@lazyatom.com> Co-authored-by: Claude <noreply@anthropic.com>
25 files changed,
+1626
−843
ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.pngbinaryios/LChat/Crypto.swift+81 −0ios/LChat/Info.plist+1 −1ios/LChat/Keychain.swift+40 −16ios/LChat/LChatAPI.swift+64 −79ios/LChat/LChatApp.swift+99 −67ios/LChat/Models.swift+74 −165ios/LChat/ThreadStore.swift+167 −0ios/LChat/Views/Avatar.swift+32 −0ios/LChat/Views/ChatListView.swift+0 −93ios/LChat/Views/ChatView.swift+78 −283ios/LChat/Views/InstanceSidebar.swift+76 −0ios/LChat/Views/JoinView.swift+174 −101ios/LChat/Views/NewChatView.swift+172 −0ios/LChat/Views/ThreadListView.swift+113 −0ios/LChatTests/ECIESVectorTest.swift+52 −0ios/LChatTests/thread-key-vector.json+7 −0ios/LChatUITests/JoinAndChatTests.swift+84 −0ios/README.md+60 −26ios/project.yml+22 −1package.json+1 −1test/threadkey.test.mjs+74 −0web/static/chat.js+117 −5web/static/join.js+20 −5web/static/style.css+18 −0
diff --git a/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png| index 105803e..09744f5 100644 |
| Binary files a/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png and b/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ |
diff --git a/ios/LChat/Crypto.swift b/ios/LChat/Crypto.swift| index 450ec49..920c598 100644 |
| --- a/ios/LChat/Crypto.swift |
| +++ b/ios/LChat/Crypto.swift |
| @@ -16,6 +16,81 @@ enum CryptoError: Error { |
| case badKey |
| case badBlob |
| case sealFailed |
| + case badWrap |
| +} |
| + |
| +// ECIES key-unwrap, byte-compatible with web/static/crypto.js `unwrapThreadKey`. |
| +// |
| +// In the multi-thread model each thread (and the instance) has its own AES key, |
| +// wrapped to each member's user public key. A wrapped blob is, base64url-encoded: |
| +// |
| +// ephPub(65) || iv(12) || ciphertext||tag |
| +// |
| +// where ephPub is an ephemeral P-256 public point (uncompressed / x963). We recover |
| +// the AES key with our *user private key* (PKCS#8): ECDH against ephPub, then |
| +// HKDF-SHA256(salt = empty, info = "lchat-thread-wrap") to the same AES-GCM key the |
| +// web derived, then open. The web uses the same "lchat-thread-wrap" info for both |
| +// thread keys and the instance key, so this one path covers both. |
| +// |
| +// This is the *only* crypto duplicated from the web (everything else stays in |
| +// chat.js, reached through the webview). The cross-implementation test vector in |
| +// ios/LChatTests/thread-key-vector.json (generated by test/threadkey.test.mjs) |
| +// guards it against drift. |
| +enum ECIES { |
| + private static let info = Data("lchat-thread-wrap".utf8) |
| + |
| + /// A fresh random 256-bit AES key (for a new thread). |
| + static func randomAESKey() -> Data { |
| + SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) } |
| + } |
| + |
| + /// Wraps a raw AES key (32 bytes) to a recipient's user public key (base64url |
| + /// raw/x963), producing the base64url blob `ephPub(65) || iv(12) || ct||tag` — |
| + /// byte-compatible with web/static/crypto.js `wrapThreadKey`. Used when starting |
| + /// a chat: the new thread key is wrapped to each member (including yourself). |
| + static func wrap(rawKey: Data, recipientPubB64: String) throws -> String { |
| + guard rawKey.count == 32, let recipientRaw = Data(base64urlEncoded: recipientPubB64) else { |
| + throw CryptoError.badWrap |
| + } |
| + do { |
| + let recipient = try P256.KeyAgreement.PublicKey(x963Representation: recipientRaw) |
| + let eph = P256.KeyAgreement.PrivateKey() |
| + let shared = try eph.sharedSecretFromKeyAgreement(with: recipient) |
| + let aes = shared.hkdfDerivedSymmetricKey( |
| + using: SHA256.self, salt: Data(), sharedInfo: info, outputByteCount: 32) |
| + let box = try AES.GCM.seal(rawKey, using: aes) |
| + guard let combined = box.combined else { throw CryptoError.sealFailed } // iv||ct||tag |
| + var out = Data(eph.publicKey.x963Representation) // 65-byte uncompressed point |
| + out.append(combined) |
| + return out.base64urlEncodedString() |
| + } catch { |
| + throw CryptoError.badWrap |
| + } |
| + } |
| + |
| + /// Unwraps a wrapped key blob (base64url) with a user private key (base64url |
| + /// PKCS#8), returning the 32-byte raw AES key. |
| + static func unwrap(wrappedB64: String, privPkcs8B64: String) throws -> Data { |
| + guard let blob = Data(base64urlEncoded: wrappedB64), blob.count > 65 + 12, |
| + let pkcs8 = Data(base64urlEncoded: privPkcs8B64) else { |
| + throw CryptoError.badWrap |
| + } |
| + let ephPub = blob.prefix(65) |
| + let sealed = blob.suffix(from: blob.startIndex + 65) // iv || ciphertext || tag |
| + do { |
| + let priv = try P256.KeyAgreement.PrivateKey(derRepresentation: pkcs8) |
| + let eph = try P256.KeyAgreement.PublicKey(x963Representation: ephPub) |
| + let shared = try priv.sharedSecretFromKeyAgreement(with: eph) |
| + let aes = shared.hkdfDerivedSymmetricKey( |
| + using: SHA256.self, salt: Data(), sharedInfo: info, outputByteCount: 32) |
| + let box = try AES.GCM.SealedBox(combined: sealed) |
| + let raw = try AES.GCM.open(box, using: aes) |
| + guard raw.count == 32 else { throw CryptoError.badWrap } |
| + return raw |
| + } catch { |
| + throw CryptoError.badWrap |
| + } |
| + } |
| } |
| struct GroupKey { |
| @@ -29,6 +104,12 @@ struct GroupKey { |
| key = SymmetricKey(data: raw) |
| } |
| + /// Wraps a raw 32-byte key (e.g. one just unwrapped via `ECIES.unwrap`). |
| + init(rawKey: Data) throws { |
| + guard rawKey.count == 32 else { throw CryptoError.badKey } |
| + key = SymmetricKey(data: rawKey) |
| + } |
| + |
| // MARK: JSON payloads (chat messages) |
| func decryptJSON<T: Decodable>(_ type: T.Type, blob: String) throws -> T { |
diff --git a/ios/LChat/Info.plist b/ios/LChat/Info.plist| index 6eed0ba..346963b 100644 |
| --- a/ios/LChat/Info.plist |
| +++ b/ios/LChat/Info.plist |
| @@ -5,7 +5,7 @@ |
| <key>CFBundleDevelopmentRegion</key> |
| <string>$(DEVELOPMENT_LANGUAGE)</string> |
| <key>CFBundleDisplayName</key> |
| - <string>lchat</string> |
| + <string>Eleven</string> |
| <key>CFBundleExecutable</key> |
| <string>$(EXECUTABLE_NAME)</string> |
| <key>CFBundleIdentifier</key> |
diff --git a/ios/LChat/Keychain.swift b/ios/LChat/Keychain.swift| index c310b2e..ea1d870 100644 |
| --- a/ios/LChat/Keychain.swift |
| +++ b/ios/LChat/Keychain.swift |
| @@ -1,8 +1,8 @@ |
| import Foundation |
| import Security |
| -// Keychain-backed JSON storage. The saved chats live here (not UserDefaults) |
| -// because their invite URLs carry the group key — the "key is load-bearing" rule |
| +// Keychain-backed JSON storage. The saved instances live here (not UserDefaults) |
| +// because each carries its user *private* key — the "key is load-bearing" rule |
| // from CLAUDE.md. |
| // |
| // Every query sets kSecUseDataProtectionKeychain so Mac Catalyst uses the same |
| @@ -30,8 +30,12 @@ enum Keychain { |
| return value |
| } |
| - static func save<T: Encodable>(_ value: T, account: String) { |
| - guard let data = try? JSONEncoder().encode(value) else { return } |
| + /// Returns whether the item actually landed in the keychain — ad-hoc dev |
| + /// builds can lack the entitlement the data-protection keychain requires, |
| + /// in which case SecItemAdd fails and callers need a fallback. |
| + @discardableResult |
| + static func save<T: Encodable>(_ value: T, account: String) -> Bool { |
| + guard let data = try? JSONEncoder().encode(value) else { return false } |
| let base: [String: Any] = [ |
| kSecClass as String: kSecClassGenericPassword, |
| kSecAttrService as String: service, |
| @@ -42,7 +46,7 @@ enum Keychain { |
| var add = base |
| add[kSecValueData as String] = data |
| add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly |
| - SecItemAdd(add as CFDictionary, nil) |
| + return SecItemAdd(add as CFDictionary, nil) == errSecSuccess |
| } |
| static func clear(account: String) { |
| @@ -55,16 +59,36 @@ enum Keychain { |
| } |
| } |
| -// The persisted list of chats. |
| -enum ChatsStore { |
| - private static let account = "chats" |
| - static func load() -> [SavedChat] { Keychain.load([SavedChat].self, account: account) ?? [] } |
| - static func save(_ chats: [SavedChat]) { Keychain.save(chats, account: account) } |
| -} |
| +// The persisted list of instances (each with its keypair). Keychain-backed because |
| +// it holds the user private keys — but ad-hoc/dev builds can't always mint the |
| +// data-protection entitlement, so a failed keychain save falls back to a JSON file |
| +// in Application Support (the Mac scaffold hit the same wall and made the same |
| +// trade-off). A ship build with real signing takes the keychain path only. |
| +enum InstancesStore { |
| + private static let account = "instances" |
| + |
| + static func load() -> [SavedInstance] { |
| + Keychain.load([SavedInstance].self, account: account) ?? fileLoad() ?? [] |
| + } |
| + |
| + static func save(_ instances: [SavedInstance]) { |
| + if Keychain.save(instances, account: account) { |
| + try? FileManager.default.removeItem(at: fileURL) // don't leave a stale copy behind |
| + } else if let data = try? JSONEncoder().encode(instances) { |
| + try? FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), |
| + withIntermediateDirectories: true) |
| + try? data.write(to: fileURL, options: .atomic) |
| + } |
| + } |
| + |
| + private static var fileURL: URL { |
| + (FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first |
| + ?? URL(fileURLWithPath: NSTemporaryDirectory())) |
| + .appendingPathComponent("lchat/instances.json") |
| + } |
| -// Legacy single-session slot, kept only to migrate old installs into the list. |
| -enum SessionStore { |
| - private static let account = "session" |
| - static func load() -> Session? { Keychain.load(Session.self, account: account) } |
| - static func clear() { Keychain.clear(account: account) } |
| + private static func fileLoad() -> [SavedInstance]? { |
| + guard let data = try? Data(contentsOf: fileURL) else { return nil } |
| + return try? JSONDecoder().decode([SavedInstance].self, from: data) |
| + } |
| } |
diff --git a/ios/LChat/LChatAPI.swift b/ios/LChat/LChatAPI.swift| index 004e373..fe2e821 100644 |
| --- a/ios/LChat/LChatAPI.swift |
| +++ b/ios/LChat/LChatAPI.swift |
| @@ -1,109 +1,94 @@ |
| import Foundation |
| -// Thin REST client for the lchat HTTP API. Every data endpoint is token-gated; |
| -// the token rides in the query string exactly as the web client sends it. The |
| -// server only ever sees ciphertext and opaque blob ids. |
| +// Thin REST client for the lchat HTTP API. Every data endpoint is token-gated; the |
| +// device token rides in the query string (GET) or JSON body (POST), exactly as the |
| +// web client sends it. The server only ever sees ciphertext, opaque blob ids, and |
| +// wrapped key blobs it cannot open. |
| struct LChatAPI { |
| let baseURL: URL |
| let token: String |
| private let session = URLSession(configuration: .default) |
| - // MARK: Invite link parsing (mirrors chat.js `parse`) |
| + // MARK: Identity / roster |
| - struct ParsedInvite { |
| - let baseURL: URL |
| - let token: String |
| - let keyB64: String |
| + /// Registers (idempotently) this device's user public key in the roster. |
| + func ensurePubkey(_ publicKey: String) async { |
| + await postJSON("/api/pubkey", ["token": token, "public_key": publicKey]) |
| } |
| - /// Parses `https://host/join/<token>#k=<key>` into its parts. |
| - static func parseInvite(_ raw: String) -> ParsedInvite? { |
| - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| - guard var comps = URLComponents(string: trimmed), |
| - let scheme = comps.scheme, let host = comps.host, |
| - scheme == "http" || scheme == "https" |
| - else { return nil } |
| - |
| - let token = comps.path.split(separator: "/").last.map(String.init) |
| - // The key lives in the fragment as `k=…` (URLComponents leaves it raw). |
| - var keyB64: String? |
| - if let frag = comps.fragment { |
| - for pair in frag.split(separator: "&") { |
| - let kv = pair.split(separator: "=", maxSplits: 1) |
| - if kv.first == "k", kv.count == 2 { keyB64 = String(kv[1]) } |
| - } |
| - } |
| - guard let token, !token.isEmpty, let keyB64, !keyB64.isEmpty else { return nil } |
| - |
| - // Reduce to a clean origin URL (scheme://host[:port]). |
| - comps.path = "" |
| - comps.query = nil |
| - comps.fragment = nil |
| - guard let base = comps.url else { return nil } |
| - return ParsedInvite(baseURL: base, token: token, keyB64: keyB64) |
| + func roster() async -> [RosterEntry] { |
| + struct Resp: Codable { let roster: [RosterEntry]? } |
| + return (try? await getJSON("/api/roster", Resp.self))?.roster ?? [] |
| } |
| - // MARK: Endpoints |
| + // MARK: Threads / keys |
| - static func inviteInfo(baseURL: URL, token: String) async throws -> InviteInfo { |
| - let url = baseURL.appending(path: "/api/invite/\(token.urlPathEscaped)") |
| - let (data, _) = try await URLSession.shared.data(from: url) |
| - return try JSONDecoder().decode(InviteInfo.self, from: data) |
| + func threads() async -> [ServerThread] { |
| + struct Resp: Codable { let threads: [ServerThread]? } |
| + return (try? await getJSON("/api/threads", Resp.self))?.threads ?? [] |
| } |
| - static func claim(baseURL: URL, token: String) async { |
| - let url = baseURL.appending(path: "/api/invite/\(token.urlPathEscaped)/claim") |
| - var req = URLRequest(url: url) |
| - req.httpMethod = "POST" |
| - _ = try? await URLSession.shared.data(for: req) |
| + /// The caller's wrapped instance key (for member avatars), or nil if not granted. |
| + func instanceKeyWrapped() async -> String? { |
| + struct Resp: Codable { let wrapped: String? } |
| + let wrapped = (try? await getJSON("/api/instance-key", Resp.self))?.wrapped |
| + return (wrapped?.isEmpty ?? true) ? nil : wrapped |
| } |
| - func groupName() async -> String { |
| - let url = baseURL.appending(path: "/api/group") |
| - guard let (data, _) = try? await session.data(from: url), |
| - let obj = try? JSONDecoder().decode([String: String].self, from: data), |
| - let name = obj["name"] |
| - else { return "lchat" } |
| - return name |
| - } |
| - |
| - func messages(after: Int64) async throws -> [ServerMessage] { |
| - var comps = URLComponents(url: baseURL.appending(path: "/api/messages"), |
| - resolvingAgainstBaseURL: false)! |
| - comps.queryItems = [ |
| + /// Messages in a thread after a cursor id — used to count unread (the feed |
| + /// itself is rendered by the webview). |
| + func messages(thread: String, after: Int64) async -> [ServerMessage] { |
| + struct Resp: Codable { let messages: [ServerMessage]? } |
| + let items = [ |
| URLQueryItem(name: "token", value: token), |
| + URLQueryItem(name: "thread", value: thread), |
| URLQueryItem(name: "after", value: String(after)), |
| ] |
| - let (data, _) = try await session.data(from: comps.url!) |
| - struct Resp: Codable { let messages: [ServerMessage]? } |
| - return try JSONDecoder().decode(Resp.self, from: data).messages ?? [] |
| + return (try? await getJSON("/api/messages", Resp.self, query: items))?.messages ?? [] |
| } |
| - /// Uploads encrypted bytes, returns the new blob id. |
| - func upload(_ data: Data) async throws -> String { |
| - var comps = URLComponents(url: baseURL.appending(path: "/api/upload"), |
| - resolvingAgainstBaseURL: false)! |
| - comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| - var req = URLRequest(url: comps.url!) |
| + /// Creates a thread (self/dm/group) with the per-member wrapped keys. Returns the |
| + /// new (or, for a deduped DM, existing) thread id. `members` is [{user_id, wrapped}]. |
| + func createThread(kind: String, nameBlob: String, members: [[String: String]]) async -> String? { |
| + struct Resp: Codable { let thread_id: String? } |
| + let body: [String: Any] = ["token": token, "kind": kind, "name_blob": nameBlob, "members": members] |
| + guard let url = URL(string: "/api/threads", relativeTo: baseURL), |
| + let data = try? JSONSerialization.data(withJSONObject: body) else { return nil } |
| + var req = URLRequest(url: url) |
| req.httpMethod = "POST" |
| - req.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") |
| - let (respData, _) = try await session.upload(for: req, from: data) |
| - struct Resp: Codable { let id: String } |
| - return try JSONDecoder().decode(Resp.self, from: respData).id |
| + req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| + req.httpBody = data |
| + guard let (respData, _) = try? await session.data(for: req), |
| + let r = try? JSONDecoder().decode(Resp.self, from: respData) else { return nil } |
| + return r.thread_id |
| + } |
| + |
| + /// The instance's display name (public; substituted into pages too). |
| + func groupName() async -> String { |
| + struct Resp: Codable { let name: String? } |
| + return (try? await getJSON("/api/group", Resp.self, authed: false))?.name ?? "lchat" |
| } |
| - /// Downloads encrypted attachment bytes by id. |
| - func blob(id: String) async throws -> Data { |
| - var comps = URLComponents(url: baseURL.appending(path: "/api/blob/\(id.urlPathEscaped)"), |
| - resolvingAgainstBaseURL: false)! |
| - comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| + // MARK: Plumbing |
| + |
| + private func getJSON<T: Decodable>( |
| + _ path: String, _ type: T.Type, query: [URLQueryItem]? = nil, authed: Bool = true |
| + ) async throws -> T { |
| + var comps = URLComponents(url: baseURL.appending(path: path), resolvingAgainstBaseURL: false)! |
| + comps.queryItems = query ?? (authed ? [URLQueryItem(name: "token", value: token)] : nil) |
| let (data, _) = try await session.data(from: comps.url!) |
| - return data |
| + return try JSONDecoder().decode(T.self, from: data) |
| } |
| -} |
| -private extension String { |
| - var urlPathEscaped: String { |
| - addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? self |
| + @discardableResult |
| + private func postJSON(_ path: String, _ body: [String: Any]) async -> Bool { |
| + guard let url = URL(string: path, relativeTo: baseURL), |
| + let data = try? JSONSerialization.data(withJSONObject: body) else { return false } |
| + var req = URLRequest(url: url) |
| + req.httpMethod = "POST" |
| + req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| + req.httpBody = data |
| + let resp = try? await session.data(for: req) |
| + return resp != nil |
| } |
| } |
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 83d0c18..3492ea8 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -12,92 +12,124 @@ struct LChatApp: App { |
| } |
| } |
| -// Owns the persisted list of chats and which one (if any) is open. |
| +// Owns the persisted list of instances (workspaces). Each instance carries its own |
| +// keypair; the list lives in the Keychain (InstancesStore). |
| final class AppState: ObservableObject { |
| - @Published var chats: [SavedChat] |
| - @Published var current: SavedChat? // nil = showing the chat list |
| + @Published var instances: [SavedInstance] |
| - init() { |
| - var saved = ChatsStore.load() |
| - // Migrate an old single-session install into the list, once. |
| - if saved.isEmpty, let old = SessionStore.load() { |
| - let url = "\(old.origin)/join/\(old.token)#k=\(old.keyB64)" |
| - saved = [SavedChat(url: url, name: old.name)] |
| - ChatsStore.save(saved) |
| - SessionStore.clear() |
| - } |
| - chats = saved |
| - } |
| - |
| - func open(_ chat: SavedChat) { current = chat } |
| - |
| - /// Back to the list. The chat's lastSeenId (so far) clears its unread badge. |
| - func close(lastSeenId: Int64) { |
| - if let cur = current, let i = chats.firstIndex(where: { $0.id == cur.id }) { |
| - chats[i].lastSeenId = max(chats[i].lastSeenId, lastSeenId) |
| - ChatsStore.save(chats) |
| - } |
| - current = nil |
| - } |
| + init() { instances = InstancesStore.load() } |
| - /// Add a chat (or update the screen name if it's already saved) and open it. |
| - func add(url: String, name: String) { |
| - let chat = SavedChat(url: url, name: name) |
| - if let i = chats.firstIndex(where: { $0.token != nil && $0.token == chat.token }) { |
| - chats[i].name = name |
| - ChatsStore.save(chats) |
| - current = chats[i] |
| + /// Add a new instance, or replace one with the same id (re-join). |
| + func add(_ inst: SavedInstance) { |
| + if let i = instances.firstIndex(where: { $0.id == inst.id }) { |
| + instances[i] = inst |
| } else { |
| - chats.append(chat) |
| - ChatsStore.save(chats) |
| - current = chat |
| + instances.append(inst) |
| } |
| + InstancesStore.save(instances) |
| } |
| - func remove(_ chat: SavedChat) { |
| - chats.removeAll { $0.id == chat.id } |
| - ChatsStore.save(chats) |
| + func remove(_ inst: SavedInstance) { |
| + instances.removeAll { $0.id == inst.id } |
| + InstancesStore.save(instances) |
| } |
| - /// Persist the session resolved by the webview's join flow (new-format, |
| - /// server-blind invites), so later opens skip the join screen and the title can |
| - /// fetch the group name. The webview already holds the live session in its |
| - /// localStorage; this just mirrors it natively. |
| - func resolveSession(token: String, key: String, name: String, for chat: SavedChat) { |
| - guard let i = chats.firstIndex(where: { $0.id == chat.id }) else { return } |
| - chats[i].claimedToken = token |
| - chats[i].claimedKey = key |
| - if !name.isEmpty { chats[i].name = name } |
| - ChatsStore.save(chats) |
| - // Refresh the open chat so the view picks up the now-resolved session |
| - // (without recreating the WKWebView — it's already showing the feed). |
| - if current?.id == chat.id { current = chats[i] } |
| + /// Persist resolved fields discovered after first load (our user id, group name). |
| + func note(id: String, myUserId: String? = nil, groupName: String? = nil) { |
| + guard let i = instances.firstIndex(where: { $0.id == id }) else { return } |
| + var changed = false |
| + if let uid = myUserId, !uid.isEmpty, instances[i].myUserId != uid { |
| + instances[i].myUserId = uid; changed = true |
| + } |
| + if let name = groupName, !name.isEmpty, instances[i].groupName != name { |
| + instances[i].groupName = name; changed = true |
| + } |
| + if changed { InstancesStore.save(instances) } |
| } |
| +} |
| - func setName(_ name: String, for chat: SavedChat) { |
| - guard let i = chats.firstIndex(where: { $0.id == chat.id }) else { return } |
| - chats[i].name = name |
| - ChatsStore.save(chats) |
| - } |
| +// Holds the live navigation: which instance (server) is open and its ThreadStore. |
| +// One ThreadStore at a time, swapped when the server changes, so the chat list and |
| +// the feed observe the same store. |
| +@MainActor |
| +final class NavModel: ObservableObject { |
| + @Published var instanceID: String? |
| + @Published var store: ThreadStore? |
| - func markSeen(_ id: Int64, in chat: SavedChat) { |
| - guard let i = chats.firstIndex(where: { $0.id == chat.id }) else { return } |
| - if id > chats[i].lastSeenId { |
| - chats[i].lastSeenId = id |
| - ChatsStore.save(chats) |
| - } |
| + func selectInstance(_ inst: SavedInstance?) { |
| + guard inst?.id != instanceID else { return } |
| + instanceID = inst?.id |
| + store = inst.map { ThreadStore(instance: $0) } |
| } |
| } |
| +// The shell, structured like the Mac app translated to a phone: the main index is |
| +// the active server's chat list; the top-left server button slides in an overlay |
| +// (the Mac rail as a drawer) to switch servers or add one; tapping a chat pushes |
| +// its feed. |
| struct RootView: View { |
| @EnvironmentObject var app: AppState |
| + @StateObject private var nav = NavModel() |
| + @State private var path: [String] = [] // pushed thread ids |
| + @State private var showServers = false |
| + @State private var showAdd = false |
| var body: some View { |
| - if let current = app.current { |
| - ChatView(chat: current) |
| - .id(current.id) // rebuild the store when switching chats |
| - } else { |
| - ChatListView() |
| + NavigationStack(path: $path) { |
| + Group { |
| + if let store = nav.store { |
| + ThreadListView(store: store, path: $path, showServers: $showServers) |
| + } else { |
| + noServers |
| + } |
| + } |
| + } |
| + .overlay { drawer } |
| + .sheet(isPresented: $showAdd) { |
| + AddInstanceView(onAdded: select) |
| + } |
| + .onAppear { |
| + if nav.instanceID == nil { nav.selectInstance(app.instances.first) } |
| + } |
| + } |
| + |
| + private func select(_ inst: SavedInstance) { |
| + path = [] // pushed chats belong to the previous server |
| + nav.selectInstance(inst) |
| + withAnimation(.easeOut(duration: 0.2)) { showServers = false } |
| + } |
| + |
| + @ViewBuilder private var drawer: some View { |
| + ZStack(alignment: .leading) { |
| + if showServers { |
| + Color.black.opacity(0.35) |
| + .ignoresSafeArea() |
| + .onTapGesture { withAnimation(.easeOut(duration: 0.2)) { showServers = false } } |
| + .transition(.opacity) |
| + ServerDrawer( |
| + activeID: nav.instanceID, |
| + onSelect: select, |
| + onAdd: { |
| + showServers = false |
| + showAdd = true |
| + } |
| + ) |
| + .transition(.move(edge: .leading)) |
| + } |
| + } |
| + .animation(.easeOut(duration: 0.22), value: showServers) |
| + } |
| + |
| + private var noServers: some View { |
| + VStack(spacing: 14) { |
| + Image(systemName: "bubble.left.and.bubble.right") |
| + .font(.system(size: 44)).foregroundStyle(.secondary) |
| + Text("No chats yet").font(.headline) |
| + Text("Add one with an invite link you were sent.") |
| + .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) |
| + Button { showAdd = true } label: { Text("Add a chat").frame(maxWidth: 220) } |
| + .buttonStyle(.borderedProminent).controlSize(.large) |
| } |
| + .padding() |
| } |
| } |
diff --git a/ios/LChat/Models.swift b/ios/LChat/Models.swift| index 0eafc02..77d4724 100644 |
| --- a/ios/LChat/Models.swift |
| +++ b/ios/LChat/Models.swift |
| @@ -1,195 +1,104 @@ |
| import Foundation |
| -// The persisted identity for one chat on this device. The key never leaves the |
| -// device and is stored in the Keychain (see Keychain.swift), mirroring the |
| -// "the key is load-bearing" rule from CLAUDE.md. |
| -struct Session: Codable, Equatable { |
| - var baseURL: URL // e.g. https://chat.example.com — derived from the invite link |
| - var token: String // invite token (transport auth only; NOT a decryption key) |
| - var keyB64: String // base64url AES-GCM key from the link's #fragment |
| - var name: String // screen name |
| - |
| - /// wss/ws URL for the live feed, matching baseURL's scheme. |
| - var socketURL: URL { |
| - var c = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! |
| - c.scheme = baseURL.scheme == "https" ? "wss" : "ws" |
| - c.path = "/ws" |
| - c.queryItems = [URLQueryItem(name: "token", value: token)] |
| - return c.url! |
| - } |
| - |
| - /// The Origin header value to present on the WebSocket upgrade, so the |
| - /// server's same-origin check is satisfied. |
| - var origin: String { |
| - var s = baseURL.scheme.map { "\($0)://" } ?? "" |
| - s += baseURL.host ?? "" |
| - if let port = baseURL.port { s += ":\(port)" } |
| - return s |
| - } |
| +// A saved instance (workspace) on this device. Identity is per-instance: each |
| +// instance gets its own ECDH P-256 keypair. The PRIVATE key is the load-bearing |
| +// secret, so the whole struct lives in the Keychain (InstancesStore) — never |
| +// UserDefaults. `userPriv` feeds the native ECIES unwrap (Crypto.swift) to open |
| +// per-thread keys for the sidebar; it is NEVER handed to a webview, which only ever |
| +// receives a single already-unwrapped thread key. |
| +struct SavedInstance: Codable, Identifiable, Equatable { |
| + var origin: String // scheme://host[:port] — the instance's URL |
| + var deviceToken: String // transport auth (NOT a decryption key) |
| + var screenName: String // our display name in this instance |
| + var userPub: String // base64url raw (uncompressed) P-256 public key |
| + var userPriv: String // base64url PKCS#8 private key — Keychain only |
| + var myUserId: String = "" // our roster user_id, resolved on first roster load |
| + var groupName: String = ""// cached instance name, for the switcher row |
| + |
| + // One identity per instance per device (v1). The origin is the stable handle. |
| + var id: String { origin } |
| + |
| + var baseURL: URL? { URL(string: origin) } |
| + |
| + /// A human label for the switcher until the real group name loads. |
| + var displayName: String { groupName.isEmpty ? (baseURL?.host ?? "lchat") : groupName } |
| } |
| -// A saved chat: the invite URL (which carries host, token and key in its |
| -// #fragment for legacy links) plus the screen name — persisted between runs. |
| -// lastSeenId drives the unread count shown in the chat list. |
| -// |
| -// New-format (server-blind) invite links don't carry the key — it's fetched and |
| -// unwrapped by the web join flow inside the WKWebView, which then hands the |
| -// resolved session back to us. We persist that as claimedToken/claimedKey so |
| -// later opens skip the join screen and the native title can fetch the group name. |
| -struct SavedChat: Codable, Identifiable, Equatable { |
| - var url: String |
| - var name: String |
| - var lastSeenId: Int64 = 0 |
| - // Filled in once a new-format invite has been claimed via the webview. nil for |
| - // legacy links (whose token/key come straight from the URL). |
| - var claimedToken: String? |
| - var claimedKey: String? |
| - |
| - var id: String { url } |
| - |
| - /// The origin (scheme://host[:port]) of the stored link. |
| - var origin: URL? { |
| - guard let c = URLComponents(string: url), let scheme = c.scheme, let host = c.host else { return nil } |
| - var o = URLComponents() |
| - o.scheme = scheme |
| - o.host = host |
| - o.port = c.port |
| - return o.url |
| - } |
| - |
| - /// A usable Session, or nil if we don't have the key yet. Prefers a resolved |
| - /// (claimed) new-format session; otherwise parses a legacy `#k=` link. |
| - var session: Session? { |
| - if let token = claimedToken, let key = claimedKey, let base = origin { |
| - return Session(baseURL: base, token: token, keyB64: key, name: name) |
| - } |
| - guard let p = LChatAPI.parseInvite(url) else { return nil } |
| - return Session(baseURL: p.baseURL, token: p.token, keyB64: p.keyB64, name: name) |
| - } |
| - |
| - /// The URL the WKWebView should load: the chat origin (`/`) once we have a |
| - /// session to seed, otherwise the raw `/join/…` link so the web claims it. |
| - var loadURL: URL? { session?.baseURL ?? URL(string: url) } |
| - |
| - /// The token used to de-duplicate chats (claimed device token, else the legacy |
| - /// invite token from the URL). |
| - var token: String? { claimedToken ?? LChatAPI.parseInvite(url)?.token } |
| +// ---- server JSON -------------------------------------------------------- |
| + |
| +// A thread as the server returns it (GET /api/threads). Opaque blobs only: the |
| +// server can't read name_blob/last_blob or unwrap `wrapped`. |
| +struct ServerThread: Codable { |
| + let id: String |
| + let kind: String // "self" | "dm" | "group" |
| + var name_blob: String? |
| + var created_at: Int64? |
| + var wrapped: String? // this user's thread key, wrapped to their pubkey |
| + var members: [String]? |
| + var last_blob: String? |
| + var last_at: Int64? |
| + var avatar: String? |
| } |
| -// What /api/messages and the "msg" WebSocket frame return. Opaque to the server. |
| +struct RosterEntry: Codable, Equatable { |
| + let user_id: String |
| + let label: String |
| + let public_key: String |
| + var avatar: String? |
| + var is_agent: Bool? // an agent/bot helper, not a person (BOT badge in pickers) |
| + |
| + var isAgent: Bool { is_agent ?? false } |
| +} |
| + |
| +// GET /api/messages row (used here only to count unread; the feed is rendered by |
| +// the webview). Opaque to the server. |
| struct ServerMessage: Codable, Identifiable { |
| let id: Int64 |
| let created_at: Int64 |
| let blob: String |
| - var reactions: [ReactionView]? |
| } |
| -struct ReactionView: Codable, Hashable { |
| - let emoji: String |
| - let count: Int |
| - var mine: Bool? |
| -} |
| +// ---- decrypted payloads (native decrypts only previews + group names) ---- |
| -// The decrypted message body — same shape chat.js builds before encrypt(). |
| +// The decrypted message body — same shape chat.js builds before encrypt(). Native |
| +// only needs `name`/`text`/`attachments` for the sidebar preview line. |
| struct MessagePayload: Codable { |
| - var name: String |
| + var name: String? |
| var text: String? |
| - var ts: Double? // ms since epoch (Date.now() on the web) |
| var attachments: [Attachment]? |
| - var replyTo: ReplyTo? |
| - var type: String? // message kind; see MessageType (nil == legacy chat) |
| -} |
| - |
| -// Message-content typing — mirrors web/static/message.js. The `type` rides |
| -// inside the encrypted payload (the server only ever sees ciphertext), so it's |
| -// an end-to-end signal between clients, never server metadata. Everything is a |
| -// "chat" message today; tagging them now lets future clients add other payload |
| -// kinds and lets each client decide which kinds it will render. |
| -enum MessageType { |
| - // The type this client stamps on every message it sends. |
| - static let chat = "chat" |
| - |
| - // A transient "is typing" presence hint. It rides its own WebSocket frame |
| - // (never stored, never pushed) and is handled before the feed, so it stays |
| - // out of `accepted` below — it must never render as a message. |
| - static let typing = "typing" |
| - |
| - // The types this client will render. A nil/absent type is a legacy message |
| - // from before payloads were tagged, so it's treated as a chat. Narrowing |
| - // this set — or dropping the nil allowance — is how we get "more limited". |
| - private static let accepted: Set<String> = [chat] |
| - |
| - // Whether a decrypted payload of the given type should be rendered. |
| - static func accepts(_ type: String?) -> Bool { |
| - guard let type else { return true } |
| - return accepted.contains(type) |
| - } |
| -} |
| - |
| -// A typing indicator's decrypted body — the sender's name and how long (seconds) |
| -// receivers should keep showing it. Encoded keys match web/static/chat.js exactly |
| -// (`type`/`name`/`expiry`) so the blob crosses platforms. `expiry` is integer |
| -// seconds; keep it Int so a peer's `15` decodes cleanly. |
| -struct TypingPayload: Codable { |
| - var type: String = MessageType.typing |
| - var name: String |
| - var expiry: Int |
| } |
| struct Attachment: Codable, Hashable { |
| var id: String |
| var name: String? |
| var type: String? |
| - var size: Int? |
| - var w: Int? |
| - var h: Int? |
| - |
| var isImage: Bool { type?.hasPrefix("image/") ?? false } |
| } |
| -struct ReplyTo: Codable, Hashable { |
| - var id: Int64 |
| - var name: String? |
| - var snippet: String? |
| -} |
| - |
| -// Invite lookup result (/api/invite/{token}). |
| -struct InviteInfo: Codable { |
| - let ok: Bool |
| - var revoked: Bool? |
| - var label: String? |
| -} |
| - |
| -// The fixed reaction set. Copied verbatim from hub.go's allowedReactions — |
| -// `❤️` carries a U+FE0F variation selector; a server reaction that doesn't match |
| -// exactly is silently dropped. |
| -let kReactions = ["👍", "❤️", "😂", "😮", "😢", "🎉"] |
| - |
| -// MARK: - Reply snippet (mirrors format.js replySnippet) |
| - |
| -// Summarise who's currently typing into a subtle one-liner — mirrors |
| -// typingSummary in web/static/format.js so both clients read identically. |
| -func typingSummary(_ names: [String]) -> String { |
| - let list = names.filter { !$0.isEmpty } |
| - if list.isEmpty { return "" } |
| - if list.count == 1 { return "\(list[0]) is typing…" } |
| - if list.count == 2 { return "\(list[0]) and \(list[1]) are typing…" } |
| - return "Several people are typing…" |
| +// A group's encrypted name payload (threads.name_blob decrypts to this). |
| +struct GroupNamePayload: Codable { var name: String? } |
| + |
| +// ---- sidebar view model ------------------------------------------------- |
| + |
| +// One row in the native thread sidebar — fully decrypted, ready to render. |
| +struct Thread: Identifiable, Equatable { |
| + let id: String |
| + let kind: String // "self" | "dm" | "group" |
| + var title: String // "Notes" / the other person / the group name |
| + var preview: String // last-message snippet |
| + var lastAt: Int64 // seconds; drives ordering + the relative time |
| + var unread: Bool |
| + var members: [String] |
| + var threadKeyB64: String // unwrapped — handed to the webview to render the feed |
| } |
| -func replySnippet(_ payload: MessagePayload, max: Int = 140) -> String { |
| - func clamp(_ s: String) -> String { |
| - guard s.count > max else { return s } |
| - let cut = s.prefix(max - 1).trimmingCharacters(in: .whitespaces) |
| - return cut + "…" |
| - } |
| - let text = (payload.text ?? "") |
| +// A one-line snippet for the sidebar: "Name: text" in groups, just text in DMs — |
| +// mirrors previewText in web/static/chat.js. |
| +func previewText(kind: String, payload: MessagePayload) -> String { |
| + var body = (payload.text ?? "") |
| .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) |
| .trimmingCharacters(in: .whitespacesAndNewlines) |
| - if !text.isEmpty { return clamp(text) } |
| - if let a = payload.attachments?.first { |
| - if a.isImage { return "📷 Photo" } |
| - return clamp("📎 " + (a.name ?? "Attachment")) |
| - } |
| - return "" |
| + if body.isEmpty, let a = payload.attachments, !a.isEmpty { body = "📷 Photo" } |
| + if kind == "group", let n = payload.name, !n.isEmpty { return "\(n): \(body)" } |
| + return body |
| } |
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift| new file mode 100644 |
| index 0000000..1529a8d |
| --- /dev/null |
| +++ b/ios/LChat/ThreadStore.swift |
| @@ -0,0 +1,167 @@ |
| +import Foundation |
| +import SwiftUI |
| + |
| +// The per-instance sidebar model. Fetches the thread list, unwraps each thread's |
| +// key *natively* (ECIES, via the user private key in the Keychain), and decrypts |
| +// just enough — group names + last-message previews — to render the sidebar. The |
| +// message feed itself is rendered by the webview, handed only the active thread's |
| +// already-unwrapped key (ThreadStore never gives the webview the private key). |
| +@MainActor |
| +final class ThreadStore: ObservableObject { |
| + @Published var threads: [Thread] = [] |
| + @Published var instanceName: String = "" |
| + @Published var loading = false |
| + @Published var loadError: String? |
| + |
| + @Published var roster: [RosterEntry] = [] // joined members (for the new-chat picker) |
| + |
| + let instance: SavedInstance |
| + private(set) var myUserId: String |
| + private var instanceKeyB64: String? // for member avatars (optional) |
| + private var rosterLabels: [String: String] = [:] |
| + |
| + private var api: LChatAPI? { |
| + guard let base = instance.baseURL else { return nil } |
| + return LChatAPI(baseURL: base, token: instance.deviceToken) |
| + } |
| + |
| + init(instance: SavedInstance) { |
| + self.instance = instance |
| + self.myUserId = instance.myUserId |
| + } |
| + |
| + /// The unwrapped instance key (base64url), handed to the webview so it can show |
| + /// member profile photos. nil until loaded / if we haven't been granted it. |
| + func instanceKey() -> String? { instanceKeyB64 } |
| + |
| + func refresh() async { |
| + guard let api else { loadError = "This instance's address is invalid."; return } |
| + loading = true |
| + defer { loading = false } |
| + |
| + await api.ensurePubkey(instance.userPub) |
| + |
| + let people = await api.roster() |
| + roster = people |
| + rosterLabels = Dictionary(people.map { ($0.user_id, $0.label) }, uniquingKeysWith: { a, _ in a }) |
| + if myUserId.isEmpty, let me = people.first(where: { $0.public_key == instance.userPub }) { |
| + myUserId = me.user_id |
| + } |
| + |
| + if let wrapped = await api.instanceKeyWrapped(), |
| + let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv) { |
| + instanceKeyB64 = raw.base64urlEncodedString() |
| + } |
| + |
| + instanceName = await api.groupName() |
| + |
| + var rows: [Thread] = [] |
| + for t in await api.threads() { |
| + // No usable key → can't decrypt anything about this thread; skip it. |
| + guard let wrapped = t.wrapped, !wrapped.isEmpty, |
| + let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv) |
| + else { continue } |
| + let key = try? GroupKey(rawKey: raw) |
| + var preview = "" |
| + if let lb = t.last_blob, !lb.isEmpty, let key, |
| + let p = try? key.decryptJSON(MessagePayload.self, blob: lb) { |
| + preview = previewText(kind: t.kind, payload: p) |
| + } |
| + let lastAt = t.last_at ?? 0 |
| + rows.append(Thread( |
| + id: t.id, |
| + kind: t.kind, |
| + title: threadTitle(t, key: key), |
| + preview: preview, |
| + lastAt: lastAt, |
| + unread: lastAt > ReadState.lastSeen(instance: instance.id, thread: t.id), |
| + members: t.members ?? [], |
| + threadKeyB64: raw.base64urlEncodedString() |
| + )) |
| + } |
| + rows.sort { $0.lastAt > $1.lastAt } |
| + threads = rows |
| + } |
| + |
| + // Start a chat with the given other members (empty = a "Notes" self thread, one = |
| + // DM, more = group). Generates a fresh thread key, wraps it to every member |
| + // (including us) via ECIES, encrypts the group name, and creates the thread; the |
| + // server dedupes DMs. Returns the thread id (refreshing the list), or nil on error. |
| + func createChat(memberIDs others: [String], groupName: String?) async -> String? { |
| + guard let api, !myUserId.isEmpty else { |
| + loadError = "Couldn't determine your identity yet — try again in a moment." |
| + return nil |
| + } |
| + var ids = Set(others) |
| + ids.insert(myUserId) |
| + |
| + // Public keys for everyone in the thread (ourselves from the instance keypair). |
| + var pubs = [myUserId: instance.userPub] |
| + for e in roster where ids.contains(e.user_id) { pubs[e.user_id] = e.public_key } |
| + guard ids.allSatisfy({ pubs[$0] != nil }) else { |
| + loadError = "Couldn't find a key for everyone selected." |
| + return nil |
| + } |
| + |
| + let raw = ECIES.randomAESKey() |
| + let kind = others.isEmpty ? "self" : (others.count == 1 ? "dm" : "group") |
| + var nameBlob = "" |
| + if kind == "group", let n = groupName?.trimmingCharacters(in: .whitespaces), !n.isEmpty, |
| + let key = try? GroupKey(rawKey: raw) { |
| + nameBlob = (try? key.encryptJSON(["name": n])) ?? "" |
| + } |
| + |
| + var members: [[String: String]] = [] |
| + for id in ids { |
| + guard let pub = pubs[id], let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: pub) else { |
| + loadError = "Couldn't wrap the chat key." |
| + return nil |
| + } |
| + members.append(["user_id": id, "wrapped": wrapped]) |
| + } |
| + |
| + guard let tid = await api.createThread(kind: kind, nameBlob: nameBlob, members: members) else { |
| + loadError = "Couldn't start the chat. Try again." |
| + return nil |
| + } |
| + await refresh() |
| + return tid |
| + } |
| + |
| + /// Clear a thread's unread state once it's opened. |
| + func markRead(_ thread: Thread) { |
| + ReadState.setLastSeen(instance: instance.id, thread: thread.id, at: thread.lastAt) |
| + if let i = threads.firstIndex(where: { $0.id == thread.id }) { threads[i].unread = false } |
| + } |
| + |
| + private func threadTitle(_ t: ServerThread, key: GroupKey?) -> String { |
| + switch t.kind { |
| + case "self": |
| + return "Notes" |
| + case "dm": |
| + let other = (t.members ?? []).first { $0 != myUserId } ?? "" |
| + return rosterLabels[other] ?? "Someone" |
| + default: |
| + if let nb = t.name_blob, !nb.isEmpty, let key, |
| + let p = try? key.decryptJSON(GroupNamePayload.self, blob: nb), |
| + let n = p.name, !n.isEmpty { |
| + return n |
| + } |
| + return "Group" |
| + } |
| + } |
| +} |
| + |
| +// Per-thread read state (last-seen message time). Just timestamps — no secrets — |
| +// so UserDefaults is fine (the keys + private material stay in the Keychain). |
| +enum ReadState { |
| + private static func key(_ instance: String, _ thread: String) -> String { |
| + "lchat.seen|\(instance)|\(thread)" |
| + } |
| + static func lastSeen(instance: String, thread: String) -> Int64 { |
| + Int64(UserDefaults.standard.integer(forKey: key(instance, thread))) |
| + } |
| + static func setLastSeen(instance: String, thread: String, at: Int64) { |
| + UserDefaults.standard.set(Int(at), forKey: key(instance, thread)) |
| + } |
| +} |
diff --git a/ios/LChat/Views/Avatar.swift b/ios/LChat/Views/Avatar.swift| new file mode 100644 |
| index 0000000..80c8f04 |
| --- /dev/null |
| +++ b/ios/LChat/Views/Avatar.swift |
| @@ -0,0 +1,32 @@ |
| +import SwiftUI |
| + |
| +// A simple initials circle. The native sidebar uses initials rather than loading |
| +// the encrypted profile-photo blobs — the feed webview already renders real avatars |
| +// inside a thread; the sidebar just needs a stable, recognisable glyph per chat. |
| +struct Avatar: View { |
| + let name: String |
| + var size: CGFloat = 40 |
| + |
| + var body: some View { |
| + Circle() |
| + .fill(color) |
| + .frame(width: size, height: size) |
| + .overlay( |
| + Text(initial) |
| + .font(.system(size: size * 0.42, weight: .semibold)) |
| + .foregroundStyle(.white) |
| + ) |
| + } |
| + |
| + private var initial: String { |
| + let s = String(name.trimmingCharacters(in: .whitespaces).prefix(1)).uppercased() |
| + return s.isEmpty ? "?" : s |
| + } |
| + |
| + private var color: Color { |
| + let palette: [Color] = [.blue, .purple, .pink, .orange, .green, .teal, .indigo, .red] |
| + var h = 5381 |
| + for b in name.utf8 { h = (h &* 33) &+ Int(b) } |
| + return palette[abs(h) % palette.count] |
| + } |
| +} |
diff --git a/ios/LChat/Views/ChatListView.swift b/ios/LChat/Views/ChatListView.swift| deleted file mode 100644 |
| index 7c9893c..0000000 |
| --- a/ios/LChat/Views/ChatListView.swift |
| +++ /dev/null |
| @@ -1,93 +0,0 @@ |
| -import SwiftUI |
| - |
| -// The home screen: every saved chat with its group name and unread count, plus a |
| -// button to add another. Tapping one opens it. |
| -struct ChatListView: View { |
| - @EnvironmentObject var app: AppState |
| - @State private var showAdd = false |
| - @State private var groupNames: [String: String] = [:] |
| - @State private var unread: [String: Int] = [:] |
| - |
| - var body: some View { |
| - NavigationStack { |
| - Group { |
| - if app.chats.isEmpty { |
| - emptyState |
| - } else { |
| - list |
| - } |
| - } |
| - .navigationTitle("Chats") |
| - .toolbar { |
| - ToolbarItem(placement: .topBarTrailing) { |
| - Button { showAdd = true } label: { Image(systemName: "plus") } |
| - } |
| - } |
| - .sheet(isPresented: $showAdd) { AddChatView() } |
| - .task(id: app.chats) { await refresh() } |
| - } |
| - } |
| - |
| - private var list: some View { |
| - List { |
| - ForEach(app.chats) { chat in |
| - Button { app.open(chat) } label: { row(chat) } |
| - .buttonStyle(.plain) |
| - } |
| - .onDelete { offsets in |
| - offsets.map { app.chats[$0] }.forEach(app.remove) |
| - } |
| - } |
| - .refreshable { await refresh() } |
| - } |
| - |
| - private func row(_ chat: SavedChat) -> some View { |
| - HStack(spacing: 12) { |
| - VStack(alignment: .leading, spacing: 2) { |
| - Text(groupNames[chat.id] ?? host(chat)).font(.headline) |
| - Text("as \(chat.name)").font(.caption).foregroundStyle(.secondary) |
| - } |
| - Spacer() |
| - if let n = unread[chat.id], n > 0 { |
| - Text("\(n)") |
| - .font(.caption.bold()).foregroundStyle(.white) |
| - .padding(.horizontal, 8).padding(.vertical, 3) |
| - .background(Color.accentColor, in: Capsule()) |
| - } |
| - Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) |
| - } |
| - .contentShape(Rectangle()) |
| - .padding(.vertical, 4) |
| - } |
| - |
| - private var emptyState: some View { |
| - VStack(spacing: 16) { |
| - Image(systemName: "bubble.left.and.bubble.right") |
| - .font(.system(size: 48)).foregroundStyle(.secondary) |
| - Text("No chats yet").font(.title3.weight(.semibold)) |
| - Text("Add a chat with an invite link you were sent.") |
| - .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) |
| - Button { showAdd = true } label: { Text("Add chat").frame(maxWidth: 220) } |
| - .buttonStyle(.borderedProminent).controlSize(.large) |
| - } |
| - .padding() |
| - } |
| - |
| - private func host(_ chat: SavedChat) -> String { |
| - chat.session?.baseURL.host ?? "Chat" |
| - } |
| - |
| - // Fetch each chat's group name and unread count. The count is simply the |
| - // number of messages after lastSeenId — no decryption needed (or possible) |
| - // here, just the server's opaque count. |
| - private func refresh() async { |
| - for chat in app.chats { |
| - guard let s = chat.session else { continue } |
| - let api = LChatAPI(baseURL: s.baseURL, token: s.token) |
| - let name = await api.groupName() |
| - let count = (try? await api.messages(after: chat.lastSeenId))?.count ?? 0 |
| - groupNames[chat.id] = name |
| - unread[chat.id] = count |
| - } |
| - } |
| -} |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index d6fdb4f..bb8abbd 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -4,113 +4,71 @@ import PhotosUI |
| import UniformTypeIdentifiers |
| import UIKit |
| -// Webview spike (hybrid): the message *feed* is the web client (chat.js) running |
| -// in a WKWebView — so styling and how messages render ship for free — while the |
| -// native shell keeps the title bar and composer for a native feel. |
| +// One thread's feed. Hybrid: 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 for free — while the native shell provides the title and composer. |
| // |
| -// The webview loads in "embed" mode (window.__lchatEmbed): chat.js hides its own |
| -// header + composer and goes full-width, leaving just the feed. The native |
| -// composer forwards typed text to chat.js's send() via window.lchatNative, so all |
| -// encryption + WebSocket logic stays in one place (the web layer). |
| +// E2E is preserved and tightened: native unwraps this thread's AES key (Keychain |
| +// private key, never leaves the device) and seeds ONLY that key into the page via |
| +// window.__lchatEmbed. chat.js does all message crypto with it. The webview never |
| +// sees the user private key or any other thread's key, so a single thread's page |
| +// can't reach another thread's content. |
| // |
| -// E2E is preserved: the Keychain (ChatsStore) stays the source of truth for keys, |
| -// and ChatWebView seeds this chat's key into the page's localStorage so crypto |
| -// runs in chat.js — the key is never transmitted; the server sees only ciphertext. |
| -// |
| -// Attachments: the native + button (and drag-drop onto the feed) stage a photo/ |
| -// file into the web composer's preview — shown just above the native composer — |
| -// to be sent with the next message. All upload/encrypt stays in the web layer. |
| -// |
| -// Deferred follow-ups: reply/edit banners (replying still works by tapping a |
| -// bubble in the feed), the web menu's rename/notifications, and unread-badge sync |
| -// on close. |
| +// The native composer forwards typed text to chat.js's window.lchatNative.sendText; |
| +// the + button stages a photo via window.lchatNative.stageAttachment. Both run the |
| +// one crypto/upload path (the web layer). |
| struct ChatView: View { |
| - @EnvironmentObject var app: AppState |
| @StateObject private var bridge = WebBridge() |
| - @State private var groupName: String? |
| @State private var text = "" |
| @State private var composerFocused = false |
| - @State private var showLeave = false |
| @State private var showPhotos = false |
| @State private var pickerItem: PhotosPickerItem? |
| @State private var attachError: String? |
| - let chat: SavedChat |
| + let instance: SavedInstance |
| + let thread: Thread |
| + var myUserId: String = "" |
| + let instanceKeyB64: String? |
| private var isMac: Bool { ProcessInfo.processInfo.isMacCatalystApp } |
| - // The chat's own name (group name), fetched from the server; falls back to the |
| - // host until it loads (and before a new-format chat is claimed, when there's no |
| - // session yet). This is the title — not the URL/host. |
| - private var title: String { groupName ?? chat.session?.baseURL.host ?? chat.origin?.host ?? "lchat" } |
| - |
| private var canSend: Bool { |
| !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || bridge.attachmentStaged |
| } |
| var body: some View { |
| - NavigationStack { |
| - VStack(spacing: 0) { |
| - if chat.loadURL != nil { |
| - // The webview loads `/` (seeded) for a known session, or the |
| - // `/join/…` link for an unclaimed new-format invite (the web |
| - // claims + unwraps it, then hands the session back to us). |
| - ChatWebView(chat: chat, bridge: bridge) |
| - .frame(maxWidth: .infinity, maxHeight: .infinity) |
| - composer |
| - } else { |
| - ContentUnavailableView( |
| - "Can't open this chat", |
| - systemImage: "exclamationmark.triangle", |
| - description: Text("The saved invite link is malformed.") |
| - ) |
| - } |
| + VStack(spacing: 0) { |
| + if let url = instance.baseURL { |
| + ChatFeedWebView(instance: instance, thread: thread, myUserId: myUserId, |
| + instanceKeyB64: instanceKeyB64, origin: url, bridge: bridge) |
| + .frame(maxWidth: .infinity, maxHeight: .infinity) |
| + composer |
| + } else { |
| + ContentUnavailableView("Can't open this chat", systemImage: "exclamationmark.triangle", |
| + description: Text("This chat's address is invalid.")) |
| } |
| - .navigationTitle(isMac ? "" : title) |
| - .navigationBarTitleDisplayMode(.inline) |
| - .toolbar { toolbarMenu } |
| - .onAppear { if isMac { composerFocused = true } } |
| - .background(EdgeSwipeBack { goBack() }) |
| - #if targetEnvironment(macCatalyst) |
| - .background(MacWindowTitle(title: title)) |
| - #endif |
| } |
| - // Re-fetch when a new-format invite becomes claimed (token appears), since |
| - // the group-name lookup needs the session's token. |
| - .task(id: chat.claimedToken) { await loadGroupName() } |
| - .confirmationDialog("Leave this chat?", isPresented: $showLeave, titleVisibility: .visible) { |
| - Button("Leave", role: .destructive) { leaveChat() } |
| - Button("Cancel", role: .cancel) {} |
| - } message: { |
| - Text("This removes it from this device. You'll need the invite link to rejoin.") |
| - } |
| - .alert( |
| - "Couldn’t attach that", |
| - isPresented: Binding(get: { attachError != nil }, set: { if !$0 { attachError = nil } }) |
| - ) { |
| + .navigationTitle(isMac ? "" : thread.title) |
| + .navigationBarTitleDisplayMode(.inline) |
| + .onAppear { if isMac { composerFocused = true } } |
| + #if targetEnvironment(macCatalyst) |
| + .background(MacWindowTitle(title: thread.title)) |
| + #endif |
| + .alert("Couldn’t attach that", |
| + isPresented: Binding(get: { attachError != nil }, set: { if !$0 { attachError = nil } })) { |
| Button("OK", role: .cancel) {} |
| } message: { |
| Text(attachError ?? "") |
| } |
| - // The web layer reports upload/stage problems back through the bridge; show |
| - // them rather than letting an attachment silently vanish. |
| .onChange(of: bridge.lastError) { _, msg in |
| if let msg { attachError = msg; bridge.lastError = nil } |
| } |
| - // The webview's join flow resolved a new-format invite — persist the |
| - // session so future opens skip the join screen. |
| - .onChange(of: bridge.joinedSession) { _, joined in |
| - if let joined { |
| - app.resolveSession(token: joined.token, key: joined.key, name: joined.name, for: chat) |
| - bridge.joinedSession = nil |
| - } |
| - } |
| } |
| - // Toggling a picker's isPresented binding directly inside a Menu action races |
| - // with the menu's own dismissal (the picker can fail to appear on Mac Catalyst). |
| - // Defer the toggle until after the menu has gone. |
| + // Toggling a picker's isPresented binding directly inside a Menu action races with |
| + // the menu's own dismissal (the picker can fail to appear on Mac Catalyst). Defer |
| + // the toggle until after the menu has gone. |
| private func presentAfterMenu(_ action: @escaping () -> Void) { |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: action) |
| } |
| @@ -119,12 +77,8 @@ struct ChatView: View { |
| private var composer: some View { |
| HStack(alignment: .bottom, spacing: 8) { |
| - // Attach a photo or file. The bytes are uploaded + encrypted by the web |
| - // layer (one crypto path) and staged in the preview above the composer, |
| - // to be sent with the next message — same path as a drag-drop. |
| Menu { |
| Button { presentAfterMenu { showPhotos = true } } label: { Label("Photo Library", systemImage: "photo") } |
| - Button { presentAfterMenu { pickDocument() } } label: { Label("Choose File…", systemImage: "doc") } |
| } label: { |
| Image(systemName: "plus") |
| .font(.system(size: 18, weight: .medium)) |
| @@ -133,9 +87,6 @@ struct ChatView: View { |
| } |
| .buttonStyle(.plain) |
| - // A rounded, grey, growing field reads as a native chat composer. On the |
| - // Mac a bare Return sends and Shift+Return inserts a newline (handled in |
| - // ComposerField at the UITextView level). |
| ComposerField( |
| text: $text, |
| focused: $composerFocused, |
| @@ -155,6 +106,7 @@ struct ChatView: View { |
| } |
| .buttonStyle(.plain) |
| .disabled(!canSend) |
| + .accessibilityLabel("Send") |
| } |
| .padding(.horizontal, 10) |
| .padding(.vertical, 8) |
| @@ -163,35 +115,6 @@ struct ChatView: View { |
| .onChange(of: pickerItem) { _, item in stagePhoto(item) } |
| } |
| - // Present UIKit's document picker directly. SwiftUI's .fileImporter does not |
| - // reliably fire its completion on Mac Catalyst (the picker opens, you choose a |
| - // file, and nothing comes back), so we drive UIDocumentPickerViewController and |
| - // its delegate ourselves — that path is reliable. |
| - private func pickDocument() { |
| - presentDocumentPicker { url in |
| - if let url { stageFile(url) } |
| - } |
| - } |
| - |
| - @ToolbarContentBuilder |
| - private var toolbarMenu: some ToolbarContent { |
| - ToolbarItem(placement: .topBarLeading) { |
| - Button { goBack() } label: { |
| - Image(systemName: "chevron.backward") |
| - } |
| - .accessibilityLabel("All chats") |
| - } |
| - ToolbarItem(placement: .topBarTrailing) { |
| - Menu { |
| - Button(role: .destructive) { showLeave = true } label: { |
| - Label("Leave chat", systemImage: "rectangle.portrait.and.arrow.right") |
| - } |
| - } label: { |
| - Image(systemName: "ellipsis.circle") |
| - } |
| - } |
| - } |
| - |
| // MARK: Actions |
| private func send() { |
| @@ -199,19 +122,10 @@ struct ChatView: View { |
| // Only clear on success: if the socket wasn't ready, keep the text so |
| // nothing is lost and the user can retry. |
| if sent { text = "" } |
| - // Keep focus so you can fire off several messages without re-tapping |
| - // the field; the send button resigns first responder, so re-assert. |
| composerFocused = true |
| } |
| } |
| - // Hand picked bytes to the web layer to encrypt, upload and stage. The web |
| - // composer shows an "Uploading…" preview (above this composer) and then the |
| - // staged chip. Fire-and-forget: success/failure comes back via the bridge. |
| - private func upload(data: Data, name: String, type: String?) { |
| - bridge.stageAttachment(data, name: name, type: type) |
| - } |
| - |
| private func stagePhoto(_ item: PhotosPickerItem?) { |
| guard let item else { return } |
| Task { |
| @@ -221,80 +135,44 @@ struct ChatView: View { |
| attachError = "Couldn’t read that photo." |
| return |
| } |
| - upload(data: data, name: "image.jpg", type: "image/jpeg") |
| + bridge.stageAttachment(data, name: "image.jpg", type: "image/jpeg") |
| } catch { |
| attachError = "Couldn’t read that photo: \(error.localizedDescription)" |
| } |
| } |
| } |
| - |
| - private func stageFile(_ url: URL) { |
| - let scoped = url.startAccessingSecurityScopedResource() |
| - defer { if scoped { url.stopAccessingSecurityScopedResource() } } |
| - do { |
| - let data = try Data(contentsOf: url) |
| - let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType |
| - upload(data: data, name: url.lastPathComponent, type: type) |
| - } catch { |
| - attachError = "Couldn’t read that file: \(error.localizedDescription)" |
| - } |
| - } |
| - |
| - private func loadGroupName() async { |
| - guard let s = chat.session else { return } |
| - let name = await LChatAPI(baseURL: s.baseURL, token: s.token).groupName() |
| - groupName = name |
| - } |
| - |
| - private func goBack() { |
| - app.close(lastSeenId: chat.lastSeenId) |
| - } |
| - |
| - private func leaveChat() { |
| - app.remove(chat) |
| - app.current = nil |
| - } |
| } |
| -// Holds a reference to the live WKWebView so the native composer can drive the |
| -// web client's send() path, and receives staged-attachment state + errors back. |
| +// Holds a reference to the live WKWebView so the native composer can drive the web |
| +// client's send() path, and receives staged-attachment state + errors back. |
| final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler { |
| weak var webView: WKWebView? |
| - // Whether the web composer currently has an attachment staged (from the native |
| - // + button or a drag-drop onto the feed). Drives the native send button so an |
| - // image-only message can be sent. Kept in sync by the "lchat" message handler. |
| + // Whether the web composer currently has a photo staged. Drives the native send |
| + // button so an image-only message can be sent. Kept in sync by the "lchat" handler. |
| @Published var attachmentStaged = false |
| - // A user-facing problem reported by the web layer (upload/stage failure) or by |
| - // the bridge call itself. ChatView surfaces it as an alert, then clears it — |
| - // so an attachment never just silently disappears. |
| + // A user-facing problem reported by the web layer (upload/stage failure). ChatView |
| + // surfaces it as an alert, then clears it — so an attachment never just vanishes. |
| @Published var lastError: String? |
| - // Forwards typed text to chat.js's window.lchatNative.sendText. JSON-encoding |
| - // the string safely escapes quotes/newlines/unicode before it crosses into JS. |
| - // Empty text is allowed (the web side sends it only if an attachment is staged). |
| - // The completion reports whether the web layer accepted it (socket open, hook |
| - // present, something to send), so the caller knows whether to clear its field. |
| + // Forwards typed text to chat.js's window.lchatNative.sendText. JSON-encoding the |
| + // string safely escapes quotes/newlines/unicode before it crosses into JS. The |
| + // completion reports whether the web layer accepted it (socket open, hook present, |
| + // something to send), so the caller knows whether to clear its field. |
| func sendText(_ text: String, completion: @escaping (Bool) -> Void) { |
| let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| guard let webView, |
| let data = try? JSONSerialization.data(withJSONObject: [trimmed]), |
| let arg = String(data: data, encoding: .utf8) else { return completion(false) } |
| - // arg is `["the text"]`; pass element 0 to the hook. The `?:` yields false |
| - // if the hook isn't installed yet, so an early send is reported as not sent. |
| webView.evaluateJavaScript("window.lchatNative ? window.lchatNative.sendText(\(arg)[0]) : false") { result, _ in |
| completion((result as? Bool) ?? false) |
| } |
| } |
| - // Hands attachment bytes to chat.js's window.lchatNative.stageAttachment, which |
| - // uploads + encrypts + previews it (same path as a drag-drop). Uses |
| - // evaluateJavaScript — the same call the text send uses, which reliably reaches |
| - // window.lchatNative here; callAsyncJavaScript silently did nothing on Catalyst. |
| - // Fire-and-forget: staging runs async in JS and reports back via the "lchat" |
| - // message handler ({staged:true} on success, {error} on failure). We only flag |
| - // the kickoff problems (no hook yet, or a JS error) synchronously here. |
| + // Hands photo bytes to chat.js's window.lchatNative.stageAttachment, which uploads |
| + // + encrypts + previews it. Fire-and-forget: staging runs async in JS and reports |
| + // back via the "lchat" handler ({staged:true} on success, {error} on failure). |
| func stageAttachment(_ data: Data, name: String, type: String?) { |
| guard let webView else { |
| lastError = "Internal error: the chat view isn’t ready." |
| @@ -310,9 +188,6 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler { |
| lastError = "Couldn’t prepare that attachment." |
| return |
| } |
| - // arg is a JSON object literal (valid JS). Return a sentinel so a missing |
| - // hook is distinguishable from a successful kickoff (the upload itself |
| - // reports completion/failure asynchronously via the message handler). |
| let js = "(function(){ if (!window.lchatNative) return 'no-bridge'; window.lchatNative.stageAttachment(\(arg)); return 'ok'; })()" |
| webView.evaluateJavaScript(js) { [weak self] result, err in |
| if let err = err { |
| @@ -323,47 +198,25 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler { |
| } |
| } |
| - // A session the web join flow resolved for a new-format invite, for the view to |
| - // persist. Equatable so .onChange fires. |
| - struct JoinedSession: Equatable { |
| - let token: String |
| - let key: String |
| - let name: String |
| - } |
| - @Published var joinedSession: JoinedSession? |
| - |
| - // chat.js posts {staged: Bool} whenever an attachment is staged or cleared |
| - // (picker, drag-drop, send, or the preview's remove button) and {error: String} |
| - // when an upload/stage fails. join.js posts {joined: {token,key,name}} after it |
| - // claims + unwraps a new-format invite. All arrive on the one "lchat" handler. |
| + // chat.js posts {staged: Bool} whenever a photo is staged or cleared, and |
| + // {error: String} when an upload/stage fails. Both arrive on the one "lchat" handler. |
| func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| guard message.name == "lchat", let body = message.body as? [String: Any] else { return } |
| if let staged = body["staged"] as? Bool { attachmentStaged = staged } |
| if let err = body["error"] as? String { lastError = err } |
| - if let j = body["joined"] as? [String: Any], |
| - let token = j["token"] as? String, let key = j["key"] as? String, let name = j["name"] as? String { |
| - joinedSession = JoinedSession(token: token, key: key, name: name) |
| - } |
| } |
| } |
| -// WKWebView host for one chat's message feed. |
| -// |
| -// When we already have the session (legacy `#k=` link, or a new-format invite we |
| -// claimed earlier), we load the origin root (`/`) and seed the session into |
| -// localStorage before any page script runs, so chat.js renders the feed directly |
| -// (no join prompt). |
| -// |
| -// For an unclaimed new-format invite we have no key, so we load the saved |
| -// `/join/<slug>#<T>` link instead: join.js claims it, unwraps the server-stored |
| -// key, stores the session in localStorage, hands it back to us (the "lchat" |
| -// message handler) and redirects to `/`. Either way the embed flag is set. |
| -// |
| -// The default (persistent) data store means this storage and chat.js's cached |
| -// state survive between opens. Each chat is its own origin (per-instance |
| -// subdomain), so the browser partitions their storage automatically. |
| -struct ChatWebView: UIViewRepresentable { |
| - let chat: SavedChat |
| +// WKWebView host for one thread's feed. Loads the instance origin (`/`) and injects |
| +// window.__lchatEmbed at documentStart with the material chat.js needs to render this |
| +// one thread: the device token, our screen name + user id, the thread id, and the |
| +// already-unwrapped thread key (plus the instance key for avatars, if we have it). |
| +struct ChatFeedWebView: UIViewRepresentable { |
| + let instance: SavedInstance |
| + let thread: Thread |
| + let myUserId: String |
| + let instanceKeyB64: String? |
| + let origin: URL |
| let bridge: WebBridge |
| func makeUIView(context: Context) -> WKWebView { |
| @@ -373,92 +226,34 @@ struct ChatWebView: UIViewRepresentable { |
| if let script = bootScript() { |
| cfg.userContentController.addUserScript(script) |
| } |
| - // Receive staged-attachment state + the resolved join session from the web |
| - // (window.webkit.messageHandlers.lchat). |
| cfg.userContentController.add(bridge, name: "lchat") |
| let web = WKWebView(frame: .zero, configuration: cfg) |
| bridge.webView = web |
| - // In debug builds, let Safari's Web Inspector attach to the feed |
| - // (Develop ▸ device ▸ this view) so the web console is reachable. |
| #if DEBUG |
| if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true } |
| #endif |
| - // session present → origin `/` (seeded); else the `/join/…` link. |
| - if let url = chat.loadURL { web.load(URLRequest(url: url)) } |
| + web.load(URLRequest(url: origin)) |
| return web |
| } |
| func updateUIView(_ web: WKWebView, context: Context) {} |
| - // Runs before any page script: marks this as the native embed (so chat.js/join.js |
| - // hide the web header/composer, expose the send hook, and use the native join |
| - // path) and — when we have a session — seeds it into localStorage so chat.js |
| - // renders directly. For an unclaimed new-format invite there's no session to |
| - // seed; join.js establishes it. |
| + // Runs before any page script: hand chat.js the embed seed for this thread. Only |
| + // the thread key crosses over — never the user private key. |
| private func bootScript() -> WKUserScript? { |
| - var js = "window.__lchatEmbed = true;" |
| - if let session = chat.session { |
| - let payload = ["token": session.token, "key": session.keyB64, "name": session.name] |
| - if let data = try? JSONSerialization.data(withJSONObject: payload), |
| - let objJSON = String(data: data, encoding: .utf8) { |
| - // objJSON is a JSON object literal (valid JS); JSON.stringify turns it |
| - // back into the string chat.js JSON.parses, sidestepping manual |
| - // escaping of a screen name that might contain quotes. |
| - js += "\ntry { localStorage.setItem('lchat_session', JSON.stringify(\(objJSON))); } catch (e) {}" |
| - } |
| - } |
| + var seed: [String: Any] = [ |
| + "token": instance.deviceToken, |
| + "name": instance.screenName, |
| + "myUserId": myUserId, |
| + "threadId": thread.id, |
| + "threadKey": thread.threadKeyB64, |
| + ] |
| + if let ik = instanceKeyB64 { seed["instanceKey"] = ik } |
| + guard let data = try? JSONSerialization.data(withJSONObject: seed), |
| + let json = String(data: data, encoding: .utf8) else { return nil } |
| + // json is a JSON object literal (valid JS). Assigning it as the flag means |
| + // chat.js both detects embed mode and reads the seed from one global. |
| + let js = "window.__lchatEmbed = \(json);" |
| return WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true) |
| } |
| } |
| - |
| -// Presents UIKit's document picker directly (SwiftUI's .fileImporter doesn't fire |
| -// its completion on Mac Catalyst). asCopy:true hands back a readable temp copy, so |
| -// the caller can read bytes without the security-scoped-resource dance. |
| -@MainActor |
| -private func presentDocumentPicker(onPick: @escaping (URL?) -> Void) { |
| - let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true) |
| - picker.allowsMultipleSelection = false |
| - let delegate = DocumentPickerDelegate(onPick: onPick) |
| - picker.delegate = delegate |
| - guard let presenter = topViewController() else { |
| - onPick(nil) |
| - return |
| - } |
| - presenter.present(picker, animated: true) |
| -} |
| - |
| -// Walks to the front-most view controller of the active foreground scene to present |
| -// from. Catalyst exposes a single UIWindowScene. |
| -@MainActor |
| -private func topViewController() -> UIViewController? { |
| - let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } |
| - let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first |
| - guard var top = scene?.keyWindow?.rootViewController |
| - ?? scene?.windows.first(where: { $0.isKeyWindow })?.rootViewController |
| - ?? scene?.windows.first?.rootViewController else { return nil } |
| - while let presented = top.presentedViewController { top = presented } |
| - return top |
| -} |
| - |
| -// UIDocumentPickerDelegate whose callbacks reliably fire on Catalyst. It retains |
| -// itself until a callback lands (the picker only holds the delegate weakly). |
| -private final class DocumentPickerDelegate: NSObject, UIDocumentPickerDelegate { |
| - private let onPick: (URL?) -> Void |
| - private var selfRef: DocumentPickerDelegate? |
| - |
| - init(onPick: @escaping (URL?) -> Void) { |
| - self.onPick = onPick |
| - super.init() |
| - selfRef = self |
| - } |
| - |
| - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { |
| - onPick(urls.first) |
| - selfRef = nil |
| - } |
| - |
| - func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { |
| - onPick(nil) |
| - selfRef = nil |
| - } |
| -} |
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift| new file mode 100644 |
| index 0000000..235c462 |
| --- /dev/null |
| +++ b/ios/LChat/Views/InstanceSidebar.swift |
| @@ -0,0 +1,76 @@ |
| +import SwiftUI |
| + |
| +// The server switcher: the Mac app's rail, as a slide-in drawer. Every saved |
| +// server, the active one ticked, plus the same "Add a chat" flow as the Mac. |
| +// Presented by RootView as a leading overlay; tapping the dimmed area dismisses. |
| +struct ServerDrawer: View { |
| + @EnvironmentObject var app: AppState |
| + let activeID: String? |
| + var onSelect: (SavedInstance) -> Void |
| + var onAdd: () -> Void |
| + |
| + var body: some View { |
| + VStack(alignment: .leading, spacing: 0) { |
| + Text("Servers") |
| + .font(.title2.weight(.semibold)) |
| + .padding(.horizontal, 20) |
| + .padding(.top, 18) |
| + .padding(.bottom, 10) |
| + |
| + ScrollView { |
| + VStack(spacing: 2) { |
| + ForEach(app.instances) { inst in |
| + row(inst) |
| + } |
| + } |
| + .padding(.horizontal, 10) |
| + } |
| + |
| + Divider() |
| + |
| + Button(action: onAdd) { |
| + Label("Add a chat", systemImage: "plus") |
| + .font(.body.weight(.medium)) |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .padding(.horizontal, 20) |
| + .padding(.vertical, 16) |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + } |
| + .frame(width: 300) |
| + .frame(maxHeight: .infinity) |
| + // The panel paints to the screen edge, but the content (the Add row) stays |
| + // inside the safe area so it clears the home indicator. |
| + .background(Color(.systemBackground).ignoresSafeArea()) |
| + } |
| + |
| + private func row(_ inst: SavedInstance) -> some View { |
| + Button { onSelect(inst) } label: { |
| + HStack(spacing: 12) { |
| + Avatar(name: inst.displayName, size: 36) |
| + VStack(alignment: .leading, spacing: 2) { |
| + Text(inst.displayName).font(.headline).lineLimit(1) |
| + Text("as \(inst.screenName)").font(.caption).foregroundStyle(.secondary).lineLimit(1) |
| + } |
| + Spacer() |
| + if inst.id == activeID { |
| + Image(systemName: "checkmark") |
| + .font(.subheadline.weight(.semibold)) |
| + .foregroundStyle(Color.accentColor) |
| + } |
| + } |
| + .padding(.horizontal, 10) |
| + .padding(.vertical, 8) |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + .background(inst.id == activeID ? Color(.secondarySystemFill) : .clear, |
| + in: RoundedRectangle(cornerRadius: 10)) |
| + .contextMenu { |
| + Button(role: .destructive) { app.remove(inst) } label: { |
| + Label("Remove", systemImage: "trash") |
| + } |
| + } |
| + } |
| +} |
diff --git a/ios/LChat/Views/JoinView.swift b/ios/LChat/Views/JoinView.swift| index 08d77c1..fada765 100644 |
| --- a/ios/LChat/Views/JoinView.swift |
| +++ b/ios/LChat/Views/JoinView.swift |
| @@ -1,130 +1,203 @@ |
| import SwiftUI |
| +import WebKit |
| -// Add a chat: paste an invite link + pick a screen name. The link carries the |
| -// secret key in its #fragment; we validate it on-device and never send it |
| -// anywhere. Presented as a sheet from the chat list. |
| -struct AddChatView: View { |
| +// Add a workspace: paste a new-format invite link (`https://…/join/<slug>#<T>`), then |
| +// let the web join flow do the keypair work. The join runs in a one-shot WKWebView |
| +// (join.js): it derives the invite secret, generates the user keypair (or unwraps a |
| +// shipped one for an add-device link), registers the public key and claims a device |
| +// token — then hands {token, name, userPub, userPriv} back to us via the bridge. We |
| +// store the private key in the Keychain (SavedInstance) and never send it anywhere. |
| +// |
| +// Keeping join in the web layer means the keypair + invite crypto lives in one place |
| +// (crypto.js); native only persists the result. |
| +// Language + behaviour match the Mac app's AddChatView: it's "Add a chat" (never |
| +// "workspace"), and the link is added as soon as the pasted text is valid — no |
| +// button to press. |
| +struct AddInstanceView: View { |
| @EnvironmentObject var app: AppState |
| @Environment(\.dismiss) private var dismiss |
| + |
| @State private var link = "" |
| - @State private var name = "" |
| + @State private var joinURL: URL? |
| + @State private var origin = "" |
| @State private var error: String? |
| - @State private var busy = false |
| + @State private var pending: Task<Void, Never>? |
| + |
| + // Called after the new chat is added, so the caller can select it (and show |
| + // its threads straight away). |
| + var onAdded: (SavedInstance) -> Void = { _ in } |
| var body: some View { |
| NavigationStack { |
| - ScrollView { |
| - VStack(alignment: .leading, spacing: 20) { |
| - Text("Paste the invite link you were sent. It contains a secret key that stays on this device.") |
| - .font(.callout) |
| - .foregroundStyle(.secondary) |
| - |
| - VStack(alignment: .leading, spacing: 6) { |
| - Text("Invite link").font(.subheadline.weight(.semibold)) |
| - TextField("https://…/join/…", text: $link, axis: .vertical) |
| - .textFieldStyle(.roundedBorder) |
| - .textInputAutocapitalization(.never) |
| - .autocorrectionDisabled() |
| - .keyboardType(.URL) |
| - .onChange(of: link) { _, _ in prefillName() } |
| - } |
| - |
| - VStack(alignment: .leading, spacing: 6) { |
| - Text("Your screen name").font(.subheadline.weight(.semibold)) |
| - TextField("e.g. Sam", text: $name) |
| - .textFieldStyle(.roundedBorder) |
| - .autocorrectionDisabled() |
| - } |
| - |
| - if let error { |
| - Text(error).font(.callout).foregroundStyle(.red) |
| - } |
| - |
| - Button(action: add) { |
| - if busy { ProgressView() } else { Text("Add chat").frame(maxWidth: .infinity) } |
| - } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| - .disabled(busy) |
| + Group { |
| + if let joinURL { |
| + JoinWebView(url: joinURL, onJoined: complete, onError: { msg in |
| + error = msg |
| + self.joinURL = nil |
| + }) |
| + } else { |
| + pasteForm |
| } |
| - .padding() |
| } |
| - .navigationTitle("Add chat") |
| + .navigationTitle("Add a chat") |
| .navigationBarTitleDisplayMode(.inline) |
| .toolbar { |
| - ToolbarItem(placement: .topBarLeading) { |
| - Button("Cancel") { dismiss() } |
| - } |
| + ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } |
| } |
| } |
| } |
| - // Fill the name field from the invite's label, once, if it's still empty. |
| - private func prefillName() { |
| - guard name.isEmpty, let p = LChatAPI.parseInvite(link) else { return } |
| - Task { |
| - if let info = try? await LChatAPI.inviteInfo(baseURL: p.baseURL, token: p.token), |
| - let label = info.label, !label.isEmpty, name.isEmpty { |
| - name = label |
| - } |
| - } |
| - } |
| + private var pasteForm: some View { |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 20) { |
| + VStack(alignment: .leading, spacing: 10) { |
| + step(1, "Ask for an invite link, or in an existing chat open the **⋮** menu and choose **Add a device**.") |
| + step(2, "Paste the link here — it's added as soon as it's valid.") |
| + } |
| - private func add() { |
| - error = nil |
| - let trimmedLink = link.trimmingCharacters(in: .whitespacesAndNewlines) |
| - let screenName = String(name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(32)) |
| - guard !screenName.isEmpty else { |
| - error = "Please enter a screen name." |
| - return |
| - } |
| + TextField("https://…/join/…", text: $link, axis: .vertical) |
| + .textFieldStyle(.roundedBorder) |
| + .textInputAutocapitalization(.never) |
| + .autocorrectionDisabled() |
| + .keyboardType(.URL) |
| + .onChange(of: link) { _, new in |
| + // Auto-start once the text is a valid link — but debounced: |
| + // a fragment is "valid" from its first character, so starting |
| + // on the spot mid-typing would fire with a truncated secret. |
| + pending?.cancel() |
| + guard parseJoinLink(new) != nil else { return } |
| + pending = Task { |
| + try? await Task.sleep(for: .milliseconds(500)) |
| + if !Task.isCancelled { start() } |
| + } |
| + } |
| - // Legacy `#k=` link: the key rides in the URL, so validate and claim natively. |
| - if let p = LChatAPI.parseInvite(trimmedLink) { |
| - guard (try? GroupKey(base64url: p.keyB64)) != nil else { |
| - error = "This link's secret key is malformed." |
| - return |
| - } |
| - busy = true |
| - Task { |
| - defer { busy = false } |
| - guard let info = try? await LChatAPI.inviteInfo(baseURL: p.baseURL, token: p.token) else { |
| - error = "Couldn't reach the server. Try again." |
| - return |
| - } |
| - guard info.ok else { |
| - error = (info.revoked == true) ? "This invite was turned off." : "This invite link is invalid." |
| - return |
| + if let error { |
| + Text(error).font(.callout).foregroundStyle(.red) |
| + } else if !link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { |
| + // Only nag once they've typed something that isn't (yet) a link. |
| + Label("That doesn't look like an invite link yet.", systemImage: "exclamationmark.circle") |
| + .font(.caption) |
| + .foregroundStyle(.secondary) |
| } |
| - await LChatAPI.claim(baseURL: p.baseURL, token: p.token) |
| - app.add(url: trimmedLink, name: screenName) |
| - dismiss() |
| } |
| - return |
| + .padding() |
| } |
| + } |
| - // New-format (server-blind) link: the key isn't in the URL — it's fetched |
| - // and unwrapped by the web join flow inside the WKWebView. We can't validate |
| - // or claim it natively, so save the link (carrying the name so the web join |
| - // is promptless) and open it; the webview does the rest. |
| - guard let joinURL = newInviteURL(trimmedLink, name: screenName) else { |
| - error = "That doesn't look like a valid invite link." |
| - return |
| + private func step(_ n: Int, _ markdown: String) -> some View { |
| + HStack(alignment: .firstTextBaseline, spacing: 8) { |
| + Text("\(n).") |
| + .font(.callout.monospacedDigit()) |
| + .foregroundStyle(.tertiary) |
| + Text((try? AttributedString(markdown: markdown)) ?? AttributedString(markdown)) |
| + .font(.callout) |
| + .foregroundStyle(.secondary) |
| + Spacer(minLength: 0) |
| } |
| - app.add(url: joinURL, name: screenName) |
| + } |
| + |
| + private func start() { |
| + error = nil |
| + guard let parsed = parseJoinLink(link) else { return } |
| + origin = parsed.origin |
| + joinURL = parsed.joinURL |
| + } |
| + |
| + private func complete(_ joined: JoinedIdentity) { |
| + let inst = SavedInstance( |
| + origin: origin, |
| + deviceToken: joined.token, |
| + screenName: joined.name, |
| + userPub: joined.userPub, |
| + userPriv: joined.userPriv |
| + ) |
| + app.add(inst) |
| + onAdded(inst) |
| dismiss() |
| } |
| +} |
| - // Validate a new-format `/join/<slug>#<T>` link and append `~<name>` so join.js |
| - // signs in without re-prompting. Returns nil if it isn't a usable join link. |
| - private func newInviteURL(_ link: String, name: String) -> String? { |
| - guard let c = URLComponents(string: link), |
| - let scheme = c.scheme, scheme == "http" || scheme == "https", |
| - c.host != nil, c.path.contains("/join/"), |
| - let frag = c.fragment, !frag.isEmpty, !frag.contains("k=") |
| - else { return nil } |
| - if frag.contains("~") { return link } // already carries a name |
| - let encoded = name.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? name |
| - return "\(link)~\(encoded)" |
| +// The fields the web join flow hands back. |
| +struct JoinedIdentity { |
| + let token: String |
| + let name: String |
| + let userPub: String |
| + let userPriv: String |
| +} |
| + |
| +// Validate a new-format `/join/<slug>#<T>` link and reduce it to (origin, joinURL). |
| +// Legacy `#k=` links are rejected — this client targets only the keypair model. |
| +func parseJoinLink(_ raw: String) -> (origin: String, joinURL: URL)? { |
| + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard let c = URLComponents(string: trimmed), |
| + let scheme = c.scheme, let host = c.host, |
| + scheme == "http" || scheme == "https", |
| + c.path.contains("/join/"), |
| + let frag = c.fragment, !frag.isEmpty, !frag.contains("k="), |
| + let joinURL = URL(string: trimmed) |
| + else { return nil } |
| + var o = URLComponents() |
| + o.scheme = scheme |
| + o.host = host |
| + o.port = c.port |
| + guard let origin = o.url else { return nil } |
| + return (origin.absoluteString, joinURL) |
| +} |
| + |
| +// Hosts join.js with window.__lchatEmbed set, so it posts the resolved identity back |
| +// on the "lchat" handler instead of navigating to "/". |
| +struct JoinWebView: UIViewRepresentable { |
| + let url: URL |
| + let onJoined: (JoinedIdentity) -> Void |
| + let onError: (String) -> Void |
| + |
| + func makeCoordinator() -> Coordinator { Coordinator(onJoined: onJoined, onError: onError) } |
| + |
| + func makeUIView(context: Context) -> WKWebView { |
| + let cfg = WKWebViewConfiguration() |
| + // One-shot webview: never inherit (or leave behind) web sessions. With the |
| + // shared default store, a previous join's localStorage session makes join.js |
| + // take its "already a member on this device" shortcut and silently consume |
| + // the invite as that old identity — the native shell owns identities, not |
| + // the webview. |
| + cfg.websiteDataStore = .nonPersistent() |
| + let flag = WKUserScript(source: "window.__lchatEmbed = true;", |
| + injectionTime: .atDocumentStart, forMainFrameOnly: true) |
| + cfg.userContentController.addUserScript(flag) |
| + cfg.userContentController.add(context.coordinator, name: "lchat") |
| + let web = WKWebView(frame: .zero, configuration: cfg) |
| + web.navigationDelegate = context.coordinator |
| + #if DEBUG |
| + if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true } |
| + #endif |
| + web.load(URLRequest(url: url)) |
| + return web |
| + } |
| + |
| + func updateUIView(_ web: WKWebView, context: Context) {} |
| + |
| + final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { |
| + let onJoined: (JoinedIdentity) -> Void |
| + let onError: (String) -> Void |
| + private var done = false |
| + |
| + init(onJoined: @escaping (JoinedIdentity) -> Void, onError: @escaping (String) -> Void) { |
| + self.onJoined = onJoined |
| + self.onError = onError |
| + } |
| + |
| + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| + guard !done, message.name == "lchat", |
| + let body = message.body as? [String: Any], |
| + let j = body["joined"] as? [String: Any], |
| + let token = j["token"] as? String, |
| + let name = j["name"] as? String, |
| + let pub = j["userPub"] as? String, |
| + let priv = j["userPriv"] as? String |
| + else { return } |
| + done = true |
| + onJoined(JoinedIdentity(token: token, name: name, userPub: pub, userPriv: priv)) |
| + } |
| } |
| } |
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift| new file mode 100644 |
| index 0000000..fec8d29 |
| --- /dev/null |
| +++ b/ios/LChat/Views/NewChatView.swift |
| @@ -0,0 +1,172 @@ |
| +import SwiftUI |
| + |
| +// Start a new chat, iMessage-style: the sheet lists people — tap one and the DM |
| +// starts immediately (the server dedupes, so an existing DM just opens). "New |
| +// group" pushes the multi-select flow. Agent helpers get their own section with |
| +// a BOT badge, mirroring the web picker. All key wrapping happens natively in |
| +// ThreadStore.createChat. |
| +struct NewChatView: View { |
| + @ObservedObject var store: ThreadStore |
| + @Environment(\.dismiss) private var dismiss |
| + let onCreated: (String) -> Void |
| + |
| + @State private var busyWith: String? // user id of the DM being created |
| + @State private var error: String? |
| + |
| + private var others: [RosterEntry] { store.roster.filter { $0.user_id != store.myUserId } } |
| + private var people: [RosterEntry] { others.filter { !$0.isAgent } } |
| + private var helpers: [RosterEntry] { others.filter { $0.isAgent } } |
| + |
| + var body: some View { |
| + NavigationStack { |
| + List { |
| + Section { |
| + NavigationLink { |
| + NewGroupView(store: store, onCreated: finish) |
| + } label: { |
| + Label { |
| + Text("New group").font(.body.weight(.medium)) |
| + } icon: { |
| + Image(systemName: "person.3.fill") |
| + .foregroundStyle(Color.accentColor) |
| + } |
| + } |
| + } |
| + |
| + if others.isEmpty { |
| + Section { |
| + Text("No one else has joined this chat yet.") |
| + .foregroundStyle(.secondary) |
| + } |
| + } |
| + if !people.isEmpty { |
| + Section("People") { |
| + ForEach(people, id: \.user_id) { person in personRow(person) } |
| + } |
| + } |
| + if !helpers.isEmpty { |
| + Section("Helpers") { |
| + ForEach(helpers, id: \.user_id) { person in personRow(person) } |
| + } |
| + } |
| + if let error { |
| + Section { Text(error).foregroundStyle(.red) } |
| + } |
| + } |
| + .navigationTitle("New chat") |
| + .navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } |
| + } |
| + } |
| + } |
| + |
| + private func personRow(_ person: RosterEntry) -> some View { |
| + Button { startDM(person) } label: { |
| + HStack(spacing: 12) { |
| + Avatar(name: person.label, size: 36) |
| + Text(person.label).foregroundStyle(.primary) |
| + if person.isAgent { BotBadge() } |
| + Spacer() |
| + if busyWith == person.user_id { ProgressView() } |
| + } |
| + } |
| + .buttonStyle(.plain) |
| + .disabled(busyWith != nil) |
| + } |
| + |
| + private func startDM(_ person: RosterEntry) { |
| + busyWith = person.user_id |
| + error = nil |
| + Task { |
| + let id = await store.createChat(memberIDs: [person.user_id], groupName: nil) |
| + busyWith = nil |
| + if let id { finish(id) } else { error = store.loadError ?? "Couldn't start the chat." } |
| + } |
| + } |
| + |
| + private func finish(_ id: String) { |
| + onCreated(id) |
| + dismiss() |
| + } |
| +} |
| + |
| +// The multi-select group flow, pushed from NewChatView. |
| +struct NewGroupView: View { |
| + @ObservedObject var store: ThreadStore |
| + let onCreated: (String) -> Void |
| + |
| + @State private var selected: Set<String> = [] |
| + @State private var groupName = "" |
| + @State private var busy = false |
| + @State private var error: String? |
| + |
| + private var others: [RosterEntry] { store.roster.filter { $0.user_id != store.myUserId } } |
| + |
| + var body: some View { |
| + Form { |
| + Section("Group name") { |
| + TextField("Optional", text: $groupName) |
| + .autocorrectionDisabled() |
| + } |
| + Section(header: Text(selected.isEmpty ? "Add people" : "\(selected.count) selected")) { |
| + ForEach(others, id: \.user_id) { person in |
| + Button { toggle(person.user_id) } label: { |
| + HStack(spacing: 12) { |
| + Avatar(name: person.label, size: 32) |
| + Text(person.label).foregroundStyle(.primary) |
| + if person.isAgent { BotBadge() } |
| + Spacer() |
| + if selected.contains(person.user_id) { |
| + Image(systemName: "checkmark.circle.fill").foregroundStyle(.tint) |
| + } else { |
| + Image(systemName: "circle").foregroundStyle(.tertiary) |
| + } |
| + } |
| + } |
| + .buttonStyle(.plain) |
| + } |
| + } |
| + if let error { |
| + Section { Text(error).foregroundStyle(.red) } |
| + } |
| + } |
| + .navigationTitle("New group") |
| + .navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + ToolbarItem(placement: .confirmationAction) { |
| + if busy { ProgressView() } |
| + else { |
| + Button("Create") { create() } |
| + .disabled(selected.isEmpty) |
| + } |
| + } |
| + } |
| + } |
| + |
| + private func toggle(_ id: String) { |
| + if selected.contains(id) { selected.remove(id) } else { selected.insert(id) } |
| + } |
| + |
| + private func create() { |
| + busy = true |
| + error = nil |
| + Task { |
| + let id = await store.createChat(memberIDs: Array(selected), groupName: groupName) |
| + busy = false |
| + if let id { onCreated(id) } else { error = store.loadError ?? "Couldn't create the group." } |
| + } |
| + } |
| +} |
| + |
| +// The "BOT" tag next to agent helpers — mirrors the web picker's badge. |
| +struct BotBadge: View { |
| + var body: some View { |
| + Text("BOT") |
| + .font(.caption2.weight(.bold)) |
| + .padding(.horizontal, 5) |
| + .padding(.vertical, 2) |
| + .background(Color.accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 4)) |
| + .foregroundStyle(Color.accentColor) |
| + } |
| +} |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| new file mode 100644 |
| index 0000000..ddf0d1d |
| --- /dev/null |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -0,0 +1,113 @@ |
| +import SwiftUI |
| + |
| +// The main index: the active server's chats (DMs, groups, Notes), each with a |
| +// decrypted name + last-message preview. Tapping one pushes its feed; the top-left |
| +// server button slides in the server drawer (see RootView). The ThreadStore does |
| +// the native ECIES unwrap + preview decryption; this view is just presentation. |
| +struct ThreadListView: View { |
| + @EnvironmentObject var app: AppState |
| + @ObservedObject var store: ThreadStore |
| + @Binding var path: [String] |
| + @Binding var showServers: Bool |
| + @State private var showNew = false |
| + |
| + var body: some View { |
| + List { |
| + ForEach(store.threads) { t in |
| + NavigationLink(value: t.id) { row(t) } |
| + } |
| + } |
| + .listStyle(.plain) |
| + .overlay { if store.loading && store.threads.isEmpty { ProgressView() } } |
| + .overlay { if !store.loading && store.threads.isEmpty { empty } } |
| + .navigationTitle(store.instanceName.isEmpty ? store.instance.displayName : store.instanceName) |
| + .navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + ToolbarItem(placement: .topBarLeading) { |
| + Button { withAnimation(.easeOut(duration: 0.22)) { showServers = true } } label: { |
| + Avatar(name: store.instanceName.isEmpty ? store.instance.displayName : store.instanceName, |
| + size: 30) |
| + } |
| + .accessibilityLabel("Servers") |
| + } |
| + ToolbarItem(placement: .primaryAction) { |
| + Button { showNew = true } label: { Image(systemName: "square.and.pencil") } |
| + .accessibilityLabel("New chat") |
| + } |
| + } |
| + .sheet(isPresented: $showNew) { |
| + NewChatView(store: store) { newID in path.append(newID) } |
| + } |
| + .navigationDestination(for: String.self) { id in |
| + if let t = store.threads.first(where: { $0.id == id }) { |
| + ChatView(instance: store.instance, thread: t, |
| + myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) |
| + .id(store.instance.id + "|" + t.id) // fresh webview per thread |
| + .onAppear { store.markRead(t) } |
| + } else { |
| + ContentUnavailableView("Chat not found", systemImage: "bubble.left") |
| + } |
| + } |
| + .refreshable { await load() } |
| + // Keyed on the instance: when the store swaps in place (switching server), |
| + // SwiftUI reuses this view, so an un-keyed .task would not re-fire and the new |
| + // server's chats would never load. |
| + .task(id: store.instance.id) { await load() } |
| + } |
| + |
| + private func row(_ t: Thread) -> some View { |
| + HStack(spacing: 12) { |
| + Avatar(name: t.title, size: 44) |
| + VStack(alignment: .leading, spacing: 2) { |
| + HStack { |
| + Text(t.title).font(.headline).lineLimit(1) |
| + Spacer() |
| + if t.lastAt > 0 { |
| + Text(relTime(t.lastAt)).font(.caption).foregroundStyle(.secondary) |
| + } |
| + } |
| + HStack(spacing: 6) { |
| + Text(t.preview.isEmpty ? " " : t.preview) |
| + .font(.subheadline).foregroundStyle(.secondary).lineLimit(1) |
| + Spacer() |
| + if t.unread { |
| + Circle().fill(Color.accentColor).frame(width: 9, height: 9) |
| + } |
| + } |
| + } |
| + } |
| + .padding(.vertical, 4) |
| + } |
| + |
| + private var empty: some View { |
| + VStack(spacing: 8) { |
| + Image(systemName: "tray").font(.system(size: 36)).foregroundStyle(.secondary) |
| + Text("No chats yet").font(.headline) |
| + Text("Tap the compose button to start one.") |
| + .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) |
| + Button { showNew = true } label: { Text("New chat").frame(maxWidth: 200) } |
| + .buttonStyle(.borderedProminent) |
| + } |
| + .padding() |
| + } |
| + |
| + private func load() async { |
| + await store.refresh() |
| + app.note(id: store.instance.id, myUserId: store.myUserId, groupName: store.instanceName) |
| + } |
| +} |
| + |
| +// A compact "last activity" label — mirrors relTime in web/static/chat.js. |
| +private func relTime(_ sec: Int64) -> String { |
| + guard sec > 0 else { return "" } |
| + let d = Date(timeIntervalSince1970: TimeInterval(sec)) |
| + let cal = Calendar.current |
| + if cal.isDateInToday(d) { |
| + return d.formatted(date: .omitted, time: .shortened) |
| + } |
| + if cal.isDateInYesterday(d) { return "Yesterday" } |
| + if let days = cal.dateComponents([.day], from: d, to: Date()).day, days < 7 { |
| + return d.formatted(.dateTime.weekday(.abbreviated)) |
| + } |
| + return d.formatted(.dateTime.day().month(.twoDigits)) |
| +} |
diff --git a/ios/LChatTests/ECIESVectorTest.swift b/ios/LChatTests/ECIESVectorTest.swift| new file mode 100644 |
| index 0000000..4dab919 |
| --- /dev/null |
| +++ b/ios/LChatTests/ECIESVectorTest.swift |
| @@ -0,0 +1,52 @@ |
| +import Foundation |
| + |
| +// Standalone gate for the native ECIES unwrap. Compiles the real Crypto.swift and |
| +// asserts it unwraps the committed cross-implementation fixture (produced by the web |
| +// crypto in test/threadkey.test.mjs) to the expected thread key. Run via: |
| +// |
| +// cd ios && swiftc -parse-as-library LChat/Crypto.swift \ |
| +// LChatTests/ECIESVectorTest.swift -o /tmp/eciesvec \ |
| +// && /tmp/eciesvec LChatTests/thread-key-vector.json |
| +// |
| +// A non-zero exit means the native ECIES has drifted from the web's wire format. |
| +@main |
| +struct ECIESVectorTest { |
| + struct Vector: Decodable { |
| + let privPkcs8B64: String |
| + let pubRawB64: String |
| + let wrappedB64: String |
| + let expectedThreadKeyB64: String |
| + } |
| + |
| + static func main() { |
| + let args = CommandLine.arguments |
| + let path = args.count > 1 ? args[1] : "LChatTests/thread-key-vector.json" |
| + guard let data = FileManager.default.contents(atPath: path), |
| + let v = try? JSONDecoder().decode(Vector.self, from: data) else { |
| + fail("could not read/parse fixture at \(path)") |
| + } |
| + guard let expected = Data(base64urlEncoded: v.expectedThreadKeyB64) else { |
| + fail("fixture expectedThreadKeyB64 is not valid base64url") |
| + } |
| + do { |
| + let got = try ECIES.unwrap(wrappedB64: v.wrappedB64, privPkcs8B64: v.privPkcs8B64) |
| + guard got == expected else { |
| + fail("unwrapped key != expected\n got: \(got.base64urlEncodedString())\n expected: \(v.expectedThreadKeyB64)") |
| + } |
| + // Wrap round-trip: wrap a known key to the fixture's public key, then |
| + // unwrap with its private key. Validates ECIES.wrap (used when starting a |
| + // chat) produces a blob the unwrap path — and thus the web — can open. |
| + let fresh = try ECIES.wrap(rawKey: expected, recipientPubB64: v.pubRawB64) |
| + let back = try ECIES.unwrap(wrappedB64: fresh, privPkcs8B64: v.privPkcs8B64) |
| + guard back == expected else { fail("wrap→unwrap round-trip mismatch") } |
| + print("ECIESVectorTest OK") |
| + } catch { |
| + fail("ECIES.unwrap threw: \(error)") |
| + } |
| + } |
| + |
| + static func fail(_ msg: String) -> Never { |
| + FileHandle.standardError.write(Data("FAIL: \(msg)\n".utf8)) |
| + exit(1) |
| + } |
| +} |
diff --git a/ios/LChatTests/thread-key-vector.json b/ios/LChatTests/thread-key-vector.json| new file mode 100644 |
| index 0000000..2b8176e |
| --- /dev/null |
| +++ b/ios/LChatTests/thread-key-vector.json |
| @@ -0,0 +1,7 @@ |
| +{ |
| + "_comment": "ECIES thread-key test vector. Generated by test/threadkey.test.mjs from web/static/crypto.js. The Swift LChatTests unwraps wrappedB64 with privPkcs8B64 and must get expectedThreadKeyB64 — a cross-implementation gate so the native ECIES never drifts from the web's. base64url throughout. Regenerate with: node test/threadkey.test.mjs --emit", |
| + "privPkcs8B64": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgHERydCsyayphKWBF_ZvnsVAdo4rkmj46SVCd7nvyScehRANCAAQEwK724nmVbpFc-HXEZTt76k0kDgjG75vQ5vDilG6gGogNkYj8-my6TRgtN380yWGO93G87VSi4qMCqqztkGWn", |
| + "pubRawB64": "BATArvbieZVukVz4dcRlO3vqTSQOCMbvm9Dm8OKUbqAaiA2RiPz6bLpNGC03fzTJYY73cbztVKLiowKqrO2QZac", |
| + "wrappedB64": "BMSCxNIL5x0UaozrSy4uC9faSE_2st77jmlGRQssrFiqL_b1wuYuaUjrZZ14FCJnXCk0Y8FDYaqWs1HwPdVC5oo0xn9sNphLehiNdYDRKwiPiBJQUL4exXqAuvWbxa2NkteBVvBhRv0DaMxu02BmYRbArErXam2T3l-v61c", |
| + "expectedThreadKeyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" |
| +} |
diff --git a/ios/LChatUITests/JoinAndChatTests.swift b/ios/LChatUITests/JoinAndChatTests.swift| new file mode 100644 |
| index 0000000..dcf744d |
| --- /dev/null |
| +++ b/ios/LChatUITests/JoinAndChatTests.swift |
| @@ -0,0 +1,84 @@ |
| +import XCTest |
| + |
| +// End-to-end drive of the real app against a live server: join via a member |
| +// invite link, land in the DM, send a message, and see the other member's live |
| +// reply arrive over the socket. The server URL/invite ride in via env |
| +// (TEST_RUNNER_INVITE_LINK etc. on the xcodebuild invocation); a scripted peer |
| +// ("Paula") must be listening server-side to produce the reply. |
| +final class JoinAndChatTests: XCTestCase { |
| + |
| + override func setUpWithError() throws { |
| + continueAfterFailure = false |
| + } |
| + |
| + func testJoinThreadListSendReceive() throws { |
| + let env = ProcessInfo.processInfo.environment |
| + guard let invite = env["INVITE_LINK"], !invite.isEmpty else { |
| + throw XCTSkip("Set TEST_RUNNER_INVITE_LINK to a /join/… link for a live server.") |
| + } |
| + let outgoing = env["SEND_TEXT"] ?? "hello from Eleven on iOS" |
| + let expectedReply = env["EXPECT_REPLY"] ?? "hello back from Paula" |
| + |
| + let app = XCUIApplication() |
| + app.launch() |
| + |
| + // Fresh install → empty state → "Add a chat" |
| + let add = app.buttons["Add a chat"].firstMatch |
| + XCTAssertTrue(add.waitForExistence(timeout: 10), "no Add a chat button") |
| + shoot(app, "01-empty-state") |
| + add.tap() |
| + |
| + // Paste the invite link — a valid link starts the join on its own (no |
| + // button), matching the Mac app. |
| + let field = app.textFields.firstMatch |
| + XCTAssertTrue(field.waitForExistence(timeout: 5), "no invite link field") |
| + field.tap() |
| + field.typeText(invite) |
| + |
| + // The web join form: screen name is prefilled from the invite label. |
| + let join = webElement(app, "Join the chat") |
| + XCTAssertTrue(join.waitForExistence(timeout: 20), "web join form never appeared") |
| + shoot(app, "02-join-form") |
| + join.tap() |
| + |
| + // Back in native: the thread list shows the DM with the inviter. |
| + let paula = app.staticTexts["Paula"].firstMatch |
| + XCTAssertTrue(paula.waitForExistence(timeout: 25), "DM with Paula not in thread list") |
| + shoot(app, "03-thread-list") |
| + paula.tap() |
| + |
| + // The feed opens; type into the native composer and send. |
| + let composer = app.textViews.firstMatch |
| + XCTAssertTrue(composer.waitForExistence(timeout: 15), "no composer") |
| + composer.tap() |
| + composer.typeText(outgoing) |
| + let send = app.buttons["Send"].firstMatch |
| + XCTAssertTrue(send.waitForExistence(timeout: 5), "no send button") |
| + send.tap() |
| + |
| + // Our message renders in the webview feed… |
| + let mine = webElement(app, outgoing) |
| + XCTAssertTrue(mine.waitForExistence(timeout: 20), "sent message never rendered") |
| + shoot(app, "04-message-sent") |
| + |
| + // …and Paula's scripted reply arrives live over the socket. |
| + let reply = webElement(app, expectedReply) |
| + XCTAssertTrue(reply.waitForExistence(timeout: 60), "Paula's reply never rendered") |
| + shoot(app, "05-reply-received") |
| + } |
| + |
| + // Web content exposes elements inconsistently (button vs link vs static text |
| + // depending on markup) — match any descendant by label. |
| + private func webElement(_ app: XCUIApplication, _ label: String) -> XCUIElement { |
| + app.webViews.descendants(matching: .any).matching( |
| + NSPredicate(format: "label CONTAINS %@", label) |
| + ).firstMatch |
| + } |
| + |
| + private func shoot(_ app: XCUIApplication, _ name: String) { |
| + let shot = XCTAttachment(screenshot: app.screenshot()) |
| + shot.name = name |
| + shot.lifetime = .keepAlways |
| + add(shot) |
| + } |
| +} |
diff --git a/ios/README.md b/ios/README.md| index 05cd918..c438eff 100644 |
| --- a/ios/README.md |
| +++ b/ios/README.md |
| @@ -2,24 +2,43 @@ |
| 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: the AES-GCM group key lives only in the |
| -invite link's `#fragment`, stays on-device (Keychain), and the server only ever |
| -sees opaque ciphertext and blob ids. |
| +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 |
| -- **Join** by pasting an invite link (`https://host/join/<token>#k=…`). The host |
| - is taken from the link — nothing is hardcoded. |
| -- **Read** history + live messages (text, inline images, reply quotes) over the |
| - `/ws` WebSocket, with auto-reconnect and gap-fill. |
| -- **Send** text and **photos** (downscaled + re-encoded on-device, which also |
| - strips EXIF/GPS — there is no server-side image proxy). |
| -- **React** (long-press a message → pick from the same six-emoji set the server |
| - allows) and **reply** (quoted, iMessage-style; the thread link rides inside the |
| - encrypted payload). |
| - |
| -Out of scope for this first cut: push notifications (needs APNs + a paid Apple |
| -developer account + server work) and link-preview cards. |
| +- **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 |
| @@ -99,19 +118,34 @@ can fire off several messages without clicking back in. |
| `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 an invite from the admin console, then paste |
| -that `…/join/…#k=…` link into the app's join screen. |
| +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 via CryptoKit; `iv(12)‖ct‖tag` base64url | `crypto.js` | |
| -| `LChatAPI.swift` | REST: invite, messages, upload, blob, group | `fetch` calls in `chat.js` | |
| -| `ChatSocket.swift` | `/ws` send/receive, ping keepalive, reconnect | WebSocket block in `chat.js` | |
| -| `ChatStore.swift` | decrypt, dedupe, reactions, reply counts | render/state in `chat.js` | |
| -| `Models.swift` | payload shape + `replySnippet` + reaction set | `format.js`, `hub.go` | |
| - |
| -The wire format is byte-identical to the web client in both directions |
| -(CryptoKit's `SealedBox.combined` is exactly `iv‖ciphertext‖tag`), so iOS and |
| -browser participants interoperate in the same chat. |
| +| `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 |
| +``` |
diff --git a/ios/project.yml b/ios/project.yml| index cb8945c..481d755 100644 |
| --- a/ios/project.yml |
| +++ b/ios/project.yml |
| @@ -29,7 +29,7 @@ targets: |
| info: |
| path: LChat/Info.plist |
| properties: |
| - CFBundleDisplayName: lchat |
| + CFBundleDisplayName: Eleven |
| UILaunchScreen: {} |
| UISupportedInterfaceOrientations: |
| - UIInterfaceOrientationPortrait |
| @@ -43,3 +43,24 @@ targets: |
| # test against a `go run .` instance during development. |
| NSAppTransportSecurity: |
| NSAllowsLocalNetworking: true |
| + # End-to-end UI tests (Simulator only). They need a live server and an invite |
| + # link, passed as TEST_RUNNER_INVITE_LINK on the xcodebuild invocation; without |
| + # it the test skips itself. See LChatUITests/JoinAndChatTests.swift. |
| + LChatUITests: |
| + type: bundle.ui-testing |
| + platform: iOS |
| + sources: |
| + - path: LChatUITests |
| + dependencies: |
| + - target: LChat |
| +schemes: |
| + LChat: |
| + build: |
| + targets: |
| + LChat: all |
| + run: |
| + config: Debug |
| + test: |
| + config: Debug |
| + targets: |
| + - LChatUITests |
diff --git a/package.json b/package.json| index 70e0660..78bd5cf 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs" |
| } |
| } |
diff --git a/test/threadkey.test.mjs b/test/threadkey.test.mjs| new file mode 100644 |
| index 0000000..72c5bb7 |
| --- /dev/null |
| +++ b/test/threadkey.test.mjs |
| @@ -0,0 +1,74 @@ |
| +// Cross-implementation gate for ECIES thread-key wrapping. The native iOS client |
| +// (ios/LChat/Crypto.swift) reimplements unwrapThreadKey in CryptoKit; if the web's |
| +// crypto.js ever changes the wire format (curve, HKDF salt/info, key encodings), |
| +// the two silently drift and threads stop opening on one platform. This test pins |
| +// the format two ways: |
| +// |
| +// 1. a fresh wrap → unwrap round-trips in JS (crypto.js self-consistency), and |
| +// 2. the committed fixture (ios/LChatTests/thread-key-vector.json) still unwraps |
| +// to its expected key — the SAME fixture the Swift unit test consumes. |
| +// |
| +// If (2) fails, crypto.js changed incompatibly: regenerate the fixture (see below) |
| +// and update Crypto.swift to match in the same change. |
| +import { readFileSync, writeFileSync } from "node:fs"; |
| +import { fileURLToPath } from "node:url"; |
| +import { dirname, join } from "node:path"; |
| +import { generateUserKeypair, wrapThreadKey, unwrapThreadKey, bytesToB64url } from "../web/static/crypto.js"; |
| + |
| +const here = dirname(fileURLToPath(import.meta.url)); |
| +const fixturePath = join(here, "..", "ios", "LChatTests", "thread-key-vector.json"); |
| + |
| +// A known 32-byte thread key (bytes 0..31), so the expected value is stable. |
| +function knownThreadKeyB64() { |
| + const raw = new Uint8Array(32); |
| + for (let i = 0; i < 32; i++) raw[i] = i; |
| + return bytesToB64url(raw); |
| +} |
| + |
| +function assert(cond, msg) { |
| + if (!cond) { |
| + console.error("FAIL:", msg); |
| + process.exit(1); |
| + } |
| +} |
| + |
| +// `node test/threadkey.test.mjs --emit` regenerates the committed fixture (the wrap |
| +// uses a random ephemeral key, so the fixture must be frozen, not recomputed). |
| +async function emit() { |
| + const expectedThreadKeyB64 = knownThreadKeyB64(); |
| + const kp = await generateUserKeypair(); |
| + const wrappedB64 = await wrapThreadKey(expectedThreadKeyB64, kp.publicKey); |
| + assert((await unwrapThreadKey(wrappedB64, kp.privateKey)) === expectedThreadKeyB64, "round-trip"); |
| + const fixture = { |
| + _comment: |
| + "ECIES thread-key test vector. Generated by test/threadkey.test.mjs from " + |
| + "web/static/crypto.js. The Swift LChatTests unwraps wrappedB64 with " + |
| + "privPkcs8B64 and must get expectedThreadKeyB64 — a cross-implementation gate " + |
| + "so the native ECIES never drifts from the web's. base64url throughout. " + |
| + "Regenerate with: node test/threadkey.test.mjs --emit", |
| + privPkcs8B64: kp.privateKey, |
| + pubRawB64: kp.publicKey, |
| + wrappedB64, |
| + expectedThreadKeyB64, |
| + }; |
| + writeFileSync(fixturePath, JSON.stringify(fixture, null, 2) + "\n"); |
| + console.log("wrote", fixturePath); |
| +} |
| + |
| +async function test() { |
| + // (1) crypto.js self-consistency. |
| + const k = knownThreadKeyB64(); |
| + const kp = await generateUserKeypair(); |
| + const wrapped = await wrapThreadKey(k, kp.publicKey); |
| + assert((await unwrapThreadKey(wrapped, kp.privateKey)) === k, "fresh wrap/unwrap round-trips"); |
| + |
| + // (2) the committed fixture (shared with the Swift test) still unwraps. |
| + const fx = JSON.parse(readFileSync(fixturePath, "utf8")); |
| + const got = await unwrapThreadKey(fx.wrappedB64, fx.privPkcs8B64); |
| + assert(got === fx.expectedThreadKeyB64, "committed fixture unwraps to expected key"); |
| + |
| + console.log("threadkey.test.mjs OK"); |
| +} |
| + |
| +if (process.argv.includes("--emit")) await emit(); |
| +else await test(); |
diff --git a/web/static/chat.js b/web/static/chat.js| index 39efa4f..6b05157 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -261,7 +261,29 @@ let typingBeat = null; |
| let typingIdle = null; |
| const app = document.getElementById("app"); |
| -const session = loadSession(); |
| +// The native shell (iOS/macOS) injects window.__lchatEmbed at documentStart with |
| +// everything needed to render ONE thread: {token, name, myUserId, threadId, |
| +// threadKey, instanceKey?}. In embed mode we render just that thread's feed — the |
| +// app draws its own sidebar, title bar and composer. The user *private* key is |
| +// never handed to the webview: native unwraps the thread key and passes only it, |
| +// so a single thread's page can't reach any other thread's content. |
| +const embed = window.__lchatEmbed || null; |
| +const session = embed ? { token: embed.token, name: embed.name } : loadSession(); |
| + |
| +// Post a message back to the native shell's "lchat" handler (no-op in a browser). |
| +function nativePost(msg) { |
| + try { |
| + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lchat) { |
| + window.webkit.messageHandlers.lchat.postMessage(msg); |
| + } |
| + } catch {} |
| +} |
| +// Surface a problem to the user. In the webview an alert() would freeze the page |
| +// (a modal dialog blocks the native message channel), so route errors to the shell. |
| +function notifyError(msg) { |
| + if (embed) nativePost({ error: msg }); |
| + else alert(msg); |
| +} |
| // App state. |
| let roster = []; // [{user_id, label, public_key}] |
| @@ -288,6 +310,7 @@ let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a bri |
| route(); |
| function route() { |
| + if (embed) return bootEmbed(); |
| if (!session) return showNoInvite(); |
| if (needsHomeScreen() && localStorage.getItem("lchat_a2hs") !== "done") { |
| return showA2HS(boot); |
| @@ -337,6 +360,80 @@ function hideLoading() { |
| setTimeout(() => el.remove(), 250); // after the CSS fade |
| } |
| +// Embed bootstrap: render a single thread's feed for the native shell. Skips the |
| +// normal roster/instance/thread *unwrap* path (the webview has no user private key) |
| +// — native already unwrapped this thread's key and handed it over. Everything past |
| +// that (feed render, send, reactions, edits, typing, attachments) is reused as-is. |
| +async function bootEmbed() { |
| + document.body.classList.add("native-embed"); |
| + try { |
| + myUserId = embed.myUserId || ""; |
| + await loadRoster(); // token-only; gives sender labels + profile photos |
| + if (embed.instanceKey) { |
| + try { |
| + instanceKeyB64 = embed.instanceKey; |
| + instanceKey = await importKey(embed.instanceKey); |
| + } catch {} |
| + } |
| + const tid = embed.threadId; |
| + threadKeyB64[tid] = embed.threadKey; |
| + threadKeys[tid] = await importKey(embed.threadKey); |
| + // Fetch thread objects for kind/members/name_blob, but only the active thread |
| + // has a key — the others can't be unwrapped here without the private key. |
| + try { |
| + const r = await (await fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)).json(); |
| + threads = r.threads || []; |
| + } catch { |
| + threads = []; |
| + } |
| + if (!threads.find((t) => t.id === tid)) threads.push({ id: tid, kind: "dm", members: [] }); |
| + threadName[tid] = await displayName(threads.find((t) => t.id === tid)); |
| + renderApp(); |
| + await openThread(tid); |
| + connectWS(); |
| + installNativeBridge(); |
| + startHeartbeat(); |
| + } catch (e) { |
| + console.error(e); |
| + nativePost({ error: "Couldn't open this chat." }); |
| + } finally { |
| + hideLoading(); |
| + } |
| +} |
| + |
| +// The bridge the native composer drives: forward typed text and staged photos into |
| +// the one crypto/WebSocket path (chat.js). Mirrors the hooks ChatView's WebBridge |
| +// calls; staged-attachment state + errors are posted back on the "lchat" handler. |
| +function installNativeBridge() { |
| + window.lchatNative = { |
| + // Returns whether the text was accepted (socket open + something to send), so |
| + // the native field knows whether to clear itself. |
| + sendText(text) { |
| + if (!ws || ws.readyState !== WebSocket.OPEN) return false; |
| + const t = (text || "").trim(); |
| + if (!t && !pending.length) return false; |
| + sendMessage(t); |
| + return true; |
| + }, |
| + // Stage a photo picked by the native + button (base64, standard alphabet). The |
| + // web composer only handles images; anything else is rejected with an error. |
| + stageAttachment(att) { |
| + try { |
| + if (!att || !att.type || !att.type.startsWith("image/")) { |
| + nativePost({ error: "Only photos can be attached." }); |
| + return; |
| + } |
| + const bin = atob(att.b64); |
| + const bytes = new Uint8Array(bin.length); |
| + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); |
| + addAttachment(new File([bytes], att.name || "photo.jpg", { type: att.type })); |
| + } catch { |
| + nativePost({ error: "Couldn't attach that photo." }); |
| + } |
| + }, |
| + }; |
| +} |
| + |
| // ---- data --------------------------------------------------------------- |
| function ensurePubkey() { |
| @@ -350,8 +447,12 @@ function ensurePubkey() { |
| async function loadRoster() { |
| const r = await (await fetch(`/api/roster?token=${encodeURIComponent(session.token)}`)).json(); |
| roster = r.roster || []; |
| - const me = roster.find((e) => e.public_key === session.userPub); |
| - myUserId = me ? me.user_id : ""; |
| + // In embed mode the shell tells us our user id up front (it can't be matched by |
| + // public key — the webview never holds one). Otherwise find ourselves by pubkey. |
| + if (!myUserId) { |
| + const me = roster.find((e) => e.public_key === session.userPub); |
| + myUserId = me ? me.user_id : ""; |
| + } |
| } |
| function rosterLabel(userId) { |
| @@ -835,6 +936,8 @@ function renderApp() { |
| }); |
| renderThreadList(); |
| + // Embed mode opens its one thread explicitly (bootEmbed); don't auto-pick here. |
| + if (embed) return Promise.resolve(); |
| // Choose the thread to land in, in priority order: a tapped-notification |
| // deep-link (#thread=), the one we were last in (so a refresh reloads where you |
| // were), else — desktop only — the first visible chat. Returns the open promise |
| @@ -2338,7 +2441,10 @@ async function addAttachment(file) { |
| pending = pending.filter((p) => p !== entry); |
| renderPreview(); |
| updateSendVisibility(); |
| - alert(isImage ? "Couldn't add that photo. Try a smaller one." : `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).`); |
| + const msg = isImage ? "Couldn't add that photo. Try a smaller one." : `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).`; |
| + // alert() is invisible inside the native shell's webview — post it instead. |
| + if (embed) nativePost({ error: msg }); |
| + else alert(msg); |
| } |
| } |
| @@ -2352,6 +2458,8 @@ async function uploadBytes(bytes) { |
| } |
| function renderPreview() { |
| + // Tell the native shell an attachment is (un)staged, so its send button enables. |
| + if (embed) nativePost({ staged: pending.length > 0 }); |
| const preview = document.getElementById("composer-preview"); |
| preview.textContent = ""; |
| if (!pending.length) { |
| @@ -2407,6 +2515,7 @@ function removeAttachment(uid) { |
| function clearPending() { |
| pending = []; |
| + if (embed) nativePost({ staged: false }); |
| const preview = document.getElementById("composer-preview"); |
| if (preview) { |
| preview.textContent = ""; |
| @@ -2771,7 +2880,10 @@ function connectWS() { |
| await loadThreads(); |
| } |
| if (!threadKeys[tid]) { |
| - // A thread we don't know yet (someone just added us / started a DM). |
| + // A thread we don't know yet (someone just added us / started a DM). In embed |
| + // mode the native sidebar owns the other threads and we have no private key to |
| + // unwrap them, so ignore anything but the active thread. |
| + if (embed) return; |
| await loadThreads(); |
| renderThreadList(); |
| return; |
diff --git a/web/static/join.js b/web/static/join.js| index 54ec1c2..6933727 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -20,7 +20,7 @@ init(); |
| function init() { |
| // Already installed? The link in this URL is likely spent — go to the app, |
| // which signs in via paste-to-join. |
| - if (isStandalone) return location.replace("/"); |
| + if (isStandalone && !window.__lchatEmbed) return location.replace("/"); |
| if (!T) return showError("This link is missing its secret."); |
| start(); |
| } |
| @@ -51,7 +51,9 @@ async function start() { |
| } |
| if (payload.threadKey) { |
| // User invite: it drops the joiner into a DM with the person who invited them. |
| - const existing = loadSession(); |
| + // In the native embed the shell owns identities (Keychain) — never adopt a |
| + // stray webview session; always run the fresh-person flow and hand back. |
| + const existing = window.__lchatEmbed ? null : loadSession(); |
| if (existing && existing.token && existing.userPub && existing.userPriv) { |
| // Already a member on this device → join that DM as your existing self. |
| showStatus("Opening your chat…"); |
| @@ -101,9 +103,22 @@ async function finish(id, name, pub, priv, wrappedSelf = "") { |
| body: JSON.stringify({ token: claim.token, public_key: pub }), |
| }); |
| } catch {} |
| - localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv })); |
| - // Land straight in the DM this invite created (the app opens it on boot). |
| - if (claim.thread_id) localStorage.setItem("lchat_open_thread", claim.thread_id); |
| + if (!window.__lchatEmbed) { |
| + localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv })); |
| + // Land straight in the DM this invite created (the app opens it on boot). |
| + if (claim.thread_id) localStorage.setItem("lchat_open_thread", claim.thread_id); |
| + } |
| + if (window.__lchatEmbed) { |
| + // Native add-instance flow: hand the resolved identity back to the shell, which |
| + // moves the private key into the Keychain. Don't navigate — the shell tears the |
| + // one-shot join webview down once it has this. |
| + try { |
| + window.webkit.messageHandlers.lchat.postMessage({ |
| + joined: { token: claim.token, name, userPub: pub, userPriv: priv }, |
| + }); |
| + } catch {} |
| + return; |
| + } |
| location.href = "/"; // drop the fragment from the URL bar |
| } |
diff --git a/web/static/style.css b/web/static/style.css| index 037ddfd..5ae062f 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1316,3 +1316,21 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .tt.show-thread .thread-pane { display: flex; } |
| .back-btn { display: inline-flex; } |
| } |
| + |
| +/* Native shell (iOS/macOS) embeds ONE thread's feed — the app draws its own |
| + sidebar, title bar, and composer. Hide the web sidebar / thread header / composer |
| + and let the feed fill the view (overriding the mobile one-pane-at-a-time rules). |
| + Gated on body.native-embed, which only chat.js sets when window.__lchatEmbed is |
| + injected; browsers are unaffected. */ |
| +.native-embed .sidebar { display: none !important; } |
| +.native-embed .thread-pane { display: flex !important; } |
| +.native-embed .thread-head, |
| +.native-embed .composer, |
| +.native-embed .composer-reply, |
| +.native-embed .composer-edit { |
| + display: none; |
| +} |
| +/* Keep the attachment preview visible: it's how a staged photo shows up just above |
| + the native composer (drag-drop or the native + button stage into it). The composer |
| + below is hidden, so add the bottom padding it would have provided. */ |
| +.native-embed .composer-preview { padding-bottom: 10px; } |