ios: receive invite links — the web→app handoff's missing half (#1262)
The scheme (elevenmessenger://add?url=…), the applinks entitlement, the per-instance AASA, and the shared parser all existed — but the iOS app had no onOpenURL, so every handoff dropped on the floor (the Mac has received these since #1173). Now: - RootView handles onOpenURL: reduce via inviteLink(fromOpenedURL:) (covers both the scheme and universal links), validate via parseJoinLink, park on AppState.pendingAddLink. Non-invite URLs (message permalinks) fall through untouched. - AddFromLinkConfirm (new): the confirm card — shows the space's host, Add/Cancel, claims via the shared SpaceClaim (all three link shapes, no webview), persists via app.add, then RootView selects the space. Never auto-joins: the tap is the confirming act for an externally-arrived URL. Deliberately no editable field — iOS still has no paste-a-link (Paul's call); only a validated external link reaches the card. - Entitlements gain applinks:*.11ch.at (both variants) so tapped /join links on fleet spaces open the app; custom domains stay on the join-page fallback. No changelog entry — it rides the next native release mint.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+126
−0
ios/LChat/LChat-Debug.entitlements+6 −0ios/LChat/LChat.entitlements+6 −0ios/LChat/LChatApp.swift+31 −0ios/LChat/Views/AddFromLinkConfirm.swift+83 −0
diff --git a/ios/LChat/LChat-Debug.entitlements b/ios/LChat/LChat-Debug.entitlements| index 2812635..89f9ceb 100644 |
| --- a/ios/LChat/LChat-Debug.entitlements |
| +++ b/ios/LChat/LChat-Debug.entitlements |
| @@ -31,6 +31,12 @@ |
| dynamically-created instance host can't be listed here ahead of |
| time. Server side: linkscmd.go (lchat links run). --> |
| <string>applinks:11ch.at</string> |
| + <!-- Fleet spaces live at <sqid>.11ch.at — the wildcard covers them |
| + so a tapped /join link opens the app's confirm card (each |
| + instance serves its own apple-app-site-association). Custom |
| + domains can never be listed here; the join page stays the |
| + universal fallback. --> |
| + <string>applinks:*.11ch.at</string> |
| <!-- "Sign in with Eleven": native passkeys bind to the home server, |
| which serves the matching apple-app-site-association. The dev |
| clone rides along so a Debug build pointed there (HomeBase's |
diff --git a/ios/LChat/LChat.entitlements b/ios/LChat/LChat.entitlements| index fc89f74..8faf7c2 100644 |
| --- a/ios/LChat/LChat.entitlements |
| +++ b/ios/LChat/LChat.entitlements |
| @@ -31,6 +31,12 @@ |
| dynamically-created instance host can't be listed here ahead of |
| time. Server side: linkscmd.go (lchat links run). --> |
| <string>applinks:11ch.at</string> |
| + <!-- Fleet spaces live at <sqid>.11ch.at — the wildcard covers them |
| + so a tapped /join link opens the app's confirm card (each |
| + instance serves its own apple-app-site-association). Custom |
| + domains can never be listed here; the join page stays the |
| + universal fallback. --> |
| + <string>applinks:*.11ch.at</string> |
| <!-- "Sign in with Eleven": native passkeys bind to the home server, |
| which serves the matching apple-app-site-association. --> |
| <string>webcredentials:home.elevenmessenger.com</string> |
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index e33dcb5..165e8c5 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -28,10 +28,23 @@ struct LChatApp: App { |
| } |
| } |
| +// An invite link that arrived from outside the app (the join page's scheme |
| +// or a universal link), validated and parked for its confirming tap. |
| +// Identifiable so the confirm sheet presents item-based — a second link |
| +// landing while the sheet is up swaps the content in place (newest wins, |
| +// like the Mac receiver). |
| +struct PendingAddLink: Identifiable, Equatable { |
| + let link: String |
| + var id: String { link } |
| +} |
| + |
| // 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 instances: [SavedInstance] |
| + // The web→app handoff's pre-fill (RootView's onOpenURL stashes the |
| + // validated link; AddFromLinkConfirm consumes it). |
| + @Published var pendingAddLink: PendingAddLink? |
| // Per-instance server photos (id -> image) shown in the server switcher. Public |
| // branding served in the clear at /api/server-avatar (not an E2E blob), so it's |
| // fetched directly here rather than through BlobImageLoader. |
| @@ -456,6 +469,24 @@ struct RootView: View { |
| .fullScreenCover(item: $createModel) { model in |
| CreateSpaceSheet(model: model) |
| } |
| + // The web→app handoff receiver (the Mac onOpenURL's iOS sibling): the |
| + // join page's "Open in the app" scheme (elevenmessenger://add?url=…, |
| + // the secret riding encoded inside the query value — it never reaches |
| + // any server) and universal links on covered instance domains both |
| + // land here. Validated, then PRE-FILLED into the confirm card rather |
| + // than joined — one confirming tap for any URL that arrived from |
| + // outside. Non-invite URLs (e.g. message permalinks) fall through. |
| + .onOpenURL { url in |
| + guard let link = inviteLink(fromOpenedURL: url), |
| + parseJoinLink(link) != nil else { return } |
| + app.pendingAddLink = PendingAddLink(link: link) |
| + } |
| + .sheet(item: $app.pendingAddLink) { pending in |
| + AddFromLinkConfirm(link: pending.link) { inst in |
| + app.pendingAddLink = nil |
| + select(inst) |
| + } |
| + } |
| // A features() fetch that failed once must not hide the affordance for |
| // the whole session — re-check whenever you come back to the home |
| // screen (the only place the + lives). |
diff --git a/ios/LChat/Views/AddFromLinkConfirm.swift b/ios/LChat/Views/AddFromLinkConfirm.swift| new file mode 100644 |
| index 0000000..a2f8b3f |
| --- /dev/null |
| +++ b/ios/LChat/Views/AddFromLinkConfirm.swift |
| @@ -0,0 +1,83 @@ |
| +import LChatCore |
| +import SwiftUI |
| + |
| +// 🤖 The iOS receiver's confirm card (web→app handoff — the Mac |
| +// AddFromLinkSheet's sibling): an invite link that arrived from OUTSIDE the |
| +// app (the join page's "Open in the app" scheme, or a universal link on an |
| +// instance subdomain) pre-fills this sheet — it never auto-joins; the Add |
| +// tap is the confirming act for any URL that came from elsewhere. |
| +// Deliberately NO editable field: iOS has no paste-a-link (Paul's call — |
| +// joining lives on the web), so only an externally-arrived, already-validated |
| +// link can reach this card. SpaceClaim handles all three link shapes |
| +// (member/setup invites, DM invites, add-device links) with no webview; the |
| +// secret in the #fragment never reaches any server. |
| +struct AddFromLinkConfirm: View { |
| + @EnvironmentObject var app: AppState |
| + @Environment(\.dismiss) private var dismiss |
| + let link: String |
| + /// Called after the claim persisted (app.add ran) — RootView selects the |
| + /// new space and drops the pending link. |
| + let onAdded: (SavedInstance) -> Void |
| + @State private var busy = false |
| + @State private var error: String? |
| + |
| + private var host: String { |
| + parseJoinLink(link).flatMap { URL(string: $0.origin)?.host } ?? "" |
| + } |
| + |
| + var body: some View { |
| + VStack(spacing: 14) { |
| + Image(systemName: "person.crop.circle.badge.plus") |
| + .font(.system(size: 44)) |
| + .foregroundStyle(.tint) |
| + .accessibilityHidden(true) |
| + Text("Add this space?") |
| + .font(.title3.bold()) |
| + Text("You opened an invite for \(host). It joins this \(DeviceName.current) and syncs to your Eleven account.") |
| + .font(.callout) |
| + .foregroundStyle(.secondary) |
| + .multilineTextAlignment(.center) |
| + if let error { |
| + Text(error) |
| + .font(.footnote) |
| + .foregroundStyle(.red) |
| + .multilineTextAlignment(.center) |
| + } |
| + Button(action: add) { |
| + HStack(spacing: 6) { |
| + if busy { ProgressView().controlSize(.small) } |
| + Text(busy ? "Joining…" : "Add Space") |
| + } |
| + .frame(maxWidth: .infinity) |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .controlSize(.large) |
| + .disabled(busy) |
| + Button("Cancel") { dismiss() } |
| + .disabled(busy) |
| + } |
| + .padding(24) |
| + .presentationDetents([.medium]) |
| + } |
| + |
| + private func add() { |
| + guard !busy else { return } |
| + busy = true |
| + error = nil |
| + Task { @MainActor in |
| + do { |
| + // Carry the name you already go by; an add-device link's own |
| + // "~name" (or "Me") wins inside the claim when this is empty. |
| + let screenName = app.instances.first(where: { !$0.isArchived })?.screenName |
| + ?? app.instances.first?.screenName ?? "" |
| + let inst = try await SpaceClaim.claim(setupLink: link, screenName: screenName) |
| + app.add(inst) // Keychain + list + vault push |
| + onAdded(inst) // RootView: select + dismiss the pending link |
| + } catch { |
| + self.error = (error as? LocalizedError)?.errorDescription |
| + ?? "Couldn't add that space." |
| + busy = false |
| + } |
| + } |
| + } |
| +} |