eleven / messenger Public

Native macOS app (Eleven) — rebuilt on main, shared-core aligned (#68)

* mac: rebuild Eleven on current main (Tier A shared-core alignment)

Re-homes the LChatMac target onto current main instead of a stale base, so it merges cleanly. Shares the platform-neutral core (LChat/Models.swift + LChat/LChatAPI.swift) with the iOS target; drops the unused Crypto/Keychain from the Mac target (the webview does all crypto). Eleven keeps its own identity model — SavedChat/Session + legacy invite parsing now live in LChatMac/MacInstance.swift, since main moved the shared model to the native-keypair SavedInstance. No structural change to the Mac app; desktop stays webview-holds-keys + local-only notifications by design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* mac: fix rail drag-to-reorder (ServerIcon was a Button, which ate the drag)

On macOS a Button swallows .onDrag, so dragging a rail icon never started. Make ServerIcon a plain tappable view (contentShape + onTapGesture) so the drag initiates; reorder + persistence were already wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 9d154f331d017f0f3ed02a132b5a3451d7ed0fb5 parent 3e8584f
18 files changed, +1276 −0
  • ios/LChatMac/AddChatView.swift +82 −0
  • ios/LChatMac/Assets.xcassets/AppIcon.appiconset/Contents.json +15 −0
  • ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_1024.png binary
  • ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_128.png binary
  • ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_16.png binary
  • ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_256.png binary
  • ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_32.png binary
  • ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_512.png binary
  • ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_64.png binary
  • ios/LChatMac/Assets.xcassets/Contents.json +3 −0
  • ios/LChatMac/ChatFeedWebView.swift +261 −0
  • ios/LChatMac/Info.plist +31 −0
  • ios/LChatMac/LChatMac.entitlements +11 −0
  • ios/LChatMac/LChatMacApp.swift +237 −0
  • ios/LChatMac/MacInstance.swift +105 −0
  • ios/LChatMac/RootView.swift +328 −0
  • ios/LChatMac/Sidebar.swift +176 −0
  • ios/project.yml +27 −0
diff --git a/ios/LChatMac/AddChatView.swift b/ios/LChatMac/AddChatView.swift
new file mode 100644
index 0000000..77a5461
--- /dev/null
+++ b/ios/LChatMac/AddChatView.swift
@@ -0,0 +1,82 @@
+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
+
+ var body: 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 — it's added as soon as it's valid.")
+ }
+
+ TextField("https://…/join/…", text: $link)
+ .textFieldStyle(.roundedBorder)
+ .focused($focused)
+ .onChange(of: link) { _, new in
+ if isValidInvite(new) {
+ app.add(url: new.trimmingCharacters(in: .whitespacesAndNewlines))
+ dismiss()
+ }
+ }
+
+ // 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)
+ }
+ }
+ .padding(20)
+ .frame(width: 460)
+ .onAppear { focused = true }
+ }
+
+ 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>.
+ // (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=`.)
+ private 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/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..52241e9
--- /dev/null
+++ b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ { "idiom" : "mac", "size" : "16x16", "scale" : "1x", "filename" : "icon_16.png" },
+ { "idiom" : "mac", "size" : "16x16", "scale" : "2x", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "size" : "32x32", "scale" : "1x", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "size" : "32x32", "scale" : "2x", "filename" : "icon_64.png" },
+ { "idiom" : "mac", "size" : "128x128", "scale" : "1x", "filename" : "icon_128.png" },
+ { "idiom" : "mac", "size" : "128x128", "scale" : "2x", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "size" : "256x256", "scale" : "1x", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "size" : "256x256", "scale" : "2x", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "size" : "512x512", "scale" : "1x", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "size" : "512x512", "scale" : "2x", "filename" : "icon_1024.png" }
+ ],
+ "info" : { "version" : 1, "author" : "xcode" }
+}
diff --git a/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_1024.png
new file mode 100644
index 0000000..f334da6
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_128.png b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_128.png
new file mode 100644
index 0000000..82473c0
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_128.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_16.png b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_16.png
new file mode 100644
index 0000000..5ebcea4
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_16.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_256.png b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_256.png
new file mode 100644
index 0000000..ecc8882
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_256.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_32.png b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_32.png
new file mode 100644
index 0000000..67bd151
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_32.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_512.png b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_512.png
new file mode 100644
index 0000000..f1386c1
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_512.png differ
diff --git a/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_64.png b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_64.png
new file mode 100644
index 0000000..5983464
Binary files /dev/null and b/ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_64.png differ
diff --git a/ios/LChatMac/Assets.xcassets/Contents.json b/ios/LChatMac/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..0ce4e31
--- /dev/null
+++ b/ios/LChatMac/Assets.xcassets/Contents.json
@@ -0,0 +1,3 @@
+{
+ "info" : { "version" : 1, "author" : "xcode" }
+}
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift
new file mode 100644
index 0000000..ed8f381
--- /dev/null
+++ b/ios/LChatMac/ChatFeedWebView.swift
@@ -0,0 +1,261 @@
+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
+
+ func makeCoordinator() -> Coordinator { Coordinator(chat: chat, app: app) }
+
+ 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.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)
+ }
+ 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) {
+ web.load(URLRequest(url: start))
+ }
+ return web
+ }
+
+ func updateNSView(_ web: WKWebView, context: Context) {}
+
+ // 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 effective light/dark 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); resolve the last via matchMedia. No web
+ // change needed — we just read what the page already sets.
+ function effectiveDark() {
+ var cs = document.documentElement.style.colorScheme || '';
+ if (cs.indexOf('dark') !== -1 && cs.indexOf('light') === -1) return true;
+ if (cs.indexOf('light') !== -1 && cs.indexOf('dark') === -1) return false;
+ return matchMedia('(prefers-color-scheme: dark)').matches;
+ }
+ function reportTheme() {
+ try { window.webkit.messageHandlers.lchatTheme.postMessage({ dark: effectiveDark() }); } catch (e) {}
+ }
+ new MutationObserver(reportTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'data-theme'] });
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', reportTheme);
+ reportTheme();
+ })();
+ """
+
+ final class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, WKScriptMessageHandler {
+ private let chat: SavedChat
+ private let app: MacAppState
+ private var didAttemptJoin = false
+ weak var webView: WKWebView?
+
+ init(chat: SavedChat, app: MacAppState) { self.chat = chat; self.app = app }
+
+ // 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]) {
+ guard let web = 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)
+ }
+
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
+ switch message.name {
+ case "lchatDrag": moveWindow(message)
+ case "lchatNotify": showNotification(message)
+ case "lchatTheme":
+ if let body = message.body as? [String: Any], let dark = body["dark"] as? Bool {
+ app.setAppearance(dark: dark, 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)
+ )
+ }
+
+ // After the origin loads, if this origin has no stored session, claim the
+ // invite once via the join link. Guarded so we never loop or re-claim.
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ guard !didAttemptJoin, let joinURL = URL(string: chat.url) else { 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
+ if !hasSession {
+ self.didAttemptJoin = true
+ webView.load(URLRequest(url: joinURL))
+ }
+ }
+ }
+
+ // 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 { response in
+ completionHandler(response == .OK ? panel.urls : nil)
+ }
+ }
+ }
+}
+
+// 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)?
+ private let fileOpts: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true]
+
+ override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { fileDrag(sender) }
+ override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { fileDrag(sender) }
+
+ override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
+ 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/Info.plist b/ios/LChatMac/Info.plist
new file mode 100644
index 0000000..20a0a16
--- /dev/null
+++ b/ios/LChatMac/Info.plist
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleDisplayName</key>
+ <string>Eleven</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>Eleven</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ <key>CFBundleShortVersionString</key>
+ <string>1.0</string>
+ <key>CFBundleVersion</key>
+ <string>1</string>
+ <key>LSApplicationCategoryType</key>
+ <string>public.app-category.social-networking</string>
+ <key>NSAppTransportSecurity</key>
+ <dict>
+ <key>NSAllowsLocalNetworking</key>
+ <true/>
+ </dict>
+</dict>
+</plist>
diff --git a/ios/LChatMac/LChatMac.entitlements b/ios/LChatMac/LChatMac.entitlements
new file mode 100644
index 0000000..d96be57
--- /dev/null
+++ b/ios/LChatMac/LChatMac.entitlements
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <!-- Scaffold build: no App Sandbox, so the traditional Keychain persists the
+ chat list under ad-hoc signing (the data-protection keychain would need a
+ team-scoped keychain-access-group entitlement we can't mint ad-hoc). A ship
+ build should re-enable the sandbox with a real signing identity, at which
+ point the data-protection keychain works and this can go back on. -->
+</dict>
+</plist>
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
new file mode 100644
index 0000000..764a09e
--- /dev/null
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -0,0 +1,237 @@
+import SwiftUI
+import AppKit
+import UserNotifications
+
+// A genuinely native macOS app — NOT Mac Catalyst. The window, sidebar, toolbar
+// 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.
+@main
+struct LChatMacApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ @StateObject private var app = MacAppState()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environmentObject(app)
+ .frame(minWidth: 820, minHeight: 560)
+ .onAppear { appDelegate.appState = app }
+ }
+ // Hide the title bar so the feed runs to the top; the web header is made
+ // draggable via an injected script (see ChatFeedWebView), and the top of the
+ // rail drags via the system title-bar region. No title, toolbar, or toggle.
+ .windowStyle(.hiddenTitleBar)
+ .defaultSize(width: 1160, height: 820)
+ .commands {
+ CommandGroup(after: .newItem) {
+ Button("Add Chat…") { app.showAdd = true }
+ .keyboardShortcut("n", modifiers: [.command, .shift])
+ }
+ // ⌘1…⌘9 jump straight to a server, like browser/Slack tabs.
+ CommandMenu("Servers") {
+ Button("Quick Switch…") { app.showPalette = true }
+ .keyboardShortcut("k", modifiers: .command)
+ Divider()
+ ForEach(Array(app.chats.prefix(9).enumerated()), id: \.element.id) { index, chat in
+ Button(app.serverName(chat)) { app.selection = chat.id }
+ .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command)
+ }
+ }
+ }
+ }
+}
+
+// Owns notification authorization + delivery. Local notifications only (no APNs /
+// no paid account): chat.js posts a decrypted preview via the `lchatNotify` bridge,
+// we display it, and a tap jumps to that instance.
+final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
+ weak var appState: MacAppState?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ let center = UNUserNotificationCenter.current()
+ center.delegate = self
+ center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
+ }
+
+ // Show the banner even when Eleven is frontmost — the web layer already dropped
+ // the one chat you're actively reading, so anything that reaches here is wanted.
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
+ completionHandler([.banner, .sound])
+ }
+
+ // Tapping a notification brings Eleven forward and selects the message's instance.
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse,
+ withCompletionHandler completionHandler: @escaping () -> Void) {
+ let id = response.notification.request.content.userInfo["instance"] as? String
+ DispatchQueue.main.async {
+ NSApp.activate(ignoringOtherApps: true)
+ if let id { self.appState?.selection = id }
+ }
+ completionHandler()
+ }
+}
+
+// The saved chats (each a separate lchat instance) and which one is selected.
+// 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] = [:]
+ // 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] = []
+
+ // Selecting a chat records it as most-recently-accessed (for the palette).
+ @Published var selection: SavedChat.ID? {
+ didSet {
+ guard let s = selection, s != oldValue else { return }
+ recent.removeAll { $0 == s }
+ recent.insert(s, at: 0)
+ MacChatsStore.saveRecent(recent)
+ applyAppearance()
+ }
+ }
+
+ init() {
+ chats = MacChatsStore.load()
+ recent = MacChatsStore.loadRecent()
+ selection = chats.first?.id // didSet doesn't fire during init
+ }
+
+ var selected: SavedChat? { chats.first { $0.id == selection } }
+
+ // The instance's display name: fetched group name, else the host from the invite
+ // URL (works for new-format links where `session` is nil).
+ func serverName(_ chat: SavedChat) -> String {
+ names[chat.id] ?? chat.origin?.host ?? "Chat"
+ }
+
+ // A stable hue per instance so its icon colour is the same across launches
+ // (String.hashValue is randomised per process, so roll a small FNV-ish hash).
+ func hue(for id: String) -> Double {
+ var h: UInt64 = 5381
+ for b in id.utf8 { h = (h &* 33) &+ UInt64(b) }
+ return Double(h % 360) / 360.0
+ }
+
+ // Icon initials for the rail + palette: one letter per instance, bumped to the
+ // first letters of the first two WORDS when instances collide on a first letter
+ // (lchat.run.garden → LR, lchat testing → LT); single-word names stay one letter.
+ func initials() -> [String: String] {
+ func words(_ chat: SavedChat) -> [Substring] {
+ serverName(chat).split { !($0.isLetter || $0.isNumber) }
+ }
+ func head(_ w: Substring?) -> String? { w?.first.map { String($0).uppercased() } }
+ var firstCount: [String: Int] = [:]
+ for chat in chats { if let h = head(words(chat).first) { firstCount[h, default: 0] += 1 } }
+ var out: [String: String] = [:]
+ for chat in chats {
+ let ws = words(chat)
+ guard let first = head(ws.first) else { out[chat.id] = "?"; continue }
+ if (firstCount[first] ?? 0) > 1, ws.count >= 2, let second = head(ws[1]) {
+ out[chat.id] = first + second
+ } else {
+ out[chat.id] = first
+ }
+ }
+ return out
+ }
+
+ // The screen name is entered in the web join flow (and editable later in the
+ // profile), so adding a chat only needs its link.
+ func add(url: String) {
+ let chat = SavedChat(url: url, name: "")
+ // De-dupe by token so re-adding an instance just re-selects it.
+ if let i = chats.firstIndex(where: { $0.token != nil && $0.token == chat.token }) {
+ selection = chats[i].id
+ } else {
+ chats.append(chat)
+ selection = chat.id
+ }
+ MacChatsStore.save(chats)
+ }
+
+ func remove(_ chat: SavedChat) {
+ chats.removeAll { $0.id == chat.id }
+ if selection == chat.id { selection = chats.first?.id }
+ MacChatsStore.save(chats)
+ }
+
+ // Live-reorder the rail during a drag: pull the dragged chat out and reinsert it
+ // at the hovered chat's slot. Call persistChats() when the drop lands.
+ func reorder(_ dragging: SavedChat, over target: SavedChat) {
+ guard dragging.id != target.id,
+ let from = chats.firstIndex(where: { $0.id == dragging.id }),
+ let to = chats.firstIndex(where: { $0.id == target.id }) else { return }
+ let item = chats.remove(at: from)
+ chats.insert(item, at: to)
+ }
+
+ func persistChats() { MacChatsStore.save(chats) }
+
+ // Native appearance follows the web feed's light/dark: each webview reports its
+ // effective mode (setAppearance), and the *selected* instance's mode drives the
+ // whole app (rail, palette, title bar) so the chrome matches what's on screen.
+ private var appearanceDark: [String: Bool] = [:]
+
+ func setAppearance(dark: Bool, for id: String) {
+ appearanceDark[id] = dark
+ if id == selection { applyAppearance() }
+ }
+
+ private func applyAppearance() {
+ guard let sel = selection, let dark = appearanceDark[sel] else { return }
+ NSApp.appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
+ }
+}
+
+// The Mac scaffold persists its server list to a JSON file in Application Support
+// rather than the Keychain. Ad-hoc code signing (no identity on this machine)
+// re-signs on every rebuild, which invalidates the Keychain item's ACL and makes
+// macOS re-prompt for access on each launch. A file avoids that. Acceptable
+// trade-off here: the app is non-sandboxed + ad-hoc, the real session keys already
+// live in the webview's own on-disk store, and the saved invite URLs are new-format
+// one-time secrets that are spent after joining. A ship build with a real signing
+// identity should switch back to the Keychain (ChatsStore).
+enum MacChatsStore {
+ private static var base: URL {
+ let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("lchat", isDirectory: true)
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir
+ }
+ private static var fileURL: URL { base.appendingPathComponent("servers.json") }
+ private static var recentURL: URL { base.appendingPathComponent("recent.json") }
+
+ static func load() -> [SavedChat] {
+ guard let data = try? Data(contentsOf: fileURL) else { return [] }
+ return (try? JSONDecoder().decode([SavedChat].self, from: data)) ?? []
+ }
+
+ static func save(_ chats: [SavedChat]) {
+ guard let data = try? JSONEncoder().encode(chats) else { return }
+ try? data.write(to: fileURL, options: .atomic)
+ }
+
+ // Recency list for the ⌘K palette (most-recent-first chat ids). Stored beside
+ // servers.json — the ids are the same invite URLs already persisted there.
+ static func loadRecent() -> [String] {
+ guard let data = try? Data(contentsOf: recentURL) else { return [] }
+ return (try? JSONDecoder().decode([String].self, from: data)) ?? []
+ }
+
+ static func saveRecent(_ ids: [String]) {
+ guard let data = try? JSONEncoder().encode(ids) else { return }
+ try? data.write(to: recentURL, options: .atomic)
+ }
+}
diff --git a/ios/LChatMac/MacInstance.swift b/ios/LChatMac/MacInstance.swift
new file mode 100644
index 0000000..9cf97cc
--- /dev/null
+++ b/ios/LChatMac/MacInstance.swift
@@ -0,0 +1,105 @@
+import Foundation
+
+// The Mac app's own identity model. Eleven keeps the "invite URL + keys live in the
+// WKWebView" model rather than the iOS app's native-keypair `SavedInstance`: on
+// desktop all crypto stays in the web layer (see ChatFeedWebView), so the native
+// side only needs the invite URL + screen name. This lives in the LChatMac target
+// (not the shared core) because main moved the shared `Models.swift` to
+// `SavedInstance`; the two apps legitimately differ on where keys live.
+
+// 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.
+struct Session: Codable, Equatable {
+ var baseURL: URL // e.g. https://chat.example.com — derived from the invite link
+ var token: String // invite token (transport auth only; NOT a decryption key)
+ var keyB64: String // base64url AES-GCM key from the link's #fragment (legacy)
+ var name: String // screen name
+
+ /// wss/ws URL for the live feed, matching baseURL's scheme.
+ var socketURL: URL {
+ var c = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
+ c.scheme = baseURL.scheme == "https" ? "wss" : "ws"
+ c.path = "/ws"
+ c.queryItems = [URLQueryItem(name: "token", value: token)]
+ return c.url!
+ }
+
+ /// The Origin header value for the WebSocket upgrade (same-origin check).
+ var origin: String {
+ var s = baseURL.scheme.map { "\($0)://" } ?? ""
+ s += baseURL.host ?? ""
+ if let port = baseURL.port { s += ":\(port)" }
+ return s
+ }
+}
+
+// A saved chat: the invite URL (which carries host, token and key in its #fragment
+// for legacy links) plus the screen name. New-format (server-blind) links don't
+// carry the key — the web join flow inside the WKWebView fetches + unwraps it and
+// hands back a resolved session, persisted as claimedToken/claimedKey.
+struct SavedChat: Codable, Identifiable, Equatable {
+ var url: String
+ var name: String
+ var lastSeenId: Int64 = 0
+ var claimedToken: String?
+ var claimedKey: String?
+
+ var id: String { url }
+
+ /// The origin (scheme://host[:port]) of the stored link.
+ var origin: URL? {
+ guard let c = URLComponents(string: url), let scheme = c.scheme, let host = c.host else { return nil }
+ var o = URLComponents()
+ o.scheme = scheme
+ o.host = host
+ o.port = c.port
+ return o.url
+ }
+
+ /// A usable Session, or nil if we don't have the key yet. Prefers a resolved
+ /// (claimed) new-format session; otherwise parses a legacy `#k=` link.
+ var session: Session? {
+ if let token = claimedToken, let key = claimedKey, let base = origin {
+ return Session(baseURL: base, token: token, keyB64: key, name: name)
+ }
+ guard let p = SavedChat.parseInvite(url) else { return nil }
+ return Session(baseURL: p.baseURL, token: p.token, keyB64: p.keyB64, name: name)
+ }
+
+ /// The URL the WKWebView should load: the chat origin (`/`) once we have a
+ /// session to seed, otherwise the raw `/join/…` link so the web claims it.
+ var loadURL: URL? { session?.baseURL ?? URL(string: url) }
+
+ /// De-dupe token (claimed device token, else the legacy invite token from the URL).
+ var token: String? { claimedToken ?? SavedChat.parseInvite(url)?.token }
+
+ struct ParsedInvite { let baseURL: URL; let token: String; let keyB64: String }
+
+ /// Parse a legacy `https://host/join/<token>#k=<key>` link. Lives here because
+ /// main's shared `LChatAPI` dropped this when iOS moved to native key management.
+ static func parseInvite(_ raw: String) -> ParsedInvite? {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard var comps = URLComponents(string: trimmed),
+ let scheme = comps.scheme, comps.host != nil,
+ scheme == "http" || scheme == "https"
+ else { return nil }
+
+ let token = comps.path.split(separator: "/").last.map(String.init)
+ // The key lives in the fragment as `k=…` (URLComponents leaves it raw).
+ var keyB64: String?
+ if let frag = comps.fragment {
+ for pair in frag.split(separator: "&") {
+ let kv = pair.split(separator: "=", maxSplits: 1)
+ if kv.first == "k", kv.count == 2 { keyB64 = String(kv[1]) }
+ }
+ }
+ guard let token, !token.isEmpty, let keyB64, !keyB64.isEmpty else { return nil }
+
+ // Reduce to a clean origin URL (scheme://host[:port]).
+ comps.path = ""
+ comps.query = nil
+ comps.fragment = nil
+ guard let base = comps.url else { return nil }
+ return ParsedInvite(baseURL: base, token: token, keyB64: keyB64)
+ }
+}
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
new file mode 100644
index 0000000..cfcb305
--- /dev/null
+++ b/ios/LChatMac/RootView.swift
@@ -0,0 +1,328 @@
+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).
+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.
+ @State private var activated: Set<SavedChat.ID> = []
+
+ var body: some View {
+ HStack(spacing: 0) {
+ Sidebar()
+ .frame(width: 76)
+ Divider()
+ detail
+ }
+ .ignoresSafeArea()
+ .coordinateSpace(.named(RailTooltip.space))
+ // The rail is narrow and clips, so the hover label is drawn here, over the
+ // whole window, positioned to the right of the hovered icon.
+ .overlayPreferenceValue(RailTooltipKey.self) { info in
+ Group {
+ if let info {
+ RailTooltipLabel(text: info.text)
+ .offset(x: 64, y: info.y - 13)
+ }
+ }
+ .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() } }
+ }
+
+ // 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
+ // the background, so switching back shows an up-to-date feed immediately).
+ private var detail: some View {
+ ZStack {
+ ForEach(app.chats) { chat in
+ if activated.contains(chat.id) {
+ ChatFeedWebView(chat: chat, app: app)
+ .opacity(chat.id == app.selection ? 1 : 0)
+ .allowsHitTesting(chat.id == app.selection)
+ }
+ }
+ if app.selected == nil {
+ ContentUnavailableView(
+ "No chat selected",
+ systemImage: "bubble.left.and.bubble.right",
+ description: Text("Add a chat with the + button.")
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onAppear { if let s = app.selection { activated.insert(s) } }
+ .onChange(of: app.selection) { _, s in if let s { activated.insert(s) } }
+ }
+}
+
+// Discord-style rail tooltip: appears instantly on hover, in a fixed position to
+// the right of the hovered icon (unlike the native ~1s-delay tooltip near the
+// cursor). Icons publish their name + vertical midpoint via a preference; RootView
+// reads it and draws the label in an overlay so it isn't clipped by the rail.
+enum RailTooltip { static let space = "rail" }
+
+struct RailTooltipInfo: Equatable {
+ var text: String
+ var y: CGFloat
+}
+
+struct RailTooltipKey: PreferenceKey {
+ static let defaultValue: RailTooltipInfo? = nil
+ static func reduce(value: inout RailTooltipInfo?, nextValue: () -> RailTooltipInfo?) {
+ value = value ?? nextValue()
+ }
+}
+
+struct RailTooltipLabel: View {
+ let text: String
+ private let fill = Color(white: 0.14)
+
+ var body: some View {
+ HStack(spacing: 0) {
+ LeftArrow()
+ .fill(fill)
+ .frame(width: 6, height: 11)
+ Text(text)
+ .font(.callout.weight(.semibold))
+ .foregroundStyle(.white)
+ .lineLimit(1)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 5)
+ .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(fill))
+ }
+ .shadow(color: .black.opacity(0.28), radius: 6, y: 1)
+ .fixedSize()
+ }
+}
+
+// A small left-pointing triangle for the tooltip — apex toward the icon.
+struct LeftArrow: Shape {
+ func path(in rect: CGRect) -> Path {
+ var p = Path()
+ p.move(to: CGPoint(x: rect.maxX, y: rect.minY))
+ p.addLine(to: CGPoint(x: rect.minX, y: rect.midY))
+ p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
+ p.closeSubpath()
+ return p
+ }
+}
+
+// ⌘K command palette (Tier 1): fuzzy-jump between servers, plus New Chat. Servers
+// are native state, so this needs no webview bridge — chats/threads live in the web
+// layer and are a later tier. Drawn as an overlay (like the rail tooltip) so it sits
+// above the WKWebView feed; type to filter, ↑/↓ to move, ↩ to open, esc to dismiss.
+struct CommandPalette: View {
+ @EnvironmentObject var app: MacAppState
+ @State private var query = ""
+ @State private var index = 0
+ @FocusState private var focused: Bool
+
+ // Recency rank for a chat (0 = most recently opened); unopened sort last.
+ private func recencyRank(_ chat: SavedChat) -> Int {
+ app.recent.firstIndex(of: chat.id) ?? Int.max
+ }
+
+ // With no query: most-recently-opened first, then any never-opened in saved
+ // order. With a query: fuzzy subsequence match on the friendly name or host,
+ // best score first, ties broken by recency — so "cfc" finds "Campbell family
+ // chat", and among comparable matches the one used most recently wins.
+ private var matches: [SavedChat] {
+ let q = query.filter { !$0.isWhitespace }
+ let ranked = app.chats.enumerated().map {
+ (chat: $0.element, order: $0.offset, recency: recencyRank($0.element))
+ }
+ if q.isEmpty {
+ return ranked
+ .sorted { $0.recency != $1.recency ? $0.recency < $1.recency : $0.order < $1.order }
+ .map(\.chat)
+ }
+ return ranked
+ .compactMap { r -> (chat: SavedChat, score: Int, order: Int, recency: Int)? in
+ let scores = [fuzzyScore(q, app.serverName(r.chat)),
+ fuzzyScore(q, r.chat.origin?.host ?? "")].compactMap { $0 }
+ guard let best = scores.max() else { return nil }
+ return (r.chat, best, r.order, r.recency)
+ }
+ .sorted {
+ if $0.score != $1.score { return $0.score > $1.score }
+ if $0.recency != $1.recency { return $0.recency < $1.recency }
+ return $0.order < $1.order
+ }
+ .map(\.chat)
+ }
+
+ // Query chars must appear in `text` in order (case-insensitive). Word-boundary
+ // and contiguous matches score higher; nil when it isn't a subsequence at all.
+ private func fuzzyScore(_ query: String, _ text: String) -> Int? {
+ let q = Array(query.lowercased())
+ let t = Array(text.lowercased())
+ var qi = 0, score = 0, prevMatch = -2
+ var prevChar: Character?
+ for (ti, ch) in t.enumerated() {
+ if qi < q.count, ch == q[qi] {
+ if prevChar == nil || !(prevChar!.isLetter || prevChar!.isNumber) { score += 10 }
+ if ti == prevMatch + 1 { score += 5 }
+ score += 1
+ prevMatch = ti
+ qi += 1
+ }
+ prevChar = ch
+ }
+ return qi == q.count ? score : nil
+ }
+ private var rowCount: Int { matches.count + 1 } // servers + "New Chat…"
+
+ var body: some View {
+ let inits = app.initials()
+ return ZStack(alignment: .top) {
+ Color.black.opacity(0.2).ignoresSafeArea()
+ .onTapGesture { close() }
+ VStack(spacing: 0) {
+ // Search field: leading magnifying glass + a big, quiet placeholder.
+ HStack(spacing: 12) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 18, weight: .medium))
+ .foregroundStyle(.secondary)
+ TextField("Jump to a chat…", text: $query)
+ .textFieldStyle(.plain)
+ .font(.system(size: 21))
+ .focused($focused)
+ .onSubmit(activate)
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 17)
+ Divider().opacity(0.5)
+ ScrollView {
+ VStack(spacing: 4) {
+ ForEach(Array(matches.enumerated()), id: \.element.id) { i, chat in
+ row(chat: chat, initial: inits[chat.id] ?? "?", highlighted: i == index) {
+ app.selection = chat.id; close()
+ }
+ }
+ newChatRow(highlighted: index == matches.count) {
+ close(); app.showAdd = true
+ }
+ }
+ .padding(8)
+ }
+ .frame(maxHeight: 340)
+ Divider().opacity(0.5)
+ // Footer hint bar, à la TinyStart's status line.
+ HStack(spacing: 16) {
+ hint("↩", "Open")
+ hint("↑ ↓", "Navigate")
+ hint("esc", "Dismiss")
+ Spacer()
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 9)
+ }
+ .frame(width: 600)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
+ .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).strokeBorder(.white.opacity(0.12)))
+ .shadow(color: .black.opacity(0.35), radius: 30, y: 12)
+ .padding(.top, 80)
+ }
+ .onAppear { index = 0; DispatchQueue.main.async { focused = true } }
+ .onChange(of: query) { _, _ in index = 0 }
+ .onKeyPress(.downArrow) { index = min(index + 1, rowCount - 1); return .handled }
+ .onKeyPress(.upArrow) { index = max(index - 1, 0); return .handled }
+ .onExitCommand { close() }
+ }
+
+ private func activate() {
+ if index < matches.count {
+ app.selection = matches[index].id
+ close()
+ } else {
+ close()
+ app.showAdd = true
+ }
+ }
+
+ private func close() { app.showPalette = false; query = "" }
+
+ // A server row, TinyStart-style: leading status dot, colour badge with the
+ // instance initial, name + host, a highlight and a ↩ on the active row.
+ @ViewBuilder
+ private func row(chat: SavedChat, initial: String, highlighted: Bool, action: @escaping () -> Void) -> some View {
+ rowShell(highlighted: highlighted, action: action) {
+ statusDot(current: app.selection == chat.id)
+ Text(initial)
+ .font(.system(size: 16, weight: .semibold))
+ .lineLimit(1).minimumScaleFactor(0.6)
+ .foregroundStyle(.white)
+ .frame(width: 38, height: 38)
+ .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color(hue: app.hue(for: chat.id), saturation: 0.55, brightness: 0.78)))
+ VStack(alignment: .leading, spacing: 1) {
+ Text(app.serverName(chat)).font(.system(size: 16, weight: .semibold))
+ if let host = chat.origin?.host, host != app.serverName(chat) {
+ Text(host).font(.system(size: 12)).foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func newChatRow(highlighted: Bool, action: @escaping () -> Void) -> some View {
+ rowShell(highlighted: highlighted, action: action) {
+ 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("New Chat…").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary)
+ }
+ }
+
+ // Shared row chrome: the highlight background, trailing ↩, padding + button.
+ @ViewBuilder
+ private func rowShell<Content: View>(highlighted: Bool, action: @escaping () -> Void, @ViewBuilder content: () -> Content) -> some View {
+ Button(action: action) {
+ HStack(spacing: 12) {
+ content()
+ Spacer(minLength: 8)
+ Image(systemName: "return")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundStyle(Color.accentColor)
+ .opacity(highlighted ? 1 : 0)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(highlighted ? Color.accentColor.opacity(0.22) : .clear))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+
+ private func statusDot(current: Bool) -> some View {
+ Circle()
+ .fill(current ? Color.accentColor : Color.secondary.opacity(0.35))
+ .frame(width: 6, height: 6)
+ }
+
+ @ViewBuilder
+ private func hint(_ key: String, _ label: String) -> some View {
+ HStack(spacing: 5) {
+ Text(key)
+ .font(.system(size: 11, weight: .semibold))
+ .padding(.horizontal, 5).padding(.vertical, 2)
+ .background(RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.18)))
+ Text(label).font(.system(size: 11)).foregroundStyle(.secondary)
+ }
+ }
+}
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift
new file mode 100644
index 0000000..4a347f2
--- /dev/null
+++ b/ios/LChatMac/Sidebar.swift
@@ -0,0 +1,176 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+// The native sidebar as a Discord/Slack-style server rail: one circular icon per
+// saved instance (an initial for now; server images come later), selected icon
+// morphs to a squircle with a leading pill indicator, and a trailing "+" adds one.
+// Threads *within* an instance are still handled by the web UI — this rail is the
+// native "which instance" switch. Hovering an icon publishes its name + position
+// via RailTooltipKey, which RootView draws as an instant flyout to the right.
+struct Sidebar: View {
+ @EnvironmentObject var app: MacAppState
+ @State private var dragging: SavedChat?
+
+ var body: some View {
+ let inits = app.initials()
+ return ScrollView {
+ VStack(spacing: 10) {
+ ForEach(app.chats) { chat in
+ ServerIcon(
+ initial: inits[chat.id] ?? "?",
+ hue: app.hue(for: chat.id),
+ selected: app.selection == chat.id,
+ tooltip: app.serverName(chat)
+ ) { app.selection = chat.id }
+ .opacity(dragging?.id == chat.id ? 0.5 : 1)
+ .contextMenu {
+ Button("Remove", role: .destructive) { app.remove(chat) }
+ }
+ .onDrag {
+ dragging = chat
+ return NSItemProvider(object: "\(chat.id)" as NSString)
+ }
+ .onDrop(of: [.text], delegate: ServerDropDelegate(item: chat, dragging: $dragging, app: app))
+ }
+ addButton
+ }
+ // Top inset clears the floating traffic-light controls (title bar hidden).
+ .padding(.top, 34)
+ .padding(.bottom, 12)
+ .frame(maxWidth: .infinity)
+ // Catch a drop that lands in the rail's gaps (not on an icon) so the drag
+ // state resets and the reordered list is saved.
+ .onDrop(of: [.text], isTargeted: nil) { _ in
+ if dragging != nil { app.persistChats(); dragging = nil }
+ return true
+ }
+ }
+ .task(id: app.chats) { await loadNames() }
+ }
+
+ private var addButton: some View {
+ Button { app.showAdd = true } label: {
+ Image(systemName: "plus")
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 46, height: 46)
+ .background(
+ RoundedRectangle(cornerRadius: 23, style: .continuous)
+ .fill(Color.secondary.opacity(0.15))
+ )
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.plain)
+ .railTooltip("Add a chat")
+ }
+
+ // Group-name lookup for the icon initial + tooltip. /api/group is public (no
+ // token) — the instance name is server-known metadata, not E2EE content — so
+ // this works for new-format chats too (where `session` is nil), and gives the
+ // friendly name instead of the host. Falls back to the host until it loads.
+ private func loadNames() async {
+ for chat in app.chats {
+ guard let base = chat.origin else { continue }
+ let name = await LChatAPI(baseURL: base, token: chat.session?.token ?? "").groupName()
+ if !name.isEmpty { app.names[chat.id] = name }
+ }
+ }
+}
+
+// One server-rail icon. Circle by default; on hover/selection it morphs toward a
+// squircle and grows a leading pill indicator, the way Slack/Discord rails do.
+private struct ServerIcon: View {
+ let initial: String
+ let hue: Double
+ let selected: Bool
+ let tooltip: String
+ let action: () -> Void
+ @State private var hovered = false
+
+ var body: some View {
+ // A plain tappable view — NOT a Button — so the rail's `.onDrag` actually
+ // starts a drag. On macOS a Button's press gesture swallows the drag, which
+ // is why drag-to-reorder didn't work while this was a Button.
+ Text(initial)
+ .font(.title3.weight(.semibold))
+ .lineLimit(1)
+ .minimumScaleFactor(0.6)
+ .foregroundStyle(.white)
+ .frame(width: 46, height: 46)
+ .background(
+ RoundedRectangle(cornerRadius: selected || hovered ? 15 : 23, style: .continuous)
+ .fill(Color(hue: hue, saturation: 0.55, brightness: 0.78))
+ )
+ .frame(maxWidth: .infinity)
+ .overlay(alignment: .leading) {
+ // Discord-style selection pill: flush against the rail's left edge,
+ // rounded on the right only (not a floating capsule). Hidden when
+ // idle, a short nub on hover, tall when selected.
+ UnevenRoundedRectangle(bottomTrailingRadius: 3, topTrailingRadius: 3, style: .continuous)
+ .fill(Color.primary.opacity(0.85))
+ .frame(width: 4, height: selected ? 26 : (hovered ? 12 : 0))
+ }
+ .contentShape(Rectangle())
+ .onTapGesture(perform: action)
+ .onHover { hovered = $0 }
+ .animation(.snappy(duration: 0.18), value: selected)
+ .animation(.snappy(duration: 0.18), value: hovered)
+ .background(RailTooltipEmitter(text: tooltip, active: hovered))
+ }
+}
+
+// Drag-to-reorder for the rail. The dragged instance is tracked in `dragging` (set
+// by onDrag); as it passes over another icon we live-reorder app.chats, and the new
+// order is persisted when the drop lands.
+private struct ServerDropDelegate: DropDelegate {
+ let item: SavedChat
+ @Binding var dragging: SavedChat?
+ let app: MacAppState
+
+ func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) }
+
+ func dropEntered(info: DropInfo) {
+ guard let d = dragging, d.id != item.id else { return }
+ withAnimation(.snappy(duration: 0.2)) { app.reorder(d, over: item) }
+ }
+
+ func performDrop(info: DropInfo) -> Bool {
+ app.persistChats()
+ dragging = nil
+ return true
+ }
+}
+
+// Publishes the hovered view's tooltip text + vertical midpoint (in the rail
+// coordinate space) so RootView can draw the flyout. Contributes nothing when the
+// view isn't hovered.
+private struct RailTooltipEmitter: View {
+ let text: String
+ let active: Bool
+
+ var body: some View {
+ GeometryReader { geo in
+ Color.clear.preference(
+ key: RailTooltipKey.self,
+ value: active ? RailTooltipInfo(text: text, y: geo.frame(in: .named(RailTooltip.space)).midY) : nil
+ )
+ }
+ }
+}
+
+// Owns its own hover state for views (like the "+" button) that don't already
+// track one; mirrors ServerIcon's emitter otherwise.
+private struct RailTooltipHover: ViewModifier {
+ let text: String
+ @State private var hovered = false
+
+ func body(content: Content) -> some View {
+ content
+ .onHover { hovered = $0 }
+ .background(RailTooltipEmitter(text: text, active: hovered))
+ }
+}
+
+private extension View {
+ func railTooltip(_ text: String) -> some View { modifier(RailTooltipHover(text: text)) }
+}
diff --git a/ios/project.yml b/ios/project.yml
index 8e1e2c4..f0eb713 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -49,6 +49,33 @@ targets:
# test against a `go run .` instance during development.
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
+
+ # A genuinely native macOS app (NOT Catalyst). Shares only the platform-neutral
+ # core (Models/LChatAPI) with the iOS target; its own identity model + all crypto
+ # live elsewhere (LChatMac/MacInstance.swift + the WKWebView). See LChatMac/.
+ LChatMac:
+ type: application
+ platform: macOS
+ deploymentTarget: "14.0"
+ sources:
+ - path: LChatMac
+ - path: LChat/Models.swift
+ - path: LChat/LChatAPI.swift
+ settings:
+ base:
+ PRODUCT_NAME: Eleven
+ PRODUCT_BUNDLE_IDENTIFIER: com.lchat.mac
+ CODE_SIGN_STYLE: Automatic
+ CODE_SIGN_ENTITLEMENTS: LChatMac/LChatMac.entitlements
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ info:
+ path: LChatMac/Info.plist
+ properties:
+ CFBundleDisplayName: Eleven
+ CFBundleName: Eleven
+ LSApplicationCategoryType: public.app-category.social-networking
+ NSAppTransportSecurity:
+ NSAllowsLocalNetworking: true
# Notification service extension: decrypts pushed ciphertext on-device (the
# E2EE half of APNs). Shares the crypto/keychain sources with the app.
LChatNSE: