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
14 changes: 6 additions & 8 deletions mac/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions mac/Sources/TokenomicsMenubar/ConnectionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
private var automaticTask: Task<Void, Never>?
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions mac/Sources/TokenomicsMenubar/Launcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 7 additions & 1 deletion mac/Sources/TokenomicsMenubar/TokenomicsMenubarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
57 changes: 55 additions & 2 deletions mac/Tests/TokenomicsMenubarTests/TokenomicsMenubarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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()
Expand Down
Loading