Foreground vault pull + Mac drops the full-frame webview (sign-in is the only door) (#466)
* ios+mac: foreground vault pull — home-page stars/archive land in the apps Sync was push-only after sign-in, so a star made on home.elevenmessenger.com sat in the vault until the next ceremony. The saved session already holds the blob key, so HomeSync.pull fetches and opens the vault silently (rate-limited); iOS pulls on launch + foreground, the Mac on every app activation. The merge is the sign-in vault-wins rule — and it UPSERTS: a space signed into on another device appears here too, credentials riding the vault. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac: the full-frame webview is gone — sign-in is the only door Paul's call: the ONLY way into the native Mac app is a passkey — no more paste-a-link. Removed: the ChatFeedWebView fallback surface (a chat without a native identity now shows a native locked pane with Remove, instead of the web app), AddChatView + every Add Chat entry point (menu ⇧⌘N, rail, first-run link), invite-link URL handling (the home-session sign-in callback is the one link the app accepts), and the palette's webview bridge (runWeb/actions/connect — chats come from the native publisher only). Joining lives on the web; signing in brings spaces here, credentials riding the vault (incl. the new foreground pull). Scoped webviews (mini-app panels) stay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: paste-a-link join is gone too — sign-in is the only door on both platforms Removed AddInstanceView/JoinWebView (the paste/QR sheet), the drawer's 'Add a chat', the first-run invite link, and invite-URL handling. The iOS 17 first-run (no PRF) now says to update or use the browser rather than offering a join that no longer exists. A NEW native join flow (no pasted links) is future work — tracked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+125
−1373
ios/LChat/LChatApp.swift+39 −34ios/LChat/Views/InstanceSidebar.swift+0 −10ios/LChat/Views/JoinView.swift+0 −414ios/LChat/Views/SignInWithEleven.swift+1 −6ios/LChatCore/Sources/LChatCore/HomeSync.swift+16 −0ios/LChatMac/AddChatView.swift+0 −174ios/LChatMac/ChatFeedWebView.swift+0 −580ios/LChatMac/LChatMacApp.swift+25 −31ios/LChatMac/MacHomeSignIn.swift+1 −5ios/LChatMac/MacInstance.swift+5 −9ios/LChatMac/RootView.swift+38 −109ios/LChatMac/Sidebar.swift+0 −1
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 92aaffd..ca2427d 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -139,6 +139,27 @@ final class AppState: ObservableObject { |
| HomeSync.pushSoon(instances: instances) |
| } |
| + /// Merge a PULLED vault into this device (the foreground sync): spaces |
| + /// signed into elsewhere are added (their credentials ride the vault), |
| + /// then the vault's stars/archive/names/order apply — the same vault-wins |
| + /// rule as sign-in. No push back: the device state now equals the vault. |
| + func merge(vault servers: [HomeVault.Server]) { |
| + for s in servers { |
| + guard let creds = s.credentials, |
| + !instances.contains(where: { $0.origin == s.url }) else { continue } |
| + instances.append(SavedInstance( |
| + origin: s.url, deviceToken: creds.token, |
| + screenName: creds.name ?? s.name ?? "", |
| + userPub: creds.userPub, userPriv: creds.userPriv, |
| + groupName: s.name ?? "", |
| + starred: s.isStarred ? true : nil, |
| + archived: s.isArchived ? true : nil)) |
| + } |
| + applyVault(servers.map { |
| + (url: $0.url, starred: $0.isStarred, archived: $0.isArchived, name: $0.name) |
| + }) |
| + } |
| + |
| /// Archive or unarchive a space: hidden from the drawer's main lists, |
| /// grouped under Archived. No reordering — position is kept for unarchive. |
| func toggleArchive(_ inst: SavedInstance) { |
| @@ -223,10 +244,6 @@ struct RootView: View { |
| // probe (EdgeTouchReporter) that observes the touch without consuming it; a |
| // SwiftUI zero-distance drag would do the same but swallow the list's taps. |
| @State private var edgeTouch = false |
| - @State private var showAdd = false |
| - // An invite link the OS opened us with (custom scheme or universal link), |
| - // waiting to pre-fill the add sheet. |
| - @State private var pendingAddLink = "" |
| private let drawerWidth: CGFloat = 300 |
| // Slide-in / settle curve. easeOut decelerates into place and never |
| @@ -272,22 +289,6 @@ struct RootView: View { |
| showServers = open |
| drawerPos = open ? 1 : 0 |
| } |
| - .sheet(isPresented: $showAdd, onDismiss: { pendingAddLink = "" }) { |
| - AddInstanceView(initialLink: pendingAddLink, onAdded: select) |
| - // Keyed on the link: a fresh OS-opened link rebuilds (and |
| - // re-seeds) the sheet even if it was already up. |
| - .id(pendingAddLink) |
| - } |
| - // The OS hands us invite links two ways: the welcome page's |
| - // elevenmessenger://add?url=<link> and universal links (the raw https |
| - // invite). Either routes into the add sheet pre-filled, which runs the |
| - // normal validate → JoinWebView flow. |
| - .onOpenURL { url in |
| - guard let raw = inviteLink(fromOpenedURL: url), parseJoinLink(raw) != nil else { return } |
| - pendingAddLink = raw |
| - showServers = false |
| - showAdd = true |
| - } |
| .onAppear { |
| if nav.instanceID == nil { nav.selectInstance(rememberedInstance) } |
| // Someone with chats wants to hear about them (no-op without the |
| @@ -296,6 +297,7 @@ struct RootView: View { |
| // A cold-launch notification tap is already waiting here (delivered |
| // before this view could subscribe) — route it now. |
| routeNotificationTap() |
| + pullHome() |
| routeChangelogTap() |
| } |
| // Sign-in restore populates instances while the first-run screen is |
| @@ -316,6 +318,9 @@ struct RootView: View { |
| // appear / server switch, so foregrounding otherwise never refreshed. |
| .onChange(of: scenePhase) { _, phase in |
| refreshInbox(when: phase == .active) |
| + // Foregrounding also pulls the vault: a star/archive made on the |
| + // home page (or another device) lands here without a re-sign-in. |
| + if phase == .active { pullHome() } |
| // Leaving for the background arms the next Background App Refresh |
| // wake, so chats keep syncing into the local store while we sleep. |
| if phase == .background, !app.instances.isEmpty { BackgroundRefresh.schedule() } |
| @@ -331,6 +336,13 @@ struct RootView: View { |
| return app.instances.first { $0.id == last } ?? app.instances.first |
| } |
| + // The silent vault pull (no-op when signed out or rate-limited). |
| + private func pullHome() { |
| + Task { |
| + if let contents = await HomeSync.pull() { app.merge(vault: contents.servers) } |
| + } |
| + } |
| + |
| // Re-fetch the active server's thread list, unless one is already in flight (the |
| // cold-launch `.task` load, a pull-to-refresh, or an overlapping trigger). |
| private func refreshInbox(when: Bool) { |
| @@ -396,10 +408,6 @@ struct RootView: View { |
| activeID: nav.instanceID, |
| signIn: signIn, |
| onSelect: select, |
| - onAdd: { |
| - showServers = false |
| - showAdd = true |
| - }, |
| onSignOut: { |
| // Sign out = clear this device (the spaces live in the |
| // member's Eleven account; signing back in restores them). |
| @@ -470,22 +478,19 @@ struct RootView: View { |
| return showServers || v.startLocation.x < 44 |
| } |
| - // First run leads with "Sign in with Eleven" (restore your spaces from a |
| - // passkey backup); joining a specific chat by invite link is the secondary |
| - // path (opens the existing paste/QR sheet). The passkey ceremony is |
| - // iOS 18-only (PRF), so iOS 17 keeps the pre-sign-in card. |
| + // First run: "Sign in with Eleven" is the ONLY door (Paul's call — no |
| + // paste-a-link; joining lives on the web, a native join flow comes later). |
| + // The ceremony is iOS 18-only (PRF), so iOS 17 says so instead. |
| @ViewBuilder private var noServers: some View { |
| if #available(iOS 18.0, *) { |
| - SignInFirstRun(model: signIn, onAddLink: { showAdd = true }) |
| + SignInFirstRun(model: signIn) |
| } else { |
| VStack(spacing: 14) { |
| - Image(systemName: "bubble.left.and.bubble.right") |
| + Image(systemName: "person.badge.key") |
| .font(.system(size: 44)).foregroundStyle(.secondary) |
| - Text("No chats yet").font(.headline) |
| - Text("Add one with an invite link you were sent.") |
| + Text("Sign in needs iOS 18").font(.headline) |
| + Text("Update your iPhone to sign in to Eleven — or use your space in the browser meanwhile.") |
| .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) |
| - Button { showAdd = true } label: { Text("Add a chat").frame(maxWidth: 220) } |
| - .buttonStyle(.borderedProminent).controlSize(.large) |
| } |
| .padding() |
| } |
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift| index 2284561..24851e0 100644 |
| --- a/ios/LChat/Views/InstanceSidebar.swift |
| +++ b/ios/LChat/Views/InstanceSidebar.swift |
| @@ -12,7 +12,6 @@ struct ServerDrawer: View { |
| /// joined by invite link can start syncing its server list any time. |
| @ObservedObject var signIn: HomeSignInModel |
| var onSelect: (SavedInstance) -> Void |
| - var onAdd: () -> Void |
| // Sign out = full local reset, owned by RootView (final sync push, wipe, |
| // fold nav/session back to first-run). |
| var onSignOut: () -> Void = {} |
| @@ -83,15 +82,6 @@ struct ServerDrawer: View { |
| Divider() |
| - Button(action: onAdd) { |
| - Label("Add a chat", systemImage: "plus") |
| - .font(.body.weight(.medium)) |
| - .frame(maxWidth: .infinity, alignment: .leading) |
| - .padding(.horizontal, 20) |
| - .padding(.vertical, 16) |
| - .contentShape(Rectangle()) |
| - } |
| - .buttonStyle(.plain) |
| if #unavailable(iOS 18.0) { |
| // The passkey ceremony (PRF) is iOS 18-only — no sign-in |
diff --git a/ios/LChat/Views/JoinView.swift b/ios/LChat/Views/JoinView.swift| deleted file mode 100644 |
| index 75aef00..0000000 |
| --- a/ios/LChat/Views/JoinView.swift |
| +++ /dev/null |
| @@ -1,414 +0,0 @@ |
| -import LChatCore |
| -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 |
| -// (join.js): it derives the invite secret, generates the user keypair (or unwraps a |
| -// shipped one for an add-device link), registers the public key and claims a device |
| -// token — then hands {token, name, userPub, userPriv} back to us via the bridge. We |
| -// store the private key in the Keychain (SavedInstance) and never send it anywhere. |
| -// |
| -// Keeping join in the web layer means the keypair + invite crypto lives in one place |
| -// (crypto.js); native only persists the result. |
| -// Language + behaviour match the Mac app's AddChatView: it's "Add a chat" (never |
| -// "workspace"), and the link is added as soon as the pasted text is valid — no |
| -// button to press. |
| -struct AddInstanceView: View { |
| - @EnvironmentObject var app: AppState |
| - @Environment(\.dismiss) private var dismiss |
| - |
| - @State private var link = "" |
| - @State private var joinURL: URL? |
| - @State private var origin = "" |
| - @State private var error: String? |
| - @State private var pending: Task<Void, Never>? |
| - // A valid link resolves to a branded confirmation ("You're joining <Name>", |
| - // with the server's photo when it has one) before the web join flow opens. |
| - @State private var confirming = false |
| - @State private var serverName = "" |
| - @State private var serverPhoto: Data? |
| - @State private var brandingTask: Task<Void, Never>? |
| - @State private var showScanner = false |
| - |
| - // A link handed to us by the OS (custom scheme / universal link) — seeded into |
| - // the paste field on appear so it runs the exact same validate/debounce/start |
| - // path as a manual paste. |
| - var initialLink: String = "" |
| - |
| - // Called after the new chat is added, so the caller can select it (and show |
| - // its threads straight away). |
| - var onAdded: (SavedInstance) -> Void = { _ in } |
| - |
| - var body: some View { |
| - NavigationStack { |
| - Group { |
| - if let joinURL { |
| - JoinWebView(url: joinURL, onJoined: complete, onError: { msg in |
| - error = msg |
| - self.joinURL = nil |
| - }) |
| - } else if confirming { |
| - confirmCard |
| - } else { |
| - pasteForm |
| - } |
| - } |
| - .navigationTitle("Add a chat") |
| - .navigationBarTitleDisplayMode(.inline) |
| - .toolbar { |
| - ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } |
| - } |
| - } |
| - // Assigning fires the field's onChange, so an OS-opened link takes the |
| - // same debounce → start() route as a paste (no second code path). |
| - .onAppear { |
| - if link.isEmpty, !initialLink.isEmpty { link = initialLink } |
| - } |
| - } |
| - |
| - private var pasteForm: some View { |
| - ScrollView { |
| - VStack(alignment: .leading, spacing: 20) { |
| - VStack(alignment: .leading, spacing: 10) { |
| - step(1, "Ask for an invite link, or in an existing chat open the **⋮** menu and choose **Add a device**.") |
| - step(2, "Paste the link here — it's added as soon as it's valid.") |
| - } |
| - |
| - TextField("https://…/join/…", text: $link, axis: .vertical) |
| - .textFieldStyle(.roundedBorder) |
| - .textInputAutocapitalization(.never) |
| - .autocorrectionDisabled() |
| - .keyboardType(.URL) |
| - .onChange(of: link) { _, new in |
| - // Auto-start once the text is a valid link — but debounced: |
| - // a fragment is "valid" from its first character, so starting |
| - // on the spot mid-typing would fire with a truncated secret. |
| - pending?.cancel() |
| - guard parseJoinLink(new) != nil else { return } |
| - pending = Task { |
| - try? await Task.sleep(for: .milliseconds(500)) |
| - if !Task.isCancelled { prepareConfirm() } |
| - } |
| - } |
| - |
| - if let error { |
| - Text(error).font(.callout).foregroundStyle(.red) |
| - } else if !link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { |
| - // Only nag once they've typed something that isn't (yet) a link. |
| - Label("That doesn't look like an invite link yet.", systemImage: "exclamationmark.circle") |
| - .font(.caption) |
| - .foregroundStyle(.secondary) |
| - } |
| - |
| - if DataScannerViewController.isSupported { |
| - Button { |
| - error = nil |
| - showScanner = true |
| - } label: { |
| - Label("Scan QR code", systemImage: "qrcode.viewfinder") |
| - } |
| - .buttonStyle(.bordered) |
| - } |
| - } |
| - .padding() |
| - } |
| - .fullScreenCover(isPresented: $showScanner) { |
| - QRScanSheet(onScanned: handleScanned) |
| - } |
| - } |
| - |
| - // 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 |
| - } |
| - prepareConfirm() |
| - } |
| - |
| - private func step(_ n: Int, _ markdown: String) -> some View { |
| - HStack(alignment: .firstTextBaseline, spacing: 8) { |
| - Text("\(n).") |
| - .font(.callout.monospacedDigit()) |
| - .foregroundStyle(.tertiary) |
| - Text((try? AttributedString(markdown: markdown)) ?? AttributedString(markdown)) |
| - .font(.callout) |
| - .foregroundStyle(.secondary) |
| - Spacer(minLength: 0) |
| - } |
| - } |
| - |
| - // A valid link → a branded confirmation card. The card is populated instantly |
| - // (host as a placeholder name) and enriched with the real name + photo when the |
| - // public branding fetch returns — a failed fetch just leaves the placeholder, |
| - // so an unreachable/plain instance can still be joined. |
| - private func prepareConfirm() { |
| - error = nil |
| - guard let parsed = parseJoinLink(link), let url = URL(string: parsed.origin) else { return } |
| - origin = parsed.origin |
| - serverName = url.host ?? parsed.origin |
| - serverPhoto = nil |
| - confirming = true |
| - brandingTask?.cancel() |
| - brandingTask = Task { |
| - let api = LChatAPI(baseURL: url, token: "") |
| - let info = await api.serverInfo() |
| - if Task.isCancelled { return } |
| - serverName = info.name |
| - if info.hasAvatar { |
| - let data = await api.serverAvatar() |
| - if Task.isCancelled { return } |
| - serverPhoto = data |
| - } |
| - } |
| - } |
| - |
| - private var confirmCard: some View { |
| - VStack(spacing: 16) { |
| - Spacer(minLength: 0) |
| - serverBadge |
| - Text("You're joining").font(.callout).foregroundStyle(.secondary) |
| - Text(serverName).font(.title2.weight(.semibold)).multilineTextAlignment(.center) |
| - Button { |
| - start() // proceed to the web join flow (pick a screen name) |
| - } label: { |
| - Text("Join").frame(maxWidth: .infinity) |
| - } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| - Button("Use a different link") { |
| - brandingTask?.cancel() |
| - confirming = false |
| - serverPhoto = nil |
| - link = "" |
| - } |
| - .buttonStyle(.plain) |
| - .foregroundStyle(.secondary) |
| - Spacer(minLength: 0) |
| - } |
| - .padding(24) |
| - } |
| - |
| - @ViewBuilder private var serverBadge: some View { |
| - if let data = serverPhoto, let ui = UIImage(data: data) { |
| - Image(uiImage: ui).resizable().scaledToFill() |
| - .frame(width: 84, height: 84) |
| - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) |
| - } else { |
| - RoundedRectangle(cornerRadius: 20, style: .continuous) |
| - .fill(Color.accentColor.opacity(0.15)) |
| - .frame(width: 84, height: 84) |
| - .overlay( |
| - Text(firstLetter(serverName)) |
| - .font(.largeTitle.weight(.semibold)) |
| - .foregroundStyle(Color.accentColor) |
| - ) |
| - } |
| - } |
| - |
| - private func firstLetter(_ s: String) -> String { |
| - s.first.map { String($0).uppercased() } ?? "?" |
| - } |
| - |
| - private func start() { |
| - error = nil |
| - guard let parsed = parseJoinLink(link) else { return } |
| - origin = parsed.origin |
| - joinURL = parsed.joinURL |
| - } |
| - |
| - private func complete(_ joined: JoinedIdentity) { |
| - let inst = SavedInstance( |
| - origin: origin, |
| - deviceToken: joined.token, |
| - screenName: joined.name, |
| - userPub: joined.userPub, |
| - userPriv: joined.userPriv |
| - ) |
| - app.add(inst) |
| - onAdded(inst) |
| - dismiss() |
| - } |
| -} |
| - |
| -// The fields the web join flow hands back. |
| -struct JoinedIdentity { |
| - let token: String |
| - let name: String |
| - let userPub: String |
| - let userPriv: String |
| -} |
| - |
| -// parseJoinLink (validation + origin extraction) lives in InviteLink.swift, |
| -// shared with the Mac target and the standalone parser test. |
| - |
| -// Hosts join.js with window.__lchatEmbed set, so it posts the resolved identity back |
| -// on the "lchat" handler instead of navigating to "/". |
| -struct JoinWebView: UIViewRepresentable { |
| - let url: URL |
| - let onJoined: (JoinedIdentity) -> Void |
| - let onError: (String) -> Void |
| - |
| - func makeCoordinator() -> Coordinator { Coordinator(onJoined: onJoined, onError: onError) } |
| - |
| - func makeUIView(context: Context) -> WKWebView { |
| - let cfg = WKWebViewConfiguration() |
| - // One-shot webview: never inherit (or leave behind) web sessions. With the |
| - // shared default store, a previous join's localStorage session makes join.js |
| - // take its "already a member on this device" shortcut and silently consume |
| - // the invite as that old identity — the native shell owns identities, not |
| - // the webview. |
| - cfg.websiteDataStore = .nonPersistent() |
| - let flag = WKUserScript(source: "window.__lchatEmbed = true;", |
| - injectionTime: .atDocumentStart, forMainFrameOnly: true) |
| - cfg.userContentController.addUserScript(flag) |
| - cfg.userContentController.add(context.coordinator, name: "lchat") |
| - let web = WKWebView(frame: .zero, configuration: cfg) |
| - web.navigationDelegate = context.coordinator |
| - #if DEBUG |
| - if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true } |
| - #endif |
| - web.load(URLRequest(url: url)) |
| - return web |
| - } |
| - |
| - func updateUIView(_ web: WKWebView, context: Context) {} |
| - |
| - final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { |
| - let onJoined: (JoinedIdentity) -> Void |
| - let onError: (String) -> Void |
| - private var done = false |
| - |
| - init(onJoined: @escaping (JoinedIdentity) -> Void, onError: @escaping (String) -> Void) { |
| - self.onJoined = onJoined |
| - self.onError = onError |
| - } |
| - |
| - func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| - guard !done, message.name == "lchat", |
| - let body = message.body as? [String: Any], |
| - let j = body["joined"] as? [String: Any], |
| - let token = j["token"] as? String, |
| - let name = j["name"] as? String, |
| - let pub = j["userPub"] as? String, |
| - let priv = j["userPriv"] as? String |
| - else { return } |
| - done = true |
| - onJoined(JoinedIdentity(token: token, name: name, userPub: pub, userPriv: priv)) |
| - } |
| - } |
| -} |
| - |
| -// 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) |
| - } |
| - } |
| -} |
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift| index 3258014..4de35a1 100644 |
| --- a/ios/LChat/Views/SignInWithEleven.swift |
| +++ b/ios/LChat/Views/SignInWithEleven.swift |
| @@ -234,7 +234,6 @@ struct SignInFirstRun: View { |
| @EnvironmentObject var app: AppState |
| @ObservedObject var model: HomeSignInModel |
| /// Opens the existing invite-link / QR add sheet. |
| - var onAddLink: () -> Void |
| var body: some View { |
| VStack(spacing: 0) { |
| @@ -293,10 +292,6 @@ struct SignInFirstRun: View { |
| .foregroundStyle(.tertiary) |
| .multilineTextAlignment(.center) |
| - Button("Add with an invite link", action: onAddLink) |
| - .font(.callout) |
| - .padding(.top, 2) |
| - .disabled(model.phase.busy) |
| } |
| .padding(.horizontal, 32) |
| .padding(.bottom, 8) |
| @@ -311,7 +306,7 @@ struct SignInFirstRun: View { |
| Label("\(n) space\(n == 1 ? "" : "s") restored.", systemImage: "checkmark.circle.fill") |
| .font(.callout).foregroundStyle(.green) |
| case .emptyBackup: |
| - Text("Signed in — no spaces saved yet. Add one with an invite link.") |
| + Text("Signed in — nothing saved on this account yet. Join a space in your browser first.") |
| .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) |
| case .backedUp(let n): |
| Label("Saved \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill") |
diff --git a/ios/LChatCore/Sources/LChatCore/HomeSync.swift b/ios/LChatCore/Sources/LChatCore/HomeSync.swift| index f65f610..5260502 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HomeSync.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeSync.swift |
| @@ -67,6 +67,22 @@ public enum HomeSync { |
| private static var queue: Task<Void, Never>? |
| + /// Silent vault fetch with the stored session (no ceremony — the DEK is |
| + /// all it needs): the foreground pull that carries changes made on the |
| + /// home page (or another device) INTO this one. Returns nil when signed |
| + /// out, offline, or the blob is gone. Rate-limited so scene-phase churn |
| + /// doesn't hammer home. |
| + public static func pull(minInterval: TimeInterval = 20) async -> HomeVault.Contents? { |
| + guard let session = HomeSessionStore.load(), let dek = session.dek else { return nil } |
| + if let last = lastPull, Date().timeIntervalSince(last) < minInterval { return nil } |
| + lastPull = Date() |
| + guard let fetch = try? await HomeClient().fetchBlob(token: session.token), |
| + let contents = try? HomeVault.open(fetch.bytes, dek: dek) else { return nil } |
| + return contents |
| + } |
| + |
| + private static var lastPull: Date? |
| + |
| /// Upsert the device's list (order + stars + credentials) into the vault. |
| /// Works on the RAW JSON object and patches only the fields this build |
| /// owns, so anything a newer web/app build stored — unknown top-level |
diff --git a/ios/LChatMac/AddChatView.swift b/ios/LChatMac/AddChatView.swift| deleted file mode 100644 |
| index f3398e4..0000000 |
| --- a/ios/LChatMac/AddChatView.swift |
| +++ /dev/null |
| @@ -1,174 +0,0 @@ |
| -import LChatCore |
| -import SwiftUI |
| - |
| -// Add a chat by pasting an invite link. As soon as the pasted text looks like a |
| -// valid invite link it's added automatically — no "Add" button to press. The link |
| -// is loaded verbatim by the webview (its #fragment secret never leaves the device), |
| -// and the web join flow is where you pick your screen name. |
| -struct AddChatView: View { |
| - @EnvironmentObject var app: MacAppState |
| - @Environment(\.dismiss) private var dismiss |
| - @State private var link = "" |
| - @FocusState private var focused: Bool |
| - // A valid link resolves to a branded confirmation ("You're joining <Name>", |
| - // with the server's photo when it has one) before the chat is added. |
| - @State private var confirming = false |
| - @State private var serverName = "" |
| - @State private var serverPhoto: Data? |
| - @State private var brandingTask: Task<Void, Never>? |
| - |
| - var body: some View { |
| - Group { |
| - if confirming { confirmCard } else { pasteForm } |
| - } |
| - .padding(20) |
| - .frame(width: 460) |
| - .onAppear { focused = true } |
| - } |
| - |
| - private var pasteForm: some View { |
| - VStack(alignment: .leading, spacing: 16) { |
| - Text("Add a chat").font(.title2.weight(.semibold)) |
| - |
| - VStack(alignment: .leading, spacing: 10) { |
| - step(1, "In an existing chat, open the **⋮** menu and choose **Add a device**.") |
| - step(2, "Paste the link here — you'll confirm the chat before joining.") |
| - } |
| - |
| - TextField("https://…/join/…", text: $link) |
| - .textFieldStyle(.roundedBorder) |
| - .focused($focused) |
| - .onChange(of: link) { _, new in |
| - if !confirming, isValidInvite(new) { prepareConfirm(new) } |
| - } |
| - |
| - // Only nag once they've typed something that isn't (yet) a link. |
| - if !link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isValidInvite(link) { |
| - Label("That doesn't look like an invite link yet.", systemImage: "exclamationmark.circle") |
| - .font(.caption) |
| - .foregroundStyle(.secondary) |
| - } |
| - |
| - HStack { |
| - Spacer() |
| - Button("Cancel") { dismiss() } |
| - .keyboardShortcut(.cancelAction) |
| - } |
| - } |
| - } |
| - |
| - private var confirmCard: some View { |
| - VStack(spacing: 16) { |
| - serverBadge |
| - Text("You're joining").font(.callout).foregroundStyle(.secondary) |
| - Text(serverName).font(.title2.weight(.semibold)).multilineTextAlignment(.center) |
| - HStack { |
| - Button("Use a different link") { |
| - brandingTask?.cancel() |
| - confirming = false |
| - serverPhoto = nil |
| - link = "" |
| - } |
| - .keyboardShortcut(.cancelAction) |
| - Spacer() |
| - Button("Join") { |
| - app.add(url: link.trimmingCharacters(in: .whitespacesAndNewlines)) |
| - dismiss() |
| - } |
| - .keyboardShortcut(.defaultAction) |
| - .buttonStyle(.borderedProminent) |
| - } |
| - } |
| - } |
| - |
| - @ViewBuilder private var serverBadge: some View { |
| - if let data = serverPhoto, let ns = NSImage(data: data) { |
| - Image(nsImage: ns).resizable().scaledToFill() |
| - .frame(width: 72, height: 72) |
| - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) |
| - } else { |
| - RoundedRectangle(cornerRadius: 16, style: .continuous) |
| - .fill(Color.accentColor.opacity(0.15)) |
| - .frame(width: 72, height: 72) |
| - .overlay( |
| - Text(firstLetter(serverName)) |
| - .font(.largeTitle.weight(.semibold)) |
| - .foregroundStyle(Color.accentColor) |
| - ) |
| - } |
| - } |
| - |
| - // A valid link → a branded confirmation. Populated instantly (host as a |
| - // placeholder name) and enriched with the real name + photo when the public |
| - // branding fetch returns; a failed fetch leaves the placeholder, so a plain or |
| - // unreachable instance can still be joined. |
| - private func prepareConfirm(_ raw: String) { |
| - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| - guard let originURL = originOf(trimmed) else { return } |
| - confirming = true // set first so the link reassignment below can't recurse |
| - link = trimmed |
| - serverName = originURL.host ?? trimmed |
| - serverPhoto = nil |
| - brandingTask?.cancel() |
| - brandingTask = Task { |
| - let api = LChatAPI(baseURL: originURL, token: "") |
| - let info = await api.serverInfo() |
| - if Task.isCancelled { return } |
| - serverName = info.name |
| - if info.hasAvatar { |
| - let data = await api.serverAvatar() |
| - if Task.isCancelled { return } |
| - serverPhoto = data |
| - } |
| - } |
| - } |
| - |
| - // The bare origin (scheme://host[:port]) of an invite link — where the public |
| - // branding endpoints live. |
| - private func originOf(_ raw: String) -> URL? { |
| - guard let c = URLComponents(string: raw), let scheme = c.scheme, let host = c.host |
| - else { return nil } |
| - var out = "\(scheme)://\(host)" |
| - if let port = c.port { out += ":\(port)" } |
| - return URL(string: out) |
| - } |
| - |
| - private func firstLetter(_ s: String) -> String { |
| - s.first.map { String($0).uppercased() } ?? "?" |
| - } |
| - |
| - private func step(_ n: Int, _ markdown: String) -> some View { |
| - HStack(alignment: .firstTextBaseline, spacing: 8) { |
| - Text("\(n).") |
| - .font(.callout.monospacedDigit()) |
| - .foregroundStyle(.tertiary) |
| - Text(md(markdown)) |
| - .font(.callout) |
| - .foregroundStyle(.secondary) |
| - Spacer(minLength: 0) |
| - } |
| - } |
| - |
| - private func md(_ s: String) -> AttributedString { |
| - (try? AttributedString(markdown: s)) ?? AttributedString(s) |
| - } |
| -} |
| - |
| -// A valid invite link for either format: https(s)://host/join/<token>#<secret>. |
| -// The secret may carry the "e1." app-hint prefix or not (older links) — both pass. |
| -// (Legacy links carry `#k=<key>`; new-format ones a one-time secret — we accept |
| -// both, so this can't rely on parseInvite, which only matches legacy `#k=`.) |
| -// File-scope so the app's onOpenURL handler validates with the same logic. |
| -func isValidInvite(_ raw: String) -> Bool { |
| - let s = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| - guard let c = URLComponents(string: s), |
| - let scheme = c.scheme, scheme == "http" || scheme == "https", |
| - let host = c.host, !host.isEmpty, |
| - let frag = c.fragment, !frag.isEmpty |
| - else { return false } |
| - let segments = c.path.split(separator: "/").map(String.init) |
| - guard let j = segments.firstIndex(of: "join"), j + 1 < segments.count, |
| - !segments[j + 1].isEmpty |
| - else { return false } |
| - return true |
| -} |
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift| deleted file mode 100644 |
| index f0c9030..0000000 |
| --- a/ios/LChatMac/ChatFeedWebView.swift |
| +++ /dev/null |
| @@ -1,580 +0,0 @@ |
| -import SwiftUI |
| -import WebKit |
| -import AppKit |
| -import UniformTypeIdentifiers |
| -import UserNotifications |
| - |
| -// Hosts one chat instance's web client (chat.js) in a WKWebView — the "feed as a |
| -// webview" half. All rendering, the composer, the WebSocket and (crucially) every |
| -// bit of encryption stay in the web layer, so there is one audited crypto path and |
| -// the server still only ever sees ciphertext. |
| -// |
| -// Loading strategy: we load the *origin* (`/`) first, not the invite link. Once an |
| -// instance has been joined, its session lives in that origin's persistent |
| -// localStorage, so the feed renders directly. Only when there's no session yet do |
| -// we fall back to the one-time join link to claim it — reloading the join link on |
| -// every open is what produced the "this link doesn't work" error (a spent invite |
| -// re-claimed) when switching between servers. |
| -// |
| -// Window dragging: a WKWebView swallows mouse events, so the OS can't drag the |
| -// window from the web header. Instead we inject a script that detects a drag on the |
| -// header (not on a button) and forwards the movement to `moveWindow`. |
| -// |
| -// File drag-and-drop: macOS WKWebView doesn't reliably deliver Finder file-drops to |
| -// the page's DOM, so DropWebView intercepts them natively and feeds the bytes into |
| -// chat.js's own file input (`window.__lchatAddFiles`) — the same path a picked file |
| -// takes, so processing/encryption/upload all stay in the web layer. |
| -struct ChatFeedWebView: NSViewRepresentable { |
| - let chat: SavedChat |
| - let app: MacAppState |
| - let isActive: Bool |
| - // Re-runs updateNSView when the Text Size pref changes so the live page |
| - // re-renders at the new size without a reload. |
| - @AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize |
| - |
| - func makeCoordinator() -> Coordinator { Coordinator(chat: chat, app: app) } |
| - |
| - // Drive the web feed's text size from the native pref via the page's |
| - // --msg-font-size variable (style.css). At the default the override is |
| - // REMOVED, so the web's own theme-sheet slider (a browser-local pref) |
| - // still rules until the Mac pref is deliberately moved. |
| - static func textSizeJS(_ size: Double) -> String { |
| - let px = Int(ChatTextSize.snap(size)) |
| - return px == Int(ChatTextSize.defaultSize) |
| - ? "document.documentElement.style.removeProperty('--msg-font-size')" |
| - : "document.documentElement.style.setProperty('--msg-font-size','\(px)px')" |
| - } |
| - |
| - func makeNSView(context: Context) -> WKWebView { |
| - let cfg = WKWebViewConfiguration() |
| - cfg.websiteDataStore = .default() // persist localStorage/session per origin |
| - // Advertise the native wrapper in the UA too (a reusable signal), but the |
| - // load-bearing bit is this: tag <html>.native at document-start on EVERY |
| - // load/navigation/reload, from the app side. That's what makes the native |
| - // styling (dropped hand cursors, non-selectable header, …) survive reloads — |
| - // relying on the server page's own UA detection wasn't reliable. |
| - cfg.applicationNameForUserAgent = "lchatMac" |
| - cfg.userContentController.addUserScript( |
| - WKUserScript(source: "document.documentElement.classList.add('native')", |
| - injectionTime: .atDocumentStart, forMainFrameOnly: true) |
| - ) |
| - cfg.userContentController.add(context.coordinator, name: "lchatDrag") |
| - cfg.userContentController.add(context.coordinator, name: "lchatNotify") |
| - cfg.userContentController.add(context.coordinator, name: "lchatTheme") |
| - cfg.userContentController.add(context.coordinator, name: "lchatMenu") |
| - cfg.userContentController.addUserScript( |
| - WKUserScript(source: Self.injectedScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true) |
| - ) |
| - let web = DropWebView(frame: .zero, configuration: cfg) |
| - web.registerForDraggedTypes([.fileURL]) |
| - web.onDropFiles = { [weak coordinator = context.coordinator] urls in |
| - coordinator?.handleDroppedFiles(urls) |
| - } |
| - web.onDragActive = { [weak coordinator = context.coordinator] active in |
| - coordinator?.setDragOverlay(active) |
| - } |
| - context.coordinator.webView = web |
| - web.uiDelegate = context.coordinator |
| - web.navigationDelegate = context.coordinator |
| - web.allowsBackForwardNavigationGestures = false // no two-finger swipe nav |
| - web.setValue(false, forKey: "drawsBackground") // let the page's own bg show |
| - #if DEBUG |
| - if #available(macOS 13.3, *) { web.isInspectable = true } |
| - #endif |
| - if let start = chat.origin ?? URL(string: chat.url) { |
| - // Hard load (like a browser shift-reload): WKWebView's subresource |
| - // cache can hang on to /static/*.js past the server's no-cache, so a |
| - // relaunch could keep running week-old app code against a redeployed |
| - // server. The assets are small; always fetch them fresh at launch. |
| - web.load(URLRequest(url: start, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData)) |
| - } |
| - return web |
| - } |
| - |
| - func updateNSView(_ web: WKWebView, context: Context) { |
| - // Track the selected instance's webview so a dropped (or picked) file is always |
| - // injected into the feed you're viewing — never a hidden, stacked instance's |
| - // webview (which is how drops leaked to a different chat). That routing in |
| - // handleDroppedFiles is the sole guarantee: every webview stays registered for |
| - // file drags, because WKWebView wipes the view's registered drag types on each |
| - // navigation. Keeping only the selected one registered left dead states where |
| - // no view accepted the drop at all — it silently did nothing until the next |
| - // chat switch re-registered the visible webview. |
| - if isActive { app.activeWebView = web } |
| - web.registerForDraggedTypes([.fileURL]) |
| - web.evaluateJavaScript(Self.textSizeJS(chatTextSize), completionHandler: nil) |
| - } |
| - |
| - // Runs on every page load. (1) Window drag: start a native window-drag when the |
| - // user presses in the header on a non-interactive element and stream the pointer |
| - // delta to native. (2) __lchatAddFiles: rebuild dropped files and hand them to |
| - // chat.js's file input so they go through the normal attachment path. |
| - private static let injectedScript = """ |
| - (function () { |
| - if (window.__lchatInstalled) return; |
| - window.__lchatInstalled = true; |
| - |
| - // Kill the macOS webview rubber-band: a two-finger swipe past the edge would |
| - // otherwise bounce/scroll the whole viewport (there's no native API for it). |
| - var _oss = document.createElement('style'); |
| - _oss.textContent = 'html,body{overscroll-behavior:none!important;}'; |
| - (document.head || document.documentElement).appendChild(_oss); |
| - |
| - var INTERACTIVE = 'button, a, input, textarea, select, label, [role="button"], [contenteditable="true"], svg'; |
| - function inHeader(el) { return el && el.closest && el.closest('.thread-head, .side-head'); } |
| - function interactive(el) { return el && el.closest && el.closest(INTERACTIVE); } |
| - function post(m) { try { window.webkit.messageHandlers.lchatDrag.postMessage(m); } catch (e) {} } |
| - document.addEventListener('mousedown', function (e) { |
| - if (e.button !== 0 || !inHeader(e.target) || interactive(e.target)) return; |
| - e.preventDefault(); |
| - function move(ev) { post({ dx: ev.movementX, dy: ev.movementY }); } |
| - function up() { |
| - document.removeEventListener('mousemove', move, true); |
| - document.removeEventListener('mouseup', up, true); |
| - } |
| - document.addEventListener('mousemove', move, true); |
| - document.addEventListener('mouseup', up, true); |
| - }, true); |
| - |
| - window.__lchatAddFiles = function (items) { |
| - var input = document.getElementById('file-input'); |
| - if (!input || !items || !items.length) return; |
| - var dt = new DataTransfer(); |
| - for (var i = 0; i < items.length; i++) { |
| - var it = items[i], bin = atob(it.b64), bytes = new Uint8Array(bin.length); |
| - for (var j = 0; j < bin.length; j++) bytes[j] = bin.charCodeAt(j); |
| - dt.items.add(new File([bytes], it.name, { type: it.type })); |
| - } |
| - input.files = dt.files; |
| - input.dispatchEvent(new Event('change', { bubbles: true })); |
| - }; |
| - |
| - // Report the web feed's appearance CHOICE to native so the app chrome (rail, |
| - // palette, title bar) matches. chat.js sets color-scheme on <html> to 'light', |
| - // 'dark', or 'light dark' (follow OS). Crucially we report the mode, never a |
| - // resolved boolean: inside a WKWebView, prefers-color-scheme reflects |
| - // NSApp.appearance — so resolving 'system' here reads back whatever native |
| - // last pinned, and a forced dark could never release to the real OS setting. |
| - function effectiveMode() { |
| - var cs = document.documentElement.style.colorScheme || ''; |
| - if (cs.indexOf('dark') !== -1 && cs.indexOf('light') === -1) return 'dark'; |
| - if (cs.indexOf('light') !== -1 && cs.indexOf('dark') === -1) return 'light'; |
| - return 'system'; |
| - } |
| - function reportTheme() { |
| - try { window.webkit.messageHandlers.lchatTheme.postMessage({ mode: effectiveMode() }); } catch (e) {} |
| - } |
| - new MutationObserver(reportTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'data-theme'] }); |
| - reportTheme(); |
| - })(); |
| - """ |
| - |
| - final class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, WKScriptMessageHandler, WKDownloadDelegate { |
| - private let chat: SavedChat |
| - private let app: MacAppState |
| - private var didAttemptJoin = false |
| - weak var webView: WKWebView? |
| - // Where each in-flight download is being written, so we can reveal it in |
| - // Finder on finish (WKDownload doesn't hand the final URL back to didFinish). |
| - private var downloadDestinations: [WKDownload: URL] = [:] |
| - |
| - init(chat: SavedChat, app: MacAppState) { |
| - self.chat = chat |
| - self.app = app |
| - super.init() |
| - NotificationCenter.default.addObserver( |
| - self, selector: #selector(appDidBecomeActive), |
| - name: NSApplication.didBecomeActiveNotification, object: nil) |
| - } |
| - |
| - deinit { NotificationCenter.default.removeObserver(self) } |
| - |
| - // When Eleven regains focus, drop the caret into the selected instance's |
| - // message box so you can start typing immediately. Only the active instance |
| - // reacts, and only when a composer is present. |
| - @objc private func appDidBecomeActive() { |
| - guard app.selection == chat.id else { return } |
| - focusComposer() |
| - } |
| - |
| - // Put the caret in this instance's message box so you can type right away — |
| - // used on app re-focus and after adding a file. Routes through shell.js's |
| - // window.lchatFocusComposer, which targets the live (id-less) chat.js |
| - // composer and no-ops on touch / when another field is focused. No-op when |
| - // no composer is present. |
| - func focusComposer() { |
| - guard let web = app.activeWebView ?? webView else { return } |
| - DispatchQueue.main.async { [app] in |
| - // Never steal the keyboard from the ⌘K palette: re-activating the |
| - // app (or finishing a file pick) while it's open must leave the |
| - // palette driving — making the webview first responder here is |
| - // exactly what knocked its field out. |
| - guard !app.showPalette else { return } |
| - web.window?.makeFirstResponder(web) |
| - web.evaluateJavaScript( |
| - "window.lchatFocusComposer && window.lchatFocusComposer()", |
| - completionHandler: nil) |
| - } |
| - } |
| - |
| - // Read dropped files (non-sandboxed, so plain reads work) and hand them to |
| - // chat.js's file input. Base64 crosses the JS bridge safely. |
| - func handleDroppedFiles(_ urls: [URL]) { |
| - // Route to the SELECTED instance's webview: whichever hidden webview's |
| - // NSView caught the Finder drop, the file must land in the feed you're |
| - // viewing — not a different chat. |
| - guard let web = app.activeWebView ?? webView else { return } |
| - var items: [[String: String]] = [] |
| - for url in urls { |
| - guard let data = try? Data(contentsOf: url) else { continue } |
| - let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream" |
| - items.append(["b64": data.base64EncodedString(), "name": url.lastPathComponent, "type": type]) |
| - } |
| - guard !items.isEmpty, |
| - let json = try? JSONSerialization.data(withJSONObject: items), |
| - let arg = String(data: json, encoding: .utf8) else { return } |
| - web.evaluateJavaScript("window.__lchatAddFiles && window.__lchatAddFiles(\(arg))", completionHandler: nil) |
| - focusComposer() // after a drop, focus the message box so you can add a caption |
| - } |
| - |
| - // Show/hide the page's .drop-overlay hover state during a Finder drag. The |
| - // native layer intercepts the drag (a WKWebView won't surface it to the |
| - // page), so the page's own dragenter/leave never fire — drive it from here. |
| - // Routed to the selected instance's webview, like handleDroppedFiles, so the |
| - // hover state shows on the chat you're viewing. |
| - func setDragOverlay(_ active: Bool) { |
| - guard let web = app.activeWebView ?? webView else { return } |
| - web.evaluateJavaScript("window.__lchatDropOverlay && window.__lchatDropOverlay(\(active))", completionHandler: nil) |
| - } |
| - |
| - func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| - switch message.name { |
| - case "lchatDrag": moveWindow(message) |
| - case "lchatNotify": showNotification(message) |
| - case "lchatMenu": showNativeMenu(message) |
| - case "lchatTheme": |
| - if let body = message.body as? [String: Any], let mode = body["mode"] as? String { |
| - app.setAppearance(mode: mode, for: chat.id) |
| - } |
| - default: break |
| - } |
| - } |
| - |
| - // Move the window by the pointer delta the injected header-drag reports. |
| - private func moveWindow(_ message: WKScriptMessage) { |
| - guard let body = message.body as? [String: Any], let window = webView?.window else { return } |
| - let dx = (body["dx"] as? Double) ?? 0 |
| - let dy = (body["dy"] as? Double) ?? 0 |
| - guard dx != 0 || dy != 0 else { return } |
| - var origin = window.frame.origin |
| - origin.x += dx |
| - origin.y -= dy // web y grows downward; AppKit's grows upward |
| - window.setFrameOrigin(origin) |
| - } |
| - |
| - // Post a local (non-APNs) notification for a message chat.js decrypted. The |
| - // web layer already suppressed your own messages + the chat you're reading; |
| - // we just display it, tagged with this instance so a tap can jump back. |
| - private func showNotification(_ message: WKScriptMessage) { |
| - guard let body = message.body as? [String: Any] else { return } |
| - let content = UNMutableNotificationContent() |
| - content.title = (body["title"] as? String) ?? "New message" |
| - content.body = (body["body"] as? String) ?? "" |
| - content.sound = .default |
| - content.userInfo = ["instance": chat.id, "thread": (body["thread"] as? String) ?? ""] |
| - UNUserNotificationCenter.current().add( |
| - UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) |
| - ) |
| - } |
| - |
| - // Render a web kebab dropdown as a real NSMenu. chat.js posts the visible |
| - // items (mirroring its own DOM menu) plus the button's rect; we pop up a |
| - // native menu and, on selection, call back so the web layer clicks the |
| - // real item — one source of truth for what each option does. A `.danger` |
| - // item (Leave / Delete) gets a leading separator, the native idiom for a |
| - // destructive action set off from the rest. |
| - private func showNativeMenu(_ message: WKScriptMessage) { |
| - guard let web = message.webView ?? webView, |
| - let body = message.body as? [String: Any], |
| - let token = body["token"] as? Int, |
| - let rawItems = body["items"] as? [[String: Any]] else { return } |
| - let menu = NSMenu() |
| - menu.autoenablesItems = false |
| - for raw in rawItems { |
| - guard let id = raw["id"] as? String, let label = raw["label"] as? String else { continue } |
| - if (raw["danger"] as? Bool) == true, let last = menu.items.last, !last.isSeparatorItem { |
| - menu.addItem(.separator()) |
| - } |
| - let item = NSMenuItem(title: label, action: #selector(nativeMenuPicked(_:)), keyEquivalent: "") |
| - item.target = self |
| - item.representedObject = ["token": token, "id": id, "web": web] |
| - menu.addItem(item) |
| - } |
| - guard !menu.items.isEmpty else { return } |
| - // Drop the menu from the button's bottom-left. The web rect is |
| - // top-left origin / y-down; convert into the webview's coordinate |
| - // space (which may or may not be flipped). |
| - let anchor = body["anchor"] as? [String: Any] |
| - let x = (anchor?["x"] as? Double) ?? 0 |
| - let bottom = ((anchor?["y"] as? Double) ?? 0) + ((anchor?["h"] as? Double) ?? 0) |
| - let point = NSPoint(x: x, y: web.isFlipped ? bottom : web.bounds.height - bottom) |
| - // popUp blocks until the menu closes; the item action (if any) has |
| - // fired by then. The trailing null-pick clears the pending token on |
| - // dismissal, and is a no-op if an item was already picked. |
| - menu.popUp(positioning: nil, at: point, in: web) |
| - web.evaluateJavaScript("window.__lchatMenuPick && window.__lchatMenuPick(\(token), null)", completionHandler: nil) |
| - } |
| - |
| - @objc private func nativeMenuPicked(_ sender: NSMenuItem) { |
| - guard let info = sender.representedObject as? [String: Any], |
| - let token = info["token"] as? Int, |
| - let id = info["id"] as? String, |
| - let web = info["web"] as? WKWebView else { return } |
| - let safeID = id.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "'", with: "\\'") |
| - web.evaluateJavaScript("window.__lchatMenuPick && window.__lchatMenuPick(\(token), '\(safeID)')", completionHandler: nil) |
| - } |
| - |
| - // After the origin loads: a stored session means we're signed in — extract |
| - // the identity natively (below). No session → claim the invite once via the |
| - // join link, guarded so we never loop or re-claim. |
| - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { |
| - // WKWebView clobbers the view's registered drag types during navigation, |
| - // so re-assert file-drop registration after every page load. |
| - webView.registerForDraggedTypes([.fileURL]) |
| - // A navigation wipes inline styles — re-apply the text-size pref. |
| - webView.evaluateJavaScript(ChatFeedWebView.textSizeJS(ChatTextSize.current), |
| - completionHandler: nil) |
| - // Only the instance's OWN origin counts — for the join fallback AND the |
| - // identity extraction. The page can navigate to another origin (a link, |
| - // or the passkey-backup flow visiting the home server): "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), and a foreign session must never be stored as this chat's. |
| - 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 else { return } |
| - if let raw = result as? String, !raw.isEmpty { |
| - // Runs on every own-origin load — noteIdentity ignores an |
| - // unchanged session, and a fresh join's session (written just |
| - // before join.js navigates home) is picked up right here. |
| - self.extractIdentity(raw) |
| - } else if !self.didAttemptJoin, let joinURL = URL(string: self.chat.url) { |
| - self.didAttemptJoin = true |
| - webView.load(URLRequest(url: joinURL)) |
| - } |
| - } |
| - } |
| - |
| - // The webview's session IS the identity: join.js stores {token, name, |
| - // userPub, userPriv} — exactly a SavedInstance minus the origin. Park it |
| - // in the native store (Keychain) so the native layer holds the same keys |
| - // the page uses; nothing is re-keyed and the key still never leaves the |
| - // device. This is the whole Mac "migration": every already-joined chat |
| - // passes through here the first time its feed loads. |
| - private func extractIdentity(_ sessionJSON: String) { |
| - struct WebSession: Decodable { |
| - let token: String |
| - let name: String |
| - let userPub: String |
| - let userPriv: String |
| - } |
| - guard let s = try? JSONDecoder().decode(WebSession.self, from: Data(sessionJSON.utf8)), |
| - let origin = chat.origin?.absoluteString else { return } |
| - // evaluateJavaScript completions arrive on the main thread; app state |
| - // is main-thread-owned, so this is a plain call. |
| - app.noteIdentity(origin: origin, token: s.token, name: s.name, |
| - userPub: s.userPub, userPriv: s.userPriv) |
| - } |
| - |
| - // macOS WKWebView needs this hook or the composer's "+" file picker does |
| - // nothing (unlike iOS). |
| - func webView(_ webView: WKWebView, |
| - runOpenPanelWith parameters: WKOpenPanelParameters, |
| - initiatedByFrame frame: WKFrameInfo, |
| - completionHandler: @escaping ([URL]?) -> Void) { |
| - let panel = NSOpenPanel() |
| - panel.canChooseFiles = true |
| - panel.canChooseDirectories = parameters.allowsDirectories |
| - panel.allowsMultipleSelection = parameters.allowsMultipleSelection |
| - panel.begin { [weak self] response in |
| - completionHandler(response == .OK ? panel.urls : nil) |
| - if response == .OK { self?.focusComposer() } |
| - } |
| - } |
| - |
| - // WKUIDelegate shows nothing for window.confirm()/alert() unless we implement |
| - // these — chat.js's "Cancel invite", "Remove from group", "Leave group", "Sign |
| - // out", and delete-message confirms all silently no-op without them. Use a |
| - // sheet, not NSAlert.runModal(): confirm()/alert() block the page's JS thread |
| - // until completionHandler fires, and a nested runModal loop risks the same |
| - // webview freeze the notifyError() comment in chat.js warns about. |
| - func webView(_ webView: WKWebView, |
| - runJavaScriptConfirmPanelWithMessage message: String, |
| - initiatedByFrame frame: WKFrameInfo, |
| - completionHandler: @escaping (Bool) -> Void) { |
| - let alert = NSAlert() |
| - alert.messageText = message |
| - alert.addButton(withTitle: "OK") |
| - alert.addButton(withTitle: "Cancel") |
| - if let window = webView.window { |
| - alert.beginSheetModal(for: window) { completionHandler($0 == .alertFirstButtonReturn) } |
| - } else { |
| - completionHandler(alert.runModal() == .alertFirstButtonReturn) |
| - } |
| - } |
| - |
| - // Same gap as runJavaScriptConfirmPanelWithMessage above, for window.alert() — |
| - // on Mac chat.js's `embed` is always nil, so notifyError() and upload-error |
| - // messages call plain alert() rather than posting to the native shell. |
| - func webView(_ webView: WKWebView, |
| - runJavaScriptAlertPanelWithMessage message: String, |
| - initiatedByFrame frame: WKFrameInfo, |
| - completionHandler: @escaping () -> Void) { |
| - let alert = NSAlert() |
| - alert.messageText = message |
| - alert.addButton(withTitle: "OK") |
| - if let window = webView.window { |
| - alert.beginSheetModal(for: window) { _ in completionHandler() } |
| - } else { |
| - alert.runModal() |
| - completionHandler() |
| - } |
| - } |
| - |
| - // Message links and link-preview cards render as target="_blank" anchors |
| - // (chat.js), which WebKit routes here as a new-window request rather than a |
| - // normal navigation. With no WKUIDelegate handling it, the click did nothing. |
| - // Hand it to the OS default browser instead of ever creating a child webview. |
| - func webView(_ webView: WKWebView, |
| - createWebViewWith configuration: WKWebViewConfiguration, |
| - for navigationAction: WKNavigationAction, |
| - windowFeatures: WKWindowFeatures) -> WKWebView? { |
| - if let url = navigationAction.request.url { |
| - NSWorkspace.shared.open(url) |
| - } |
| - return nil |
| - } |
| - |
| - // A file chip (chat.js) and the lightbox's "Save Image" both render as |
| - // download-attributed anchors pointing at an in-page blob: URL. WKWebView |
| - // routes those here with shouldPerformDownload set; without turning them into |
| - // a real WKDownload the click silently does nothing (there's no download UI at |
| - // all on macOS). Everything else — the join redirect, in-app navigation — is a |
| - // normal load and passes through. |
| - func webView(_ webView: WKWebView, |
| - decidePolicyFor navigationAction: WKNavigationAction, |
| - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { |
| - decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow) |
| - } |
| - |
| - func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { |
| - download.delegate = self |
| - } |
| - |
| - func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { |
| - download.delegate = self |
| - } |
| - |
| - // Save straight to ~/Downloads (Safari-like), matching what a click does in the |
| - // web app — no per-file save panel. WebKit fails the download if the URL already |
| - // exists, so a colliding name gets " 2", " 3", … appended (as Finder would). |
| - func download(_ download: WKDownload, |
| - decideDestinationUsing response: URLResponse, |
| - suggestedFilename: String, |
| - completionHandler: @escaping (URL?) -> Void) { |
| - let dir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first |
| - ?? FileManager.default.temporaryDirectory |
| - let dest = Self.uniqueDestination(in: dir, filename: suggestedFilename) |
| - downloadDestinations[download] = dest |
| - completionHandler(dest) |
| - } |
| - |
| - // No download shelf in the app, so reveal the saved file in Finder — otherwise a |
| - // silent write leaves no sign the download worked. |
| - func downloadDidFinish(_ download: WKDownload) { |
| - guard let url = downloadDestinations.removeValue(forKey: download) else { return } |
| - NSWorkspace.shared.activateFileViewerSelecting([url]) |
| - } |
| - |
| - func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { |
| - downloadDestinations.removeValue(forKey: download) |
| - let alert = NSAlert() |
| - alert.messageText = "Download failed" |
| - alert.informativeText = error.localizedDescription |
| - alert.addButton(withTitle: "OK") |
| - if let window = webView?.window { |
| - alert.beginSheetModal(for: window, completionHandler: nil) |
| - } else { |
| - alert.runModal() |
| - } |
| - } |
| - |
| - // A destination that doesn't yet exist (WebKit refuses to overwrite): "name.ext", |
| - // then "name 2.ext", "name 3.ext", … — the Finder convention. |
| - private static func uniqueDestination(in dir: URL, filename: String) -> URL { |
| - let fm = FileManager.default |
| - let name = filename.isEmpty ? "download" : filename |
| - var candidate = dir.appendingPathComponent(name) |
| - guard fm.fileExists(atPath: candidate.path) else { return candidate } |
| - let ext = (name as NSString).pathExtension |
| - let base = (name as NSString).deletingPathExtension |
| - var n = 2 |
| - repeat { |
| - let next = ext.isEmpty ? "\(base) \(n)" : "\(base) \(n).\(ext)" |
| - candidate = dir.appendingPathComponent(next) |
| - n += 1 |
| - } while fm.fileExists(atPath: candidate.path) |
| - return candidate |
| - } |
| - } |
| -} |
| - |
| -// A WKWebView that accepts Finder file-drops itself (macOS WKWebView otherwise |
| -// won't surface them to the page). Non-file drags aren't registered, so the |
| -// webview's own behaviour is unaffected for those. |
| -final class DropWebView: WKWebView { |
| - var onDropFiles: (([URL]) -> Void)? |
| - // Toggled as a file drag enters/leaves so the page can show its .drop-overlay |
| - // hover state — WebKit swallows the drag natively, so the web dragenter/leave |
| - // never fire in the app (see chat.js window.__lchatDropOverlay). |
| - var onDragActive: ((Bool) -> Void)? |
| - private let fileOpts: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true] |
| - |
| - override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { |
| - let op = fileDrag(sender) |
| - if op == .copy { onDragActive?(true) } // show the drop overlay while a file hovers |
| - return op |
| - } |
| - override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { fileDrag(sender) } |
| - |
| - // Hide the overlay whenever the drag leaves or ends without a successful drop |
| - // (dragged back out, or cancelled) — performDragOperation covers the drop case. |
| - override func draggingExited(_ sender: NSDraggingInfo?) { onDragActive?(false) } |
| - override func draggingEnded(_ sender: NSDraggingInfo) { onDragActive?(false) } |
| - |
| - override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { |
| - onDragActive?(false) // drop landed — clear the hover overlay |
| - guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: fileOpts) as? [URL], |
| - !urls.isEmpty else { return false } |
| - onDropFiles?(urls) |
| - return true |
| - } |
| - |
| - // A chat window, not a browser: drop WebKit's navigation context-menu items |
| - // (Reload / Back / Forward / Open Frame) so a right-click doesn't offer them. |
| - // Text/link items (Copy, Look Up, spellcheck) are left intact. |
| - override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { |
| - let navItems = ["Reload", "GoBack", "GoForward", "OpenFrameInNewWindow"] |
| - menu.items.removeAll { item in |
| - guard let id = item.identifier?.rawValue else { return false } |
| - return navItems.contains { id.contains($0) } |
| - } |
| - } |
| - |
| - private func fileDrag(_ sender: NSDraggingInfo) -> NSDragOperation { |
| - sender.draggingPasteboard.canReadObject(forClasses: [NSURL.self], options: fileOpts) ? .copy : NSDragOperation() |
| - } |
| -} |
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift| index c4916e8..bd3d7dd 100644 |
| --- a/ios/LChatMac/LChatMacApp.swift |
| +++ b/ios/LChatMac/LChatMacApp.swift |
| @@ -16,10 +16,10 @@ extension Logger { |
| // and menu bar are real AppKit-backed SwiftUI, so scrolling, the title bar and the |
| // menu bar feel like a Mac app instead of an iPad app in a window. |
| // |
| -// The message feed + composer are still the existing web client (chat.js) loaded |
| -// in a WKWebView (see ChatFeedWebView): reused as-is, so all rendering and — the |
| -// load-bearing part — all encryption stay in the one audited web layer. This is |
| -// the "native chrome around the web feed" spike, minus Catalyst. |
| +// Fully native: the feed, composer and crypto all run in Swift (LChatCore). |
| +// The only WKWebViews left are SCOPED — mini-app panels (AppThreadWebView) and |
| +// nothing else; the full-frame web fallback (and its paste-a-link join) is |
| +// gone. Entry is by signing in with Eleven; identities ride the vault. |
| @main |
| struct LChatMacApp: App { |
| @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate |
| @@ -49,23 +49,13 @@ struct LChatMacApp: App { |
| .environmentObject(app) |
| .frame(minWidth: 820, minHeight: 560) |
| .onAppear { appDelegate.appState = app } |
| - // Invite links from outside: the welcome page's |
| - // elevenmessenger://add?url=<link> (universal links can't |
| - // validate against an ad-hoc-signed app, but a pasted/clicked |
| - // https invite arriving here still works). add(url:) de-dupes |
| - // and selects, so re-opening an existing chat just jumps to it. |
| .onOpenURL { url in |
| - // The browser sign-in handoff returns here |
| - // (elevenmessenger://home-session#…) — see MacHomeSignIn. |
| + // The browser sign-in handoff (elevenmessenger:// |
| + // home-session#…) — the ONLY link the Mac app accepts now: |
| + // joining happens on the web, entry here is by signing in. |
| if MacHomeSignIn.handleCallback(url, into: app) { |
| NSApp.activate(ignoringOtherApps: true) |
| - return |
| } |
| - guard let raw = inviteLink(fromOpenedURL: url), |
| - isValidInvite(raw) else { return } |
| - app.add(url: raw.trimmingCharacters(in: .whitespacesAndNewlines)) |
| - app.showAdd = false // an open add sheet is now moot |
| - NSApp.activate(ignoringOtherApps: true) |
| } |
| } |
| // Hide the title bar so the feed runs to the top; the web header is made |
| @@ -75,8 +65,6 @@ struct LChatMacApp: App { |
| .defaultSize(width: 1160, height: 820) |
| .commands { |
| CommandGroup(after: .newItem) { |
| - Button("Add Chat…") { app.showAdd = true } |
| - .keyboardShortcut("n", modifiers: [.command, .shift]) |
| // A MENU shortcut, deliberately: a view-level hidden button |
| // lost ⌘F whenever a greedy first responder (a mini-app's |
| // WKWebView, a text view) swallowed the key equivalent — menu |
| @@ -147,6 +135,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent |
| let updaterController = SPUStandardUpdaterController( |
| startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) |
| + // Every activation pulls the vault — switching back from the browser or |
| + // another Mac is exactly when a home-page change is freshest. |
| + func applicationDidBecomeActive(_ notification: Notification) { |
| + appState?.pullHome() |
| + } |
| + |
| func applicationDidFinishLaunching(_ notification: Notification) { |
| // "cmd and plus": the View menu's ⌘+ equivalent strictly needs the |
| // shifted key (⌘⇧=), but people press ⌘= and expect bigger text — |
| @@ -188,7 +182,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent |
| // Mirrors the iOS AppState, trimmed to what the Mac shell needs. |
| final class MacAppState: ObservableObject { |
| @Published var chats: [SavedChat] |
| - @Published var showAdd = false |
| @Published var showPalette = false |
| // Fetched group names (chat id -> name), shared by the rail and the ⌘K palette. |
| @Published var names: [String: String] = [:] |
| @@ -199,17 +192,9 @@ final class MacAppState: ObservableObject { |
| // Chat ids, most-recently-selected first — drives the ⌘K palette order. The rail |
| // keeps its own manual (drag-reorderable) order in `chats`. |
| @Published var recent: [String] = [] |
| - // The selected instance's webview (set by ChatFeedWebView.updateNSView). NOT |
| - // @Published — it's a routing handle, not UI state. A Finder drop can be caught |
| - // by any instance's (hidden, stacked) webview, so we always inject the file into |
| - // THIS one — the feed you're actually viewing — never a different chat. |
| - weak var activeWebView: WKWebView? |
| - // Native identities, keyed by origin — the same {token, name, keypair} the |
| - // webview keeps in its localStorage session, extracted as each feed loads |
| - // (ChatFeedWebView.didFinish) and parked in the Keychain (InstancesStore, |
| - // file fallback for unsigned dev builds). Nothing reads these yet: they're |
| - // the prerequisite for the native feed (docs/native-chat.md commit 4 — |
| - // native WebSocket + decrypt needs the private key natively). |
| + // Native identities, keyed by origin ({token, name, keypair}), parked in |
| + // the Keychain (InstancesStore) — arriving via the vault (sign-in + |
| + // foreground pulls); everything native reads from here. |
| @Published private(set) var instances: [SavedInstance] |
| // The photo / video the lightbox is showing (nil = closed). App-level state |
| // because the overlay must cover the whole window, not the pane that |
| @@ -387,6 +372,15 @@ final class MacAppState: ObservableObject { |
| /// until then Mac stars live on this device only (iOS-first, deliberately). |
| func syncHome() { HomeSync.pushSoon(instances: instancesInRailOrder()) } |
| + /// The silent vault pull (app activation): stars/archive/order made on the |
| + /// home page or another device land here without a re-sign-in. applyVault |
| + /// is the same vault-wins merge sign-in uses. |
| + func pullHome() { |
| + Task { @MainActor in |
| + if let contents = await HomeSync.pull() { _ = applyVault(contents.servers) } |
| + } |
| + } |
| + |
| /// Sign-in merge — the vault wins: upsert each vault server's identity + |
| /// star, ensure a rail entry (SavedChat) exists, and apply the vault's |
| /// order to the rail (Mac-only chats tail). Returns how many servers were |
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift| index ca678a9..2d73171 100644 |
| --- a/ios/LChatMac/MacHomeSignIn.swift |
| +++ b/ios/LChatMac/MacHomeSignIn.swift |
| @@ -335,10 +335,6 @@ struct MacFirstRun: View { |
| .padding(.top, 2) |
| .disabled(model.phase.busy) |
| - Button("Add with an invite link") { app.showAdd = true } |
| - .buttonStyle(.link) |
| - .padding(.top, 2) |
| - .disabled(model.phase.busy) |
| } |
| .padding(.bottom, 48) |
| } |
| @@ -352,7 +348,7 @@ struct MacFirstRun: View { |
| Label(msg, systemImage: "exclamationmark.triangle") |
| .font(.footnote).foregroundStyle(.secondary) |
| case .emptyBackup: |
| - Text("Signed in — no spaces saved yet. Add one with an invite link.") |
| + Text("Signed in — nothing saved on this account yet. Join a space in your browser first.") |
| .font(.callout).foregroundStyle(.secondary) |
| case .restored(let n): |
| // Visible only in the beat before the rail fills and RootView |
diff --git a/ios/LChatMac/MacInstance.swift b/ios/LChatMac/MacInstance.swift| index 4bb1a05..e6d5c4f 100644 |
| --- a/ios/LChatMac/MacInstance.swift |
| +++ b/ios/LChatMac/MacInstance.swift |
| @@ -1,14 +1,10 @@ |
| import Foundation |
| -// The Mac app's webview-plumbing record: the invite URL the rail was seeded with |
| -// (plus legacy claimed fields). This is no longer the identity model — the Mac |
| -// now also holds the iOS-style native identity (`SavedInstance` in the Keychain |
| -// via LChatCore's InstancesStore), extracted from each webview's session as its |
| -// feed loads (ChatFeedWebView.extractIdentity; docs/native-chat.md commit 3). |
| -// SavedChat stays because the feed is still the full web page: it needs the |
| -// original invite URL for the one-shot join fallback and drives the rail order. |
| -// Retiring it in favour of SavedInstance is deliberately deferred to the native |
| -// feed work (commit 4+), when native data starts driving the UI. |
| +// The Mac app's RAIL record: which spaces show, in what order, plus legacy |
| +// fields from the webview era (the full-frame webview and its join are gone — |
| +// identity is `SavedInstance` in the Keychain, arriving via the vault). |
| +// Retiring SavedChat entirely in favour of SavedInstance is deliberate future |
| +// work; today it only drives rail order and carries legacy tokens. |
| // The persisted session for one chat. The key never leaves the device — it lives in |
| // the webview's own storage, not here; this just carries what the webview needs. |
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift| index 1f4ee80..95d3657 100644 |
| --- a/ios/LChatMac/RootView.swift |
| +++ b/ios/LChatMac/RootView.swift |
| @@ -1,16 +1,16 @@ |
| import LChatCore |
| import SwiftUI |
| -// Native layout: a fixed server rail on the left, the selected instance's web feed |
| -// on the right. A plain HStack (not NavigationSplitView) keeps the rail always |
| -// visible — no collapse / "Hide Sidebar" toggle. The title bar is hidden |
| -// (LChatMacApp); the top of the rail is draggable via the system title-bar region, |
| -// and the web header is made draggable by an injected script (see ChatFeedWebView). |
| +// Native layout: a fixed server rail on the left, the selected instance's |
| +// NATIVE chat surface on the right. A plain HStack (not NavigationSplitView) |
| +// keeps the rail always visible — no collapse / "Hide Sidebar" toggle. The |
| +// title bar is hidden (LChatMacApp); the rail's top and the chat headers act |
| +// as the drag region (NativeChatView's window-drag monitor). |
| struct RootView: View { |
| @EnvironmentObject var app: MacAppState |
| - // Chats whose webview has been created. Once opened, a webview stays mounted |
| - // (just hidden) so switching back is instant — no reload / loader. |
| + // Chats whose pane has been created. Once opened, a pane stays mounted |
| + // (just hidden) so switching back is instant — models stay parked, sockets live. |
| @State private var activated: Set<SavedChat.ID> = [] |
| // Drives the first-run "Sign in" ceremony (empty rail only). Settings owns |
| @@ -38,7 +38,6 @@ struct RootView: View { |
| .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
| .allowsHitTesting(false) |
| } |
| - .sheet(isPresented: $app.showAdd) { AddChatView() } |
| // ⌘K quick-switch palette, drawn over the whole window (above the web feed). |
| .overlay { if app.showPalette { CommandPalette() } } |
| // The photo / video lightboxes, zooming out of their bubble over black. |
| @@ -64,6 +63,18 @@ struct RootView: View { |
| return app.instances.first { $0.origin == origin } |
| } |
| + // A rail entry with no native identity: signing in again pulls the key |
| + // from the member's Eleven account; there is deliberately no web fallback. |
| + private func lockedPane(_ chat: SavedChat) -> some View { |
| + ContentUnavailableView { |
| + Label(app.serverName(chat), systemImage: "lock") |
| + } description: { |
| + Text("This space can't be unlocked on this Mac. Sign in with Eleven to bring its key back, or remove it from the rail.") |
| + } actions: { |
| + Button("Remove from This Mac", role: .destructive) { app.remove(chat) } |
| + } |
| + } |
| + |
| // Every opened instance stays alive in the ZStack; only the selected one is |
| // visible and interactive. A newly-selected instance is added to `activated`, |
| // so its webview is created once and then cached (its WebSocket stays live in |
| @@ -73,15 +84,16 @@ struct RootView: View { |
| ForEach(app.chats) { chat in |
| if activated.contains(chat.id) { |
| Group { |
| - // The takeover: native chat is the main surface once an |
| - // instance holds its native identity; the webview runs |
| - // it otherwise (which is also how a fresh join gets its |
| - // identity extracted in the first place). |
| + // Native chat is the ONLY surface — the full-frame |
| + // webview (and its paste-a-link join) is gone: the app |
| + // is entered by signing in with Eleven, identities ride |
| + // the vault. A chat without one is an out-of-sync |
| + // remnant and says so, natively. |
| if let inst = nativeInstance(for: chat) { |
| NativeChatView(instance: inst, isActive: chat.id == app.selection) |
| .id(inst.id) |
| } else { |
| - ChatFeedWebView(chat: chat, app: app, isActive: chat.id == app.selection) |
| + lockedPane(chat) |
| } |
| } |
| .opacity(chat.id == app.selection ? 1 : 0) |
| @@ -94,9 +106,9 @@ struct RootView: View { |
| MacFirstRun(model: firstRunSignIn) |
| } else if app.selected == nil { |
| ContentUnavailableView( |
| - "No chat selected", |
| + "No space selected", |
| systemImage: "bubble.left.and.bubble.right", |
| - description: Text("Add a chat with the + button.") |
| + description: Text("Pick a space from the rail.") |
| ) |
| } |
| } |
| @@ -167,29 +179,18 @@ private struct PaletteChat: Decodable, Identifiable { |
| let active: Bool |
| } |
| -private struct PaletteAction: Decodable, Identifiable { |
| - let id: String |
| - let label: String |
| - var danger: Bool? |
| -} |
| - |
| private struct PaletteWebItems: Decodable { |
| var chats: [PaletteChat] |
| - var actions: [PaletteAction] |
| } |
| // One flat, keyboard-navigable list mixing all four row kinds. |
| private enum PaletteRow: Identifiable { |
| case chat(PaletteChat) |
| case server(SavedChat) |
| - case action(PaletteAction) |
| - case connect |
| var id: String { |
| switch self { |
| case .chat(let c): return "chat:" + c.id |
| case .server(let s): return "server:" + s.id |
| - case .action(let a): return "action:" + a.id |
| - case .connect: return "connect" |
| } |
| } |
| } |
| @@ -205,7 +206,7 @@ struct CommandPalette: View { |
| @EnvironmentObject var app: MacAppState |
| @State private var query = "" |
| @State private var index = 0 |
| - @State private var web = PaletteWebItems(chats: [], actions: []) |
| + @State private var web = PaletteWebItems(chats: []) |
| @FocusState private var focused: Bool |
| // Recency rank for a server (0 = most recently opened); unopened sort last. |
| @@ -244,9 +245,9 @@ struct CommandPalette: View { |
| .map(\.chat) |
| } |
| - // The full list, in section order: chats on this server (the web layer already |
| - // sorted them pinned-then-recent, capped when idle so servers and actions stay |
| - // in reach), other servers, actions, and "Connect to a new space…" pinned last. |
| + // The full list, in section order: chats on this server (sorted |
| + // pinned-then-recent by the publisher, capped when idle so servers stay in |
| + // reach), then other servers. |
| private var rows: [PaletteRow] { |
| let q = query.filter { !$0.isWhitespace } |
| var out: [PaletteRow] = [] |
| @@ -259,15 +260,6 @@ struct CommandPalette: View { |
| .map { .chat($0.chat) } |
| } |
| out += serverMatches(q).map { .server($0) } |
| - if q.isEmpty { |
| - out += web.actions.map { .action($0) } |
| - } else { |
| - out += web.actions |
| - .compactMap { a in fuzzyScore(q, a.label).map { (action: a, score: $0) } } |
| - .sorted { $0.score > $1.score } |
| - .map { .action($0.action) } |
| - } |
| - out.append(.connect) |
| return out |
| } |
| @@ -378,35 +370,13 @@ struct CommandPalette: View { |
| // Ask the selected instance's surface what it can offer. The native |
| // takeover publishes its threads on MacAppState (no webview to ask); the |
| // webview path asks lchatPaletteItems. Fails soft either way: the palette |
| - // still switches servers and connects new ones — just no chat rows. |
| + // still switches servers — just no chat rows. |
| private func loadWebItems() { |
| - if !app.nativeChats.isEmpty { |
| - web = PaletteWebItems(chats: app.nativeChats.map { |
| - PaletteChat(id: $0.id, name: $0.title, glyph: $0.glyph, active: false) |
| - }, actions: []) |
| - return |
| - } |
| - web = PaletteWebItems(chats: [], actions: []) // never show a stale instance's rows |
| - guard let webView = app.activeWebView else { return } |
| - webView.evaluateJavaScript( |
| - "JSON.stringify(window.lchatPaletteItems ? window.lchatPaletteItems() : null)" |
| - ) { result, _ in |
| - guard let json = result as? String, |
| - let data = json.data(using: .utf8), |
| - let items = try? JSONDecoder().decode(PaletteWebItems.self, from: data) else { return } |
| - web = items |
| - } |
| - } |
| - |
| - // Post an activation back to the web layer, which runs the real handler. |
| - private func runWeb(_ kind: String, _ id: String) { |
| - guard let webView = app.activeWebView else { return } |
| - let safe = id |
| - .replacingOccurrences(of: "\\", with: "\\\\") |
| - .replacingOccurrences(of: "'", with: "\\'") |
| - webView.evaluateJavaScript( |
| - "window.lchatPaletteRun && window.lchatPaletteRun('\(kind)', '\(safe)')", |
| - completionHandler: nil) |
| + // The active NativeChatView publishes its threads on MacAppState — |
| + // the sole chat source now the webview bridge is gone. |
| + web = PaletteWebItems(chats: app.nativeChats.map { |
| + PaletteChat(id: $0.id, name: $0.title, glyph: $0.glyph, active: false) |
| + }) |
| } |
| private func activate() { |
| @@ -418,21 +388,11 @@ struct CommandPalette: View { |
| private func pick(_ row: PaletteRow) { |
| switch row { |
| case .chat(let c): |
| - if app.nativeChats.isEmpty { |
| - runWeb("chat", c.id) |
| - } else { |
| - app.nativeOpenThread = c.id // consumed by the active NativeChatView |
| - } |
| + app.nativeOpenThread = c.id // consumed by the active NativeChatView |
| close() |
| case .server(let s): |
| app.selection = s.id |
| close() |
| - case .action(let a): |
| - runWeb("action", a.id) |
| - close() |
| - case .connect: |
| - close() |
| - app.showAdd = true |
| } |
| } |
| @@ -445,10 +405,6 @@ struct CommandPalette: View { |
| chatRow(c, highlighted: highlighted) |
| case .server(let s): |
| serverRow(s, initial: inits[s.id] ?? "?", highlighted: highlighted) |
| - case .action(let a): |
| - actionRow(a, highlighted: highlighted) |
| - case .connect: |
| - connectRow(highlighted: highlighted) |
| } |
| } |
| @@ -500,34 +456,7 @@ struct CommandPalette: View { |
| } |
| @ViewBuilder |
| - private func actionRow(_ a: PaletteAction, highlighted: Bool) -> some View { |
| - rowShell(highlighted: highlighted, action: { pick(.action(a)) }) { |
| - statusDot(current: false) |
| - ZStack { |
| - RoundedRectangle(cornerRadius: 10, style: .continuous).fill(.secondary.opacity(0.18)) |
| - Image(systemName: actionSymbol(a.id)) |
| - .font(.system(size: 15, weight: .semibold)) |
| - .foregroundStyle(a.danger == true ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) |
| - } |
| - .frame(width: 38, height: 38) |
| - Text(a.label) |
| - .font(.system(size: 16, weight: .semibold)) |
| - .foregroundStyle(a.danger == true ? AnyShapeStyle(.red) : AnyShapeStyle(.primary)) |
| - } |
| - } |
| - @ViewBuilder |
| - private func connectRow(highlighted: Bool) -> some View { |
| - rowShell(highlighted: highlighted, action: { pick(.connect) }) { |
| - statusDot(current: false) |
| - ZStack { |
| - RoundedRectangle(cornerRadius: 10, style: .continuous).fill(.secondary.opacity(0.18)) |
| - Image(systemName: "plus").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary) |
| - } |
| - .frame(width: 38, height: 38) |
| - Text("Connect to a new space…").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary) |
| - } |
| - } |
| // Chat-name initials, mirroring MacAppState.initials(): one letter for a |
| // single-word name, first letters of the first two words otherwise. |
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift| index dd44ad8..4a66f45 100644 |
| --- a/ios/LChatMac/Sidebar.swift |
| +++ b/ios/LChatMac/Sidebar.swift |
| @@ -115,7 +115,6 @@ struct Sidebar: View { |
| } |
| } |
| Divider() |
| - Button("Add a Chat…") { app.showAdd = true } |
| Button("Manage Spaces on Eleven…") { |
| NSWorkspace.shared.open(URL(string: "https://home.elevenmessenger.com/")!) |
| } |