eleven / messenger Public

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>
Paul Campbell 05454ec15af82dcc8e9e81c31a4f53eeebf557ab parent 8cece43
4 files changed, +126 −0
  • ios/LChat/LChat-Debug.entitlements +6 −0
  • ios/LChat/LChat.entitlements +6 −0
  • ios/LChat/LChatApp.swift +31 −0
  • ios/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
+ }
+ }
+ }
+}