From 782986850b8d9b4b9a766ab2774d93f9a73fe122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sat, 16 May 2026 22:45:09 +0100 Subject: [PATCH 1/3] feat(mac): menu bar connector overhaul + inline sign-in card Reshape the menu bar popover so connectors and sign-in feel native. Connectors UX - Replace +Add buttons with switch toggles on Photos / Health / Screen Time / WhatsApp. - Local folder + Google Chrome rows now open a native NSMenu cascading from the row's right edge (was a SwiftUI popover balloon that drifted off-screen near the menu bar edge). Click anywhere on the row to open. - Obsidian connector: auto-discovers vaults from ~/Library/Application Support/ obsidian/obsidian.json, lists them as checkmark items, reuses the existing local-folder bookmark pipeline. iCloud-backed vaults that fail a directory read are flagged "(needs Full Disk Access)" inline so toggling them on doesn't silently no-sync. - Hide WhatsApp + Obsidian rows entirely when the app isn't installed, matching the existing browser-row behavior. - Rename the section "Integrations" -> "Device connectors" to match the app.lobu.ai web UI label. Disclosure consistency - Inbox and Recent activity become collapsible disclosures with a count next to the header (e.g. "INBOX 3", "RECENT ACTIVITY 8"). Default collapsed. - Device connectors header gains the same count treatment. Sign-in flow - Kill the dedicated sign-in screen. Always show the main popover; render a compact connection card (URL field + Connect button) when signed-out. - Drop the Cloud / Self-hosted / Local picker; one editable URL field instead. Localhost URLs auto-start the embedded server before OAuth, anything else just OAuths. - Default URL is http://localhost:8787. Existing users keep their stored preference via a migration in the @Published initializer. - "Run on this Mac" remains the only path that auto-spawns lobu run. Misc - Fix LobuUpdater wedging on "Checking..." forever when Sparkle finds no update. updaterDidNotFindUpdate now sets latestVersion to the current bundle version so the footer flips to "Up to date". - Add docs/plans/personal-mode-auth.md design doc covering the next step (auth bypass for menu-bar-spawned localhost server). --- apps/mac/Lobu.xcodeproj/project.pbxproj | 4 + apps/mac/Lobu/AppState.swift | 68 +- apps/mac/Lobu/BrowserProfilesView.swift | 95 +-- apps/mac/Lobu/LobuUpdater.swift | 18 + apps/mac/Lobu/MenuBarContent.swift | 775 ++++++++++++----------- apps/mac/Lobu/ObsidianVaultManager.swift | 64 ++ docs/plans/personal-mode-auth.md | 262 ++++++++ 7 files changed, 860 insertions(+), 426 deletions(-) create mode 100644 apps/mac/Lobu/ObsidianVaultManager.swift create mode 100644 docs/plans/personal-mode-auth.md diff --git a/apps/mac/Lobu.xcodeproj/project.pbxproj b/apps/mac/Lobu.xcodeproj/project.pbxproj index 10569d2ee..0637e135c 100644 --- a/apps/mac/Lobu.xcodeproj/project.pbxproj +++ b/apps/mac/Lobu.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ B10000000000000000000010 /* BrowserProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000009 /* BrowserProfileManager.swift */; }; B10000000000000000000011 /* BrowserProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* BrowserProfilesView.swift */; }; B10000000000000000000014 /* PhotosSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000014 /* PhotosSyncService.swift */; }; + B10000000000000000000015 /* ObsidianVaultManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000015 /* ObsidianVaultManager.swift */; }; B93000000000000000000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B92000000000000000000001 /* Sparkle */; }; /* End PBXBuildFile section */ @@ -44,6 +45,7 @@ B20000000000000000000009 /* BrowserProfileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserProfileManager.swift; sourceTree = ""; }; B20000000000000000000013 /* BrowserProfilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserProfilesView.swift; sourceTree = ""; }; B20000000000000000000014 /* PhotosSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotosSyncService.swift; sourceTree = ""; }; + B20000000000000000000015 /* ObsidianVaultManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObsidianVaultManager.swift; sourceTree = ""; }; B20000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B20000000000000000000011 /* Lobu.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Lobu.entitlements; sourceTree = ""; }; B2000000000000000000000A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -85,6 +87,7 @@ B2000000000000000000000C /* LocalLobuRunner.swift */, B2000000000000000000000D /* HealthKitSyncService.swift */, B20000000000000000000014 /* PhotosSyncService.swift */, + B20000000000000000000015 /* ObsidianVaultManager.swift */, B2000000000000000000000E /* WhatsAppLocalSyncService.swift */, B2000000000000000000000F /* LobuUpdater.swift */, B20000000000000000000009 /* BrowserProfileManager.swift */, @@ -210,6 +213,7 @@ B1000000000000000000000C /* LocalLobuRunner.swift in Sources */, B1000000000000000000000D /* HealthKitSyncService.swift in Sources */, B10000000000000000000014 /* PhotosSyncService.swift in Sources */, + B10000000000000000000015 /* ObsidianVaultManager.swift in Sources */, B1000000000000000000000E /* WhatsAppLocalSyncService.swift in Sources */, B1000000000000000000000F /* LobuUpdater.swift in Sources */, B10000000000000000000010 /* BrowserProfileManager.swift in Sources */, diff --git a/apps/mac/Lobu/AppState.swift b/apps/mac/Lobu/AppState.swift index 6d790944a..2d30210f5 100644 --- a/apps/mac/Lobu/AppState.swift +++ b/apps/mac/Lobu/AppState.swift @@ -77,10 +77,15 @@ private struct PersistedRecentJob: Decodable { // MARK: - Connect mode -------------------------------------------------------- /// Which Lobu the bridge talks to. Chosen on the sign-in screen, persisted. +/// Where the menu bar is pointing the gateway at. **Derived from the URL** the +/// user typed (`connect()` parses it), not a picker selection — there is no +/// signed-out picker anymore. Kept as a typed mode because several runtime +/// paths (auto-restart of the local runner, stop-on-quit) only fire in `.local`. enum ServerMode: String, CaseIterable { - case cloud // app.lobu.ai - case custom // a self-hosted URL the user enters - case local // a `lobu run` the bridge starts on this Mac (project at ~/lobu) + /// A `lobu run` the menu bar started on this Mac (URL is loopback). + case local + /// Any non-loopback URL — Lobu Cloud, self-hosted, tailscale, etc. + case remote } /// State of the bridge-managed local `lobu run` (only meaningful in `.local` mode). @@ -161,10 +166,21 @@ final class AppState: ObservableObject { // Sign-in screen state. @Published var serverMode: ServerMode = { - ServerMode(rawValue: UserDefaults.standard.string(forKey: "lobuServerMode") ?? "") ?? .cloud + // Migrate the old "cloud" / "custom" values to the merged "remote" mode + // so existing installs don't get bounced back to a default they didn't + // choose. `.local` stays as-is. + switch UserDefaults.standard.string(forKey: "lobuServerMode") { + case "local": return .local + case "cloud", "custom", "remote": return .remote + default: return .local + } }() { didSet { UserDefaults.standard.set(serverMode.rawValue, forKey: "lobuServerMode") } } - /// Draft URL for `.custom` mode (the text field). Persisted so it survives restarts. - @Published var customServerDraft: String = UserDefaults.standard.string(forKey: "lobuCustomServerURL") ?? "" { + /// URL the user is pointing the menu bar at (text field next to Connect). + /// Persisted so it survives restarts. Defaults to localhost so a fresh + /// install offers the safest path — we'll auto-start the embedded server + /// when they click Connect. Editable to a Lobu Cloud / self-hosted URL. + @Published var customServerDraft: String = UserDefaults.standard.string(forKey: "lobuCustomServerURL") + ?? "http://localhost:8787" { didSet { UserDefaults.standard.set(customServerDraft, forKey: "lobuCustomServerURL") } } /// Result of the last reachability probe of `customServerDraft` — nil = not checked yet. @@ -355,23 +371,22 @@ final class AppState: ObservableObject { /// chosen mode (Cloud / self-hosted / a local `lobu run` we start here), /// then runs the OAuth device flow against it. func connect() async { - switch serverMode { - case .cloud: - setBaseURL(cloudURL) - await signIn() - case .custom: - let url = customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !url.isEmpty, URL(string: url)?.scheme != nil else { - setStatus("Enter a server URL (e.g. http://localhost:8787).") - return - } - setBaseURL(url) - await signIn() // discover() failure now names the URL - case .local: + let raw = customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines) + let urlString = raw.isEmpty ? cloudURL : raw + guard let url = URL(string: urlString), url.scheme != nil else { + setStatus("Enter a server URL (e.g. http://localhost:8787).") + return + } + // Localhost URLs imply the embedded server — start it for the user so + // they don't have to know about `lobu run`. Non-local hosts just connect. + let isLocal = url.host.map { $0 == "localhost" || $0 == "127.0.0.1" } ?? false + serverMode = isLocal ? .local : .remote + if isLocal && !localLobuStatus.isRunning { await startLocalLobu() - guard localLobuStatus.isRunning else { return } // start failed — error already shown - await signIn() + guard localLobuStatus.isRunning else { return } } + setBaseURL(urlString) + await signIn() } /// Start (or reconnect to) the bridge-managed `lobu run`. Idempotent: if it's @@ -407,15 +422,6 @@ final class AppState: ObservableObject { serverReachable = await LocalLobuRunner.isLobuReachable(url) } - /// On the sign-in screen, look for a `lobu run` already up locally and - /// pre-fill the self-hosted field with it so the user doesn't have to type. - func suggestLocalServerIfPresent() async { - guard serverMode == .custom, customServerDraft.isEmpty else { return } - if await LocalLobuRunner.isLobuReachable(LocalLobuRunner.baseURL) { - customServerDraft = LocalLobuRunner.baseURL - serverReachable = true - } - } // MARK: - Auto-poll --------------------------------------------------------- @@ -874,7 +880,7 @@ final class AppState: ObservableObject { // MARK: - - private func setStatus(_ message: String) { + func setStatus(_ message: String) { status = message NSLog("[Lobu] \(message)") } diff --git a/apps/mac/Lobu/BrowserProfilesView.swift b/apps/mac/Lobu/BrowserProfilesView.swift index 934d52279..1a868781e 100644 --- a/apps/mac/Lobu/BrowserProfilesView.swift +++ b/apps/mac/Lobu/BrowserProfilesView.swift @@ -61,6 +61,7 @@ struct SingleBrowserRow: View { @State private var sourceProfiles: [InstalledBrowserProfile] = [] @State private var savingDir: String? + @State private var rowAnchor: NSView? /// Detected CDP port via DevToolsActivePort on view appear. Surfaced /// inline in the "Connect to my Chrome" menu row; nil when Chrome /// isn't exposing remote debugging. @@ -86,41 +87,7 @@ struct SingleBrowserRow: View { let mirroredCount = sourceProfiles.filter { mirroredProfile(for: $0) != nil }.count return VStack(alignment: .leading, spacing: 2) { - Menu { - // CDP attach row — only when DevToolsActivePort detected a - // live Chrome listener. One Chrome process = one CDP server, - // so this is browser-wide, sits above the per-profile - // section. The label embeds the detected port so the user - // sees what they're attaching to without a separate textfield - // (NSMenu can't host one cleanly anyway). - if let port = detectedCdpPort { - Section("Live browser session") { - Toggle( - "Connect to my Chrome (port \(port))", - isOn: $allowCdp - ) - if allowCdp { - Button("Disconnect Chrome", role: .destructive) { - allowCdp = false - } - } - } - } - - Section(sourceProfiles.isEmpty ? "" : "Profiles") { - ForEach(sourceProfiles) { src in - Toggle( - isOn: profileBinding(for: src) - ) { - Text(src.displayName) - } - .disabled(savingDir == src.directoryName) - } - if sourceProfiles.isEmpty { - Text("No \(browser.kind.displayName) profiles found") - } - } - } label: { + Button(action: showChromeMenu) { HStack(spacing: 8) { Image(systemName: "globe") .foregroundStyle(.blue) @@ -138,13 +105,13 @@ struct SingleBrowserRow: View { .font(.caption2) .foregroundStyle(.tertiary) } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) // we draw our own chevron to match the other Integration rows + .buttonStyle(.plain) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(MenuAnchorView { rowAnchor = $0 }) // Surface any save / fetch failure inline so the user sees // what's wrong instead of "I clicked Mirror and nothing @@ -176,6 +143,54 @@ struct SingleBrowserRow: View { } } + private func buildChromeMenu() -> NSMenu { + let menu = NSMenu() + + if let port = detectedCdpPort { + let cdpItem = ClosureMenuItem( + title: "Connect to my Chrome (port \(port))", + state: allowCdp ? .on : .off + ) { [self] in + allowCdp.toggle() + } + menu.addItem(cdpItem) + menu.addItem(NSMenuItem.separator()) + } + + if sourceProfiles.isEmpty { + let empty = NSMenuItem( + title: "No \(browser.kind.displayName) profiles found", + action: nil, + keyEquivalent: "" + ) + empty.isEnabled = false + menu.addItem(empty) + } else { + for src in sourceProfiles { + let isMirrored = mirroredProfile(for: src) != nil + let isSaving = savingDir == src.directoryName + let item = ClosureMenuItem( + title: src.displayName, + state: isMirrored ? .on : .off + ) { [self] in + if isMirrored, let existing = mirroredProfile(for: src) { + Task { await delete(existing) } + } else { + Task { await mirror(src) } + } + } + item.isEnabled = !isSaving + menu.addItem(item) + } + } + + return menu + } + + private func showChromeMenu() { + popUpNativeMenu(buildChromeMenu(), anchoredTo: rowAnchor) + } + private func headerStatus(mirroredCount: Int, totalCount: Int) -> String { // When the user already has profiles mirrored, the count is the // most useful summary — the descriptive blurbs belong on the diff --git a/apps/mac/Lobu/LobuUpdater.swift b/apps/mac/Lobu/LobuUpdater.swift index b0d995833..e749280bb 100644 --- a/apps/mac/Lobu/LobuUpdater.swift +++ b/apps/mac/Lobu/LobuUpdater.swift @@ -56,8 +56,26 @@ extension LobuUpdater: SPUUpdaterDelegate { } nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater) { + // Use the installed version as the "latest seen" so the menu-bar + // footer can flip from "Checking…" to "Up to date" — otherwise the + // status sticks at "Checking…" forever after a successful check that + // returned no newer release. + let current = Bundle.main + .infoDictionary?["CFBundleShortVersionString"] as? String Task { @MainActor in self.updateAvailable = false + self.latestVersion = current + } + } + + nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + // Same idea on network errors / appcast parse failures: surface "Up to + // date" rather than wedging the UI. Sparkle has already logged the + // detail; the menu-bar footer doesn't need to spell it out. + let current = Bundle.main + .infoDictionary?["CFBundleShortVersionString"] as? String + Task { @MainActor in + self.latestVersion = current } } } diff --git a/apps/mac/Lobu/MenuBarContent.swift b/apps/mac/Lobu/MenuBarContent.swift index 45da91384..1ef6b4abe 100644 --- a/apps/mac/Lobu/MenuBarContent.swift +++ b/apps/mac/Lobu/MenuBarContent.swift @@ -13,9 +13,13 @@ import SwiftUI struct MenuBarContent: View { @ObservedObject var state: AppState @State private var integrationsExpanded = false + @State private var recentRunsExpanded = false + @State private var inboxExpanded = false @State private var accountExpanded = false @StateObject private var browserHub = BrowserProfilesHub() @FocusState private var searchFocused: Bool + @State private var localFolderRowAnchor: NSView? + @State private var obsidianRowAnchor: NSView? var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -23,7 +27,7 @@ struct MenuBarContent: View { sectionDivider if state.credentials == nil { - signInSection + connectionCard } else { userRow sectionDivider @@ -40,11 +44,14 @@ struct MenuBarContent: View { sectionDivider recentRunsSection } - sectionDivider - integrationsDisclosure } } + // Connectors visible regardless of sign-in so users can pre-configure + // their device sources before connecting. + sectionDivider + integrationsDisclosure + sectionDivider footerRow } @@ -266,52 +273,41 @@ struct MenuBarContent: View { private var notificationsSection: some View { VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text("INBOX") - .font(.caption2) - .foregroundStyle(.tertiary) - .fontWeight(.semibold) - .tracking(0.4) - if state.unreadCount > 0 { - Text("\(state.unreadCount)") - .font(.caption2) - .foregroundStyle(.white) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(Capsule().fill(Color.red)) - } - Spacer() - } - .padding(.horizontal, 6) - .padding(.bottom, 1) - ForEach(state.notifications.prefix(5)) { notification in - Button { handleNotificationTap(notification) } label: { - HStack(alignment: .top, spacing: 8) { - Circle() - .fill(notification.is_read ? Color.clear : Color.accentColor) - .frame(width: 6, height: 6) - .padding(.top, 5) - VStack(alignment: .leading, spacing: 1) { - Text(notification.title) - .font(.caption) - .fontWeight(notification.is_read ? .regular : .medium) - .lineLimit(1) - if let body = notification.body, !body.isEmpty { - Text(body) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(2) + disclosureHeader( + title: "Inbox", + count: state.unreadCount > 0 ? state.unreadCount : state.notifications.count, + expanded: $inboxExpanded + ) + if inboxExpanded { + ForEach(state.notifications.prefix(5)) { notification in + Button { handleNotificationTap(notification) } label: { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(notification.is_read ? Color.clear : Color.accentColor) + .frame(width: 6, height: 6) + .padding(.top, 5) + VStack(alignment: .leading, spacing: 1) { + Text(notification.title) + .font(.caption) + .fontWeight(notification.is_read ? .regular : .medium) + .lineLimit(1) + if let body = notification.body, !body.isEmpty { + Text(body) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } } + Spacer() + Text(relativeTime(notification.created_at)) + .font(.caption2) + .foregroundStyle(.tertiary) } - Spacer() - Text(relativeTime(notification.created_at)) - .font(.caption2) - .foregroundStyle(.tertiary) + .contentShape(Rectangle()) } - .contentShape(Rectangle()) + .buttonStyle(.plain) + .menuRow() } - .buttonStyle(.plain) - .menuRow() } } } @@ -362,33 +358,39 @@ struct MenuBarContent: View { private var recentRunsSection: some View { VStack(alignment: .leading, spacing: 2) { - sectionLabel("Recent activity") - ForEach(state.recentRuns.prefix(5)) { run in - Button { openRun(run) } label: { - HStack(spacing: 8) { - Circle() - .fill(runStatusColor(run.status)) - .frame(width: 6, height: 6) - VStack(alignment: .leading, spacing: 1) { - Text(runDisplayLabel(run)).font(.caption).lineLimit(1) - if let err = run.error_message, !err.isEmpty { - Text(err) + disclosureHeader( + title: "Recent activity", + count: state.recentRuns.count, + expanded: $recentRunsExpanded + ) + if recentRunsExpanded { + ForEach(state.recentRuns.prefix(5)) { run in + Button { openRun(run) } label: { + HStack(spacing: 8) { + Circle() + .fill(runStatusColor(run.status)) + .frame(width: 6, height: 6) + VStack(alignment: .leading, spacing: 1) { + Text(runDisplayLabel(run)).font(.caption).lineLimit(1) + if let err = run.error_message, !err.isEmpty { + Text(err) + .font(.caption2) + .foregroundStyle(.orange) + .lineLimit(1) + } + } + Spacer() + if let ts = run.completed_at ?? run.created_at { + Text(relativeTime(ts)) .font(.caption2) - .foregroundStyle(.orange) - .lineLimit(1) + .foregroundStyle(.tertiary) } } - Spacer() - if let ts = run.completed_at ?? run.created_at { - Text(relativeTime(ts)) - .font(.caption2) - .foregroundStyle(.tertiary) - } + .contentShape(Rectangle()) } - .contentShape(Rectangle()) + .buttonStyle(.plain) + .menuRow() } - .buttonStyle(.plain) - .menuRow() } } } @@ -424,113 +426,71 @@ struct MenuBarContent: View { // MARK: 4. Sign-in // ------------------------------------------------------------------------- - private var signInSection: some View { - VStack(alignment: .leading, spacing: 5) { - sectionLabel("Connect to Lobu") - - Picker("", selection: $state.serverMode) { - Text("Lobu Cloud").tag(ServerMode.cloud) - Text("Self-hosted").tag(ServerMode.custom) - Text("Run on this Mac").tag(ServerMode.local) - } - .pickerStyle(.radioGroup) - .labelsHidden() - .padding(.horizontal, 6) - .disabled(state.isLoggingIn) - .onChange(of: state.serverMode) { _, mode in - if mode == .custom { Task { await state.suggestLocalServerIfPresent() } } - } - - modeDetail - .padding(.horizontal, 6) + /// Compact card shown in the popover when not signed in. URL field + + /// Connect button. Localhost URLs auto-start the embedded server inside + /// AppState.connect() — the user doesn't pick a "mode". + private var connectionCard: some View { + VStack(alignment: .leading, spacing: 6) { + TextField("https://app.lobu.ai", text: $state.customServerDraft) + .textFieldStyle(.roundedBorder) + .font(.caption) + .disabled(state.isLoggingIn) + .onSubmit { Task { await state.connect() } } Button(connectButtonTitle) { Task { await state.connect() } } .buttonStyle(.borderedProminent) .controlSize(.large) .frame(maxWidth: .infinity) - .disabled(connectDisabled) - .padding(.horizontal, 6) - .padding(.top, 2) + .disabled(state.isLoggingIn || state.localLobuStatus == .starting) + // Inline status only when there's something the user needs to know + // (CLI missing, runner failure, OAuth code). Otherwise the card + // stays a quiet two-row affair. + connectStatusLine if let code = state.loginCode { HStack { Text("Code").foregroundStyle(.secondary) Spacer() Text(code).monospaced() } - .font(.caption) - .menuRow(interactive: false) - } - } - .task { await state.suggestLocalServerIfPresent() } - } - - @ViewBuilder private var modeDetail: some View { - switch state.serverMode { - case .cloud: - EmptyView() - case .custom: - VStack(alignment: .leading, spacing: 3) { - TextField("http://localhost:8787", text: $state.customServerDraft) - .textFieldStyle(.roundedBorder) - .font(.caption) - .disabled(state.isLoggingIn) - .onSubmit { Task { await state.probeServer() } } - if let reachable = state.serverReachable, !state.customServerDraft.isEmpty { - Label( - reachable ? "Reachable" : "Couldn't reach a Lobu there", - systemImage: reachable ? "checkmark.circle.fill" : "xmark.circle" - ) - .font(.caption2) - .foregroundStyle(reachable ? Color.green : Color.secondary) - } - } - case .local: - VStack(alignment: .leading, spacing: 3) { - switch state.localLobuStatus { - case .cliMissing: - Text("Install the Lobu CLI first: npm i -g @lobu/cli") - .font(.caption2) - .foregroundStyle(.orange) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - case .starting: - HStack(spacing: 4) { - ProgressView().controlSize(.mini) - Text("Starting…").font(.caption2).foregroundStyle(.secondary) - } - case .running: - Label("Running", systemImage: "checkmark.circle.fill") - .font(.caption2) - .foregroundStyle(.green) - case let .failed(message): - Text(message) - .font(.caption2) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - case .stopped: - EmptyView() - } + .font(.caption2) } } + .padding(.horizontal, 6) + .padding(.vertical, 4) } private var connectButtonTitle: String { if state.isLoggingIn { return "Waiting for approval…" } - switch state.serverMode { - case .cloud: return "Sign in with Lobu" - case .custom: return "Sign in" - case .local: return state.localLobuStatus.isRunning ? "Sign in" : "Start & sign in" + let url = state.customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines) + let isLocal = url.contains("localhost") || url.contains("127.0.0.1") + if isLocal && !state.localLobuStatus.isRunning { + return "Start & sign in" } + return "Sign in" } - private var connectDisabled: Bool { - if state.isLoggingIn || state.localLobuStatus == .starting { return true } - if state.serverMode == .custom, - state.customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true + @ViewBuilder private var connectStatusLine: some View { + switch state.localLobuStatus { + case .cliMissing: + Text("Install the Lobu CLI first: npm i -g @lobu/cli") + .font(.caption2) + .foregroundStyle(.orange) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + case .starting: + HStack(spacing: 4) { + ProgressView().controlSize(.mini) + Text("Starting…").font(.caption2).foregroundStyle(.secondary) + } + case let .failed(message): + Text(message) + .font(.caption2) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + case .running, .stopped: + EmptyView() } - return false } // ------------------------------------------------------------------------- @@ -538,17 +498,28 @@ struct MenuBarContent: View { // ------------------------------------------------------------------------- private var integrationsDisclosure: some View { - VStack(alignment: .leading, spacing: 2) { - disclosureHeader(title: "Integrations", expanded: $integrationsExpanded) + let obsidianAvailable = ObsidianVaultManager.isInstalled() + let whatsAppAvailable = WhatsAppLocalSyncService.isAvailable() + let connectorCount = BrowserProfileManager.installedBrowsers().count + + 4 // Local folder, Screen Time, Apple Health, Apple Photos always count + + (obsidianAvailable ? 1 : 0) + + (whatsAppAvailable ? 1 : 0) + return VStack(alignment: .leading, spacing: 2) { + disclosureHeader( + title: "Device connectors", + count: connectorCount, + expanded: $integrationsExpanded + ) if integrationsExpanded { ForEach(BrowserProfileManager.installedBrowsers()) { browser in SingleBrowserRow(state: state, browser: browser, hub: browserHub) } localFolderRows + if obsidianAvailable { obsidianRow } screenTimeRow healthKitRow photosRow - whatsAppLocalRow + if whatsAppAvailable { whatsAppLocalRow } } } .task { await browserHub.loadIfNeeded(state: state) } @@ -556,192 +527,129 @@ struct MenuBarContent: View { private var healthKitRow: some View { let enabled = state.healthKitAvailable && state.hasHealthKit && !state.healthKitDisabled - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Image(systemName: "heart.fill") - .foregroundStyle(.pink) - .frame(width: 18) - VStack(alignment: .leading, spacing: 1) { - Text("Apple Health").font(.caption) - if !state.healthKitAvailable { - Text("Not available on this Mac.") - .font(.caption2).foregroundStyle(.secondary) - } else { - Text("Daily activity + workouts, synced via iCloud Health.") - .font(.caption2).foregroundStyle(.secondary) - } - } - Spacer() + return integrationRow( + icon: "heart.fill", + iconColor: .pink, + title: "Apple Health", + subtitle: state.healthKitAvailable + ? "Daily activity + workouts, synced via iCloud Health." + : "Not available on this Mac.", + trailing: { if !state.healthKitAvailable { - Text("Unavailable").font(.caption2).foregroundStyle(.secondary) - } else if !enabled { - Button(action: { - if state.hasHealthKit { state.healthKitDisabled = false } - else { Task { await state.requestHealthKitAccess() } } - }) { - HStack(spacing: 2) { - Image(systemName: "plus").font(.caption2) - Text("Add").font(.caption) - } - .foregroundStyle(.blue) - } - .buttonStyle(.plain) + AnyView(Text("Unavailable").font(.caption2).foregroundStyle(.secondary)) + } else { + AnyView(integrationToggle( + isOn: enabled, + enable: { + if state.hasHealthKit { state.healthKitDisabled = false } + else { Task { await state.requestHealthKitAccess() } } + }, + disable: { state.healthKitDisabled = true } + )) } } - .menuRow() - if enabled { - integrationSourceRow( - path: "iCloud Health", - onRemove: { state.healthKitDisabled = true } - ) - } - } + ) } private var photosRow: some View { let enabled = state.hasPhotos && !state.photosDisabled - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Image(systemName: "photo.fill") - .foregroundStyle(.orange) - .frame(width: 18) - VStack(alignment: .leading, spacing: 1) { - Text("Apple Photos").font(.caption) - Text("Library metadata: dates, location, albums.") - .font(.caption2).foregroundStyle(.secondary) - } - Spacer() - if !enabled { - Button(action: { + return integrationRow( + icon: "photo.fill", + iconColor: .orange, + title: "Apple Photos", + subtitle: "Library metadata: dates, location, albums.", + trailing: { + AnyView(integrationToggle( + isOn: enabled, + enable: { if state.hasPhotos { state.photosDisabled = false } else { Task { await state.requestPhotosAccess() } } - }) { - HStack(spacing: 2) { - Image(systemName: "plus").font(.caption2) - Text("Add").font(.caption) - } - .foregroundStyle(.blue) - } - .buttonStyle(.plain) - } + }, + disable: { state.photosDisabled = true } + )) } - .menuRow() - if enabled { - integrationSourceRow( - path: "Photos library", - onRemove: { state.photosDisabled = true } - ) - } - } + ) } private var screenTimeRow: some View { let enabled = state.hasFDA && !state.screenTimeDisabled - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Image(systemName: "clock.fill") - .foregroundStyle(.purple) - .frame(width: 18) - VStack(alignment: .leading, spacing: 1) { - Text("Screen Time").font(.caption) - if !state.hasFDA { - Text("Per-app usage. Needs Full Disk Access.") - .font(.caption2).foregroundStyle(.secondary) - } else { - Text("Per-app usage, synced from your Mac.") - .font(.caption2).foregroundStyle(.secondary) - } - } - Spacer() - if !enabled { - Button(action: { if state.hasFDA { state.screenTimeDisabled = false } else { openFDASettings() } }) { - HStack(spacing: 2) { - Image(systemName: "plus").font(.caption2) - Text("Add").font(.caption) - } - .foregroundStyle(.blue) - } - .buttonStyle(.plain) - } - } - .menuRow() - if enabled { - integrationSourceRow( - path: "~/Library/Application Support/Knowledge/", - onRemove: { state.screenTimeDisabled = true } - ) + return integrationRow( + icon: "clock.fill", + iconColor: .purple, + title: "Screen Time", + subtitle: state.hasFDA + ? "Per-app usage, synced from your Mac." + : "Per-app usage. Needs Full Disk Access.", + trailing: { + AnyView(integrationToggle( + isOn: enabled, + enable: { + if state.hasFDA { state.screenTimeDisabled = false } + else { openFDASettings() } + }, + disable: { state.screenTimeDisabled = true } + )) } - } + ) } private var whatsAppLocalRow: some View { - let available = WhatsAppLocalSyncService.isAvailable() - let enabled = state.hasFDA && available && !state.whatsAppDisabled - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Image(systemName: "message.fill") - .foregroundStyle(.green) - .frame(width: 18) - VStack(alignment: .leading, spacing: 1) { - Text("WhatsApp").font(.caption) - if !available { - Text("Install WhatsApp Desktop to enable.") - .font(.caption2).foregroundStyle(.secondary) - } else if !state.hasFDA { - Text("Reads from WhatsApp Desktop. Needs Full Disk Access.") - .font(.caption2).foregroundStyle(.secondary) - } else { - Text("Reads messages directly from WhatsApp Desktop.") - .font(.caption2).foregroundStyle(.secondary) - } - } - Spacer() - if !available { - Text("Not installed").font(.caption2).foregroundStyle(.secondary) - } else if !enabled { - Button(action: { if state.hasFDA { state.whatsAppDisabled = false } else { openFDASettings() } }) { - HStack(spacing: 2) { - Image(systemName: "plus").font(.caption2) - Text("Add").font(.caption) - } - .foregroundStyle(.blue) - } - .buttonStyle(.plain) - } - } - .menuRow() - if enabled { - integrationSourceRow( - path: "~/Library/Group Containers/group.net.whatsapp.WhatsAppMac.shared/", - onRemove: { state.whatsAppDisabled = true } - ) + let enabled = state.hasFDA && !state.whatsAppDisabled + return integrationRow( + icon: "message.fill", + iconColor: .green, + title: "WhatsApp", + subtitle: state.hasFDA + ? "Reads messages directly from WhatsApp Desktop." + : "Reads from WhatsApp Desktop. Needs Full Disk Access.", + trailing: { + AnyView(integrationToggle( + isOn: enabled, + enable: { + if state.hasFDA { state.whatsAppDisabled = false } + else { openFDASettings() } + }, + disable: { state.whatsAppDisabled = true } + )) } - } + ) } - private func integrationSourceRow(path: String, onRemove: @escaping () -> Void) -> some View { - HStack(spacing: 4) { - Image(systemName: "folder") - .font(.caption2) - .foregroundStyle(.secondary) + private func integrationRow( + icon: String, + iconColor: Color, + title: String, + subtitle: String, + @ViewBuilder trailing: () -> Trailing + ) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundStyle(iconColor) .frame(width: 18) - Text(path) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button(action: onRemove) { - Image(systemName: "xmark") - .font(.caption2) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(title).font(.caption) + Text(subtitle).font(.caption2).foregroundStyle(.secondary) } - .buttonStyle(.plain) + Spacer() + trailing() } - .padding(.leading, 26) .menuRow() } + private func integrationToggle( + isOn: Bool, + enable: @escaping () -> Void, + disable: @escaping () -> Void + ) -> some View { + Toggle("", isOn: Binding( + get: { isOn }, + set: { newValue in newValue ? enable() : disable() } + )) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + private func openFDASettings() { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") { NSWorkspace.shared.open(url) @@ -749,60 +657,152 @@ struct MenuBarContent: View { } private var localFolderRows: some View { - VStack(alignment: .leading, spacing: 2) { + Button(action: showLocalFolderMenu) { HStack(spacing: 8) { Image(systemName: "folder.fill") .foregroundStyle(.blue) .frame(width: 18) VStack(alignment: .leading, spacing: 1) { Text("Local folder").font(.caption) - Text("Syncs txt, md, json, csv, and html files.") - .font(.caption2) - .foregroundStyle(.secondary) + Text(localFolderSubtitle) + .font(.caption2).foregroundStyle(.secondary) } Spacer() - Button(action: { openFolderPanel() }) { - HStack(spacing: 2) { - Image(systemName: "plus").font(.caption2) - Text("Add").font(.caption) - } - .foregroundStyle(.blue) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .menuRow() + .background(MenuAnchorView { localFolderRowAnchor = $0 }) + } + + private func showLocalFolderMenu() { + popUpNativeMenu(buildLocalFolderMenu(), anchoredTo: localFolderRowAnchor) + } + + // ------------------------------------------------------------------------- + // MARK: Obsidian vaults (reuses local-folder sync under the hood) + // ------------------------------------------------------------------------- + + private var obsidianRow: some View { + let vaults = ObsidianVaultManager.vaults() + let mirroredCount = vaults.filter { isVaultMirrored($0) }.count + return Button(action: showObsidianMenu) { + HStack(spacing: 8) { + Image(systemName: "doc.text.fill") + .foregroundStyle(.purple) + .frame(width: 18) + VStack(alignment: .leading, spacing: 1) { + Text("Obsidian").font(.caption) + Text(obsidianSubtitle(mirrored: mirroredCount, total: vaults.count)) + .font(.caption2).foregroundStyle(.secondary) } - .buttonStyle(.plain) + Spacer() + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .menuRow() + .background(MenuAnchorView { obsidianRowAnchor = $0 }) + } + + private func obsidianSubtitle(mirrored: Int, total: Int) -> String { + if total == 0 { return "No vaults found in Obsidian." } + let label = total == 1 ? "vault" : "vaults" + return "\(mirrored) of \(total) \(label) synced" + } + + private func isVaultMirrored(_ vault: ObsidianVault) -> Bool { + let target = vault.url.standardizedFileURL.path + for idx in state.localFolders.indices { + if state.resolvedURLForBookmark(at: idx)?.standardizedFileURL.path == target { + return true + } + } + return false + } + + private func indexOfMirroredFolder(for vault: ObsidianVault) -> Int? { + let target = vault.url.standardizedFileURL.path + for idx in state.localFolders.indices { + if state.resolvedURLForBookmark(at: idx)?.standardizedFileURL.path == target { + return idx + } + } + return nil + } + + private func toggleVault(_ vault: ObsidianVault) { + if let idx = indexOfMirroredFolder(for: vault) { + state.removeFolderBookmark(at: idx) + } else if vault.isReadable { + state.addFolderBookmark(url: vault.url) + } else { + // iCloud or other TCC-protected location — bookmark would succeed + // but sync would silently fail later. Surface that now so the user + // doesn't think the toggle worked. + state.setStatus( + "Couldn't read \(vault.displayName). Grant Lobu Full Disk Access in System Settings → Privacy & Security to sync iCloud-backed vaults." + ) + } + } + + private func showObsidianMenu() { + let menu = NSMenu() + let vaults = ObsidianVaultManager.vaults() + if vaults.isEmpty { + let empty = NSMenuItem(title: "No Obsidian vaults found", action: nil, keyEquivalent: "") + empty.isEnabled = false + menu.addItem(empty) + } else { + for vault in vaults { + let mirrored = isVaultMirrored(vault) + let readable = vault.isReadable + // Annotate unreadable vaults inline so the user sees why the + // toggle won't take. Still clickable so they get the status + // message explaining the FDA path. + let title = readable ? vault.displayName : "\(vault.displayName) (needs Full Disk Access)" + let item = ClosureMenuItem( + title: title, + state: mirrored ? .on : .off + ) { [self] in toggleVault(vault) } + menu.addItem(item) } - .menuRow() - ForEach(Array(state.localFolders.enumerated()), id: \.element.folderId) { idx, folder in + } + popUpNativeMenu(menu, anchoredTo: obsidianRowAnchor) + } + + private var localFolderSubtitle: String { + if state.localFolders.isEmpty { + return "Syncs txt, md, json, csv, and html files." + } + let n = state.localFolders.count + return n == 1 ? "1 folder" : "\(n) folders" + } + + private func buildLocalFolderMenu() -> NSMenu { + let menu = NSMenu() + menu.addItem(ClosureMenuItem(title: "Add folder…") { [self] in + openFolderPanel() + }) + if !state.localFolders.isEmpty { + menu.addItem(NSMenuItem.separator()) + for (idx, folder) in state.localFolders.enumerated() { let path = state.resolvedURLForBookmark(at: idx)? .path.replacingOccurrences(of: NSHomeDirectory(), with: "~") ?? folder.displayName - HStack(spacing: 4) { - Image(systemName: "folder") - .font(.caption2) - .foregroundStyle(.secondary) - .frame(width: 18) - Text(path) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - if folder.feedId == nil { - Text("(syncing…)") - .font(.caption2).foregroundStyle(.secondary) - } - Spacer() - Button { - state.removeFolderBookmark(at: idx) - } label: { - Image(systemName: "xmark") - .font(.caption2) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - .padding(.leading, 26) - .menuRow() + menu.addItem(ClosureMenuItem(title: path, state: .on) { [state] in + state.removeFolderBookmark(at: idx) + }) } } + return menu } // ------------------------------------------------------------------------- @@ -939,16 +939,25 @@ struct MenuBarContent: View { .padding(.bottom, 1) } - private func disclosureHeader(title: String, expanded: Binding) -> some View { + private func disclosureHeader( + title: String, + count: Int? = nil, + expanded: Binding + ) -> some View { Button { withAnimation(.easeInOut(duration: 0.15)) { expanded.wrappedValue.toggle() } } label: { - HStack { + HStack(spacing: 4) { Text(title.uppercased()) .font(.caption2) .foregroundStyle(.tertiary) .fontWeight(.semibold) .tracking(0.4) + if let count, count > 0 { + Text("\(count)") + .font(.caption2) + .foregroundStyle(.tertiary) + } Spacer() Image(systemName: "chevron.right") .font(.caption2) @@ -1016,3 +1025,59 @@ private extension View { modifier(MenuRowStyle(interactive: interactive)) } } + +// ----------------------------------------------------------------------------- +// MARK: Native NSMenu flyout +// ----------------------------------------------------------------------------- + +/// NSMenuItem that runs a Swift closure on selection — lets us build menus +/// declaratively without dragging target/action plumbing into every call site. +final class ClosureMenuItem: NSMenuItem { + private var handler: (() -> Void)? + + convenience init( + title: String, + state: NSControl.StateValue = .off, + keyEquivalent: String = "", + handler: @escaping () -> Void + ) { + self.init(title: title, action: #selector(invoke), keyEquivalent: keyEquivalent) + self.target = self + self.state = state + self.handler = handler + } + + @objc private func invoke() { handler?() } +} + +/// Show an NSMenu cascading from the right edge of an anchor view, matching +/// the way macOS submenus open. Falls back to the cursor position if no +/// anchor is wired in. +func popUpNativeMenu(_ menu: NSMenu, anchoredTo view: NSView?) { + if let view { + let topRight = NSPoint( + x: view.bounds.maxX, + y: view.isFlipped ? view.bounds.minY : view.bounds.maxY + ) + menu.popUp(positioning: nil, at: topRight, in: view) + } else { + menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) + } +} + +/// Invisible NSViewRepresentable that hands the parent its backing NSView via +/// a callback. Used as a `.background` on rows that need to anchor a native +/// NSMenu to themselves (so the menu cascades from the row's right edge). +struct MenuAnchorView: NSViewRepresentable { + let onAttached: (NSView) -> Void + + func makeNSView(context: Context) -> NSView { + let v = NSView() + DispatchQueue.main.async { onAttached(v) } + return v + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { onAttached(nsView) } + } +} diff --git a/apps/mac/Lobu/ObsidianVaultManager.swift b/apps/mac/Lobu/ObsidianVaultManager.swift new file mode 100644 index 000000000..606ba0e6c --- /dev/null +++ b/apps/mac/Lobu/ObsidianVaultManager.swift @@ -0,0 +1,64 @@ +import Foundation + +/// One Obsidian vault as enumerated from `obsidian.json`. The id is the +/// short hex string Obsidian assigns each vault (also the filename of its +/// per-vault settings JSON in the same directory). +struct ObsidianVault: Identifiable, Equatable { + let id: String + let path: String + + /// Friendly label = the vault folder's last path component. Obsidian + /// uses the same thing in its window title and switcher. + var displayName: String { URL(fileURLWithPath: path).lastPathComponent } + + var url: URL { URL(fileURLWithPath: path) } + + /// Skip vaults whose folder no longer exists on disk (e.g. user deleted + /// it). Obsidian leaves stale entries in obsidian.json indefinitely. + var exists: Bool { FileManager.default.fileExists(atPath: path) } + + /// True when the vault directory is actually readable by this process. + /// iCloud-backed vaults (`~/Library/Mobile Documents/iCloud~md~obsidian/`) + /// are TCC-gated even for unsandboxed apps — `exists` says yes but the + /// first directory enumeration fails. Pre-checking with a directory read + /// lets us warn the user instead of silently no-syncing. + var isReadable: Bool { + (try? FileManager.default.contentsOfDirectory(atPath: path)) != nil + } +} + +/// Reads Obsidian's per-user vault registry. Obsidian persists every vault +/// the user has ever opened to `~/Library/Application Support/obsidian/obsidian.json` +/// in the shape `{"vaults":{"":{"path":"...","ts":...,"open":true}}}`. +enum ObsidianVaultManager { + /// True when Obsidian is installed on this Mac. We check the bundle path + /// rather than the config file so we don't show the connector for users + /// who happen to have a stale obsidian.json from an uninstalled app. + static func isInstalled() -> Bool { + FileManager.default.fileExists(atPath: "/Applications/Obsidian.app") + || FileManager.default.fileExists( + atPath: NSHomeDirectory() + "/Applications/Obsidian.app" + ) + } + + static func vaults() -> [ObsidianVault] { + let configURL = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library/Application Support/obsidian/obsidian.json") + guard let data = try? Data(contentsOf: configURL), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let vaults = root["vaults"] as? [String: Any] + else { return [] } + + return vaults.compactMap { id, raw -> ObsidianVault? in + guard let dict = raw as? [String: Any], + let path = dict["path"] as? String, + !path.isEmpty + else { return nil } + return ObsidianVault(id: id, path: path) + } + .filter(\.exists) + // Sort by display name so the menu ordering is stable across launches + // (the dictionary's insertion order isn't guaranteed). + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + } +} diff --git a/docs/plans/personal-mode-auth.md b/docs/plans/personal-mode-auth.md new file mode 100644 index 000000000..225480a8e --- /dev/null +++ b/docs/plans/personal-mode-auth.md @@ -0,0 +1,262 @@ +# Personal-mode auth for the Mac menu bar app + +## Goal + +When the macOS menu bar app starts the embedded Lobu server on this Mac, replace the OAuth device flow with a frictionless local-only auth model. The Mac user becomes the Lobu user automatically. No sign-in screen, no device code, no email entry. + +## Non-goals + +- Multi-user / org / team auth on the same install. If a user wants that, they run Lobu directly (`lobu run`, Docker, K8s) and the Mac app's "Remote" field points at it. +- Replacing Better Auth / OAuth in the gateway. This work *adds* a local-mode path that integrates with existing auth, not replaces it. +- Migrating a personal-mode install into a cloud account. Treated as a one-way choice for v1; export/import is a follow-up. + +## What already exists in `main` (audit results) + +This section anchors the design to actual code so we don't propose duplicates. + +| Concern | Existing code | Reuse / extend? | +|---|---|---| +| Loopback validation | `packages/server/src/start-local.ts:82` checks `127.0.0.1` / `localhost` / `::1` | **Extend** — add bind+verify semantics, refuse `0.0.0.0`/external. | +| Localhost URL validation | `packages/server/src/gateway/auth/oauth/utils.ts` | **Reuse** — same helper. | +| User + org auto-provision | `ensurePersonalOrganization()` (in `personal-org-provisioning.ts`) — idempotent, slug collision + reserved names handled, anchors via `personal_org_for_user_id` metadata | **Reuse** — call from local-mode bootstrap with a synthesized user record instead of a Better-Auth-issued one. | +| Keychain | `apps/mac/Lobu/KeychainTokenStore.swift` — service `ai.lobu.mac`, `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | **Extend** — add a separate Keychain account key for the personal-mode secret, same service. | +| CORS / cookie credentials | `packages/server/src/index.ts:266` — `isAllowedCorsOrigin()` checks localhost variants, `credentials: true` | **Extend** — fold CSRF middleware (Sec-Fetch-Site / Host / Content-Type / custom-header) into the same chain. | +| Data dir | `LOBU_DATA_DIR` env (defaults `~/.lobu/data`), set by `LocalLobuRunner.swift:74` | **Extend** — switch menu bar to per-user subdir (`~/.lobu-menubar//data`). | +| Bind port | `LocalLobuRunner` hardcodes `:8787` | **Replace** — per-user free port discovery. | +| Better Auth sessions | Used by the web SPA | **Integrate** — local-mode bootstrap mints a Better Auth session for the local user, so the SPA needs zero changes. | + +Genuinely missing (the surface this doc specifies): +- Secret bootstrap channel (stdin handshake) and `LOBU_PERSONAL_MODE=1` env. +- `personal.marker` data-dir file + startup refusal logic. +- `personalAuth` middleware (Bearer + `X-Lobu-Client`). +- Bootstrap-token endpoints (`/__local/bootstrap`, `/__local/exchange`). +- Tunnel detection. +- Per-user free port allocation. +- Reset / desync recovery path. + +--- + +## Auth model + +Two distinct authenticated paths, each fit for purpose. + +### 1. Menu bar app ↔ embedded server + +**Secret provisioning at server startup — stdin handshake.** No env var (leaks via same-user `/proc` or `ps auxe` on Linux; even on macOS it's a softer surface than stdin). No argv (visible in `ps`). No disk artifact (extra surface to manage). + +Sequence: + +1. Menu bar app generates a 32-byte random secret (base64). +2. Menu bar app writes Keychain entry: service `ai.lobu.mac`, account `personal-auth-token`, accessibility `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. +3. Menu bar app spawns `lobu run` with stdin attached, `LOBU_PERSONAL_MODE=1` + `LOBU_DATA_DIR=` + `--bind 127.0.0.1` + `--port 0` (request a free port). +4. Menu bar app writes a single JSON line to stdin and closes the write half: + ```json + { + "secret": "", + "identity": { + "handle": "", + "display_name": "", + "hostname": "" + } + } + ``` +5. Server reads the line on boot, validates personal mode, stores the secret in memory, and: + - Reads/writes `personal.marker` in the data dir (see §Boundaries). + - Calls `ensurePersonalOrganization()` with the supplied identity (synthesized email = `@.local`, `auth_provider="local"`). + - Binds the listener and verifies `server.address()` is loopback (see §Boundaries). + - Prints `LOBU_LISTEN_PORT=` to stdout as the first line, terminated with `\n`. +6. Menu bar app reads that line to learn the actual port and persists it to `~/.lobu-menubar//port`. + +Why stdin: it's not visible to `ps`, doesn't land on disk, and the parent-child pipe is exclusive — no other process can read it. Same-user processes with `ptrace`/`task_for_pid` can still attach and read the running server's memory, but at that point the whole user account is compromised; same threat boundary as the Keychain itself. + +**Steady-state auth (after first start):** + +- Every menu-bar HTTP call sets: + - `Authorization: Bearer ` + - `X-Lobu-Client: menubar` +- Server validates both. Custom header makes browser-driven CSRF preflight-only; CORS denies the preflight (see §CSRF), so a malicious site can't forge the header. +- If the server returns 401, the menu bar **deletes the Keychain entry, stops the runner, and starts fresh**. This is also the manual reset path. + +### 2. Browser ↔ embedded server ("Open Lobu" flow) + +The web SPA already uses Better Auth sessions. We integrate, not parallel. + +Sequence: + +1. User clicks "Open Lobu" in the menu bar. +2. Menu bar app calls `POST /__local/bootstrap` (authenticated with the Keychain secret). Server generates a one-time bootstrap token (32 random bytes, base64), stores **the hash** (`sha256(token)`) in an in-memory map keyed by hash, with TTL 10 seconds and a single-use flag. Server returns the plaintext token to the menu bar. +3. Menu bar app opens `http://127.0.0.1:/?bootstrap=` in the user's browser. +4. The SPA's bootstrap handler (small new entry in `packages/web/src/`): + 1. Reads `?bootstrap=` from `window.location`. + 2. `history.replaceState()` immediately to strip the query (no history leakage). + 3. `POST /__local/exchange` with `{ token }` (no auth header — the token IS the auth). + 4. Server hashes the supplied token, atomically deletes the entry from the in-memory map (the `Map.delete()` return value is the only authoritative "is this the first call" signal — prevents double-submit races), verifies TTL, then calls Better Auth to mint a session for the local user. Sets the standard Better Auth session cookie (HttpOnly, SameSite=Lax, no `Secure` flag since localhost is plain HTTP). +5. Web SPA continues with the Better Auth session as normal. Existing API routes recognize it without modification. + +**Session lifetime / refresh:** + +- The browser session uses Better Auth's default TTL (today: 30 days). The bootstrap is fast and friction-free, so we don't try to engineer "silent refresh." When the session expires the SPA shows its existing expired-session redirect; the user clicks "Open Lobu" in the menu bar and gets a fresh session in one click. +- The 10-second TTL on the bootstrap token is for the URL handoff only — short enough that a leaked token (browser sync, screen-share) expires before it's useful. + +--- + +## Personal-mode boundaries (structural) + +The "you can't accidentally expose this" requirement only holds if enforced by the server, not by a config the user can flip. + +### Bind enforcement + +- Server reads `LOBU_PERSONAL_MODE=1` at boot. If set: + - Refuse to start unless `bind` is `127.0.0.1` or `::1`. + - After `server.listen()`, call `server.address()` and assert the result is loopback. Crash with a clear message if not. + - Reject `0.0.0.0`, `::`, external interface IPs, and `localhost` resolutions that aren't loopback (some custom `/etc/hosts` setups). +- Extends the existing check in `start-local.ts:82` with a post-listen assertion. + +### `personal.marker` + +- On first server start in personal mode, write `/personal.marker` containing `{ "created_at": "", "owner_handle": "" }`. +- On subsequent starts: + - If `LOBU_PERSONAL_MODE=1` and marker present → load identity from marker, proceed. + - If `LOBU_PERSONAL_MODE=1` and marker absent → first run (create marker). + - If `LOBU_PERSONAL_MODE=1` unset and marker present → refuse to start. Forces deliberate migration away from personal mode (delete the marker manually is the documented one-way step). +- Marker file is the structural boundary. Mode is not a runtime flag the same data dir can flip. + +### CSRF + CORS lockdown + +A custom header (`X-Lobu-Client: menubar`) only helps if CORS doesn't grant it to arbitrary origins. The chain (extending the existing middleware in `index.ts:266`): + +- **CORS:** allowed origins are exactly `http://127.0.0.1:` and `http://localhost:`. No wildcards. No reflection of `Origin`. `Access-Control-Allow-Headers` does **not** include `X-Lobu-Client` or `Authorization` for cross-origin preflights — a foreign tab's preflight fails before the actual request runs. +- **Origin / Sec-Fetch-Site:** all mutating routes (POST/PUT/PATCH/DELETE) require either: + - `Sec-Fetch-Site: same-origin` or `Sec-Fetch-Site: none` (no-CORS, navigation), OR + - `Origin` present and in the allowed list above. +- **Missing-`Origin` behavior:** native clients (the Mac app) often omit `Origin`. For mutating routes, require either (a) `Origin` present-and-allowed, OR (b) `X-Lobu-Client: menubar` + valid Bearer (the menu bar path). Pure missing-Origin without the Lobu client header → 403. +- **`Host` allowlist:** request `Host` header must be `127.0.0.1[:port]` or `localhost[:port]` — reject DNS rebinding attacks. +- **`Content-Type`:** mutations must be `application/json` (no `text/plain`, no `application/x-www-form-urlencoded`, no `multipart/form-data`). Defeats CSRF "simple request" posts. +- **`OPTIONS`:** preflight allowed only from same-origin; deny all cross-origin preflights silently. + +### Tunnel detection (advisory, not a boundary) + +Pi was right: process scanning is bypassable. We do it anyway as a hint, but the security guarantee is loopback bind + the CSRF stack above. + +On startup, log a warning (not an error, doesn't refuse to start) if any of: + +- `tailscaled` is running AND `tailscale status --json` shows the local node has Funnel enabled. +- `ngrok`, `cloudflared`, or `frpc` processes are running. + +Surface the warning to the menu bar app via the startup-stdout protocol (one extra `LOBU_WARNING=` line). The menu bar shows it as a notification with a "Learn more" link. Reframed from the previous draft: not a refusal, an advisory. + +--- + +## User / org auto-provision (integration with existing infra) + +Reuses `ensurePersonalOrganization()`. + +On stdin handshake: + +1. Server checks if a user with `auth_provider = "local"` and `handle = ` already exists. +2. If not, inserts a user row inside a transaction with a `UNIQUE (auth_provider, handle)` constraint to prevent races. Fields: + - `id`: UUID. + - `handle`: ``. + - `display_name`: `` (fall back to handle if empty). + - `email`: `@.local` (placeholder, never sent anywhere). + - `auth_provider`: `"local"`. + - `created_at`: now. +3. Calls `ensurePersonalOrganization(userId, { displayName, handle })`. This is idempotent today, so retries are safe. +4. The default agent is created by `ensurePersonalOrganization` already (existing behavior — verify in implementation). + +If the user already exists (data dir from a previous run), step 2 is skipped and step 3 is a no-op. Bootstrap is idempotent. + +Avatar / real email: not in v1. Documented in §Open questions. + +--- + +## Threat model + +| Threat | Mitigation | +|---|---| +| Network attacker on LAN reaches `:` | Loopback bind enforced at server (refuses non-loopback in personal mode; post-listen assertion verifies). | +| Browser tab on the same Mac CSRFs the API | Strict CORS (no foreign-origin preflight passes for `X-Lobu-Client`/`Authorization`) + Origin + Sec-Fetch-Site + Host + Content-Type checks. | +| Other process on the same Mac (same user) reads Keychain or attaches to server memory | Out of scope — same-user adversary already owns the data. | +| Other macOS user on a shared Mac hits localhost | Per-user data dir + per-user port = each user runs their own server on their own port. A sibling user can hit `127.0.0.1:` but doesn't have the Keychain secret, so all sensitive endpoints 401. CSRF stack still applies. | +| Tunnel (Tailscale Funnel / ngrok / cloudflared) exposes localhost to internet | Loopback bind doesn't prevent tunnels by itself, but: (a) advisory startup warning, (b) `Host` allowlist rejects requests with non-localhost `Host` headers (most tunnels rewrite this), (c) menu bar UI flags the warning. Not a hard guarantee; documented. | +| User configures `HOST=0.0.0.0` thinking it'll just work | Server in personal mode refuses to start with non-loopback bind. Marker enforces single-mode-per-data-dir. | +| Bootstrap token leaks (browser sync, screen-share, history) | One-time use enforced by atomic `Map.delete()`. 10-second TTL. Stripped from URL via `history.replaceState()` immediately. Stored as hash, not plaintext, so server memory dump doesn't reveal usable tokens. | +| Bootstrap token replay between server restarts | Token store is in-memory only. Server restart invalidates all tokens. | +| Long-lived Keychain secret leaks | Same boundary as user's filesystem. Mitigation: revoke + regenerate is one click ("Reset Lobu" — see §Reset). | +| Cross-tab credential confusion (a tab from Cloud thinks it's local) | Cookie scoped to `Path=/`, Better Auth issues distinct session per origin. Web SPA loaded from `app.lobu.ai` can't read a localhost cookie. | + +--- + +## Reset / desync recovery + +The Keychain and server-side `personal.marker` can desync (Keychain wiped, data dir copied to a new machine, etc). Recovery must be explicit and visible. + +**Detection:** +- Menu bar app gets 401 on a steady-state call → assumes desync. + +**Action:** +- Menu bar app shows: "Local Lobu is out of sync with this Mac. Reset and start fresh?" with one button. +- On confirm: delete Keychain entry, stop the runner, delete `~/.lobu-menubar//data/` (including `personal.marker`), restart the runner. Triggers the full first-launch handshake. +- Connector configs (folder bookmarks, vault selections) live in UserDefaults and survive the reset. Server-side history (events, runs) is wiped — same as a fresh install. + +This is also what "Sign out" maps to in personal mode (the menu has no "Sign out" today when signed in; we add a "Reset Lobu…" item to the footer that does the above). + +--- + +## Implementation surface area + +### Server (`packages/server/src/`) + +- New: stdin handshake reader at boot (before HTTP listener starts). Parses identity + secret. +- New: `personalAuth` middleware that validates Bearer + `X-Lobu-Client`. +- New: CSRF middleware (Origin / Sec-Fetch-Site / Host / Content-Type), folded into the existing CORS chain in `index.ts:266`. +- New: bind-enforcement assertion after `server.listen()`, extending `start-local.ts:82`. +- New: `personal.marker` write/read + mode-conflict refusal. +- New routes: + - `POST /__local/bootstrap` — auth: personal-Bearer. Mints a bootstrap token. Returns `{ token }`. + - `POST /__local/exchange` — no auth header (token IS auth). Atomically burns token, mints Better Auth session, sets cookie. + - `GET /__local/identity` — auth: personal-Bearer. Returns current local user info. (Optional, for the menu bar to show "Signed in as Burak Emre Kabakcı".) +- Tunnel detection: best-effort startup check, emits `LOBU_WARNING=…` to stdout. +- Print `LOBU_LISTEN_PORT=` to stdout as the first protocol line after handshake. + +### Mac app (`apps/mac/Lobu/`) + +- `KeychainTokenStore.swift`: add a `personal-auth-token` account on the existing `ai.lobu.mac` service. +- `LocalLobuRunner.swift`: + - Pick a per-user free port (try a fixed list `8787, 8788, ..., 8800`, then fall back to `:0` if needed and read it back from `LOBU_LISTEN_PORT=` stdout). + - Pass `LOBU_PERSONAL_MODE=1`, `LOBU_DATA_DIR=~/.lobu-menubar//data`, `--bind 127.0.0.1`, `--port `. + - Attach stdin pipe, write the handshake JSON on spawn, close write half. + - Read first stdout line, parse port + warning. + - Persist port to `~/.lobu-menubar//port` for crash recovery. +- New: `PersonalAuthClient.swift` — handles secret generation, Keychain round-trip, `Authorization` + `X-Lobu-Client` header injection on every API call, bootstrap-token mint + browser-open helper for "Open Lobu", 401 → reset trigger. +- `MenuBarContent.swift`: + - When URL is loopback, skip the OAuth UI entirely. Button label becomes "Start" (the runner does the work; no separate sign-in step). Footer adds a "Reset Lobu…" inline-confirm action. + +### Web (`packages/web/src/`) + +- Small bootstrap handler in the SPA entry: reads `?bootstrap=`, `history.replaceState`, `POST /__local/exchange`, then proceeds. Treats the resulting Better Auth session like any other. +- Existing routes unchanged. + +### CLI (`packages/cli/src/`) + +- `lobu run`: accept `--bind`, `--port` (with `0` = OS-assigned), and read stdin for the handshake when `LOBU_PERSONAL_MODE=1`. No CLI flag changes for non-personal mode. + +### Tests + +- Server: stdin handshake (valid / malformed / wrong-mode), personalAuth middleware (valid / missing-header / wrong-token), CSRF (cross-origin preflight rejected, missing Origin without Lobu header rejected, wrong Host rejected, wrong content-type rejected), bootstrap (one-time, expiry, atomic burn), bind enforcement (post-listen assertion crashes on 0.0.0.0). +- Mac app: Keychain round-trip, stdin write/read, port discovery, 401 reset flow. + +--- + +## Open questions + +- **Real email detection from Contacts "Me" card.** Out of v1. +- **Avatar from macOS account picture.** Out of v1 — `~/Library/Caches/com.apple.iconservices.store` is gnarly. +- **Export to cloud.** Personal mode is one-way for v1. Future: `lobu export --personal` / `lobu import` ships events + connections + agents to a target org. Marker file is the signal. +- **Multi-server on the same Mac (e.g., dev + personal at the same time).** Per-user data dir handles separate users; for the same user running both, the menu bar uses `~/.lobu-menubar//`, and a developer running `lobu run` directly uses whatever they configure (default `~/.lobu`). They won't collide unless the dev points at the menu bar's data dir — documented in `lobu run --help`. +- **What happens if the user kills `lobu run` from outside (e.g. `pkill`).** Menu bar's runner watcher should detect, restart with the same handshake (Keychain secret is reused). Verify in implementation. +- **"Open Lobu" when the server is stopped.** Currently the footer opens `state.baseURL` blindly. New behavior: if not running, start it first, then open. Specified in the Mac app section. + +## Out of scope (do later) + +- Avatar, real email, export/import, multi-server dev/personal coexistence beyond per-user data dir. From a26d1d6c32e3a031222891cc96ad1523f8728089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sat, 16 May 2026 22:53:40 +0100 Subject: [PATCH 2/3] fix(mac): address pi review findings on menu bar overhaul PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - connect() now only auto-starts the embedded lobu runner when the URL matches the runner's exact host:port (LocalLobuRunner.baseURL). A user typing http://localhost:9999 used to silently start :8787 and then OAuth against :9999, which would fail. Now we only auto-start when it's our managed instance. - Reject non-http(s) URL schemes up front. file:/ftp:/custom schemes used to be persisted as baseURL and then fail somewhere deeper in the stack. - Add AppState.isLoopback(_:URL) — URL.host-parsing loopback check shared between connect() and MenuBarContent's button-title logic so they can't disagree on the same URL. Previously the button title did substring contains("localhost") which classified https://localhost.evil.com as local while connect() correctly classified it as remote. - Migrate ex-cloud users to keep the Lobu Cloud URL when their stored customServerDraft is empty. Without this, the previous PR's "remove Cloud picker" change would silently point cloud users at localhost on next sign-out because customServerDraft defaulted to localhost. - Obsidian NSMenu now shows the full vault path (collapsed to ~) next to the display name so users can verify what they'd actually sync. A stale or malicious obsidian.json could point "Notes" at any folder; the user needs to see the path before they click toggle. --- apps/mac/Lobu/AppState.swift | 69 +++++++++++++++++++++++------- apps/mac/Lobu/MenuBarContent.swift | 16 ++++--- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/apps/mac/Lobu/AppState.swift b/apps/mac/Lobu/AppState.swift index 2d30210f5..cbc4fab06 100644 --- a/apps/mac/Lobu/AppState.swift +++ b/apps/mac/Lobu/AppState.swift @@ -176,11 +176,22 @@ final class AppState: ObservableObject { } }() { didSet { UserDefaults.standard.set(serverMode.rawValue, forKey: "lobuServerMode") } } /// URL the user is pointing the menu bar at (text field next to Connect). - /// Persisted so it survives restarts. Defaults to localhost so a fresh - /// install offers the safest path — we'll auto-start the embedded server - /// when they click Connect. Editable to a Lobu Cloud / self-hosted URL. - @Published var customServerDraft: String = UserDefaults.standard.string(forKey: "lobuCustomServerURL") - ?? "http://localhost:8787" { + /// Persisted so it survives restarts. Default depends on the stored mode: + /// fresh installs and ex-`.local` users get `localhost`; ex-`.cloud` users + /// get the Lobu Cloud URL so the merge doesn't silently strand them on + /// local mode. Editable to anything http(s). + @Published var customServerDraft: String = { + if let stored = UserDefaults.standard.string(forKey: "lobuCustomServerURL"), + !stored.isEmpty { + return stored + } + // No stored URL → derive from the old serverMode value if present. + switch UserDefaults.standard.string(forKey: "lobuServerMode") { + case "cloud": return "https://app.lobu.ai" + case "custom", "remote": return "https://app.lobu.ai" + default: return "http://localhost:8787" + } + }() { didSet { UserDefaults.standard.set(customServerDraft, forKey: "lobuCustomServerURL") } } /// Result of the last reachability probe of `customServerDraft` — nil = not checked yet. @@ -365,30 +376,56 @@ final class AppState: ObservableObject { setStatus("") } - // MARK: - Connect (mode-aware sign-in) -------------------------------------- + // MARK: - Connect (URL-driven sign-in) -------------------------------------- - /// The sign-in screen's primary action. Resolves the gateway URL for the - /// chosen mode (Cloud / self-hosted / a local `lobu run` we start here), - /// then runs the OAuth device flow against it. + /// The connection card's primary action. Auto-starts the embedded server + /// when the URL points at the loopback port we manage; otherwise just + /// runs OAuth against the typed URL. func connect() async { let raw = customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines) let urlString = raw.isEmpty ? cloudURL : raw - guard let url = URL(string: urlString), url.scheme != nil else { - setStatus("Enter a server URL (e.g. http://localhost:8787).") + guard let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { + setStatus("Enter an http(s) URL (e.g. http://localhost:8787).") return } - // Localhost URLs imply the embedded server — start it for the user so - // they don't have to know about `lobu run`. Non-local hosts just connect. - let isLocal = url.host.map { $0 == "localhost" || $0 == "127.0.0.1" } ?? false - serverMode = isLocal ? .local : .remote - if isLocal && !localLobuStatus.isRunning { + // Auto-start the embedded server ONLY when the URL is the exact one we + // know how to manage (host + port). A user pointing at localhost:9999 + // is connecting to their own thing — we shouldn't start :8787 alongside. + if shouldAutoStartLocal(for: url) && !localLobuStatus.isRunning { await startLocalLobu() guard localLobuStatus.isRunning else { return } } + serverMode = AppState.isLoopback(url) ? .local : .remote setBaseURL(urlString) await signIn() } + /// True iff this URL is the embedded server URL the menu bar manages. + /// Auto-start only fires for an exact host:port match — typing a different + /// loopback port means "connect to my own thing," not "spawn yours alongside." + private func shouldAutoStartLocal(for url: URL) -> Bool { + guard let runnerURL = URL(string: LocalLobuRunner.baseURL) else { return false } + return url.host == runnerURL.host && (url.port ?? defaultPort(for: url)) == runnerURL.port + } + + private func defaultPort(for url: URL) -> Int? { + switch url.scheme?.lowercased() { + case "http": return 80 + case "https": return 443 + default: return nil + } + } + + /// Host-parse-based loopback check shared between connect() and the + /// connection card's button title so they can't disagree on the same URL. + static func isLoopback(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" + } + /// Start (or reconnect to) the bridge-managed `lobu run`. Idempotent: if it's /// already up on the port, just adopts it. Updates `baseURL` + status. func startLocalLobu() async { diff --git a/apps/mac/Lobu/MenuBarContent.swift b/apps/mac/Lobu/MenuBarContent.swift index 1ef6b4abe..7fec5c462 100644 --- a/apps/mac/Lobu/MenuBarContent.swift +++ b/apps/mac/Lobu/MenuBarContent.swift @@ -462,8 +462,9 @@ struct MenuBarContent: View { private var connectButtonTitle: String { if state.isLoggingIn { return "Waiting for approval…" } - let url = state.customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines) - let isLocal = url.contains("localhost") || url.contains("127.0.0.1") + let raw = state.customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines) + let url = URL(string: raw) + let isLocal = url.map(AppState.isLoopback) ?? false if isLocal && !state.localLobuStatus.isRunning { return "Start & sign in" } @@ -764,10 +765,13 @@ struct MenuBarContent: View { for vault in vaults { let mirrored = isVaultMirrored(vault) let readable = vault.isReadable - // Annotate unreadable vaults inline so the user sees why the - // toggle won't take. Still clickable so they get the status - // message explaining the FDA path. - let title = readable ? vault.displayName : "\(vault.displayName) (needs Full Disk Access)" + // Show the full path (collapsed to ~) so the user can verify + // what they'd actually sync — vault names alone are too easy + // to mistake for an innocuous folder when obsidian.json points + // elsewhere. + let path = vault.url.path.replacingOccurrences(of: NSHomeDirectory(), with: "~") + let suffix = readable ? "" : " (needs Full Disk Access)" + let title = "\(vault.displayName) — \(path)\(suffix)" let item = ClosureMenuItem( title: title, state: mirrored ? .on : .off From ac646c3ec3db362b1ff8e02823d37e31b0c0a589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sat, 16 May 2026 23:03:53 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(mac):=20pi=20round-2=20=E2=80=94=20runn?= =?UTF-8?q?er-match=20precision=20+=20button=20parity=20+=20path=20hardeni?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens the loopback / auto-start logic after pi's second pass. - AppState.matchesManagedRunner(_:URL) replaces the loose isLoopback check. Returns true only when scheme + canonical-loopback host + effective port all match LocalLobuRunner.baseURL exactly. localhost / 127.0.0.1 / ::1 / [::1] all normalize to one canonical form so they compare equal. - connect() now sets serverMode = .local only when the URL matches the managed runner — not for any loopback URL. Previously, signing in against localhost:9999 would still set .local and cause the next app launch's auto-start path to spawn :8787 alongside. - Reject http(s) URLs with no host (e.g. bare "https://") up front instead of letting them fail at OAuth time with a worse error. - Button title now mirrors matchesManagedRunner exactly so it never says "Start & sign in" for a URL the runner won't actually start. - customServerDraft default cascade now also reads the legacy lobuBaseURL UserDefault, so users whose URL lived there (pre-merge custom mode) get their URL back instead of falling through to the cloud default. - Replace plain substring `replacingOccurrences(of: NSHomeDirectory(), with: "~")` with a prefix-aware abbreviatedHomePath() helper. Plain substring mangles /Users/burakemre.backup/foo into ~.backup/foo. Used in both the Obsidian vault menu and the Local folder menu. --- apps/mac/Lobu/AppState.swift | 85 +++++++++++++++++++----------- apps/mac/Lobu/MenuBarContent.swift | 29 +++++++--- 2 files changed, 75 insertions(+), 39 deletions(-) diff --git a/apps/mac/Lobu/AppState.swift b/apps/mac/Lobu/AppState.swift index cbc4fab06..5128e124d 100644 --- a/apps/mac/Lobu/AppState.swift +++ b/apps/mac/Lobu/AppState.swift @@ -176,20 +176,26 @@ final class AppState: ObservableObject { } }() { didSet { UserDefaults.standard.set(serverMode.rawValue, forKey: "lobuServerMode") } } /// URL the user is pointing the menu bar at (text field next to Connect). - /// Persisted so it survives restarts. Default depends on the stored mode: - /// fresh installs and ex-`.local` users get `localhost`; ex-`.cloud` users - /// get the Lobu Cloud URL so the merge doesn't silently strand them on - /// local mode. Editable to anything http(s). + /// Persisted so it survives restarts. Default cascade: + /// 1. `lobuCustomServerURL` if non-empty (the canonical field today). + /// 2. `lobuBaseURL` if non-empty (legacy field used before the merge — + /// ex-custom users persisted the URL here). + /// 3. The Lobu Cloud URL if the old `lobuServerMode` was `"cloud"` / + /// `"custom"` / `"remote"`, so ex-cloud users aren't silently + /// pointed at localhost on first launch after the merge. + /// 4. Otherwise `http://localhost:8787` (fresh install / ex-local). @Published var customServerDraft: String = { if let stored = UserDefaults.standard.string(forKey: "lobuCustomServerURL"), !stored.isEmpty { return stored } - // No stored URL → derive from the old serverMode value if present. + if let legacy = UserDefaults.standard.string(forKey: "lobuBaseURL"), + !legacy.isEmpty { + return legacy + } switch UserDefaults.standard.string(forKey: "lobuServerMode") { - case "cloud": return "https://app.lobu.ai" - case "custom", "remote": return "https://app.lobu.ai" - default: return "http://localhost:8787" + case "cloud", "custom", "remote": return "https://app.lobu.ai" + default: return "http://localhost:8787" } }() { didSet { UserDefaults.standard.set(customServerDraft, forKey: "lobuCustomServerURL") } @@ -379,53 +385,68 @@ final class AppState: ObservableObject { // MARK: - Connect (URL-driven sign-in) -------------------------------------- /// The connection card's primary action. Auto-starts the embedded server - /// when the URL points at the loopback port we manage; otherwise just - /// runs OAuth against the typed URL. + /// when the URL is the exact one our runner manages; otherwise just OAuths + /// against the typed URL. func connect() async { let raw = customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines) let urlString = raw.isEmpty ? cloudURL : raw guard let url = URL(string: urlString), let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" + scheme == "http" || scheme == "https", + let host = url.host, !host.isEmpty else { - setStatus("Enter an http(s) URL (e.g. http://localhost:8787).") + setStatus("Enter an http(s) URL with a host (e.g. http://localhost:8787).") return } - // Auto-start the embedded server ONLY when the URL is the exact one we - // know how to manage (host + port). A user pointing at localhost:9999 - // is connecting to their own thing — we shouldn't start :8787 alongside. - if shouldAutoStartLocal(for: url) && !localLobuStatus.isRunning { + let autoStart = AppState.matchesManagedRunner(url) + if autoStart && !localLobuStatus.isRunning { await startLocalLobu() guard localLobuStatus.isRunning else { return } } - serverMode = AppState.isLoopback(url) ? .local : .remote + // serverMode = .local ONLY when this URL is the runner we manage. Other + // loopback URLs (someone else's localhost dev server, custom ports) get + // .remote so we don't auto-spawn our runner on next launch. + serverMode = autoStart ? .local : .remote setBaseURL(urlString) await signIn() } - /// True iff this URL is the embedded server URL the menu bar manages. - /// Auto-start only fires for an exact host:port match — typing a different - /// loopback port means "connect to my own thing," not "spawn yours alongside." - private func shouldAutoStartLocal(for url: URL) -> Bool { - guard let runnerURL = URL(string: LocalLobuRunner.baseURL) else { return false } - return url.host == runnerURL.host && (url.port ?? defaultPort(for: url)) == runnerURL.port + /// True iff this URL targets the embedded server the menu bar manages. + /// Requires an exact scheme + host + effective-port match against + /// `LocalLobuRunner.baseURL`. Treats `localhost`, `127.0.0.1`, `::1`, and + /// `[::1]` as equivalent loopback hosts. Case-insensitive on the host. + static func matchesManagedRunner(_ url: URL) -> Bool { + guard let runnerURL = URL(string: LocalLobuRunner.baseURL), + let runnerScheme = runnerURL.scheme?.lowercased(), + let urlScheme = url.scheme?.lowercased(), + runnerScheme == urlScheme + else { return false } + let urlPort = url.port ?? defaultPort(for: urlScheme) + let runnerPort = runnerURL.port ?? defaultPort(for: runnerScheme) + guard urlPort == runnerPort else { return false } + return normalizedLoopback(url.host) != nil + && normalizedLoopback(url.host) == normalizedLoopback(runnerURL.host) + } + + /// Map every loopback alias to one canonical form so `127.0.0.1:8787` and + /// `localhost:8787` and `[::1]:8787` all compare equal. Returns nil for + /// non-loopback hosts. + private static func normalizedLoopback(_ host: String?) -> String? { + let lowered = host?.lowercased() + switch lowered { + case "localhost", "127.0.0.1", "::1", "[::1]": return "localhost" + default: return nil + } } - private func defaultPort(for url: URL) -> Int? { - switch url.scheme?.lowercased() { + private static func defaultPort(for scheme: String) -> Int? { + switch scheme { case "http": return 80 case "https": return 443 default: return nil } } - /// Host-parse-based loopback check shared between connect() and the - /// connection card's button title so they can't disagree on the same URL. - static func isLoopback(_ url: URL) -> Bool { - guard let host = url.host?.lowercased() else { return false } - return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" - } - /// Start (or reconnect to) the bridge-managed `lobu run`. Idempotent: if it's /// already up on the port, just adopts it. Updates `baseURL` + status. func startLocalLobu() async { diff --git a/apps/mac/Lobu/MenuBarContent.swift b/apps/mac/Lobu/MenuBarContent.swift index 7fec5c462..ddf029ac5 100644 --- a/apps/mac/Lobu/MenuBarContent.swift +++ b/apps/mac/Lobu/MenuBarContent.swift @@ -463,9 +463,11 @@ struct MenuBarContent: View { private var connectButtonTitle: String { if state.isLoggingIn { return "Waiting for approval…" } let raw = state.customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines) - let url = URL(string: raw) - let isLocal = url.map(AppState.isLoopback) ?? false - if isLocal && !state.localLobuStatus.isRunning { + // "Start & sign in" exactly when connect() would auto-start the runner. + // Anything else (other loopback ports, https-on-localhost, remote URLs) + // is just a plain "Sign in" because we won't spawn the runner. + let willStartRunner = URL(string: raw).map(AppState.matchesManagedRunner) ?? false + if willStartRunner && !state.localLobuStatus.isRunning { return "Start & sign in" } return "Sign in" @@ -657,6 +659,18 @@ struct MenuBarContent: View { } } + /// Collapse the user's home directory to `~`, but only when it actually + /// prefixes the path as a directory boundary. Plain substring replacement + /// would mangle `/Users/burakemre.backup/foo` into `~.backup/foo`. + private func abbreviatedHomePath(_ path: String) -> String { + let home = NSHomeDirectory() + if path == home { return "~" } + if path.hasPrefix(home + "/") { + return "~" + path.dropFirst(home.count) + } + return path + } + private var localFolderRows: some View { Button(action: showLocalFolderMenu) { HStack(spacing: 8) { @@ -768,8 +782,9 @@ struct MenuBarContent: View { // Show the full path (collapsed to ~) so the user can verify // what they'd actually sync — vault names alone are too easy // to mistake for an innocuous folder when obsidian.json points - // elsewhere. - let path = vault.url.path.replacingOccurrences(of: NSHomeDirectory(), with: "~") + // elsewhere. Prefix-only replacement so `/Users/x.backup/...` + // doesn't get mangled into `~.backup/...`. + let path = abbreviatedHomePath(vault.url.path) let suffix = readable ? "" : " (needs Full Disk Access)" let title = "\(vault.displayName) — \(path)\(suffix)" let item = ClosureMenuItem( @@ -798,8 +813,8 @@ struct MenuBarContent: View { if !state.localFolders.isEmpty { menu.addItem(NSMenuItem.separator()) for (idx, folder) in state.localFolders.enumerated() { - let path = state.resolvedURLForBookmark(at: idx)? - .path.replacingOccurrences(of: NSHomeDirectory(), with: "~") + let path = state.resolvedURLForBookmark(at: idx) + .map { abbreviatedHomePath($0.path) } ?? folder.displayName menu.addItem(ClosureMenuItem(title: path, state: .on) { [state] in state.removeFolderBookmark(at: idx)