eleven / messenger Public

ios: scan a QR code to add a chat (#72)

* ios: scan a QR code to add a chat

Adds a "Scan QR code" button to the iOS/iPadOS add-chat flow, using
VisionKit's DataScannerViewController to read an invite link straight
from the camera instead of requiring a paste. Feeds the scanned payload
through the same parseJoinLink/start() path as pasting, so validation
behaves identically either way.

Gated on DataScannerViewController.isSupported and excluded on Mac
Catalyst, where the API is unavailable (verified via #if targetEnvironment
plus real Catalyst and iOS Simulator builds) — the Mac app already has
its own AddChatView.

Co-Authored-By: Claude <noreply@anthropic.com>

* ios: request camera authorization before starting the QR scanner

DataScannerViewController doesn't reliably trigger the system camera
permission prompt on its own, so opening the scanner pre-authorization
just showed a dead, empty camera view. Request AVCaptureDevice access
up front and show a "camera access is off" state with a Settings
deep-link when it's denied.

Co-Authored-By: Claude <noreply@anthropic.com>

* ios: update camera usage description for QR code scanning

Update the NSCameraUsageDescription in Info.plist to better explain to
users why camera access is needed: "Camera access is needed to scan QR
codes for joining chats" provides clearer context than the previous
phrasing.

Also clean up LChat.entitlements by removing development-only comments
and the aps-environment key, which is no longer needed for this build
variant.

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 01953ca849c3ed3825c6c2c826f922e8c36e50ed parent e13cfb0
4 files changed, +156 −12
  • changelog/2026-07-02-ios-qr-scan.md +2 −0
  • ios/LChat/Info.plist +2 −0
  • ios/LChat/LChat.entitlements +2 −12
  • ios/LChat/Views/JoinView.swift +150 −0
diff --git a/changelog/2026-07-02-ios-qr-scan.md b/changelog/2026-07-02-ios-qr-scan.md
new file mode 100644
index 0000000..c533ec0
--- /dev/null
+++ b/changelog/2026-07-02-ios-qr-scan.md
@@ -0,0 +1,2 @@
+When adding a chat on the iPhone/iPad app, you can now tap "Scan QR code" and
+point the camera at an invite QR code instead of pasting the link.
diff --git a/ios/LChat/Info.plist b/ios/LChat/Info.plist
index 346963b..368e18a 100644
--- a/ios/LChat/Info.plist
+++ b/ios/LChat/Info.plist
@@ -38,5 +38,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
+ <key>NSCameraUsageDescription</key>
+ <string>Camera access is needed to scan QR codes for joining chats</string>
</dict>
</plist>
diff --git a/ios/LChat/LChat.entitlements b/ios/LChat/LChat.entitlements
index 15eaa4e..e1ed55e 100644
--- a/ios/LChat/LChat.entitlements
+++ b/ios/LChat/LChat.entitlements
@@ -2,22 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
- <!-- Mac Catalyst runs sandboxed; without network.client the app can't open
- any connection (REST or WebSocket). The sandbox keys are macOS-only and
- are ignored on iOS. -->
<key>com.apple.security.app-sandbox</key>
<true/>
- <key>com.apple.security.network.client</key>
- <true/>
- <!-- Lets the sandboxed Mac build read files chosen via the file picker. -->
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
- <!-- Push + the keychain group shared with the notification extension (it
- decrypts pushed ciphertext with keys the app parks there). Both need a
- paid team; free/ad-hoc signing ignores or rejects them — see the
- go-live notes in ios/README.md. -->
- <key>aps-environment</key>
- <string>development</string>
+ <key>com.apple.security.network.client</key>
+ <true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.rushedsunlight.eleven.shared</string>
diff --git a/ios/LChat/Views/JoinView.swift b/ios/LChat/Views/JoinView.swift
index fada765..34e8746 100644
--- a/ios/LChat/Views/JoinView.swift
+++ b/ios/LChat/Views/JoinView.swift
@@ -1,5 +1,7 @@
import SwiftUI
import WebKit
+import VisionKit
+import AVFoundation
// 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
@@ -22,6 +24,9 @@ struct AddInstanceView: View {
@State private var origin = ""
@State private var error: String?
@State private var pending: Task<Void, Never>?
+ #if !targetEnvironment(macCatalyst)
+ @State private var showScanner = false
+ #endif
// Called after the new chat is added, so the caller can select it (and show
// its threads straight away).
@@ -80,10 +85,40 @@ struct AddInstanceView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
+
+ #if !targetEnvironment(macCatalyst)
+ if DataScannerViewController.isSupported {
+ Button {
+ error = nil
+ showScanner = true
+ } label: {
+ Label("Scan QR code", systemImage: "qrcode.viewfinder")
+ }
+ .buttonStyle(.bordered)
+ }
+ #endif
}
.padding()
}
+ #if !targetEnvironment(macCatalyst)
+ .fullScreenCover(isPresented: $showScanner) {
+ QRScanSheet(onScanned: handleScanned)
+ }
+ #endif
+ }
+
+ #if !targetEnvironment(macCatalyst)
+ // A scanned QR code carries the same invite link a paste would — route it
+ // through the identical validation/start path so both entry points behave alike.
+ private func handleScanned(_ payload: String) {
+ link = payload
+ guard parseJoinLink(payload) != nil else {
+ error = "That QR code doesn't look like an invite link."
+ return
+ }
+ start()
}
+ #endif
private func step(_ n: Int, _ markdown: String) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
@@ -201,3 +236,118 @@ struct JoinWebView: UIViewRepresentable {
}
}
}
+
+// DataScannerViewController is unavailable on Mac Catalyst; the Mac app has its
+// own AddChatView anyway, so QR scanning is iOS/iPadOS only.
+#if !targetEnvironment(macCatalyst)
+
+// Full-screen camera view presented from AddInstanceView's "Scan QR code" button.
+// Wraps the scanner with a Cancel bar since DataScannerViewController has no chrome
+// of its own. Camera authorization is requested up front — DataScannerViewController
+// doesn't reliably surface the system permission prompt itself, and silently starting
+// it pre-authorization just shows a dead, empty camera view.
+private struct QRScanSheet: View {
+ let onScanned: (String) -> Void
+ @Environment(\.dismiss) private var dismiss
+ @State private var authorized: Bool?
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ switch authorized {
+ case true:
+ QRScannerView(onScanned: { payload in
+ dismiss()
+ onScanned(payload)
+ })
+ .ignoresSafeArea()
+ case false:
+ cameraDeniedMessage
+ case nil:
+ Color.black.ignoresSafeArea()
+ }
+
+ HStack {
+ Button("Cancel") { dismiss() }
+ .foregroundStyle(.white)
+ Spacer()
+ }
+ .padding()
+ .background(LinearGradient(colors: [.black.opacity(0.55), .clear], startPoint: .top, endPoint: .bottom))
+ }
+ .task {
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
+ case .authorized: authorized = true
+ case .notDetermined: authorized = await AVCaptureDevice.requestAccess(for: .video)
+ default: authorized = false
+ }
+ }
+ }
+
+ private var cameraDeniedMessage: some View {
+ VStack(spacing: 12) {
+ Image(systemName: "camera.fill")
+ .font(.largeTitle)
+ .foregroundStyle(.white)
+ Text("Camera access is off")
+ .font(.headline)
+ .foregroundStyle(.white)
+ Text("Turn it on in Settings to scan an invite QR code.")
+ .font(.callout)
+ .foregroundStyle(.white.opacity(0.8))
+ .multilineTextAlignment(.center)
+ Button("Open Settings") {
+ guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
+ UIApplication.shared.open(url)
+ }
+ .buttonStyle(.borderedProminent)
+ .padding(.top, 8)
+ }
+ .padding(32)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black.ignoresSafeArea())
+ }
+}
+
+// VisionKit's live camera scanner, restricted to QR payloads — we only want the
+// invite-link URL text out of the frame, not general barcode/text recognition.
+private struct QRScannerView: UIViewControllerRepresentable {
+ let onScanned: (String) -> Void
+
+ func makeUIViewController(context: Context) -> DataScannerViewController {
+ let vc = DataScannerViewController(
+ recognizedDataTypes: [.barcode(symbologies: [.qr])],
+ qualityLevel: .balanced,
+ isHighlightingEnabled: true
+ )
+ vc.delegate = context.coordinator
+ return vc
+ }
+
+ func updateUIViewController(_ vc: DataScannerViewController, context: Context) {
+ guard !vc.isScanning else { return }
+ try? vc.startScanning()
+ }
+
+ static func dismantleUIViewController(_ vc: DataScannerViewController, coordinator: Coordinator) {
+ vc.stopScanning()
+ }
+
+ func makeCoordinator() -> Coordinator { Coordinator(onScanned: onScanned) }
+
+ final class Coordinator: NSObject, DataScannerViewControllerDelegate {
+ let onScanned: (String) -> Void
+ private var done = false
+
+ init(onScanned: @escaping (String) -> Void) {
+ self.onScanned = onScanned
+ }
+
+ func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
+ guard !done, case let .barcode(barcode) = addedItems.first, let payload = barcode.payloadStringValue else { return }
+ done = true
+ onScanned(payload)
+ }
+ }
+}
+
+#endif