Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/mac/Lobu.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand All @@ -44,6 +45,7 @@
B20000000000000000000009 /* BrowserProfileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserProfileManager.swift; sourceTree = "<group>"; };
B20000000000000000000013 /* BrowserProfilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserProfilesView.swift; sourceTree = "<group>"; };
B20000000000000000000014 /* PhotosSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotosSyncService.swift; sourceTree = "<group>"; };
B20000000000000000000015 /* ObsidianVaultManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObsidianVaultManager.swift; sourceTree = "<group>"; };
B20000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B20000000000000000000011 /* Lobu.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Lobu.entitlements; sourceTree = "<group>"; };
B2000000000000000000000A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
134 changes: 99 additions & 35 deletions apps/mac/Lobu/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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 ---------------------------------------------------------

Expand Down Expand Up @@ -874,7 +938,7 @@ final class AppState: ObservableObject {

// MARK: -

private func setStatus(_ message: String) {
func setStatus(_ message: String) {
status = message
NSLog("[Lobu] \(message)")
}
Expand Down
95 changes: 55 additions & 40 deletions apps/mac/Lobu/BrowserProfilesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions apps/mac/Lobu/LobuUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Comment on lines +71 to 80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing updateAvailable = false assignment.

When an update check aborts with an error, latestVersion is set to the current version but updateAvailable is not cleared. If a previous check had found an update (updateAvailable = true), the UI will remain in an inconsistent state—showing "Update to v1.0.0" when the user is already on v1.0.0.

The comment confirms the intent is to show "Up to date" rather than wedging the UI, which requires clearing the update flag. This mirrors the logic in updaterDidNotFindUpdate above.

🔧 Proposed fix
         let current = Bundle.main
             .infoDictionary?["CFBundleShortVersionString"] as? String
         Task { `@MainActor` in
+            self.updateAvailable = false
             self.latestVersion = current
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mac/Lobu/LobuUpdater.swift` around lines 71 - 80, The abort handler
updater(_:didAbortWithError:) sets latestVersion but forgets to clear the update
flag; updateAvailable should be set to false just like in
updaterDidNotFindUpdate so the UI shows "Up to date". Edit the Task { `@MainActor`
in ... } block in updater(_:didAbortWithError:) to also assign
self.updateAvailable = false (after setting self.latestVersion) to mirror the
existing no-update path and ensure consistent UI state.

}
Loading
Loading