ios: support the new server-blind invite link format
The #15 invite redesign moved to short, server-blind links
(/join/<slug>#<T>, where T unwraps a server-stored wrapped key) but the
iOS client was never updated: LChatAPI.parseInvite only understands the
legacy /join/<token>#k= form and Crypto.swift has none of the new
primitives (deriveInvite/unwrapGroupKey). So new invites couldn't be
added (AddChatView rejected the paste) or opened (the chat showed "Can't
open this chat" and the webview couldn't be seeded).
Rather than port the invite crypto to Swift, delegate the join to the
webview (keeps crypto in one place):
- ChatWebView: if we already have the session (legacy link, or a
new-format invite claimed earlier) seed it and load `/` as before; for
an unclaimed new-format invite, load the saved `/join/<slug>#<T>` link
so join.js claims it, unwraps the key, and renders the feed.
- join.js (native webview only, gated on window.__lchatEmbed): skip the
Home-Screen detour, redirect to `/` if a session already exists (don't
re-claim a single-use invite), and post the resolved {token,key,name}
back to the app on a successful join. Browser behaviour is unchanged.
- SavedChat: persist the resolved {claimedToken, claimedKey} handed back
by the webview, so later opens take the fast seed-`/` path and the
native title can fetch the group name. session/loadURL/token/origin
prefer the resolved values, else fall back to the legacy URL parse.
- AddChatView: accept new-format links (no native parse/claim — defer to
the webview), appending ~name so the web join is promptless. Legacy
links keep validating + claiming natively.
Verified: Catalyst build + go/npm tests pass; a headless end-to-end of
the server-blind flow (mint → fetch wrapped → unwrap → claim) round-trips
the group key and enforces single-use. The native webview join path still
needs an on-device test (headless can't drive the WKWebView).
Co-Authored-By: Claude <noreply@anthropic.com>
5 files changed,
+186
−57
ios/LChat/LChatApp.swift+15 −0ios/LChat/Models.swift+34 −6ios/LChat/Views/ChatView.swift+66 −28ios/LChat/Views/JoinView.swift+48 −23web/static/join.js+23 −0
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 44dc7ae..83d0c18 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -59,6 +59,21 @@ final class AppState: ObservableObject { |
| ChatsStore.save(chats) |
| } |
| + /// 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] } |
| + } |
| + |
| func setName(_ name: String, for chat: SavedChat) { |
| guard let i = chats.firstIndex(where: { $0.id == chat.id }) else { return } |
| chats[i].name = name |
diff --git a/ios/LChat/Models.swift b/ios/LChat/Models.swift| index 34e606e..0eafc02 100644 |
| --- a/ios/LChat/Models.swift |
| +++ b/ios/LChat/Models.swift |
| @@ -28,24 +28,52 @@ struct Session: Codable, Equatable { |
| } |
| } |
| -// A saved chat: just the invite URL (which carries host, token and key in its |
| -// #fragment) plus the screen name — persisted between runs. lastSeenId drives the |
| -// unread count shown in the chat list. |
| +// 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 } |
| - /// Parse the stored invite URL into a usable Session (nil if malformed). |
| + /// 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 invite token, used to de-duplicate chats. |
| - var token: String? { LChatAPI.parseInvite(url)?.token } |
| + /// 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 } |
| } |
| // What /api/messages and the "msg" WebSocket frame return. Opaque to the server. |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 0fd81f6..d6fdb4f 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -41,8 +41,9 @@ struct ChatView: View { |
| 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. This is the title — not the URL/host. |
| - private var title: String { groupName ?? chat.session?.baseURL.host ?? "lchat" } |
| + // 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 |
| @@ -51,8 +52,11 @@ struct ChatView: View { |
| var body: some View { |
| NavigationStack { |
| VStack(spacing: 0) { |
| - if let session = chat.session { |
| - ChatWebView(session: session, bridge: bridge) |
| + 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 { |
| @@ -72,7 +76,9 @@ struct ChatView: View { |
| .background(MacWindowTitle(title: title)) |
| #endif |
| } |
| - .task { await loadGroupName() } |
| + // 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) {} |
| @@ -92,6 +98,14 @@ struct ChatView: View { |
| .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 |
| @@ -309,28 +323,47 @@ 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 — so the native send button can enable/disable and |
| - // failures surface instead of vanishing. |
| + // 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. |
| 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. We load the origin root (`/`), NOT |
| -// the saved `/join/<token>#k=` URL: join.js re-prompts for a screen name every |
| -// visit, whereas `/` (chat.js) renders the chat directly when a session is present |
| -// in localStorage. So we seed that session — and the embed flag — before any page |
| -// script runs. |
| +// 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 session: Session |
| + let chat: SavedChat |
| let bridge: WebBridge |
| func makeUIView(context: Context) -> WKWebView { |
| @@ -340,7 +373,8 @@ struct ChatWebView: UIViewRepresentable { |
| if let script = bootScript() { |
| cfg.userContentController.addUserScript(script) |
| } |
| - // Receive staged-attachment state from chat.js (window.webkit.messageHandlers.lchat). |
| + // 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 |
| @@ -349,26 +383,30 @@ struct ChatWebView: UIViewRepresentable { |
| #if DEBUG |
| if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true } |
| #endif |
| - web.load(URLRequest(url: session.baseURL)) // origin root → chat.js renders the feed |
| + // session present → origin `/` (seeded); else the `/join/…` link. |
| + if let url = chat.loadURL { web.load(URLRequest(url: url)) } |
| return web |
| } |
| func updateUIView(_ web: WKWebView, context: Context) {} |
| - // Runs before any page script: marks this as the native embed (so chat.js hides |
| - // its header/composer and exposes the send hook) and seeds the web session |
| - // (token/key/screen name) into localStorage so chat.js renders directly. |
| + // 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. |
| private func bootScript() -> WKUserScript? { |
| - let payload = ["token": session.token, "key": session.keyB64, "name": session.name] |
| - guard let data = try? JSONSerialization.data(withJSONObject: payload), |
| - let objJSON = String(data: data, encoding: .utf8) else { return nil } |
| - // 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. |
| - let js = """ |
| - window.__lchatEmbed = true; |
| - try { localStorage.setItem('lchat_session', JSON.stringify(\(objJSON))); } catch (e) {} |
| - """ |
| + 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) {}" |
| + } |
| + } |
| return WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true) |
| } |
| } |
diff --git a/ios/LChat/Views/JoinView.swift b/ios/LChat/Views/JoinView.swift| index 725b0ac..08d77c1 100644 |
| --- a/ios/LChat/Views/JoinView.swift |
| +++ b/ios/LChat/Views/JoinView.swift |
| @@ -21,7 +21,7 @@ struct AddChatView: View { |
| VStack(alignment: .leading, spacing: 6) { |
| Text("Invite link").font(.subheadline.weight(.semibold)) |
| - TextField("https://…/join/…#k=…", text: $link, axis: .vertical) |
| + TextField("https://…/join/…", text: $link, axis: .vertical) |
| .textFieldStyle(.roundedBorder) |
| .textInputAutocapitalization(.never) |
| .autocorrectionDisabled() |
| @@ -72,34 +72,59 @@ struct AddChatView: View { |
| private func add() { |
| error = nil |
| - guard let p = LChatAPI.parseInvite(link) else { |
| - error = "That doesn't look like a valid invite link." |
| - return |
| - } |
| - let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) |
| - guard !trimmedName.isEmpty else { |
| + 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 |
| } |
| - 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." |
| + |
| + // 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 |
| } |
| - guard info.ok else { |
| - error = (info.revoked == true) ? "This invite was turned off." : "This invite link is invalid." |
| - 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 |
| + } |
| + await LChatAPI.claim(baseURL: p.baseURL, token: p.token) |
| + app.add(url: trimmedLink, name: screenName) |
| + dismiss() |
| } |
| - await LChatAPI.claim(baseURL: p.baseURL, token: p.token) |
| - app.add(url: link.trimmingCharacters(in: .whitespacesAndNewlines), |
| - name: String(trimmedName.prefix(32))) |
| - dismiss() |
| + return |
| + } |
| + |
| + // 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 |
| } |
| + app.add(url: joinURL, name: screenName) |
| + 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)" |
| } |
| } |
diff --git a/web/static/join.js b/web/static/join.js| index 2ea6906..68e13de 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -30,16 +30,39 @@ function init() { |
| // to install it (likely already spent) — don't claim it here. Send them to the |
| // root, where the app signs in via paste-to-join. |
| if (isStandalone) return location.replace("/"); |
| + // Inside the native app's WKWebView: if this chat is already joined (its session |
| + // is in localStorage), don't re-claim the single-use invite — just show the feed. |
| + if (window.__lchatEmbed && hasStoredSession()) return location.replace("/"); |
| if (!legacyKey && !T) return showError("This link is missing its secret."); |
| if (legacyKey) return initLegacy(); |
| return initNew(); |
| } |
| +function hasStoredSession() { |
| + try { |
| + const s = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); |
| + return !!(s && s.token && s.key && s.name); |
| + } catch { |
| + return false; |
| + } |
| +} |
| + |
| // After a successful claim: store the session, then either open the chat or — on |
| // iOS Safari, where notifications need a Home Screen app with its own storage — |
| // guide the Home Screen setup (mint a fresh link to paste inside the app). |
| function finishJoin(session) { |
| localStorage.setItem(SESSION_KEY, JSON.stringify(session)); |
| + // Inside the native app's WKWebView: hand the resolved session to the app so it |
| + // persists it (future opens skip this join screen) and go straight to the feed — |
| + // there's no "Add to Home Screen" step inside the app. |
| + if (window.__lchatEmbed) { |
| + try { |
| + window.webkit?.messageHandlers?.lchat?.postMessage({ |
| + joined: { token: session.token, key: session.key, name: session.name }, |
| + }); |
| + } catch (e) {} |
| + return location.replace("/"); |
| + } |
| if (needsHomeScreen()) return showHomeScreenSetup(session); |
| location.href = "/"; // drop the fragment from the URL bar |
| } |