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>
18 files changed,
+1276
−0
ios/LChatMac/AddChatView.swift+82 −0ios/LChatMac/Assets.xcassets/AppIcon.appiconset/Contents.json+15 −0ios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_1024.pngbinaryios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_128.pngbinaryios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_16.pngbinaryios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_256.pngbinaryios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_32.pngbinaryios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_512.pngbinaryios/LChatMac/Assets.xcassets/AppIcon.appiconset/icon_64.pngbinaryios/LChatMac/Assets.xcassets/Contents.json+3 −0ios/LChatMac/ChatFeedWebView.swift+261 −0ios/LChatMac/Info.plist+31 −0ios/LChatMac/LChatMac.entitlements+11 −0ios/LChatMac/LChatMacApp.swift+237 −0ios/LChatMac/MacInstance.swift+105 −0ios/LChatMac/RootView.swift+328 −0ios/LChatMac/Sidebar.swift+176 −0ios/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: |