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..5128e124d 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,38 @@ 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. 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 + } + if let legacy = UserDefaults.standard.string(forKey: "lobuBaseURL"), + !legacy.isEmpty { + return legacy + } + switch UserDefaults.standard.string(forKey: "lobuServerMode") { + case "cloud", "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. @@ -349,28 +382,68 @@ 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 is the exact one our runner manages; otherwise just OAuths + /// against the typed URL. 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), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host, !host.isEmpty + else { + setStatus("Enter an http(s) URL with a host (e.g. http://localhost:8787).") + return + } + let autoStart = AppState.matchesManagedRunner(url) + if autoStart && !localLobuStatus.isRunning { await startLocalLobu() - guard localLobuStatus.isRunning else { return } // start failed — error already shown - await signIn() + guard localLobuStatus.isRunning else { return } + } + // 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 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 static func defaultPort(for scheme: String) -> Int? { + switch scheme { + case "http": return 80 + case "https": return 443 + default: return nil } } @@ -407,15 +480,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 +938,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..ddf029ac5 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,74 @@ 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 raw = state.customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines) + // "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" } - 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 +501,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,253 +530,298 @@ 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) } } + /// 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 { - 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) } - .menuRow() - ForEach(Array(state.localFolders.enumerated()), id: \.element.folderId) { idx, folder in - let path = state.resolvedURLForBookmark(at: idx)? - .path.replacingOccurrences(of: NSHomeDirectory(), with: "~") + .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 + // 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. 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( + title: title, + state: mirrored ? .on : .off + ) { [self] in toggleVault(vault) } + menu.addItem(item) + } + } + 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) + .map { abbreviatedHomePath($0.path) } ?? 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 +958,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 +1044,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.