Fix Mac-app backup flow + banner copy/overflow (#115)
Mac app: the backup redirect navigated the instance's own WKWebView to the home
server; on that foreign origin the app's "no session → claim the invite"
fallback fired and replayed the spent invite ("this invite was just used").
Two fixes:
- ChatFeedWebView: the join fallback now only runs when the webview is on the
instance's OWN origin — a cross-origin navigation can never replay the invite.
- backup.js: in the native wrapper, "Save securely" opens the system browser
(where passkeys work) instead of navigating the webview; enroll.js ends on a
"done" screen for native (no redirect to an origin the browser has no session
for). Snoozes the nudge on launch; native auto-clear via deep link is a follow-up.
Banner polish (from feedback):
- Copy reframed to the benefit: "Save your chat securely" / "Saving lets you get
back to your chats later and on your other devices…" / button "Save securely".
- Fix overflow: the escalated "I can lose access…" dismiss label now wraps
inside the banner instead of spilling past its edge.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+50
−7
home/web/static/enroll.js+8 −0ios/LChatMac/ChatFeedWebView.swift+9 −0web/static/backup.js+24 −6web/static/style.css+9 −1
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js| index 55a50a4..163b1eb 100644 |
| --- a/home/web/static/enroll.js |
| +++ b/home/web/static/enroll.js |
| @@ -54,7 +54,15 @@ const returnOrigin = payload ? new URL(payload.ret || payload.entry.url).origin |
| // goBack returns to the chat. On success we carry the one-time nonce the |
| // instance gave us, so it can trust the redirect is genuine; on cancel, nothing. |
| +// In the native case there's no useful redirect target (this opened in the |
| +// system browser, which has no session for the instance), so we just end here. |
| function goBack(withNonce) { |
| + if (payload && payload.native) { |
| + setStatus(withNonce |
| + ? "Backup complete. You can close this tab and return to Eleven." |
| + : "You can close this tab and return to Eleven.", withNonce ? "ok" : ""); |
| + return; |
| + } |
| if (!returnOrigin) return; |
| const frag = withNonce && payload.nonce ? "#lchat_backup=" + encodeURIComponent(payload.nonce) : ""; |
| location.replace(returnOrigin + "/" + frag); |
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift| index 903ad63..fbd770b 100644 |
| --- a/ios/LChatMac/ChatFeedWebView.swift |
| +++ b/ios/LChatMac/ChatFeedWebView.swift |
| @@ -254,6 +254,15 @@ struct ChatFeedWebView: NSViewRepresentable { |
| // so re-assert file-drop registration after every page load. |
| webView.registerForDraggedTypes([.fileURL]) |
| guard !didAttemptJoin, let joinURL = URL(string: chat.url) else { return } |
| + // Only the instance's OWN origin may trigger the join fallback. The page |
| + // can navigate to another origin (a link, or the passkey-backup flow |
| + // visiting the home server), and "no session" on a foreign origin must |
| + // NEVER replay the one-time invite — that's what produced a spurious |
| + // "this invite was just used" after a backup redirect. |
| + if let cur = webView.url, let base = chat.origin, |
| + !(cur.scheme == base.scheme && cur.host == base.host && cur.port == base.port) { |
| + return |
| + } |
| webView.evaluateJavaScript("localStorage.getItem('lchat_session')") { [weak self] result, _ in |
| guard let self, !self.didAttemptJoin else { return } |
| let hasSession = (result as? String).map { !$0.isEmpty } ?? false |
diff --git a/web/static/backup.js b/web/static/backup.js| index e6ccf4f..096ea77 100644 |
| --- a/web/static/backup.js |
| +++ b/web/static/backup.js |
| @@ -93,16 +93,16 @@ function renderBanner(el, session) { |
| const title = document.createElement("div"); |
| title.className = "backup-banner-title"; |
| - title.textContent = "Back up your access"; |
| + title.textContent = "Save your chat securely"; |
| const body = document.createElement("div"); |
| body.className = "backup-banner-body"; |
| body.textContent = |
| - "Save a passkey so you don't lose your chats if you lose this device or browser."; |
| + "Saving lets you get back to your chats later and on your other devices. It's protected by a passkey — nothing to remember."; |
| const cta = document.createElement("button"); |
| cta.className = "backup-banner-cta"; |
| - cta.textContent = "Back up with a passkey"; |
| + cta.textContent = "Save securely"; |
| cta.addEventListener("click", () => launchEnrol(session)); |
| // Double-dismiss: the first click snoozes for a week and escalates the label to |
| @@ -141,12 +141,14 @@ function renderBanner(el, session) { |
| export function launchEnrol(session) { |
| const home = window.__homeOrigin; |
| if (!home) return; |
| + const native = isNativeWrapper(); |
| const nonce = randomNonce(); |
| localStorage.setItem(PENDING_KEY, nonce); |
| const payload = { |
| v: 1, |
| nonce, // echoed back in the return fragment to prove the redirect is genuine |
| - ret: location.origin, // where /enroll sends the user back to |
| + ret: location.origin, // where /enroll sends the user back to (web only) |
| + native, // native wrapper: /enroll ends on a "done" screen, no redirect back |
| entry: { |
| url: location.origin, // the instance being enrolled |
| name: groupName(), // best-effort, for the confirmation UI |
| @@ -159,8 +161,24 @@ export function launchEnrol(session) { |
| }, |
| }; |
| const encoded = bytesToB64url(new TextEncoder().encode(JSON.stringify(payload))); |
| - // The payload rides in the #fragment — it never reaches the home server. |
| - location.assign(`${home}/enroll#${encoded}`); |
| + const url = `${home}/enroll#${encoded}`; // payload rides in the #fragment — never sent to a server |
| + if (native) { |
| + // In the native wrapper (Mac app), the passkey ceremony can't run in the |
| + // WKWebView, and navigating the instance's own webview cross-origin trips the |
| + // app's join fallback. Open the system browser instead (the app routes |
| + // window.open there), where passkeys work. Snooze the nudge so it doesn't nag |
| + // while the user finishes in the browser. (Native auto-clear on return is a |
| + // follow-up — needs a deep-link handshake back into the app.) |
| + localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS)); |
| + removeBanner(); |
| + window.open(url, "_blank"); |
| + } else { |
| + location.assign(url); // same-tab redirect: Safari blocks WebAuthn in a popup |
| + } |
| +} |
| + |
| +function isNativeWrapper() { |
| + return document.documentElement.classList.contains("native"); |
| } |
| function randomNonce() { |
diff --git a/web/static/style.css b/web/static/style.css| index a14a9b2..b9c0d64 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1210,7 +1210,15 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .backup-banner-title { font-weight: 700; font-size: 14px; color: var(--ink); } |
| .backup-banner-body { font-size: 13px; line-height: 1.4; color: var(--muted); } |
| .backup-banner-cta { align-self: flex-start; padding: 8px 14px; font-size: 13px; } |
| -.backup-banner-dismiss { align-self: flex-start; font-size: 13px; } |
| +/* Full-width + left-aligned so the escalated "I can lose access…" label wraps |
| + inside the banner instead of overflowing its right edge. */ |
| +.backup-banner-dismiss { |
| + align-self: stretch; |
| + text-align: left; |
| + font-size: 13px; |
| + white-space: normal; |
| + overflow-wrap: anywhere; |
| +} |
| /* Both headers share a height so the thread pane's header lines up with the |
| sidebar's. Only the thread header carries a divider — the sidebar's "Chats" |
| header flows straight into the chat list (with the search field below it). */ |