diff --git a/.gitignore b/.gitignore index 9055b965d..92c67d0a7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,11 @@ llama.cpp/ llama.cpp-rpc-b2b/ target/ llama.cpp.bak/ + +# macOS Swift menu bar app local build/config artifacts +macos/.build/ +macos/.swiftpm/ +macos/Package.resolved +macos/dist/ +macos/.DS_Store +macos/Sources/.DS_Store diff --git a/README.md b/README.md index 6e85a15ae..2177c045a 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,25 @@ Or join without a GPU: mesh-llm --client --join ``` +## macOS Menu Bar App + +This repo also contains a native macOS menu bar controller app in `macos/`. +It bundles `mesh-llm` (plus `rpc-server`/`llama-server`) into a distributable +`Mesh LLM.app`. + +Build and package from this repo root: + +```bash +cd macos +./scripts/package-app.sh +``` + +Output: + +```bash +macos/dist/Mesh LLM.app +``` + ## How it works A common question is around latency and networks, there are a few ways that are addressed. This uses mesh tech (quic) to distribute inference workload: diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 000000000..585922a2f --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,3 @@ +.build/ +dist/ +external/decentralized-inference/mesh-llm/.artifacts/ diff --git a/macos/Package.swift b/macos/Package.swift new file mode 100644 index 000000000..6949db6bb --- /dev/null +++ b/macos/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "MeshLLMMenuBar", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "MeshLLMMenuBar", targets: ["MeshLLMMenuBar"]) + ], + targets: [ + .executableTarget( + name: "MeshLLMMenuBar", + path: "Sources", + resources: [ + .copy("Resources") + ] + ) + ] +) diff --git a/macos/README.md b/macos/README.md new file mode 100644 index 000000000..b0c482717 --- /dev/null +++ b/macos/README.md @@ -0,0 +1,89 @@ +# Mesh LLM macOS Menu Bar App + +This directory contains the macOS menu bar app in Swift that controls `mesh-llm` directly (no virtualization). + +## Current Status +- Menu bar item with actions: + - `Start Mesh` + - `Stop Mesh` + - `Restart Mesh` + - `Copy Token` + - `Open Console` + - `Open Logs` + - `Settings...` + - `Quit` +- State-driven enable/disable behavior for menu actions +- `mesh-llm` process manager wired to start/stop/restart a local binary +- Status icon tint changes by state (running/error/transition/stopped) using the same base icon +- App packaging bundles `mesh-llm` into `.app/Contents/Resources/mesh-llm` +- Real health/token flow via local endpoints (`/health`, console `/api/status`) +- Writes logs to `~/Library/Logs/MeshLLMMenuBar/mesh-llm.log` and can open them in Console.app +- Startup waits for mesh health and may take a long time on first run while model download/loading completes + +## Build (dev) +```bash +swift build +``` + +## Run (dev) +```bash +swift run MeshLLMMenuBar +``` + +The app runs as an accessory app (menu bar only). + +## Runtime Configuration +Configure via `Settings...` in the menu. + +Defaults: +- Model: `Qwen2.5-32B-Instruct-Q4_K_M` +- API port: `9337` +- Console port: `3131` + +Environment variables still override settings: + +- `MESH_LLM_MODEL_PATH` (path to GGUF/model arg for `--model`) +- `MESH_LLM_JOIN_TOKEN` (token for `--join`) +- `MESH_LLM_API_PORT` (default `9337`) +- `MESH_LLM_CONSOLE_PORT` (default `3131`, needed for token fetch) + +Example: +```bash +MESH_LLM_MODEL_PATH="$HOME/.models/GLM-4.7-Flash-Q4_K_M.gguf" \ +MESH_LLM_API_PORT=9337 \ +MESH_LLM_CONSOLE_PORT=3131 \ +swift run MeshLLMMenuBar +``` + +## Build + Bundle App With mesh-llm +This is the main workflow for distributable app packaging: + +From `external/decentralized-inference/macos`: + +```bash +./scripts/package-app.sh +``` + +From `external/decentralized-inference`: + +```bash +cd macos +./scripts/package-app.sh +``` + +What this does: +1. Runs `../mesh-llm/scripts/build-mesh-binary.sh` +2. Builds release binary (`swift build -c release`) +3. Creates `dist/Mesh LLM.app` and bundles `mesh-llm` into `Contents/Resources` +4. Bundles `rpc-server` and `llama-server` from `../llama.cpp/build/bin` +5. Bundles app icon from `Sources/Resources/MeshLLM.icns` + +## Project Layout +- `Sources/App`: app entry + app state +- `Sources/MenuBar`: menu bar controller and action wiring +- `Sources/Persistence`: settings storage + defaults +- `Sources/Process`: mesh process manager and bundled binary discovery +- `Sources/Mesh`: health/token provider +- `Sources/Utilities`: utility services (clipboard) +- `scripts/package-app.sh`: packaging pipeline (build mesh + build app + bundle mesh) +- `../mesh-llm/scripts/build-mesh-binary.sh`: local mesh binary build contract diff --git a/macos/Sources/App/AppDelegate.swift b/macos/Sources/App/AppDelegate.swift new file mode 100644 index 000000000..741483a09 --- /dev/null +++ b/macos/Sources/App/AppDelegate.swift @@ -0,0 +1,208 @@ +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate { + private var menuBarController: MenuBarController? + private var settingsWindowController: SettingsWindowController? + private var meshManager: MeshProcessManager? + private let settingsStore = MeshSettingsStore() + private let stateStore = AppStateStore() + private let logService = LogService() + private let loginItemService = LoginItemService() + private var startupModelDisplayName: String = MeshDefaults.model + + func applicationDidFinishLaunching(_ notification: Notification) { + logService.append("MeshLLMMenuBar launched") + let settings = settingsStore.load() + let runtime = makeRuntime(from: settings) + startupModelDisplayName = runtime.modelDisplayName + stateStore.configurationError = settings.validationError + do { + try loginItemService.apply(enabled: settings.startOnLogin) + } catch { + logService.append("Failed to apply login item setting: \(error.localizedDescription)") + } + self.meshManager = runtime.meshManager + let clipboardService = ClipboardService() + menuBarController = MenuBarController( + stateStore: stateStore, + meshManager: runtime.meshManager, + tokenProvider: runtime.tokenProvider, + clipboardService: clipboardService, + consoleURL: runtime.consoleURL, + openLogsHandler: { [weak self] in self?.openLogs() }, + openSettingsHandler: { [weak self] in self?.openSettingsWindow() } + ) + } + + func applicationWillTerminate(_ notification: Notification) { + logService.append("MeshLLMMenuBar terminating") + try? meshManager?.stop() + } + + private func openSettingsWindow() { + let controller = SettingsWindowController(settingsStore: settingsStore) { [weak self] settings in + guard let self else { return } + self.applySettings(settings) + } + settingsWindowController = controller + controller.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func openLogs() { + logService.openInConsoleApp() + } + + private func handleMeshOutputLine(_ line: String) { + let isTransitioning = stateStore.meshStatus == .starting || stateStore.meshStatus == .restarting + let lower = line.lowercased() + if line.hasPrefix("Invite token:") { + let token = line.replacingOccurrences(of: "Invite token:", with: "").trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.stateStore.token = token + self.menuBarController?.refresh() + } + } + } + + if lower.contains("error:") { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.stateStore.meshStatus = .error + self.stateStore.startupDetail = nil + self.stateStore.lastErrorMessage = line + self.menuBarController?.refresh() + } + return + } + + guard isTransitioning else { return } + + var detail: String? + var shouldMarkRunning = false + if lower.contains("download") { + detail = "Downloading \(startupModelDisplayName)..." + } else if lower.contains("waiting for peers") || lower.contains("waiting for peer") { + detail = "Waiting for peers..." + shouldMarkRunning = true + } else if lower.contains("ready") || lower.contains("llama-server ready") { + detail = "Finishing startup..." + shouldMarkRunning = true + } + + guard let detail else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if shouldMarkRunning { + self.stateStore.meshStatus = .running + } + self.stateStore.startupDetail = detail + self.menuBarController?.refresh() + } + } + + private func handleMeshOutputText(_ text: String) { + _ = text + } + + private func handleMeshProgressPercent(_ percent: String) { + guard stateStore.meshStatus == .starting || stateStore.meshStatus == .restarting else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let next = "Downloading \(self.startupModelDisplayName) \(percent)%" + if self.stateStore.startupDetail != next { + self.stateStore.startupDetail = next + self.menuBarController?.refresh() + } + } + } + + private static func displayModelName(from modelPathOrName: String?) -> String { + guard let raw = modelPathOrName?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return MeshDefaults.model + } + let last = URL(fileURLWithPath: raw).lastPathComponent + return last.isEmpty ? raw : last + } + + private func makeRuntime(from settings: MeshRuntimeSettings) -> (meshManager: MeshProcessManager, tokenProvider: HTTPTokenProvider, consoleURL: URL, modelDisplayName: String) { + let meshManager = MeshProcessManager( + launchArguments: settings.launchArguments(), + logService: logService + ) + meshManager.onOutputLine = { [weak self] line in + self?.handleMeshOutputLine(line) + } + meshManager.onOutputText = { [weak self] text in + self?.handleMeshOutputText(text) + } + meshManager.onProgressPercent = { [weak self] percent in + self?.handleMeshProgressPercent(percent) + } + + let tokenProvider = HTTPTokenProvider( + apiBaseURL: URL(string: "http://127.0.0.1:\(settings.apiPort)")!, + consoleBaseURL: URL(string: "http://127.0.0.1:\(settings.consolePort)")! + ) + return ( + meshManager, + tokenProvider, + URL(string: "http://127.0.0.1:\(settings.consolePort)")!, + Self.displayModelName(from: settings.modelPath) + ) + } + + private func applySettings(_ settings: MeshRuntimeSettings) { + let wasRunning = stateStore.meshStatus == .running + try? meshManager?.stop() + + let runtime = makeRuntime(from: settings) + startupModelDisplayName = runtime.modelDisplayName + meshManager = runtime.meshManager + do { + try loginItemService.apply(enabled: settings.startOnLogin) + } catch { + logService.append("Failed to apply login item setting: \(error.localizedDescription)") + } + stateStore.configurationError = settings.validationError + stateStore.lastErrorMessage = nil + stateStore.startupDetail = nil + + menuBarController?.updateRuntime( + meshManager: runtime.meshManager, + tokenProvider: runtime.tokenProvider, + consoleURL: runtime.consoleURL + ) + + guard wasRunning, settings.validationError == nil else { + stateStore.meshStatus = .stopped + menuBarController?.refresh() + return + } + + Task { @MainActor in + stateStore.meshStatus = .starting + stateStore.startupDetail = "Applying settings..." + menuBarController?.refresh() + do { + try runtime.meshManager.start() + try await runtime.tokenProvider.waitForReady() + stateStore.meshStatus = .running + stateStore.startupDetail = nil + do { + stateStore.token = try await runtime.tokenProvider.fetchToken() + } catch { + stateStore.token = nil + stateStore.lastErrorMessage = "Mesh restarted but token unavailable: \(error.localizedDescription)" + } + } catch { + stateStore.meshStatus = .error + stateStore.startupDetail = nil + stateStore.lastErrorMessage = "Failed to apply settings: \(error.localizedDescription)" + } + menuBarController?.refresh() + } + } +} diff --git a/macos/Sources/App/AppStateStore.swift b/macos/Sources/App/AppStateStore.swift new file mode 100644 index 000000000..58762a6d5 --- /dev/null +++ b/macos/Sources/App/AppStateStore.swift @@ -0,0 +1,39 @@ +import Foundation + +enum MeshStatus: String { + case stopped = "Stopped" + case starting = "Starting" + case running = "Running" + case stopping = "Stopping" + case restarting = "Restarting" + case error = "Error" +} + +final class AppStateStore { + var meshStatus: MeshStatus = .stopped + var configurationError: String? + var startupDetail: String? + var token: String? + var lastErrorMessage: String? + + var canStart: Bool { + (meshStatus == .stopped || meshStatus == .error) && configurationError == nil + } + + var canStop: Bool { + meshStatus == .running || meshStatus == .starting + } + + var canRestart: Bool { + meshStatus == .running + } + + var canOpenConsole: Bool { + meshStatus == .running || meshStatus == .starting + } + + var canCopyToken: Bool { + guard let token else { return false } + return !token.isEmpty && (meshStatus == .running || meshStatus == .starting) + } +} diff --git a/macos/Sources/App/SettingsWindowController.swift b/macos/Sources/App/SettingsWindowController.swift new file mode 100644 index 000000000..640c97a48 --- /dev/null +++ b/macos/Sources/App/SettingsWindowController.swift @@ -0,0 +1,211 @@ +import AppKit + +final class SettingsWindowController: NSWindowController { + private let settingsStore: MeshSettingsStore + private let onSave: (MeshRuntimeSettings) -> Void + + private let modelField = NSTextField(string: "") + private let joinField = NSTextField(string: "") + private let apiPortField = NSTextField(string: "") + private let consolePortField = NSTextField(string: "") + private let startOnLoginCheckbox = NSButton(checkboxWithTitle: "Start app on login", target: nil, action: nil) + private let infoLabel = NSTextField(labelWithString: "") + private let titleLabel = NSTextField(labelWithString: "Mesh LLM Settings") + private let subtitleLabel = NSTextField(labelWithString: "Configure runtime and connection options.") + + init(settingsStore: MeshSettingsStore, onSave: @escaping (MeshRuntimeSettings) -> Void) { + self.settingsStore = settingsStore + self.onSave = onSave + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 430), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "Mesh LLM Settings" + window.minSize = NSSize(width: 700, height: 430) + window.center() + + super.init(window: window) + buildUI() + loadValues() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func buildUI() { + guard let content = window?.contentView else { return } + + titleLabel.font = .systemFont(ofSize: 20, weight: .semibold) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + subtitleLabel.textColor = .secondaryLabelColor + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + + let runtimeBox = NSBox() + runtimeBox.title = "Runtime" + runtimeBox.contentViewMargins = NSSize(width: 12, height: 12) + runtimeBox.translatesAutoresizingMaskIntoConstraints = false + + let runtimeGrid = NSGridView(views: [ + [label("Model"), modelField], + [label("Join Token"), joinField] + ]) + runtimeGrid.rowSpacing = 10 + runtimeGrid.columnSpacing = 12 + runtimeGrid.translatesAutoresizingMaskIntoConstraints = false + runtimeGrid.column(at: 0).xPlacement = .trailing + runtimeGrid.column(at: 0).width = 100 + runtimeGrid.column(at: 1).xPlacement = .fill + runtimeBox.contentView?.addSubview(runtimeGrid) + + let networkBox = NSBox() + networkBox.title = "Network" + networkBox.contentViewMargins = NSSize(width: 12, height: 12) + networkBox.translatesAutoresizingMaskIntoConstraints = false + + let networkGrid = NSGridView(views: [ + [label("API Port"), apiPortField], + [label("Console Port"), consolePortField] + ]) + networkGrid.rowSpacing = 10 + networkGrid.columnSpacing = 12 + networkGrid.translatesAutoresizingMaskIntoConstraints = false + networkGrid.column(at: 0).xPlacement = .trailing + networkGrid.column(at: 0).width = 100 + networkGrid.column(at: 1).xPlacement = .fill + networkBox.contentView?.addSubview(networkGrid) + + infoLabel.stringValue = "Save applies immediately. If mesh is running, it restarts with the new settings." + infoLabel.textColor = .secondaryLabelColor + infoLabel.translatesAutoresizingMaskIntoConstraints = false + infoLabel.lineBreakMode = .byWordWrapping + infoLabel.maximumNumberOfLines = 0 + + let buttons = NSStackView() + buttons.orientation = .horizontal + buttons.spacing = 8 + buttons.alignment = .centerY + buttons.translatesAutoresizingMaskIntoConstraints = false + + let saveButton = NSButton(title: "Save & Apply", target: self, action: #selector(savePressed)) + saveButton.bezelStyle = .rounded + saveButton.keyEquivalent = "\r" + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelPressed)) + cancelButton.bezelStyle = .rounded + buttons.addArrangedSubview(saveButton) + buttons.addArrangedSubview(cancelButton) + + content.addSubview(titleLabel) + content.addSubview(subtitleLabel) + content.addSubview(runtimeBox) + content.addSubview(networkBox) + content.addSubview(startOnLoginCheckbox) + content.addSubview(infoLabel) + content.addSubview(buttons) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 18), + titleLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + + subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), + subtitleLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + subtitleLabel.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + + runtimeBox.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 14), + runtimeBox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + runtimeBox.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + runtimeBox.heightAnchor.constraint(equalToConstant: 138), + + runtimeGrid.topAnchor.constraint(equalTo: runtimeBox.contentView!.topAnchor), + runtimeGrid.leadingAnchor.constraint(equalTo: runtimeBox.contentView!.leadingAnchor), + runtimeGrid.trailingAnchor.constraint(equalTo: runtimeBox.contentView!.trailingAnchor), + runtimeGrid.bottomAnchor.constraint(equalTo: runtimeBox.contentView!.bottomAnchor), + + networkBox.topAnchor.constraint(equalTo: runtimeBox.bottomAnchor, constant: 12), + networkBox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + networkBox.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + networkBox.heightAnchor.constraint(equalToConstant: 104), + + networkGrid.topAnchor.constraint(equalTo: networkBox.contentView!.topAnchor), + networkGrid.leadingAnchor.constraint(equalTo: networkBox.contentView!.leadingAnchor), + networkGrid.trailingAnchor.constraint(equalTo: networkBox.contentView!.trailingAnchor), + networkGrid.bottomAnchor.constraint(equalTo: networkBox.contentView!.bottomAnchor), + + startOnLoginCheckbox.topAnchor.constraint(equalTo: networkBox.bottomAnchor, constant: 10), + startOnLoginCheckbox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + + infoLabel.topAnchor.constraint(equalTo: startOnLoginCheckbox.bottomAnchor, constant: 8), + infoLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + infoLabel.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + + buttons.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 12), + buttons.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + buttons.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -20), + buttons.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -20) + ]) + + modelField.placeholderString = "Qwen2.5-32B-Instruct-Q4_K_M" + joinField.placeholderString = "Optional invite token" + apiPortField.placeholderString = "9337" + consolePortField.placeholderString = "3131" + modelField.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + joinField.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + [modelField, joinField, apiPortField, consolePortField].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.widthAnchor.constraint(greaterThanOrEqualToConstant: 460).isActive = true + } + startOnLoginCheckbox.target = self + startOnLoginCheckbox.action = #selector(startOnLoginToggled) + startOnLoginCheckbox.translatesAutoresizingMaskIntoConstraints = false + } + + private func label(_ text: String) -> NSTextField { + let field = NSTextField(labelWithString: text) + field.font = .systemFont(ofSize: 12, weight: .medium) + return field + } + + private func loadValues() { + let settings = settingsStore.load() + modelField.stringValue = settings.modelPath ?? MeshDefaults.model + joinField.stringValue = settings.joinToken ?? "" + apiPortField.stringValue = String(settings.apiPort) + consolePortField.stringValue = String(settings.consolePort) + startOnLoginCheckbox.state = settings.startOnLogin ? .on : .off + } + + @objc private func savePressed() { + let model = modelField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let join = joinField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + + guard let apiPort = Int(apiPortField.stringValue), apiPort > 0, + let consolePort = Int(consolePortField.stringValue), consolePort > 0 else { + infoLabel.stringValue = "Ports must be positive integers." + infoLabel.textColor = .systemRed + return + } + + settingsStore.save( + modelPath: model, + joinToken: join, + apiPort: apiPort, + consolePort: consolePort, + startOnLogin: startOnLoginCheckbox.state == .on + ) + onSave(settingsStore.load()) + window?.close() + } + + @objc private func cancelPressed() { + window?.close() + } + + @objc private func startOnLoginToggled() { + infoLabel.stringValue = "Save applies immediately. If mesh is running, it restarts with the new settings." + infoLabel.textColor = .secondaryLabelColor + } +} diff --git a/macos/Sources/MenuBar/MenuBarController.swift b/macos/Sources/MenuBar/MenuBarController.swift new file mode 100644 index 000000000..70e82e6f0 --- /dev/null +++ b/macos/Sources/MenuBar/MenuBarController.swift @@ -0,0 +1,350 @@ +import AppKit +import Foundation + +final class MenuBarController: NSObject, NSMenuDelegate, NSMenuItemValidation { + private let stateStore: AppStateStore + private var meshManager: MeshProcessControlling + private var tokenProvider: TokenProviding + private let clipboardService: ClipboardService + private var consoleURL: URL + private let openLogsHandler: () -> Void + private let openSettingsHandler: () -> Void + + private let statusItem: NSStatusItem + private let menu: NSMenu + private let baseStatusIcon: NSImage? + private let grayStatusIcon: NSImage? + private let whiteStatusIcon: NSImage? + private let redStatusIcon: NSImage? + private let blueStatusIcon: NSImage? + + private let statusMenuItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") + private lazy var startMenuItem = NSMenuItem(title: "Start Mesh", action: #selector(startMesh), keyEquivalent: "s") + private lazy var stopMenuItem = NSMenuItem(title: "Stop Mesh", action: #selector(stopMesh), keyEquivalent: "x") + private lazy var restartMenuItem = NSMenuItem(title: "Restart Mesh", action: #selector(restartMesh), keyEquivalent: "r") + private lazy var copyTokenMenuItem = NSMenuItem(title: "Copy Token", action: #selector(copyToken), keyEquivalent: "c") + private lazy var openConsoleMenuItem = NSMenuItem(title: "Open Console", action: #selector(openConsole), keyEquivalent: "o") + private lazy var openLogsMenuItem = NSMenuItem(title: "Open Logs", action: #selector(openLogs), keyEquivalent: "l") + private lazy var settingsMenuItem = NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",") + private lazy var errorMenuItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") + private lazy var quitMenuItem = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q") + + init( + stateStore: AppStateStore, + meshManager: MeshProcessControlling, + tokenProvider: TokenProviding, + clipboardService: ClipboardService, + consoleURL: URL, + openLogsHandler: @escaping () -> Void, + openSettingsHandler: @escaping () -> Void + ) { + self.stateStore = stateStore + self.meshManager = meshManager + self.tokenProvider = tokenProvider + self.clipboardService = clipboardService + self.consoleURL = consoleURL + self.openLogsHandler = openLogsHandler + self.openSettingsHandler = openSettingsHandler + self.baseStatusIcon = Self.loadIcon(named: "MeshLLM") + self.grayStatusIcon = Self.loadIcon(named: "MeshLLM-gray") + self.whiteStatusIcon = Self.loadIcon(named: "MeshLLM-white") + self.redStatusIcon = Self.loadIcon(named: "MeshLLM-red") + self.blueStatusIcon = Self.loadIcon(named: "MeshLLM-blue") + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + menu = NSMenu() + super.init() + buildMenu() + refreshMenuState() + } + + private func buildMenu() { + if let button = statusItem.button { + if let icon = baseStatusIcon { + button.image = icon + button.imagePosition = .imageOnly + button.toolTip = "Mesh LLM" + } else { + button.title = "Mesh LLM" + } + } + statusItem.menu = menu + menu.delegate = self + + startMenuItem.target = self + stopMenuItem.target = self + restartMenuItem.target = self + copyTokenMenuItem.target = self + openConsoleMenuItem.target = self + openLogsMenuItem.target = self + settingsMenuItem.target = self + quitMenuItem.target = self + + errorMenuItem.isHidden = true + + menu.addItem(statusMenuItem) + menu.addItem(.separator()) + menu.addItem(startMenuItem) + menu.addItem(stopMenuItem) + menu.addItem(restartMenuItem) + menu.addItem(.separator()) + menu.addItem(copyTokenMenuItem) + menu.addItem(openConsoleMenuItem) + menu.addItem(openLogsMenuItem) + menu.addItem(.separator()) + menu.addItem(settingsMenuItem) + menu.addItem(errorMenuItem) + menu.addItem(.separator()) + menu.addItem(quitMenuItem) + } + + private func refreshMenuState() { + switch stateStore.meshStatus { + case .starting, .restarting: + if let detail = stateStore.startupDetail, !detail.isEmpty { + statusMenuItem.title = "\(stateStore.meshStatus.rawValue) (\(detail))" + } else { + statusMenuItem.title = "\(stateStore.meshStatus.rawValue) (first run may take a while)" + } + default: + statusMenuItem.title = stateStore.meshStatus.rawValue + } + statusItem.button?.toolTip = statusMenuItem.title + updateStatusIcon() + + let errorText = stateStore.lastErrorMessage ?? stateStore.configurationError + if let message = errorText, !message.isEmpty { + errorMenuItem.title = "Error: \(message)" + errorMenuItem.isHidden = false + } else { + errorMenuItem.title = "" + errorMenuItem.isHidden = true + } + } + + func refresh() { + refreshMenuState() + } + + func menuWillOpen(_ menu: NSMenu) { + refreshMenuState() + } + + func menuNeedsUpdate(_ menu: NSMenu) { + refreshMenuState() + } + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + guard let action = menuItem.action else { return menuItem.isEnabled } + switch action { + case #selector(startMesh): + return stateStore.canStart + case #selector(stopMesh): + return stateStore.canStop + case #selector(restartMesh): + return stateStore.canRestart + case #selector(copyToken): + return stateStore.canCopyToken + case #selector(openConsole): + return stateStore.canOpenConsole + case #selector(openLogs), #selector(openSettings), #selector(quit): + return true + default: + return menuItem.isEnabled + } + } + + func updateRuntime( + meshManager: MeshProcessControlling, + tokenProvider: TokenProviding, + consoleURL: URL + ) { + self.meshManager = meshManager + self.tokenProvider = tokenProvider + self.consoleURL = consoleURL + refreshMenuState() + } + + private func updateStatusIcon() { + guard let button = statusItem.button else { return } + switch stateStore.meshStatus { + case .running: + if let icon = whiteStatusIcon ?? baseStatusIcon { + button.image = icon + return + } + case .error: + if let icon = redStatusIcon ?? baseStatusIcon { + button.image = icon + return + } + case .starting, .stopping, .restarting: + if let icon = blueStatusIcon ?? baseStatusIcon { + button.image = icon + return + } + case .stopped: + if let icon = grayStatusIcon ?? baseStatusIcon { + button.image = icon + return + } + } + guard let base = baseStatusIcon else { return } + let fallbackColor: NSColor + switch stateStore.meshStatus { + case .running: fallbackColor = .white + case .error: fallbackColor = .systemRed + case .starting, .stopping, .restarting: fallbackColor = .systemBlue + case .stopped: fallbackColor = .systemGray + } + button.image = makeBadgedIcon(base: base, badgeColor: fallbackColor) + button.imagePosition = .imageOnly + } + + private func makeBadgedIcon(base: NSImage, badgeColor: NSColor) -> NSImage { + let size = base.size + let image = NSImage(size: size) + image.lockFocus() + base.draw(in: NSRect(origin: .zero, size: size)) + + let badgeDiameter: CGFloat = max(5, size.width * 0.34) + let x = size.width - badgeDiameter - 1 + let y: CGFloat = 1 + let badgeRect = NSRect(x: x, y: y, width: badgeDiameter, height: badgeDiameter) + + NSColor.black.withAlphaComponent(0.45).setFill() + NSBezierPath(ovalIn: badgeRect.insetBy(dx: -0.8, dy: -0.8)).fill() + badgeColor.setFill() + NSBezierPath(ovalIn: badgeRect).fill() + + image.unlockFocus() + image.isTemplate = false + return image + } + + private static func loadIcon(named name: String) -> NSImage? { + for ext in ["icns", "png"] { + if let path = Bundle.main.path(forResource: name, ofType: ext), + let icon = NSImage(contentsOfFile: path) { + icon.size = NSSize(width: 18, height: 18) + icon.isTemplate = false + return icon + } + } + + for ext in ["icns", "png"] { + let devPath = FileManager.default.currentDirectoryPath + "/Sources/Resources/\(name).\(ext)" + if FileManager.default.fileExists(atPath: devPath), + let icon = NSImage(contentsOfFile: devPath) { + icon.size = NSSize(width: 18, height: 18) + icon.isTemplate = false + return icon + } + } + return nil + } + + @objc private func startMesh() { + Task { await runStartFlow() } + } + + @objc private func stopMesh() { + Task { await runStopFlow() } + } + + @objc private func restartMesh() { + Task { await runRestartFlow() } + } + + @objc private func copyToken() { + guard let token = stateStore.token, !token.isEmpty else { return } + clipboardService.copy(token) + } + + @objc private func openConsole() { + NSWorkspace.shared.open(consoleURL) + } + + @objc private func openLogs() { + openLogsHandler() + } + + @objc private func openSettings() { + openSettingsHandler() + } + + @objc private func quit() { + NSApplication.shared.terminate(nil) + } + + private func runStartFlow() async { + stateStore.meshStatus = .starting + stateStore.startupDetail = "waiting for health" + stateStore.lastErrorMessage = nil + refreshMenuState() + + do { + try meshManager.start() + try await tokenProvider.waitForReady() + stateStore.meshStatus = .running + stateStore.startupDetail = nil + + do { + stateStore.token = try await tokenProvider.fetchToken() + } catch { + stateStore.token = nil + stateStore.lastErrorMessage = "Mesh started but token unavailable: \(error.localizedDescription)" + } + } catch { + stateStore.meshStatus = .error + stateStore.lastErrorMessage = "Failed to start mesh: \(error.localizedDescription)" + stateStore.startupDetail = nil + } + + refreshMenuState() + } + + private func runStopFlow() async { + stateStore.meshStatus = .stopping + stateStore.startupDetail = nil + stateStore.lastErrorMessage = nil + refreshMenuState() + + do { + try meshManager.stop() + stateStore.meshStatus = .stopped + stateStore.token = nil + } catch { + stateStore.meshStatus = .error + stateStore.lastErrorMessage = "Failed to stop mesh: \(error.localizedDescription)" + } + + refreshMenuState() + } + + private func runRestartFlow() async { + stateStore.meshStatus = .restarting + stateStore.startupDetail = "waiting for health" + stateStore.lastErrorMessage = nil + refreshMenuState() + + do { + try meshManager.restart() + try await tokenProvider.waitForReady() + stateStore.meshStatus = .running + stateStore.startupDetail = nil + + do { + stateStore.token = try await tokenProvider.fetchToken() + } catch { + stateStore.token = nil + stateStore.lastErrorMessage = "Mesh restarted but token unavailable: \(error.localizedDescription)" + } + } catch { + stateStore.meshStatus = .error + stateStore.lastErrorMessage = "Failed to restart mesh: \(error.localizedDescription)" + stateStore.startupDetail = nil + } + + refreshMenuState() + } +} diff --git a/macos/Sources/Mesh/TokenProvider.swift b/macos/Sources/Mesh/TokenProvider.swift new file mode 100644 index 000000000..9f0b579cc --- /dev/null +++ b/macos/Sources/Mesh/TokenProvider.swift @@ -0,0 +1,90 @@ +import Foundation + +protocol TokenProviding { + func waitForReady() async throws + func fetchToken() async throws -> String +} + +enum TokenProviderError: LocalizedError { + case healthTimeout + case invalidStatusPayload + case missingToken + + var errorDescription: String? { + switch self { + case .healthTimeout: + return "mesh-llm is still starting (model download/load can take a long time)" + case .invalidStatusPayload: + return "mesh-llm console returned invalid status payload" + case .missingToken: + return "invite token not present in mesh-llm status" + } + } +} + +private struct ConsoleStatusResponse: Decodable { + let token: String? +} + +final class HTTPTokenProvider: TokenProviding { + private let apiBaseURL: URL + private let consoleBaseURL: URL + private let session: URLSession + private let timeoutSeconds: Int + + init( + apiBaseURL: URL = URL(string: "http://127.0.0.1:9337")!, + consoleBaseURL: URL = URL(string: "http://127.0.0.1:3131")!, + timeoutSeconds: Int = 3600, + session: URLSession = .shared + ) { + self.apiBaseURL = apiBaseURL + self.consoleBaseURL = consoleBaseURL + self.timeoutSeconds = timeoutSeconds + self.session = session + } + + func fetchToken() async throws -> String { + return try await fetchInviteToken() + } + + func waitForReady() async throws { + try await waitForHealth() + } + + private func waitForHealth() async throws { + let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) + let healthURL = apiBaseURL.appendingPathComponent("health") + while Date() < deadline { + var request = URLRequest(url: healthURL) + request.timeoutInterval = 2 + do { + let (_, response) = try await session.data(for: request) + if let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) { + return + } + } catch { + // mesh startup can take time; keep polling until timeout. + } + try await Task.sleep(nanoseconds: 1_000_000_000) + } + throw TokenProviderError.healthTimeout + } + + private func fetchInviteToken() async throws -> String { + let statusURL = consoleBaseURL + .appendingPathComponent("api") + .appendingPathComponent("status") + var request = URLRequest(url: statusURL) + request.timeoutInterval = 5 + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw TokenProviderError.invalidStatusPayload + } + let decoded = try JSONDecoder().decode(ConsoleStatusResponse.self, from: data) + guard let token = decoded.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { + throw TokenProviderError.missingToken + } + return token + } +} diff --git a/macos/Sources/Persistence/MeshSettingsStore.swift b/macos/Sources/Persistence/MeshSettingsStore.swift new file mode 100644 index 000000000..6f58249f0 --- /dev/null +++ b/macos/Sources/Persistence/MeshSettingsStore.swift @@ -0,0 +1,118 @@ +import Foundation + +enum MeshSettingsKeys { + static let modelPath = "mesh.modelPath" + static let joinToken = "mesh.joinToken" + static let apiPort = "mesh.apiPort" + static let consolePort = "mesh.consolePort" + static let startOnLogin = "mesh.startOnLogin" +} + +enum MeshDefaults { + static let model = "Qwen2.5-32B-Instruct-Q4_K_M" + static let apiPort = 9337 + static let consolePort = 3131 +} + +struct MeshRuntimeSettings { + let modelPath: String? + let joinToken: String? + let apiPort: Int + let consolePort: Int + let startOnLogin: Bool + + var validationError: String? { + let hasModel = !(modelPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + let hasJoin = !(joinToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + if hasModel || hasJoin { + return nil + } + return "Configure model path or join token before starting mesh" + } + + func launchArguments() -> [String] { + var args: [String] = [ + "--port", String(apiPort), + "--console", String(consolePort) + ] + if let modelPath, !modelPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--model", modelPath]) + } + if let joinToken, !joinToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--join", joinToken]) + } + return args + } +} + +final class MeshSettingsStore { + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func load() -> MeshRuntimeSettings { + let modelPath = env("MESH_LLM_MODEL_PATH") + ?? defaults.string(forKey: MeshSettingsKeys.modelPath) + ?? MeshDefaults.model + let joinToken = env("MESH_LLM_JOIN_TOKEN") ?? defaults.string(forKey: MeshSettingsKeys.joinToken) + let apiPort = intEnv("MESH_LLM_API_PORT") + ?? defaults.integer(forKey: MeshSettingsKeys.apiPort).nonZeroOrNil + ?? MeshDefaults.apiPort + let consolePort = intEnv("MESH_LLM_CONSOLE_PORT") + ?? defaults.integer(forKey: MeshSettingsKeys.consolePort).nonZeroOrNil + ?? MeshDefaults.consolePort + + return MeshRuntimeSettings( + modelPath: modelPath, + joinToken: joinToken, + apiPort: apiPort, + consolePort: consolePort, + startOnLogin: boolEnv("MESH_LLM_START_ON_LOGIN") + ?? defaults.object(forKey: MeshSettingsKeys.startOnLogin) as? Bool + ?? true + ) + } + + func save(modelPath: String, joinToken: String, apiPort: Int, consolePort: Int, startOnLogin: Bool) { + defaults.set(modelPath, forKey: MeshSettingsKeys.modelPath) + defaults.set(joinToken, forKey: MeshSettingsKeys.joinToken) + defaults.set(apiPort, forKey: MeshSettingsKeys.apiPort) + defaults.set(consolePort, forKey: MeshSettingsKeys.consolePort) + defaults.set(startOnLogin, forKey: MeshSettingsKeys.startOnLogin) + } + + private func env(_ key: String) -> String? { + let value = ProcessInfo.processInfo.environment[key] + if let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return value + } + return nil + } + + private func intEnv(_ key: String) -> Int? { + guard let raw = env(key), let value = Int(raw), value > 0 else { + return nil + } + return value + } + + private func boolEnv(_ key: String) -> Bool? { + guard let raw = env(key)?.lowercased() else { return nil } + switch raw { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return nil + } + } +} + +private extension Int { + var nonZeroOrNil: Int? { + self > 0 ? self : nil + } +} diff --git a/macos/Sources/Process/MeshProcessManager.swift b/macos/Sources/Process/MeshProcessManager.swift new file mode 100644 index 000000000..fa729e9c5 --- /dev/null +++ b/macos/Sources/Process/MeshProcessManager.swift @@ -0,0 +1,216 @@ +import Foundation +import Darwin + +protocol MeshProcessControlling { + func start() throws + func stop() throws + func restart() throws +} + +enum MeshProcessError: LocalizedError { + case invalidState(String) + case binaryNotFound + case failedToOpenLog(String) + case failedToLaunch(String) + + var errorDescription: String? { + switch self { + case .invalidState(let message): + return message + case .binaryNotFound: + return "Bundled mesh-llm binary not found" + case .failedToOpenLog(let message): + return "Failed to open mesh log file: \(message)" + case .failedToLaunch(let message): + return "Failed to launch mesh-llm: \(message)" + } + } +} + +enum MeshBinaryLocator { + static func findMeshBinaryPath() -> String? { + let fileManager = FileManager.default + if let bundled = Bundle.main.path(forResource: "mesh-llm", ofType: nil), + fileManager.isExecutableFile(atPath: bundled) { + return bundled + } + + let devPath = fileManager.currentDirectoryPath + "/../mesh-llm/target/release/mesh-llm" + if fileManager.isExecutableFile(atPath: devPath) { + return devPath + } + return nil + } +} + +final class MeshProcessManager: MeshProcessControlling { + private var process: Process? + private var stdoutBuffer = "" + private var stderrBuffer = "" + private var progressBuffer = "" + private var lastProgressPercent: String? + private let lock = NSLock() + private let launchArguments: [String] + private let logService: LogService + var onOutputLine: ((String) -> Void)? + var onOutputText: ((String) -> Void)? + var onProgressPercent: ((String) -> Void)? + + init(launchArguments: [String] = [], logService: LogService) { + self.launchArguments = launchArguments + self.logService = logService + } + + func restart() throws { + try stop() + try start() + } + + func start() throws { + lock.lock() + if let process, process.isRunning { + lock.unlock() + throw MeshProcessError.invalidState("mesh-llm is already running") + } + lock.unlock() + + guard let binaryPath = MeshBinaryLocator.findMeshBinaryPath() else { + throw MeshProcessError.binaryNotFound + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.arguments = launchArguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let argsString = launchArguments.joined(separator: " ") + logService.append("Launching mesh-llm: \(binaryPath) \(argsString)") + process.terminationHandler = { [weak self] terminated in + self?.logService.append("mesh-llm exited with status \(terminated.terminationStatus)") + } + setupOutputHandlers(stdoutPipe: stdoutPipe, stderrPipe: stderrPipe) + + do { + try process.run() + } catch { + throw MeshProcessError.failedToLaunch(error.localizedDescription) + } + + lock.lock() + self.process = process + lock.unlock() + } + + func stop() throws { + lock.lock() + guard let process = process else { + lock.unlock() + return + } + lock.unlock() + + if !process.isRunning { + lock.lock() + self.process = nil + lock.unlock() + return + } + + process.terminate() + let deadline = Date().addingTimeInterval(5) + while process.isRunning && Date() < deadline { + usleep(100_000) + } + + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + usleep(100_000) + } + teardownOutputHandlers(process: process) + stdoutBuffer = "" + stderrBuffer = "" + progressBuffer = "" + lastProgressPercent = nil + + lock.lock() + self.process = nil + lock.unlock() + } + + private func setupOutputHandlers(stdoutPipe: Pipe, stderrPipe: Pipe) { + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + self?.consumeOutput(from: handle, isError: false) + } + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + self?.consumeOutput(from: handle, isError: true) + } + } + + private func teardownOutputHandlers(process: Process) { + if let stdoutPipe = process.standardOutput as? Pipe { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + } + if let stderrPipe = process.standardError as? Pipe { + stderrPipe.fileHandleForReading.readabilityHandler = nil + } + } + + private func consumeOutput(from handle: FileHandle, isError: Bool) { + let data = handle.availableData + guard !data.isEmpty else { return } + + let text = String(data: data, encoding: .utf8) ?? "" + onOutputText?(text) + updateProgress(from: text) + if isError { + stderrBuffer.append(text) + flushLines(from: &stderrBuffer) + } else { + stdoutBuffer.append(text) + flushLines(from: &stdoutBuffer) + } + } + + private func updateProgress(from text: String) { + progressBuffer.append(text) + if progressBuffer.count > 8192 { + progressBuffer = String(progressBuffer.suffix(2048)) + } + + let pattern = #"([0-9]{1,3}(?:\.[0-9])?)%"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return + } + let range = NSRange(progressBuffer.startIndex.. FileHandle { + try prepareLogFileIfNeeded() + let handle = try FileHandle(forWritingTo: logFileURL) + try handle.seekToEnd() + return handle + } + + func append(_ message: String) { + do { + let handle = try openAppendHandle() + defer { try? handle.close() } + let line = "\(timestamp()) \(message)\n" + if let data = line.data(using: .utf8) { + try handle.write(contentsOf: data) + } + } catch { + // Best effort logging; avoid crashing app path. + } + } + + func openInConsoleApp() { + do { + try prepareLogFileIfNeeded() + } catch { + return + } + + let consoleAppURL = URL(fileURLWithPath: "/System/Applications/Utilities/Console.app") + if fileManager.fileExists(atPath: consoleAppURL.path) { + NSWorkspace.shared.open( + [logFileURL], + withApplicationAt: consoleAppURL, + configuration: NSWorkspace.OpenConfiguration() + ) + } else { + NSWorkspace.shared.open(logFileURL) + } + } + + private func prepareLogFileIfNeeded() throws { + if !fileManager.fileExists(atPath: logDirectory.path) { + try fileManager.createDirectory(at: logDirectory, withIntermediateDirectories: true) + } + if !fileManager.fileExists(atPath: logFileURL.path) { + fileManager.createFile(atPath: logFileURL.path, contents: nil) + } + } + + private func timestamp() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: Date()) + } +} diff --git a/macos/Sources/Utilities/LoginItemService.swift b/macos/Sources/Utilities/LoginItemService.swift new file mode 100644 index 000000000..fe67a4ac0 --- /dev/null +++ b/macos/Sources/Utilities/LoginItemService.swift @@ -0,0 +1,15 @@ +import Foundation +import ServiceManagement + +final class LoginItemService { + func apply(enabled: Bool) throws { + guard #available(macOS 13.0, *) else { + return + } + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } +} diff --git a/macos/Sources/main.swift b/macos/Sources/main.swift new file mode 100644 index 000000000..c39deb831 --- /dev/null +++ b/macos/Sources/main.swift @@ -0,0 +1,7 @@ +import AppKit + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/macos/scripts/package-app.sh b/macos/scripts/package-app.sh new file mode 100755 index 000000000..63e2fce1d --- /dev/null +++ b/macos/scripts/package-app.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DI_ROOT="$(cd "$ROOT_DIR/.." && pwd)" +MESH_BUILD_SCRIPT="$DI_ROOT/mesh-llm/scripts/build-mesh-binary.sh" +LLAMA_BIN_DIR="$DI_ROOT/llama.cpp/build/bin" +RPC_SERVER_PATH="$LLAMA_BIN_DIR/rpc-server" +LLAMA_SERVER_PATH="$LLAMA_BIN_DIR/llama-server" +APP_ICON_DIR="$ROOT_DIR/Sources/Resources" +APP_ICON_PATH="$APP_ICON_DIR/MeshLLM.icns" +DIST_DIR="$ROOT_DIR/dist" +APP_EXECUTABLE="MeshLLMMenuBar" +APP_DISPLAY_NAME="Mesh LLM" +APP_BUNDLE="$DIST_DIR/$APP_DISPLAY_NAME.app" +CONTENTS_DIR="$APP_BUNDLE/Contents" +MACOS_DIR="$CONTENTS_DIR/MacOS" +RESOURCES_DIR="$CONTENTS_DIR/Resources" + +if [ ! -x "$MESH_BUILD_SCRIPT" ]; then + echo "Missing executable mesh build script: $MESH_BUILD_SCRIPT" >&2 + exit 1 +fi +if [ ! -f "$APP_ICON_PATH" ]; then + echo "Missing app icon: $APP_ICON_PATH" >&2 + exit 1 +fi + +echo "[1/3] Building mesh-llm from source tree" +BUILD_OUTPUT="$($MESH_BUILD_SCRIPT)" +MESH_BINARY_PATH="$(printf '%s\n' "$BUILD_OUTPUT" | awk -F= '/^BINARY_PATH=/{print $2}' | tail -n1)" + +if [ -z "$MESH_BINARY_PATH" ] || [ ! -x "$MESH_BINARY_PATH" ]; then + echo "Mesh build did not produce a valid BINARY_PATH" >&2 + printf '%s\n' "$BUILD_OUTPUT" >&2 + exit 1 +fi + +echo "[2/4] Ensuring llama.cpp runtime binaries (rpc-server, llama-server)" +if [ ! -x "$RPC_SERVER_PATH" ] || [ ! -x "$LLAMA_SERVER_PATH" ]; then + if ! command -v just >/dev/null 2>&1; then + echo "Missing required binaries and 'just' is not installed." >&2 + echo "Install 'just' and run: (cd \"$DI_ROOT\" && just build)" >&2 + exit 1 + fi + echo "Building distributed-inference prerequisites via just build..." + (cd "$DI_ROOT" && just build) +fi + +if [ ! -x "$RPC_SERVER_PATH" ] || [ ! -x "$LLAMA_SERVER_PATH" ]; then + echo "Required binaries not found after build:" >&2 + echo " $RPC_SERVER_PATH" >&2 + echo " $LLAMA_SERVER_PATH" >&2 + exit 1 +fi + +echo "[3/4] Building release binary" +cd "$ROOT_DIR" +if pgrep -x "$APP_EXECUTABLE" >/dev/null 2>&1; then + echo "Stopping running $APP_EXECUTABLE process..." + pkill -x "$APP_EXECUTABLE" || true + sleep 1 +fi +echo "Cleaning Swift package build artifacts..." +swift package clean +swift build -c release + +BIN_PATH="$ROOT_DIR/.build/release/$APP_EXECUTABLE" +if [ ! -x "$BIN_PATH" ]; then + echo "Missing built binary: $BIN_PATH" >&2 + exit 1 +fi + +echo "[4/4] Assembling app bundle" +rm -rf "$APP_BUNDLE" +mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" +cp "$BIN_PATH" "$MACOS_DIR/$APP_EXECUTABLE" +cp "$MESH_BINARY_PATH" "$RESOURCES_DIR/mesh-llm" +chmod +x "$RESOURCES_DIR/mesh-llm" +cp "$RPC_SERVER_PATH" "$RESOURCES_DIR/rpc-server" +cp "$LLAMA_SERVER_PATH" "$RESOURCES_DIR/llama-server" +chmod +x "$RESOURCES_DIR/rpc-server" "$RESOURCES_DIR/llama-server" +for dylib in "$LLAMA_BIN_DIR"/*.dylib; do + [ -f "$dylib" ] || continue + cp "$dylib" "$RESOURCES_DIR/" +done +cp "$APP_ICON_PATH" "$RESOURCES_DIR/MeshLLM.icns" +for icon in "$APP_ICON_DIR"/MeshLLM-*.icns; do + [ -f "$icon" ] || continue + cp "$icon" "$RESOURCES_DIR/" +done +for icon in "$APP_ICON_DIR"/MeshLLM-*.png; do + [ -f "$icon" ] || continue + cp "$icon" "$RESOURCES_DIR/" +done +RESOURCE_BUNDLE_PATH="$ROOT_DIR/.build/release/${APP_EXECUTABLE}_${APP_EXECUTABLE}.resources" +if [ -d "$RESOURCE_BUNDLE_PATH" ]; then + cp -R "$RESOURCE_BUNDLE_PATH" "$RESOURCES_DIR/" +fi + +cat > "$CONTENTS_DIR/Info.plist" < + + + + CFBundleExecutable + $APP_EXECUTABLE + CFBundleIdentifier + com.meshllm.menubar + CFBundleName + $APP_DISPLAY_NAME + CFBundlePackageType + APPL + CFBundleIconFile + MeshLLM + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + $(date +%s) + LSUIElement + + + +PLIST + +echo "Built app bundle: $APP_BUNDLE" +echo "Bundled mesh-llm binary: $RESOURCES_DIR/mesh-llm" +echo "Bundled rpc-server: $RESOURCES_DIR/rpc-server" +echo "Bundled llama-server: $RESOURCES_DIR/llama-server" +echo "Bundled app icon: $RESOURCES_DIR/MeshLLM.icns" +echo "Bundled variant icons (if present): $RESOURCES_DIR/MeshLLM-*.{icns,png}"