diff --git a/apps/mac/Lobu/AppState.swift b/apps/mac/Lobu/AppState.swift index 6997fa001..25458db9e 100644 --- a/apps/mac/Lobu/AppState.swift +++ b/apps/mac/Lobu/AppState.swift @@ -236,6 +236,13 @@ final class AppState: ObservableObject { } startAutoPollIfSignedIn() } + } else if credentials == nil, shouldAutoConnectAtLaunch() { + // First launch with no stored creds AND the URL points at the + // embedded runner we manage → auto-connect. No need to surface + // a "Start" button: spawn the runner, adopt no-auth credentials, + // signed in within ~1 s. Failures (CLI missing, port conflict) + // leave `credentials` nil and the connection card surfaces. + Task { @MainActor in await connect() } } else { startAutoPollIfSignedIn() } @@ -465,6 +472,16 @@ final class AppState: ObservableObject { setStatus("") } + /// True iff init() should auto-trigger connect() at launch instead of + /// showing the connection card. We only do this when the URL targets the + /// embedded runner we manage — auto-spawning a process for a URL the + /// user typed pointing at someone else's server would be surprising. + private func shouldAutoConnectAtLaunch() -> Bool { + let raw = customServerDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: raw) else { return false } + return AppState.matchesManagedRunner(url) + } + // Contract with `ensureBootstrapPat` in packages/server/src/start-local.ts. // Changing either side without the other makes the menu bar's display // disagree with reality. @@ -523,10 +540,16 @@ final class AppState: ObservableObject { setStatus("Lobu is running on this Mac (~/lobu).") } catch LocalLobuRunner.RunnerError.cliNotFound { localLobuStatus = .cliMissing - setStatus(LocalLobuRunner.RunnerError.cliNotFound.errorDescription ?? "Lobu CLI not installed.") + // The connection card surfaces `cliMissing` inline with the + // install hint — don't echo it into the header status too. + setStatus("") } catch { localLobuStatus = .failed(message: error.localizedDescription) - setStatus(error.localizedDescription) + // Same — connectionCard's `connectStatusLine` renders the failure + // in orange right under the Start button. Echoing it into + // `state.status` would also push it into the header, producing + // the duplicate the user saw. + setStatus("") } } diff --git a/apps/mac/Lobu/LocalLobuRunner.swift b/apps/mac/Lobu/LocalLobuRunner.swift index 8b0674cfc..b3b4db44f 100644 --- a/apps/mac/Lobu/LocalLobuRunner.swift +++ b/apps/mac/Lobu/LocalLobuRunner.swift @@ -88,6 +88,18 @@ final class LocalLobuRunner { // bind. start-local.ts defaults HOST to 0.0.0.0, so we must pin it // explicitly here — otherwise the runner just crashes on boot. env["HOST"] = "127.0.0.1" + // The embedded gateway refuses to boot without an ENCRYPTION_KEY (used + // to encrypt at-rest connection secrets). For a personal-use install + // the only sensible default is an ephemeral key generated on first + // boot and persisted under LOBU_DATA_DIR — opt in here so the user + // doesn't have to manage a secret manually. + env["LOBU_ALLOW_EPHEMERAL_ENCRYPTION_KEY"] = "1" + // The embedded server requires Node 22.x–24.x (isolated-vm constraint). + // If the user's PATH leads with a newer Node (Homebrew often does + // `node` = latest), inject the keg-only Node 22 location first so + // `lobu` picks the right interpreter without forcing the user to + // manage PATH manually. + env["PATH"] = Self.preferredPath(currentPath: env["PATH"] ?? "") proc.environment = env proc.standardInput = FileHandle.nullDevice // no TTY — any prompt gets EOF and fails fast @@ -151,6 +163,29 @@ final class LocalLobuRunner { return true } + /// Return a PATH that puts a known Node 22 install first if one exists, + /// so the spawned `lobu run` picks an interpreter the embedded server + /// supports (22.x–24.x; isolated-vm doesn't build on Node 25+). + /// + /// Looks in the obvious places: Homebrew keg-only `node@22`, plus the + /// version-manager shim paths the user might already have configured. + /// Order matters — Homebrew's keg-only formula is the most common case + /// on macOS, so it goes first. + private static func preferredPath(currentPath: String) -> String { + let candidates = [ + "/opt/homebrew/opt/node@22/bin", + "/usr/local/opt/node@22/bin", + "/opt/homebrew/opt/node@24/bin", + "/usr/local/opt/node@24/bin", + "\(NSHomeDirectory())/.local/share/mise/installs/node/22/bin", + "\(NSHomeDirectory())/.local/share/fnm/aliases/default/bin", + ] + let fm = FileManager.default + let prefixes = candidates.filter { fm.isExecutableFile(atPath: "\($0)/node") } + if prefixes.isEmpty { return currentPath } + return (prefixes + [currentPath]).joined(separator: ":") + } + private static func locateLobuCLI() -> String? { if let resourceURL = Bundle.main.resourceURL { let bundled = resourceURL.appendingPathComponent("lobu-cli/bin/lobu").path