From f6df458aff323fdf8c3f65b548d179ae6bdf966e Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Tue, 4 Aug 2026 15:01:28 -0400 Subject: [PATCH] Start Tokenomics backend from menu bar app The coordinator previously depended on SwiftUI view tasks and could not discover the standard installed or source launcher without a persisted command. Start it from the application lifecycle and resolve persisted, explicit, source-checkout, and ~/.local launcher configurations while preserving exact endpoint ownership checks. Verified with the 18 focused Swift lifecycle tests, a warnings-as-errors release build, all 262 Node tests, and an offline/start/reuse runtime smoke. The full Swift suite remains 38/40 because of the existing headless AppKit activation-policy and settings-window size failures. --- mac/README.md | 14 ++--- .../ConnectionCoordinator.swift | 16 +++--- mac/Sources/TokenomicsMenubar/Launcher.swift | 51 +++++++++++++++++ .../TokenomicsMenubarApp.swift | 8 ++- .../TokenomicsMenubarTests.swift | 57 ++++++++++++++++++- 5 files changed, 126 insertions(+), 20 deletions(-) diff --git a/mac/README.md b/mac/README.md index e48bc79..8701dd5 100644 --- a/mac/README.md +++ b/mac/README.md @@ -13,14 +13,12 @@ budget policy; the Swift app consumes its versioned summary contract. ## Run from source -Run the repository's one-line installer first. On macOS it atomically writes -`~/Library/Application Support/Tokenomics Viewer/tokenomics-launch.json`, so -the app can start the installed `tokenomics-launch` wrapper when the configured -loopback service is offline. On launch, wake, and refresh, the app probes that -exact endpoint and starts the wrapper with `--no-open` only when no Tokenomics -service answers. It does not replace an unrelated service occupying the port, -and only **Open Dashboard** opens a browser. A source-only setup can select an -executable wrapper in **Settings → Launcher fallback**. +On launch, wake, and refresh, the app probes the configured loopback endpoint. +If Tokenomics is offline, it starts the backend with `--no-open`. It resolves the +launcher from the installer's persisted command, an explicit **Settings → +Launcher fallback**, the current source checkout when run with `swift run`, or +`~/.local/bin/tokenomics-launch`. It does not replace an unrelated service +occupying the port, and only **Open Dashboard** opens a browser. ```sh cd mac diff --git a/mac/Sources/TokenomicsMenubar/ConnectionCoordinator.swift b/mac/Sources/TokenomicsMenubar/ConnectionCoordinator.swift index e48aea5..cf64837 100644 --- a/mac/Sources/TokenomicsMenubar/ConnectionCoordinator.swift +++ b/mac/Sources/TokenomicsMenubar/ConnectionCoordinator.swift @@ -17,6 +17,7 @@ public final class ConnectionCoordinator: ObservableObject { private let client: any TokenomicsHTTPClient private let launcher: any TokenomicsLauncher + private let launcherConfigurationResolver: @MainActor (String) -> PersistedLauncherConfiguration? private var launcherProcess: (any TokenomicsProcessHandle)? private var operationTask: Task? private var automaticTask: Task? @@ -31,11 +32,15 @@ public final class ConnectionCoordinator: ObservableObject { public init( preferences: PreferencesStore = PreferencesStore(), client: any TokenomicsHTTPClient = URLSessionTokenomicsClient(), - launcher: any TokenomicsLauncher = DirectTokenomicsLauncher() + launcher: any TokenomicsLauncher = DirectTokenomicsLauncher(), + launcherConfigurationResolver: @escaping @MainActor (String) -> PersistedLauncherConfiguration? = { + LauncherConfigurationStore.resolveConfiguration(fallbackPath: $0) + } ) { self.preferences = preferences self.client = client self.launcher = launcher + self.launcherConfigurationResolver = launcherConfigurationResolver self.observedPreferredPort = preferences.preferredPort observedWake = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, @@ -331,14 +336,7 @@ public final class ConnectionCoordinator: ObservableObject { } private func launcherConfiguration() -> PersistedLauncherConfiguration? { - // The CLI's persisted command includes the interpreter/script argument - // when needed. Prefer it so a launch never loses that argv contract. - if let persisted = LauncherConfigurationStore.readConfiguration() { return persisted } - guard RuntimePreferences.validAbsolutePath(preferences.launcherPath), - !preferences.launcherPath.isEmpty, - FileManager.default.isExecutableFile(atPath: preferences.launcherPath) - else { return nil } - return PersistedLauncherConfiguration(command: preferences.launcherPath) + launcherConfigurationResolver(preferences.launcherPath) } private func syncAndWait(at endpoint: Endpoint, generation: Int) async throws -> SyncProbe { diff --git a/mac/Sources/TokenomicsMenubar/Launcher.swift b/mac/Sources/TokenomicsMenubar/Launcher.swift index fc58469..909faad 100644 --- a/mac/Sources/TokenomicsMenubar/Launcher.swift +++ b/mac/Sources/TokenomicsMenubar/Launcher.swift @@ -260,4 +260,55 @@ public enum LauncherConfigurationStore { public static func read(from url: URL = defaultURL) -> String? { readConfiguration(from: url)?.command } + + /// Resolve the backend launcher without requiring a separate menu-bar + /// preference. Installed applications use the stable wrapper under + /// ~/.local/bin; `swift run` uses the launcher from the source checkout. + public static func resolveConfiguration( + fallbackPath: String, + persistedURL: URL = defaultURL, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + executableURL: URL? = Bundle.main.executableURL + ) -> PersistedLauncherConfiguration? { + if let persisted = readConfiguration(from: persistedURL) { return persisted } + if let fallback = executableConfiguration(atPath: fallbackPath) { return fallback } + if let development = developmentConfiguration(executableURL: executableURL) { return development } + return executableConfiguration( + atPath: homeDirectory + .appendingPathComponent(".local/bin/tokenomics-launch") + .path + ) + } + + private static func developmentConfiguration(executableURL: URL?) -> PersistedLauncherConfiguration? { + guard let executableURL else { return nil } + var directory = executableURL.deletingLastPathComponent().standardizedFileURL + let fileManager = FileManager.default + + // A SwiftPM executable lives below mac/.build. Walk only its ancestor + // chain and require the repository shape before trusting launcher.js. + for _ in 0..<12 { + let launcher = directory.appendingPathComponent("launcher.js") + let app = directory.appendingPathComponent("app.js") + let package = directory.appendingPathComponent("mac/Package.swift") + if fileManager.fileExists(atPath: app.path), + fileManager.fileExists(atPath: package.path), + let configuration = executableConfiguration(atPath: launcher.path) + { + return configuration + } + let parent = directory.deletingLastPathComponent() + if parent.path == directory.path { break } + directory = parent + } + return nil + } + + private static func executableConfiguration(atPath path: String) -> PersistedLauncherConfiguration? { + guard RuntimePreferences.validAbsolutePath(path), + !path.isEmpty, + FileManager.default.isExecutableFile(atPath: path) + else { return nil } + return PersistedLauncherConfiguration(command: path) + } } diff --git a/mac/Sources/TokenomicsMenubar/TokenomicsMenubarApp.swift b/mac/Sources/TokenomicsMenubar/TokenomicsMenubarApp.swift index e37b5ee..9041888 100644 --- a/mac/Sources/TokenomicsMenubar/TokenomicsMenubarApp.swift +++ b/mac/Sources/TokenomicsMenubar/TokenomicsMenubarApp.swift @@ -17,9 +17,15 @@ struct TokenomicsMenubar: App { init() { let prefs = PreferencesStore() - _coordinator = StateObject(wrappedValue: ConnectionCoordinator(preferences: prefs)) + let coordinator = ConnectionCoordinator(preferences: prefs) + _coordinator = StateObject(wrappedValue: coordinator) _clock = StateObject(wrappedValue: MinuteClock()) settingsWindowController = SettingsWindowController(preferences: prefs) + + // A MenuBarExtra label is not guaranteed to enter the SwiftUI view + // hierarchy before the user opens it. Start from the application + // lifecycle so the backend is available without that first click. + coordinator.start() } var body: some Scene { diff --git a/mac/Tests/TokenomicsMenubarTests/TokenomicsMenubarTests.swift b/mac/Tests/TokenomicsMenubarTests/TokenomicsMenubarTests.swift index 0135d89..128ce55 100644 --- a/mac/Tests/TokenomicsMenubarTests/TokenomicsMenubarTests.swift +++ b/mac/Tests/TokenomicsMenubarTests/TokenomicsMenubarTests.swift @@ -309,6 +309,55 @@ final class DirectLauncherTests: XCTestCase { } } +final class LauncherConfigurationStoreTests: XCTestCase { + func testFindsTheStandardInstalledLauncherWithoutPersistedConfiguration() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let launcher = home.appendingPathComponent(".local/bin/tokenomics-launch") + try FileManager.default.createDirectory( + at: launcher.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("#!/bin/sh\n".utf8).write(to: launcher) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: launcher.path) + defer { try? FileManager.default.removeItem(at: home) } + + let configuration = LauncherConfigurationStore.resolveConfiguration( + fallbackPath: "", + persistedURL: home.appendingPathComponent("missing.json"), + homeDirectory: home, + executableURL: nil + ) + + XCTAssertEqual(configuration, PersistedLauncherConfiguration(command: launcher.path)) + } + + func testSwiftRunFindsTheRepositoryLauncherFromTheExecutableAncestors() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let launcher = root.appendingPathComponent("launcher.js") + let executable = root.appendingPathComponent("mac/.build/debug/TokenomicsMenubar") + try FileManager.default.createDirectory( + at: executable.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("#!/usr/bin/env node\n".utf8).write(to: launcher) + try Data().write(to: root.appendingPathComponent("app.js")) + try Data().write(to: root.appendingPathComponent("mac/Package.swift")) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: launcher.path) + defer { try? FileManager.default.removeItem(at: root) } + + let configuration = LauncherConfigurationStore.resolveConfiguration( + fallbackPath: "", + persistedURL: root.appendingPathComponent("missing.json"), + homeDirectory: root.appendingPathComponent("empty-home"), + executableURL: executable + ) + + XCTAssertEqual(configuration, PersistedLauncherConfiguration(command: launcher.path)) + } +} + final class SummaryDecodingTests: XCTestCase { func testDecodesNodeGeneratedSummaryContractFixture() throws { let url = try XCTUnwrap(Bundle.module.url(forResource: "summary-v1", withExtension: "json", subdirectory: "Fixtures")) @@ -637,10 +686,14 @@ final class CoordinatorSyncTests: XCTestCase { let suite = UserDefaults(suiteName: "TokenomicsMenubarTests.explicit-start")! suite.removePersistentDomain(forName: "TokenomicsMenubarTests.explicit-start") let preferences = PreferencesStore(defaults: suite) - preferences.launcherPath = "/bin/sh" let client = StartableClient() let launcher = RecordingLauncher(client: client) - let coordinator = ConnectionCoordinator(preferences: preferences, client: client, launcher: launcher) + let coordinator = ConnectionCoordinator( + preferences: preferences, + client: client, + launcher: launcher, + launcherConfigurationResolver: { _ in PersistedLauncherConfiguration(command: "/bin/sh") } + ) defer { coordinator.stop() } coordinator.start()