eleven / messenger Public

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>
Paul Campbell 900c49787c699dbda31aab050a170b0d34c3185f parent 8dc135f
4 files changed, +50 −7
  • home/web/static/enroll.js +8 −0
  • ios/LChatMac/ChatFeedWebView.swift +9 −0
  • web/static/backup.js +24 −6
  • web/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). */