ios: native joins land in Eleven's welcome conversation + keyless self-heal (#1376) (#1387)
The Mac "Open in the app" join soft-locked a first-time joiner: no welcome conversation ever existed natively (SpaceClaim never created the thread), and a keyless member could neither obtain the instance key nor set a name — so they, and everyone they messaged, rendered as "Someone" forever. Chain A — the conversation now exists: - SpaceSetup.createWelcomeThread, the twin of createSetupThread, called from SpaceClaim.claim for member/DM-invite joins (not add-device, not admin-granting setup links), plus WelcomeLanding.markPending/isPending mirroring SetupLanding — so threadToLand finds the thread and the bundled welcome flow (FlowEngine + Flows/welcome.json) runs and asks the name. Chain B — keyless members self-heal: - LChatAPI.nudgeInstanceKey (POST /api/instance-key/nudge, token in query). - Nudge on claim, plus ThreadStore's keyless loop (refresh covers foreground and socket activity; the loop covers idle stretches at the web's ~60s cadence with short post-nudge polls). - The holder side too: instance_key_nudge frames route to ThreadStore.handleInstanceKeyNudge on both platforms, wrapping only to the roster-published key (shell.js parity) — a native-only space can now grant. - ThreadStore.setScreenName queues-or-surfaces instead of silently failing while keyless (SetNameOutcome; PendingLabel parks the name and the first keyed refresh publishes it), and the welcome flow's name step parks the same way. Settings on both platforms say "shortly" honestly when queued. Also: the Mac gains its applinks associated-domains entitlement (docs/native-onboarding.md E.2; server half is PR #1383), and the stale "no welcome conversation natively yet" doc note is corrected. Tests: WelcomePendingTests (landing flags + parked-name store); SpaceClaimIntegrationTests extended with a member-invite claim (welcome thread created + decryptable, joiner keyless, no key re-mint, admin claims create no welcome thread) — verified against a real local server.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
14 files changed,
+429
−37
docs/native-onboarding.md+15 −7ios/LChat/Views/ServerSettingsSheet.swift+10 −5ios/LChat/Views/ThreadListView.swift+8 −0ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift+15 −3ios/LChatCore/Sources/LChatCore/LChatAPI.swift+14 −0ios/LChatCore/Sources/LChatCore/SpaceCreate.swift+42 −8ios/LChatCore/Sources/LChatCore/ThreadStore.swift+126 −6ios/LChatCore/Sources/LChatCore/WelcomeLanding.swift+29 −5ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift+56 −0ios/LChatCore/Tests/LChatCoreTests/WelcomePendingTests.swift+79 −0ios/LChatMac/LChatMac-Dev.entitlements+5 −0ios/LChatMac/LChatMac.entitlements+13 −0ios/LChatMac/MacSettingsView.swift+9 −3ios/LChatMac/NativeChatView.swift+8 −0
diff --git a/docs/native-onboarding.md b/docs/native-onboarding.md| index ca84dd8..0a5bd67 100644 |
| --- a/docs/native-onboarding.md |
| +++ b/docs/native-onboarding.md |
| @@ -107,8 +107,11 @@ Same shape, one platform beat behind (iOS-first rule): |
| 1. Server: `serveAASA` adds the Mac app id |
| (`J5NDF5PCD8.com.elevenmessenger.messenger.mac`) beside the iOS one — |
| - today's payload names only iOS. |
| -2. Entitlements: the Mac gains its first `applinks` entries (same domains). |
| + **done** (PR #1383). |
| +2. Entitlements: the Mac gains its first `applinks` entries (same domains) — |
| + **done** (`LChatMac.entitlements`, with #1383's server half; the Mac App |
| + ID needs the Associated Domains capability before a release build's |
| + profile authorizes it). |
| 3. `LChatMacApp.onOpenURL` grows a second accepted URL family: join links → |
| the same welcome card in a sheet. |
| @@ -118,11 +121,16 @@ here — it fires the custom scheme, which macOS routes unconditionally. |
| ## What deliberately stays out |
| -- **No welcome *conversation* natively (yet).** The web's scripted joiner |
| - conversation (name → photo → passkey, `welcome.flow.js`) is JS-only; the |
| - conversation-scripts doc's Swift interpreter is real future work. The native |
| - welcome card + the passkey nudge covers the essentials without it. When the |
| - interpreter lands, the card can hand off to the full conversation. |
| +- ~~**No welcome *conversation* natively (yet).**~~ **Superseded — the |
| + interpreter shipped.** The Swift flow interpreter is real now |
| + (`FlowEngine.swift` running the bundled `Flows/welcome.json`, a byte-derived |
| + copy of `welcome.flow.js` drift-gated by `test/flow.test.mjs`, hosted by |
| + `ElevenConversationModel`), and a native member/DM-invite claim creates the |
| + welcome app-thread and lands the joiner in the full conversation — which |
| + asks their name — on both platforms (`SpaceClaim.claim` → |
| + `WelcomeLanding.markPending`/`threadToLand`, #1376). The passkey ceremony |
| + step stays web-only by design (`env.native` skips it — native access *is* |
| + the sign-in). |
| - **No QR scanning revival, no paste field.** Links open the app; that's the |
| door. (The `NSCameraUsageDescription` string left from the retired QR flow |
| should be dropped when the welcome card lands.) |
diff --git a/ios/LChat/Views/ServerSettingsSheet.swift b/ios/LChat/Views/ServerSettingsSheet.swift| index 04de197..5e739eb 100644 |
| --- a/ios/LChat/Views/ServerSettingsSheet.swift |
| +++ b/ios/LChat/Views/ServerSettingsSheet.swift |
| @@ -220,8 +220,8 @@ struct ServerSettingsSheet: View { |
| guard !trimmed.isEmpty else { return } |
| saving = true |
| Task { |
| - let ok = await store.setScreenName(trimmed) |
| - if ok { |
| + let outcome = await store.setScreenName(trimmed) |
| + if outcome != .failed { |
| // Outgoing payloads carry the SavedInstance's name — persist it. |
| var instances = InstancesStore.load() |
| if let i = instances.firstIndex(where: { $0.id == store.instance.id }) { |
| @@ -230,9 +230,14 @@ struct ServerSettingsSheet: View { |
| } |
| } |
| saving = false |
| - note = ok ? "Saved — everyone sees “\(trimmed)” now." |
| - : "Couldn't save. Try again." |
| - if ok, thenDismiss { dismiss() } |
| + switch outcome { |
| + case .published: note = "Saved — everyone sees “\(trimmed)” now." |
| + // Keyless (a fresh join whose key grant is still in flight): the |
| + // name is parked and publishes itself the moment the key arrives. |
| + case .queued: note = "Saved — “\(trimmed)” will show for everyone shortly." |
| + case .failed: note = "Couldn't save. Try again." |
| + } |
| + if outcome != .failed, thenDismiss { dismiss() } |
| } |
| } |
| } |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index b50840c..2222d3c 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -482,6 +482,14 @@ struct ThreadListView: View { |
| store.refreshAppCatalog() |
| return |
| } |
| + // A member lacks the instance key (#1376): if we hold it, wrap it |
| + // to them — the holder side of the keyless self-heal, mirroring |
| + // the web (shell.js). Without this a native-only space never |
| + // grants, and the joiner stays "Someone" to everyone. |
| + if frame.type == "instance_key_nudge", let uid = frame.user_id { |
| + store.handleInstanceKeyNudge(userID: uid) |
| + return |
| + } |
| // The space thread's own frames: fold installs in place — the |
| // thread never appears in /api/threads, so a refresh would learn |
| // nothing about it. |
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift| index 761d688..ee0e381 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| @@ -489,11 +489,23 @@ extension ElevenConversationModel: FlowIO { |
| guard let api else { return } |
| // The instance key comes wrapped to us on the roster path; SpaceClaim |
| // minted it for creators. Fetch + unwrap via the instance keypair. |
| - guard let wrapped = await api.instanceKeyWrapped(), |
| - let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv), |
| + guard let wrapped = await api.instanceKeyWrapped() else { |
| + // A keyless joiner mid-welcome (#1376): the grant hasn't landed |
| + // yet. Park the name — ThreadStore's keyless loop is already |
| + // nudging holders, and the refresh that unwraps the granted key |
| + // publishes the parked name. onNameSet persisted it locally, so |
| + // outgoing messages carry it in the meantime. |
| + PendingLabel.park(name, instanceID: instance.id) |
| + await api.nudgeInstanceKey() |
| + return |
| + } |
| + guard let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv), |
| let ik = try? GroupKey(rawKey: raw), |
| let blob = try? ik.encryptJSON(name) else { return } |
| - _ = await api.postLabel(labelBlob: blob) |
| + if await api.postLabel(labelBlob: blob) { |
| + // A name parked in an earlier keyless moment is superseded. |
| + PendingLabel.clear(instanceID: instance.id) |
| + } |
| } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index b57ec3f..a76ee9e 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -157,6 +157,20 @@ public struct LChatAPI { |
| return (resp as? HTTPURLResponse)?.statusCode == 200 |
| } |
| + /// Ask any online instance-key holder to wrap the key to us — the web's |
| + /// keyless self-heal (shell.js setupInstanceKey → /api/instance-key/nudge). |
| + /// The token rides the query string: that's the only place the handler |
| + /// looks (main.go nudgeInstanceKey), unlike the JSON-body POSTs above. |
| + public func nudgeInstanceKey() async { |
| + var comps = URLComponents(url: baseURL.appending(path: "api/instance-key/nudge"), |
| + resolvingAgainstBaseURL: false) |
| + comps?.queryItems = [URLQueryItem(name: "token", value: token)] |
| + guard let url = comps?.url else { return } |
| + var req = URLRequest(url: url) |
| + req.httpMethod = "POST" |
| + _ = try? await session.data(for: req) |
| + } |
| + |
| /// The space's connected third-party services (connect.go): the set the |
| /// pickers gate service apps on (AppRegistry.updateConnected). nil on a |
| /// network error so callers keep what they hold. |
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift| index ac24b32..7d55c7f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift |
| @@ -177,15 +177,37 @@ public enum SpaceClaim { |
| // a label under a key the server never kept decrypts for no one. |
| if !inst.myUserId.isEmpty, |
| let status = await api.instanceKeyStatus(), |
| - status.wrapped == nil, status.have.isEmpty { |
| - let raw = ECIES.randomAESKey() |
| - if let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: keypair.pub), |
| - await api.grantInstanceKey([["user_id": inst.myUserId, "wrapped": wrapped]]), |
| - !screenName.isEmpty, let ik = try? GroupKey(rawKey: raw), |
| - let blob = try? ik.encryptJSON(screenName) { |
| - await api.postLabel(labelBlob: blob) |
| + status.wrapped == nil { |
| + if status.have.isEmpty { |
| + let raw = ECIES.randomAESKey() |
| + if let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: keypair.pub), |
| + await api.grantInstanceKey([["user_id": inst.myUserId, "wrapped": wrapped]]), |
| + !screenName.isEmpty, let ik = try? GroupKey(rawKey: raw), |
| + let blob = try? ik.encryptJSON(screenName) { |
| + await api.postLabel(labelBlob: blob) |
| + } |
| + } else { |
| + // Joining an existing space: the key exists but no copy is |
| + // wrapped to us yet. Nudge any online holder right now (the |
| + // web's setupInstanceKey keyless branch); ThreadStore keeps |
| + // re-nudging on foreground + a slow timer until the grant |
| + // lands — a keyless member renders everyone as "Someone". |
| + await api.nudgeInstanceKey() |
| } |
| } |
| + |
| + // A fresh member/DM-invite joiner — not add-device (an existing |
| + // member's new device) and not an admin-granting setup link (that |
| + // path lands in Eleven's SETUP conversation via SpaceCreationFlow): |
| + // create Eleven's welcome app-thread now and flag the landing, so |
| + // their first screen is the welcome conversation asking their name — |
| + // the native twin of join.js markWelcomePending + shell.js |
| + // maybeStartWelcome. Without it a native joiner never meets Eleven, |
| + // is never asked a name, and stays "Someone" to everyone (#1376). |
| + if !addDevice, info.grants_admin != true, |
| + await SpaceSetup.createWelcomeThread(inst) { |
| + await WelcomeLanding.markPending(inst) |
| + } |
| return inst |
| } |
| @@ -281,6 +303,18 @@ public enum SpaceSetup { |
| /// natively): a self thread whose encrypted name_blob is {app, title}, |
| /// key wrapped only to us. Nil myUserId (roster lag) skips quietly. |
| static func createSetupThread(_ inst: SavedInstance) async -> Bool { |
| + await createElevenThread(inst, app: "setup") |
| + } |
| + |
| + /// The setup thread's joiner-side twin (#1376): the "welcome" app-thread — |
| + /// Eleven's member welcome conversation (the web's |
| + /// resolveAppThread("welcome"), natively), where a fresh joiner is greeted |
| + /// and asked their name. Created by SpaceClaim for member/DM-invite joins. |
| + static func createWelcomeThread(_ inst: SavedInstance) async -> Bool { |
| + await createElevenThread(inst, app: "welcome") |
| + } |
| + |
| + private static func createElevenThread(_ inst: SavedInstance, app: String) async -> Bool { |
| guard let base = inst.baseURL, !inst.myUserId.isEmpty else { return false } |
| let api = LChatAPI(baseURL: base, token: inst.deviceToken) |
| let userPub = inst.userPub |
| @@ -288,7 +322,7 @@ public enum SpaceSetup { |
| await Task.detached(priority: .userInitiated) { |
| let raw = ECIES.randomAESKey() |
| guard let key = try? GroupKey(rawKey: raw), |
| - let nameBlob = try? key.encryptJSON(["app": "setup", "title": "Eleven"]), |
| + let nameBlob = try? key.encryptJSON(["app": app, "title": "Eleven"]), |
| let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: userPub) |
| else { return nil } |
| return (nameBlob, wrapped) |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index c81008f..a2c3b08 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -235,7 +235,10 @@ public final class ThreadStore: ObservableObject { |
| if myUserId.isEmpty, let me = people.first(where: { $0.public_key == instance.userPub }) { |
| myUserId = me.user_id |
| } |
| - let wrappedInstanceKey = await api.instanceKeyWrapped() |
| + // Full status, not just our wrapped copy: `have` is what tells a |
| + // keyless member the key EXISTS and holders can be nudged (#1376). |
| + let instanceKeyState = await api.instanceKeyStatus() |
| + let wrappedInstanceKey = instanceKeyState?.wrapped |
| // Sealed branding: groupName() deliberately degrades to "" on a sealed |
| // instance — we're a member, so open the blob via the instance-key |
| // copy of B (SealedBranding.swift). LATCHED to one resolution per |
| @@ -312,6 +315,21 @@ public final class ThreadStore: ObservableObject { |
| tierRows: tierRows, manifests: manifests) |
| }.value |
| instanceKeyB64 = folded.instanceKeyB64 |
| + // Keyless self-heal (#1376): while the key exists but no copy is |
| + // wrapped to us, keep prodding online holders — a keyless member |
| + // renders everyone as "Someone" until the grant lands. |
| + updateKeylessNudge(keyExists: !(instanceKeyState?.have.isEmpty ?? true)) |
| + // A name accepted while keyless (setScreenName / the welcome flow's |
| + // name step) publishes the moment the key arrives; cleared only once |
| + // the server verifiably took the blob, so a failed post retries on |
| + // the next pass. |
| + if let ik = instanceKeyB64, let parked = PendingLabel.peek(instanceID: instance.id), |
| + await publishLabel(parked, ikB64: ik) { |
| + PendingLabel.clear(instanceID: instance.id) |
| + // The pass that just folded read the roster BEFORE this publish — |
| + // queue a trailing pass so the fresh label lands in the rows. |
| + Task { [weak self] in await self?.refresh() } |
| + } |
| tierKeysB64 = folded.tierKeysB64 |
| tiers = tierRows |
| // A level we belong to whose key hasn't reached this device: prod the |
| @@ -1714,20 +1732,102 @@ public final class ThreadStore: ObservableObject { |
| return Int64(bitPattern: h) |
| } |
| + /// How a screen-name change landed: published to everyone, queued for the |
| + /// moment the instance key arrives, or failed outright. `queued` is a |
| + /// success the UI should say honestly ("shows for everyone shortly"), not |
| + /// silently misreport as published (#1376). |
| + public enum SetNameOutcome { case published, queued, failed } |
| + |
| /// Change your display name across the instance: encrypted under the |
| /// instance key (the web's publishMyLabel). Callers should also persist |
| /// the new name into the SavedInstance so outgoing payloads carry it. |
| + /// While the key hasn't reached this device (a fresh joiner whose grant |
| + /// is still in flight) the name PARKS instead of silently failing — the |
| + /// refresh that unwraps the granted key publishes it (#1376). |
| @discardableResult |
| - public func setScreenName(_ name: String) async -> Bool { |
| - guard let api, let ikB64 = instanceKeyB64 else { return false } |
| + public func setScreenName(_ name: String) async -> SetNameOutcome { |
| + guard let api else { return .failed } |
| + guard let ikB64 = instanceKeyB64 else { |
| + PendingLabel.park(name, instanceID: instance.id) |
| + await api.nudgeInstanceKey() // hurry the grant along |
| + return .queued |
| + } |
| + guard await publishLabel(name, ikB64: ikB64) else { return .failed } |
| + // A name parked in an earlier keyless moment is superseded — it must |
| + // not overwrite this one when the key arrives. |
| + PendingLabel.clear(instanceID: instance.id) |
| + await refresh() |
| + return .published |
| + } |
| + |
| + // The label post itself (crypto off main): true only when the server took |
| + // the blob. Shared by setScreenName and the parked-name publish above. |
| + private func publishLabel(_ name: String, ikB64: String) async -> Bool { |
| + guard let api else { return false } |
| let blob = await Task.detached(priority: .userInitiated) { () -> String? in |
| guard let ik = try? GroupKey(base64url: ikB64) else { return nil } |
| return try? ik.encryptJSON(name) |
| }.value |
| guard let blob else { return false } |
| - let ok = await api.postLabel(labelBlob: blob) |
| - if ok { await refresh() } |
| - return ok |
| + return await api.postLabel(labelBlob: blob) |
| + } |
| + |
| + // ---- keyless self-heal (#1376) ------------------------------------------- |
| + // The web nudges persistently (shell.js setupInstanceKey: on boot, on |
| + // focus, and every ~60s) because the first holder to see a nudge may come |
| + // online long after the join. Natively, refresh() already refires on |
| + // foregrounding and socket activity; this loop covers the idle stretches |
| + // in between, polling briefly after each nudge so a grant lands within |
| + // seconds of a holder answering. |
| + private var keylessNudge: Task<Void, Never>? |
| + |
| + private func updateKeylessNudge(keyExists: Bool) { |
| + guard instanceKeyB64 == nil, keyExists else { |
| + keylessNudge?.cancel() |
| + keylessNudge = nil |
| + return |
| + } |
| + guard keylessNudge == nil, let api else { return } |
| + keylessNudge = Task { [weak self] in |
| + while !Task.isCancelled, self != nil { |
| + await api.nudgeInstanceKey() |
| + for _ in 0..<8 { // ~20s of quick polls, then back off |
| + try? await Task.sleep(nanoseconds: 2_500_000_000) |
| + if Task.isCancelled || self == nil { return } |
| + if await api.instanceKeyWrapped() != nil { |
| + self?.keylessNudge = nil |
| + await self?.refresh() // unwraps the key, folds labels, publishes any parked name |
| + return |
| + } |
| + } |
| + try? await Task.sleep(nanoseconds: 40_000_000_000) // the web's ~60s cadence |
| + } |
| + } |
| + } |
| + |
| + deinit { keylessNudge?.cancel() } |
| + |
| + /// A member without the instance key asked for it (an instance_key_nudge |
| + /// frame) — the holder side of the self-heal, mirroring shell.js: never |
| + /// wrap to a key handed to us in a raw frame (a compromised server could |
| + /// forge one and harvest members' names/photos); re-fetch the roster and |
| + /// wrap only to the public key the server has PUBLISHED for that user. |
| + /// An unseen/forged nudge is simply ignored — the joiner re-nudges. |
| + public func handleInstanceKeyNudge(userID: String) { |
| + guard let api, let ikB64 = instanceKeyB64, !userID.isEmpty else { return } |
| + Task { [weak self] in |
| + let people = await api.roster() |
| + guard let target = people.first(where: { $0.user_id == userID && !$0.public_key.isEmpty }), |
| + let raw = Data(base64urlEncoded: ikB64) else { return } |
| + let pub = target.public_key |
| + let wrapped = await Task.detached(priority: .userInitiated) { |
| + try? ECIES.wrap(rawKey: raw, recipientPubB64: pub) |
| + }.value |
| + guard let wrapped else { return } |
| + if await api.grantInstanceKey([["user_id": userID, "wrapped": wrapped]]) { |
| + await self?.refresh() // the joiner may be new to our roster too |
| + } |
| + } |
| } |
| /// Rename a group (encrypted under its own thread key, like the web). |
| @@ -1783,6 +1883,26 @@ public final class ThreadStore: ObservableObject { |
| } |
| } |
| +// 🤖 A screen name accepted while the instance key hadn't reached this device |
| +// yet (#1376): parked per-instance and published by the first refresh that |
| +// holds the key, so "set my name" — from Settings or the welcome flow's name |
| +// step — never silently vanishes for a keyless joiner. Just a name, no key |
| +// material, so UserDefaults is fine (main-actor writes only, like ReadState). |
| +@MainActor |
| +enum PendingLabel { |
| + static func key(_ instanceID: String) -> String { "lchat.pendingLabel|" + instanceID } |
| + static func park(_ name: String, instanceID: String) { |
| + UserDefaults.standard.set(name, forKey: key(instanceID)) |
| + } |
| + static func peek(instanceID: String) -> String? { |
| + let name = UserDefaults.standard.string(forKey: key(instanceID)) |
| + return (name?.isEmpty ?? true) ? nil : name |
| + } |
| + static func clear(instanceID: String) { |
| + UserDefaults.standard.removeObject(forKey: key(instanceID)) |
| + } |
| +} |
| + |
| // Per-thread read state (last-seen message time). Just timestamps — no secrets — |
| // so UserDefaults is fine (the keys + private material stay in the Keychain). |
| // Public: the iOS screen reads the pre-tap watermark to anchor unreads. |
diff --git a/ios/LChatCore/Sources/LChatCore/WelcomeLanding.swift b/ios/LChatCore/Sources/LChatCore/WelcomeLanding.swift| index f393b5c..2497cd7 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/WelcomeLanding.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/WelcomeLanding.swift |
| @@ -1,10 +1,12 @@ |
| import Foundation |
| // 🤖 Landing a member in Eleven's welcome conversation, natively — the native |
| -// arm of the web's maybeStartWelcome. The conversation itself is the web's |
| -// client-local flow (welcome.js + flow.js), which the app-thread's embedded |
| -// webview runs; the native shell's only job is to OPEN that thread on first |
| -// entry into a space, and only while the conversation is unfinished. |
| +// arm of the web's maybeStartWelcome. The conversation itself runs natively |
| +// too (FlowEngine + the bundled Flows/welcome.json, hosted by |
| +// ElevenConversationModel — wire-identical to the web's welcome.flow.js, so a |
| +// conversation started on one surface resumes on the other); the shell's job |
| +// here is to OPEN that thread on first entry into a space, and only while the |
| +// conversation is unfinished. |
| // |
| // "Unfinished" is read from the conversation's own persisted state: the flow's |
| // finish step records itself into the encrypted "apps" snapshot, which native |
| @@ -14,6 +16,24 @@ import Foundation |
| @MainActor |
| public enum WelcomeLanding { |
| static func flagKey(_ instance: SavedInstance) -> String { "welcomeLanded-" + instance.id } |
| + static func pendingKey(_ instance: SavedInstance) -> String { "welcomePending-" + instance.id } |
| + |
| + /// Flag a freshly-claimed join for the welcome landing (SpaceClaim) — the |
| + /// joiner-side twin of SetupLanding.markPending, and the native form of |
| + /// join.js's markWelcomePending. A fresh join must land even if an earlier |
| + /// life of this space (same origin, e.g. left and re-invited) already |
| + /// landed once on this device, so the once-per-device flag re-arms here. |
| + public static func markPending(_ instance: SavedInstance) { |
| + UserDefaults.standard.set(true, forKey: pendingKey(instance)) |
| + UserDefaults.standard.removeObject(forKey: flagKey(instance)) |
| + } |
| + |
| + /// Whether a native claim armed the welcome conversation for this space. |
| + /// Stays true after landing (reopening the thread mid-conversation resumes |
| + /// it) and clears for good once the flow's finish marker is seen. |
| + public static func isPending(_ instance: SavedInstance) -> Bool { |
| + UserDefaults.standard.bool(forKey: pendingKey(instance)) |
| + } |
| /// The welcome thread to auto-open on this device, or nil (none exists, |
| /// already landed once, or the conversation is finished). Marks the flag |
| @@ -25,7 +45,11 @@ public enum WelcomeLanding { |
| let key = flagKey(instance) |
| guard !UserDefaults.standard.bool(forKey: key) else { return nil } |
| UserDefaults.standard.set(true, forKey: key) |
| - return await conversationFinished(instance: instance, thread: thread, app: "welcome") ? nil : thread |
| + if await conversationFinished(instance: instance, thread: thread, app: "welcome") { |
| + UserDefaults.standard.removeObject(forKey: pendingKey(instance)) // done — disarm for good |
| + return nil |
| + } |
| + return thread |
| } |
| // The flow's finish step records { steps: { finish: { done: true } } } into |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift| index 30ad15d..81150c5 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift |
| @@ -75,5 +75,61 @@ final class SpaceClaimIntegrationTests: XCTestCase { |
| screenName: "Second Claimer") |
| XCTFail("single-use invite claimed twice") |
| } catch {} |
| + |
| + // A setup (admin-granting) claim lands in the SETUP conversation, so |
| + // the claim must NOT have created a welcome thread for the admin. |
| + let adminThreads = await api.threads() |
| + for th in adminThreads { |
| + guard th.kind == "self", let nb = th.name_blob, !nb.isEmpty, |
| + let w = th.wrapped, !w.isEmpty, |
| + let rawT = try? ECIES.unwrap(wrappedB64: w, privPkcs8B64: inst.userPriv), |
| + let payload = try? GroupKey(rawKey: rawT) |
| + .decryptJSON(AppThreadPayload.self, blob: nb) else { continue } |
| + XCTAssertNotEqual(payload.app, "welcome", |
| + "an admin-granting claim must not create a welcome thread") |
| + } |
| + |
| + // ---- the joiner side (#1376): a MEMBER invite claimed natively ------ |
| + // Mint a plain member invite (no grants_admin) and claim it as a |
| + // second person — the Mac/iOS "Add from link" path. |
| + let t2 = InviteCrypto.randomSecret() |
| + let derived2 = try InviteCrypto.derive(t2) |
| + var mint2 = URLRequest(url: base.appending(path: "/api/admin/key-invites")) |
| + mint2.httpMethod = "POST" |
| + mint2.setValue("Bearer " + admin, forHTTPHeaderField: "Authorization") |
| + mint2.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| + mint2.httpBody = try JSONSerialization.data(withJSONObject: [ |
| + "id": derived2.id, "claim_hash": derived2.claimHash, "label": "Joiner", |
| + "expires": Int(Date().addingTimeInterval(3600).timeIntervalSince1970), |
| + ]) |
| + let (_, mint2Resp) = try await URLSession.shared.data(for: mint2) |
| + XCTAssertEqual((mint2Resp as? HTTPURLResponse)?.statusCode, 200, "member invite mint failed") |
| + |
| + let joiner = try await SpaceClaim.claim(setupLink: origin + "/join/member#" + t2, |
| + screenName: "") |
| + XCTAssertFalse(joiner.myUserId.isEmpty) |
| + |
| + // The joiner is keyless (the key exists, held by the admin, and no |
| + // grant has landed — no live holder answered the claim-time nudge) |
| + // and the claim must NOT have re-minted the instance key over it. |
| + let joinerAPI = LChatAPI(baseURL: base, token: joiner.deviceToken) |
| + let joinerStatus = await joinerAPI.instanceKeyStatus() |
| + XCTAssertNil(joinerStatus?.wrapped, "no holder was online to grant") |
| + XCTAssertEqual(joinerStatus?.have, [inst.myUserId], |
| + "the admin's key stands — a joiner never re-mints") |
| + |
| + // The welcome app-thread exists, keyed to the joiner, its encrypted |
| + // name_blob naming the welcome app — what WelcomeLanding.threadToLand |
| + // scans for, so the bundled flow runs and asks the name. |
| + let joinerThreads = await joinerAPI.threads() |
| + let welcome = joinerThreads.first { th in |
| + guard th.kind == "self", let nb = th.name_blob, !nb.isEmpty, |
| + let w = th.wrapped, !w.isEmpty, |
| + let rawT = try? ECIES.unwrap(wrappedB64: w, privPkcs8B64: joiner.userPriv), |
| + let payload = try? GroupKey(rawKey: rawT) |
| + .decryptJSON(AppThreadPayload.self, blob: nb) else { return false } |
| + return payload.app == "welcome" |
| + } |
| + XCTAssertNotNil(welcome, "a member-invite claim creates Eleven's welcome thread (#1376)") |
| } |
| } |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/WelcomePendingTests.swift b/ios/LChatCore/Tests/LChatCoreTests/WelcomePendingTests.swift| new file mode 100644 |
| index 0000000..ffc0268 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/WelcomePendingTests.swift |
| @@ -0,0 +1,79 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// 🤖 The joiner-side welcome landing (#1376): SpaceClaim marks a fresh |
| +// member/DM-invite join pending and creates the welcome app-thread, so |
| +// WelcomeLanding hands the thread to the first screen and the bundled flow |
| +// asks the joiner's name. These pin the flag semantics (UserDefaults) and the |
| +// parked-name store that bridges "name accepted" → "instance key arrived". |
| +@MainActor |
| +final class WelcomePendingTests: XCTestCase { |
| + // A unique origin per test so UserDefaults flags never leak across runs. |
| + private func freshInstance() -> SavedInstance { |
| + SavedInstance(origin: "https://test-\(UUID().uuidString).invalid", |
| + deviceToken: "tok", screenName: "", userPub: "pub", userPriv: "priv") |
| + } |
| + |
| + private func welcomeThread(id: String = "t1", hidden: Bool = false, |
| + key: String = "k") -> ChatThread { |
| + ChatThread(id: id, kind: "self", title: "Eleven", preview: "", lastAt: 0, |
| + unread: false, pinned: false, hidden: hidden, muted: nil, |
| + members: ["u1"], threadKeyB64: key, avatar: nil, |
| + isApp: true, appId: "welcome", glyph: nil, badge: nil) |
| + } |
| + |
| + func testMarkPendingArmsAndRearmsTheLanding() async { |
| + let inst = freshInstance() |
| + XCTAssertFalse(WelcomeLanding.isPending(inst)) |
| + |
| + // A previous life of this space (same origin) already landed once — |
| + // a FRESH join must land again, so markPending re-arms the flag. |
| + UserDefaults.standard.set(true, forKey: WelcomeLanding.flagKey(inst)) |
| + WelcomeLanding.markPending(inst) |
| + XCTAssertTrue(WelcomeLanding.isPending(inst)) |
| + XCTAssertFalse(UserDefaults.standard.bool(forKey: WelcomeLanding.flagKey(inst))) |
| + |
| + // The invalid origin means conversationFinished can't answer "done", |
| + // so the thread lands — and only ONCE per device (the landed flag). |
| + let landed = await WelcomeLanding.threadToLand(instance: inst, threads: [welcomeThread()]) |
| + XCTAssertEqual(landed?.id, "t1") |
| + let again = await WelcomeLanding.threadToLand(instance: inst, threads: [welcomeThread()]) |
| + XCTAssertNil(again, "the landing is once per device+instance") |
| + XCTAssertTrue(WelcomeLanding.isPending(inst), |
| + "pending survives the landing — reopening resumes the conversation") |
| + } |
| + |
| + func testThreadToLandSkipsHiddenAndKeylessThreadsAndNonWelcomeApps() async { |
| + let inst = freshInstance() |
| + let hidden = welcomeThread(hidden: true) |
| + let keyless = welcomeThread(key: "") |
| + var other = welcomeThread() |
| + other.appId = "todo" |
| + let noLanding = await WelcomeLanding.threadToLand(instance: inst, |
| + threads: [hidden, keyless, other]) |
| + XCTAssertNil(noLanding) |
| + // And crucially: a no-thread answer must NOT burn the landed flag — |
| + // the claim's thread can appear one refresh later. |
| + let landed = await WelcomeLanding.threadToLand(instance: inst, threads: [welcomeThread()]) |
| + XCTAssertEqual(landed?.id, "t1") |
| + } |
| + |
| + func testPendingLabelParksPeeksAndClears() { |
| + let id = "https://label-\(UUID().uuidString).invalid" |
| + XCTAssertNil(PendingLabel.peek(instanceID: id)) |
| + PendingLabel.park("Ada", instanceID: id) |
| + XCTAssertEqual(PendingLabel.peek(instanceID: id), "Ada") |
| + PendingLabel.park("Grace", instanceID: id) // a newer answer supersedes |
| + XCTAssertEqual(PendingLabel.peek(instanceID: id), "Grace") |
| + PendingLabel.clear(instanceID: id) |
| + XCTAssertNil(PendingLabel.peek(instanceID: id)) |
| + } |
| + |
| + func testPendingLabelTreatsEmptyAsNothingParked() { |
| + let id = "https://label-\(UUID().uuidString).invalid" |
| + PendingLabel.park("", instanceID: id) |
| + XCTAssertNil(PendingLabel.peek(instanceID: id), |
| + "an empty name must never publish as a blank label") |
| + PendingLabel.clear(instanceID: id) |
| + } |
| +} |
diff --git a/ios/LChatMac/LChatMac-Dev.entitlements b/ios/LChatMac/LChatMac-Dev.entitlements| index eaa17d2..f755d9e 100644 |
| --- a/ios/LChatMac/LChatMac-Dev.entitlements |
| +++ b/ios/LChatMac/LChatMac-Dev.entitlements |
| @@ -16,6 +16,11 @@ |
| LCHAT_HOME_BASE / home_base_override. Plain association like the |
| one above (?mode=developer never engaged on macOS). --> |
| <string>webcredentials:home.dev.elevenmessenger.com</string> |
| + <!-- Universal links over /join (#1376) — mirrors the ship set |
| + (LChatMac.entitlements), plain association as above. --> |
| + <string>applinks:*.elevenmessenger.com</string> |
| + <string>applinks:11ch.at</string> |
| + <string>applinks:*.11ch.at</string> |
| </array> |
| <!-- Everything below mirrors LChatMac.entitlements (the ship set) so dev |
| builds run under the same sandbox they'll ship with. --> |
diff --git a/ios/LChatMac/LChatMac.entitlements b/ios/LChatMac/LChatMac.entitlements| index 59fe79c..1a4aee7 100644 |
| --- a/ios/LChatMac/LChatMac.entitlements |
| +++ b/ios/LChatMac/LChatMac.entitlements |
| @@ -40,6 +40,19 @@ |
| sender's photo/monogram as the icon (CommunicationNotification.swift). --> |
| <key>com.apple.developer.usernotifications.communication</key> |
| <true/> |
| + <!-- Universal links (docs/native-onboarding.md E.2, #1376): clicking an |
| + https /join link on a covered domain opens the Mac app directly |
| + instead of riding the elevenmessenger:// custom-scheme handoff. Same |
| + domain set as iOS (LChat.entitlements — Apple's rule: every domain |
| + named in the shipped binary, so custom domains keep the web join page |
| + + its "Open in the app" button). Inert until the server half deploys: |
| + the AASA names the Mac app id since PR #1383 (main.go serveAASA). --> |
| + <key>com.apple.developer.associated-domains</key> |
| + <array> |
| + <string>applinks:*.elevenmessenger.com</string> |
| + <string>applinks:11ch.at</string> |
| + <string>applinks:*.11ch.at</string> |
| + </array> |
| <!-- Sparkle under the sandbox: the running app must connect to the |
| installer's status (-spks) and installer (-spki) mach services, or |
| every update dies at "An error occurred while running the updater" — |
diff --git a/ios/LChatMac/MacSettingsView.swift b/ios/LChatMac/MacSettingsView.swift| index 687d844..847228e 100644 |
| --- a/ios/LChatMac/MacSettingsView.swift |
| +++ b/ios/LChatMac/MacSettingsView.swift |
| @@ -245,13 +245,19 @@ struct MacSettingsView: View { |
| Task { |
| let store = ThreadStore(instance: inst) |
| await store.refresh() |
| - let ok = await store.setScreenName(trimmed) |
| - if ok { |
| + let outcome = await store.setScreenName(trimmed) |
| + if outcome != .failed { |
| app.noteIdentity(origin: inst.origin, token: inst.deviceToken, |
| name: trimmed, userPub: inst.userPub, userPriv: inst.userPriv) |
| } |
| saving = false |
| - note = ok ? "Saved — everyone sees “\(trimmed)” now." : "Couldn't save. Try again." |
| + switch outcome { |
| + case .published: note = "Saved — everyone sees “\(trimmed)” now." |
| + // Keyless (a fresh join whose key grant is still in flight): the |
| + // name is parked and publishes itself the moment the key arrives. |
| + case .queued: note = "Saved — “\(trimmed)” will show for everyone shortly." |
| + case .failed: note = "Couldn't save. Try again." |
| + } |
| } |
| } |
| } |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index 7441d12..12f6507 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -1678,6 +1678,14 @@ struct NativeChatView: View { |
| store.refreshAppCatalog() |
| return |
| } |
| + // A member lacks the instance key (#1376): if we hold it, wrap it |
| + // to them — the holder side of the keyless self-heal, mirroring |
| + // the web (shell.js). Without this a native-only space never |
| + // grants, and the joiner stays "Someone" to everyone. |
| + if frame.type == "instance_key_nudge", let uid = frame.user_id { |
| + store.handleInstanceKeyNudge(userID: uid) |
| + return |
| + } |
| // The space thread's own frames: fold installs in place — the |
| // thread never appears in /api/threads, so a refresh would learn |
| // nothing about it. |