diff --git a/tools/shrimp-menubar/.gitignore b/tools/shrimp-menubar/.gitignore new file mode 100644 index 0000000..a83978d --- /dev/null +++ b/tools/shrimp-menubar/.gitignore @@ -0,0 +1,5 @@ +.build/ +.DS_Store +DerivedData/ +*.app +dist/ diff --git a/tools/shrimp-menubar/Package.swift b/tools/shrimp-menubar/Package.swift new file mode 100644 index 0000000..f9d8541 --- /dev/null +++ b/tools/shrimp-menubar/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "ShrimpMenubar", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable(name: "ShrimpMenubar", targets: ["ShrimpMenubar"]) + ], + targets: [ + .executableTarget( + name: "ShrimpMenubar", + path: "Sources/ShrimpMenubar" + ) + ] +) diff --git a/tools/shrimp-menubar/README.md b/tools/shrimp-menubar/README.md new file mode 100644 index 0000000..2dda5fc --- /dev/null +++ b/tools/shrimp-menubar/README.md @@ -0,0 +1,131 @@ +# ShrimpMenubar + +macOS menubar app for a **multi-relay** shrimp workflow: + +```text +configure one or more remote OpenClaw WSS targets +-> derive team Feishu login URL from each WSS hostname +-> open Feishu login in the relay browser profile +-> verify login against derived /af/openclaw/overview +-> start one or more local ws:// relays on different ports +``` + +## What this version does + +- stores config in `~/Library/Application Support/ShrimpMenubar/config.json` +- supports **multiple relay configs** in one menubar app +- each relay can listen on its **own local port** +- derives the Feishu team login URL from the WSS hostname + - example: + - WSS: `wss://gcnxj886r06f-app_4jqhmcy1aypwy-1859632864877715.aiforce.run/af/openclaw` + - team id: `gcnxj886r06f` + - login URL: `https://gcnxj886r06f.feishu.cn` +- keeps using the existing persistent browser profile + relay scripts +- starts the local relay using `shrimp_ws_bridge.mjs bridge` +- exposes per-relay logs + quick links in the menubar menu + +## Important scope change + +Mission Control has been removed from the app surface. + +This tool is now focused on: +- Feishu login / cookie preparation +- local ws relay startup +- multi-port relay management + +It is **not** a Mission Control launcher anymore. + +## Run + +```bash +cd /Users/ai/clawd/workspaces/Engineering/shrimp-menubar +swift run ShrimpMenubar +``` + +## First-time setup + +On first launch the app creates: + +```text +~/Library/Application Support/ShrimpMenubar/config.json +``` + +The new format is: + +```json +{ + "relays": [ + { + "id": "relay-1", + "name": "shrimp-3", + "listenMode": "localhost", + "bridgePort": 18790, + "remoteGatewayWSS": "wss://.../af/openclaw", + "gatewayToken": "SET_ME", + "browserProfileDir": "...", + "browserChannel": "chromium", + "relayScriptPath": ".../shrimp_relay.mjs", + "bridgeScriptPath": ".../shrimp_ws_bridge.mjs", + "nodeBinary": "node", + "loginLogPath": "...", + "bridgeLogPath": "..." + } + ] +} +``` + +Minimum fields to review for each relay: +- `name` +- `bridgePort` +- `remoteGatewayWSS` +- `gatewayToken` +- `relayScriptPath` +- `bridgeScriptPath` +- `nodeBinary` + +## Validation behavior + +The app surfaces problems directly in the menu before startup: +- missing placeholder token +- invalid / missing remote WSS +- failure to derive the team Feishu URL from WSS +- missing relay / bridge scripts +- missing `node` +- malformed, incomplete, or legacy config (it rewrites/migrates config and shows a warning) + +## Main actions per relay + +1. **Check Login** + - verifies login state using the derived OpenClaw overview URL for that WSS target + +2. **Open Feishu Login** + - opens the derived team Feishu URL in the persistent relay browser profile + - intended for Feishu / Miaoda auth preparation + +3. **Start Relay** + - starts a local WebSocket relay on the configured local port + +4. **Stop Relay** + - stops that relay only + +5. **One-click Start** + - checks login first + - if logged in: starts relay + - otherwise: opens Feishu login flow + +## Settings window + +The settings window is a standalone native macOS window, not a menu sheet. + +It supports: +- adding multiple relay configs +- deleting relay configs +- editing per-relay ports / WSS / tokens / profile dirs / log paths + +## Logs + +Each relay has separate logs: +- login log +- bridge log + +Use the per-relay log buttons in the menu to inspect them. diff --git a/tools/shrimp-menubar/Sources/ShrimpMenubar/AppModel.swift b/tools/shrimp-menubar/Sources/ShrimpMenubar/AppModel.swift new file mode 100644 index 0000000..1bdb481 --- /dev/null +++ b/tools/shrimp-menubar/Sources/ShrimpMenubar/AppModel.swift @@ -0,0 +1,684 @@ +import Foundation +import SwiftUI +import AppKit +import Darwin + +enum ListenMode: String, CaseIterable, Codable, Identifiable { + case localhost + case lan + + var id: String { rawValue } + + var bindHost: String { + switch self { + case .localhost: return "127.0.0.1" + case .lan: return "0.0.0.0" + } + } + + var title: String { + switch self { + case .localhost: return "localhost" + case .lan: return "lan" + } + } +} + +struct RelayConfig: Codable, Equatable, Identifiable { + var id: String + var name: String + var listenMode: ListenMode + var bridgePort: Int + var remoteGatewayWSS: String + var gatewayToken: String + var browserProfileDir: String + var browserChannel: String + var relayScriptPath: String + var bridgeScriptPath: String + var nodeBinary: String + var loginLogPath: String + var bridgeLogPath: String + + static func template(index: Int) -> RelayConfig { + let slug = "relay-\(index)" + return RelayConfig( + id: slug, + name: index == 1 ? "shrimp-3" : slug, + listenMode: .localhost, + bridgePort: 18789 + index, + remoteGatewayWSS: index == 1 + ? "wss://gcnxj886r06f-app_4jqhmcy1aypwy-1859632864877715.aiforce.run/af/openclaw" + : "wss://example-team-app_xxx.aiforce.run/af/openclaw", + gatewayToken: "SET_ME", + browserProfileDir: NSString(string: "~/Library/Application Support/ShrimpMenubar/browser-profile-\(slug)").expandingTildeInPath, + browserChannel: "chromium", + relayScriptPath: "/Users/ai/clawd/workspaces/Leadership/tools/shrimp_relay.mjs", + bridgeScriptPath: "/Users/ai/clawd/workspaces/Leadership/tools/shrimp_ws_bridge.mjs", + nodeBinary: "node", + loginLogPath: NSString(string: "~/.openclaw/logs/\(slug)-login/out.log").expandingTildeInPath, + bridgeLogPath: NSString(string: "~/.openclaw/logs/\(slug)-bridge/out.log").expandingTildeInPath + ) + } +} + +struct AppSettings: Codable, Equatable { + var relays: [RelayConfig] + + static let `default` = AppSettings(relays: [.template(index: 1)]) +} + +private struct LegacySingleConfig: Codable { + var listenMode: ListenMode + var bridgePort: Int + var missionControlPort: Int? + var overviewURL: String? + var remoteGatewayWSS: String + var gatewayToken: String + var browserProfileDir: String + var browserChannel: String + var relayScriptPath: String + var bridgeScriptPath: String + var missionControlPath: String? + var nodeBinary: String + var npmBinary: String? + var loginLogPath: String + var bridgeLogPath: String + var missionControlLogPath: String? +} + +struct RelayStatusProbe: Codable { + let summary: String + let navigationError: String? + let currentUrl: String? +} + +@MainActor +final class RelayRuntime: ObservableObject { + enum LoginState: String { + case unknown + case checking + case loginRequired = "login_required" + case loggedIn = "logged_in" + case waitingUserLogin = "waiting_user_login" + case failed + } + + let id: String + @Published var config: RelayConfig + @Published var loginState: LoginState = .unknown + @Published var bridgeRunning = false + @Published var loginHelperRunning = false + @Published var lastError: String? + + private var loginProcess: Process? + private var bridgeProcess: Process? + + init(config: RelayConfig, warning: String? = nil) { + self.id = config.id + self.config = config + self.lastError = warning + ensureDirectories() + applyConfigWarnings() + } + + var name: String { config.name } + var listenModeText: String { config.listenMode.title } + var loginStateText: String { + switch loginState { + case .unknown: return "unknown" + case .checking: return "checking" + case .loginRequired: return "login required" + case .loggedIn: return "logged in" + case .waitingUserLogin: return "waiting user" + case .failed: return "failed" + } + } + var bridgeStateText: String { bridgeRunning ? "running" : "stopped" } + var loginHelperStateText: String { loginHelperRunning ? "running" : "stopped" } + var localRelayURL: String { "ws://127.0.0.1:\(config.bridgePort)" } + var relayBindHost: String { config.listenMode.bindHost } + + var statusIconName: String { + if bridgeRunning { return "checkmark.circle.fill" } + switch loginState { + case .loginRequired: return "person.crop.circle.badge.exclamationmark" + case .loggedIn: return "person.crop.circle.badge.checkmark" + case .waitingUserLogin, .checking: return "person.crop.circle.badge.clock" + case .failed: return "xmark.circle" + case .unknown: return "circle.dashed" + } + } + + var summaryLine: String { + if bridgeRunning { return "Relay running at \(localRelayURL)" } + switch loginState { + case .loggedIn: + return "Logged in. Ready to start relay." + case .loginRequired: + return "Feishu login required" + case .waitingUserLogin: + return "Login flow started in persistent browser profile." + case .checking: + return "Checking login state" + case .failed: + return "Login check failed" + case .unknown: + return "Configure WSS, token, browser profile, and port." + } + } + + var teamFeishuURL: String? { + guard + let host = URL(string: config.remoteGatewayWSS.trimmingCharacters(in: .whitespacesAndNewlines))?.host, + let rawTeam = host.split(separator: "-").first, + !rawTeam.isEmpty + else { return nil } + return "https://\(rawTeam).feishu.cn" + } + + var derivedOverviewURL: String? { + let trimmed = config.remoteGatewayWSS.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let url = URL(string: trimmed), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + if components.scheme == "wss" { components.scheme = "https" } + if components.scheme == "ws" { components.scheme = "http" } + components.path = "/af/openclaw/overview" + components.query = nil + return components.url?.absoluteString + } + + func updateConfig(_ newConfig: RelayConfig) { + let wasBridgeRunning = bridgeProcess?.isRunning == true + let wasLoginRunning = loginProcess?.isRunning == true + stopAll() + config = newConfig + ensureDirectories() + applyConfigWarnings() + if wasBridgeRunning || wasLoginRunning { + lastError = "Config updated. Restart relay/login flow manually so the new settings take effect." + } + } + + func startAll() { + Task { + applyConfigWarnings() + await checkLoginState() + guard loginState == .loggedIn else { + openFeishuLogin() + return + } + startRelay() + } + } + + func stopAll() { + stopProcess(&bridgeProcess) + stopProcess(&loginProcess) + bridgeRunning = false + loginHelperRunning = false + } + + func checkLoginState() async { + guard validateConfigForLogin() else { return } + loginState = .checking + lastError = nil + do { + let output = try await runToolCapture( + currentDirectory: scriptDirectory(for: config.relayScriptPath), + executable: "/usr/bin/env", + arguments: [config.nodeBinary, config.relayScriptPath, "status", "1", "--with-token"], + environment: shrimpEnvironment(targetURL: derivedOverviewURL ?? "") + ) + let probe = try JSONDecoder().decode(RelayStatusProbe.self, from: Data(output.utf8)) + switch probe.summary { + case "overview_loaded": + loginState = .loggedIn + lastError = nil + case "sso_required": + loginState = .loginRequired + lastError = probe.navigationError + default: + loginState = .failed + lastError = probe.navigationError ?? "Unexpected relay status: \(probe.summary)" + } + } catch { + loginState = .failed + lastError = "Login check failed: \(error.localizedDescription)" + } + } + + func openFeishuLogin() { + guard validateConfigForLogin() else { return } + guard let loginURL = teamFeishuURL else { + lastError = "Could not derive team Feishu URL from Remote WSS." + return + } + stopProcess(&loginProcess) + do { + let process = try launchProcess( + currentDirectory: scriptDirectory(for: config.relayScriptPath), + executable: "/usr/bin/env", + arguments: [config.nodeBinary, config.relayScriptPath, "open", "1", "--hold"], + environment: shrimpEnvironment(targetURL: loginURL), + logPath: config.loginLogPath, + onTerminate: { [weak self] in + Task { @MainActor in self?.loginHelperRunning = false } + } + ) + loginProcess = process + loginHelperRunning = true + loginState = .waitingUserLogin + lastError = nil + } catch { + lastError = "Failed to open Feishu login window: \(error.localizedDescription)" + } + } + + func stopLoginHelper() { + stopProcess(&loginProcess) + loginHelperRunning = false + } + + func startRelay() { + guard validateConfigForRelay() else { return } + stopProcess(&loginProcess) + loginHelperRunning = false + stopProcess(&bridgeProcess) + do { + let process = try launchProcess( + currentDirectory: scriptDirectory(for: config.bridgeScriptPath), + executable: "/usr/bin/env", + arguments: [config.nodeBinary, config.bridgeScriptPath, "bridge", "1", "--host", relayBindHost, "--port", String(config.bridgePort)], + environment: shrimpEnvironment(targetURL: derivedOverviewURL ?? ""), + logPath: config.bridgeLogPath, + onTerminate: { [weak self] in + Task { @MainActor in self?.bridgeRunning = false } + } + ) + bridgeProcess = process + bridgeRunning = true + Task { + let ready = await waitForLocalTCPServer(host: "127.0.0.1", port: config.bridgePort, timeout: 8) + if !ready { + lastError = "Relay did not become reachable on 127.0.0.1:\(config.bridgePort) within 8s. Check bridge log." + } + } + } catch { + lastError = "Relay failed to start: \(error.localizedDescription)" + } + } + + func stopRelay() { + stopProcess(&bridgeProcess) + bridgeRunning = false + } + + func openTeamFeishu() { + if let url = teamFeishuURL { openURL(url) } + } + + func openOverview() { + if let url = derivedOverviewURL { openURL(url) } + } + + func openLoginLog() { NSWorkspace.shared.open(URL(fileURLWithPath: config.loginLogPath)) } + func openBridgeLog() { NSWorkspace.shared.open(URL(fileURLWithPath: config.bridgeLogPath)) } + + private func validateConfigForLogin() -> Bool { + let issues = configIssues(requireRemoteWSS: false) + guard issues.isEmpty else { + lastError = issues.joined(separator: "\n") + return false + } + return true + } + + private func validateConfigForRelay() -> Bool { + let issues = configIssues(requireRemoteWSS: true) + guard issues.isEmpty else { + lastError = issues.joined(separator: "\n") + return false + } + return true + } + + private func applyConfigWarnings() { + let issues = configIssues(requireRemoteWSS: false) + if !issues.isEmpty { + lastError = issues.joined(separator: "\n") + } + } + + private func configIssues(requireRemoteWSS: Bool) -> [String] { + var issues: [String] = [] + + if derivedOverviewURL?.isEmpty != false { + issues.append("Set a valid Remote WSS first.") + } + + if teamFeishuURL == nil { + issues.append("Could not derive team Feishu URL from Remote WSS.") + } + + let token = config.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty || token == "SET_ME" { + issues.append("Set a real Gateway Token (current value is placeholder).") + } + + if requireRemoteWSS && config.remoteGatewayWSS.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + issues.append("Set Remote WSS before starting the relay.") + } + + if !FileManager.default.fileExists(atPath: config.relayScriptPath) { + issues.append("Relay script not found: \(config.relayScriptPath)") + } + + if !FileManager.default.fileExists(atPath: config.bridgeScriptPath) { + issues.append("Bridge script not found: \(config.bridgeScriptPath)") + } + + if !commandExists(config.nodeBinary) { + issues.append("node binary not found in PATH: \(config.nodeBinary)") + } + + return issues + } + + private func shrimpEnvironment(targetURL: String) -> [String: String] { + var env = ProcessInfo.processInfo.environment + env["SHRIMP_PROFILE_DIR"] = config.browserProfileDir + env["SHRIMP_BROWSER_CHANNEL"] = config.browserChannel + env["SHRIMP_1_NAME"] = config.name + env["SHRIMP_1_OVERVIEW_URL"] = targetURL + env["SHRIMP_1_TOKEN"] = config.gatewayToken + return env + } + + private func ensureDirectories() { + let fm = FileManager.default + try? fm.createDirectory(atPath: config.browserProfileDir, withIntermediateDirectories: true) + [config.loginLogPath, config.bridgeLogPath].forEach { path in + let dir = (path as NSString).deletingLastPathComponent + try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + if !fm.fileExists(atPath: path) { + fm.createFile(atPath: path, contents: nil) + } + } + } + + private func scriptDirectory(for path: String) -> String { + URL(fileURLWithPath: path).deletingLastPathComponent().path + } + + private func launchProcess( + currentDirectory: String, + executable: String, + arguments: [String], + environment: [String: String], + logPath: String, + onTerminate: @escaping @Sendable () -> Void + ) throws -> Process { + ensureDirectories() + let process = Process() + process.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.environment = environment + let fileHandle = try openAppendHandle(at: logPath) + process.standardOutput = fileHandle + process.standardError = fileHandle + process.terminationHandler = { _ in + try? fileHandle.close() + onTerminate() + } + try process.run() + return process + } + + private func openAppendHandle(at path: String) throws -> FileHandle { + let fm = FileManager.default + if !fm.fileExists(atPath: path) { + fm.createFile(atPath: path, contents: nil) + } + let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path)) + try handle.seekToEnd() + return handle + } + + private func runToolCapture( + currentDirectory: String, + executable: String, + arguments: [String], + environment: [String: String] + ) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + let stdout = Pipe() + let stderr = Pipe() + process.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.environment = environment + process.standardOutput = stdout + process.standardError = stderr + process.terminationHandler = { process in + let out = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let err = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + if process.terminationStatus == 0 { + continuation.resume(returning: out.trimmingCharacters(in: .whitespacesAndNewlines)) + } else { + let message = err.isEmpty ? out : err + continuation.resume(throwing: NSError(domain: "ShrimpMenubar", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: message])) + } + } + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } + + private func waitForLocalTCPServer(host: String, port: Int, timeout: TimeInterval) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if canConnect(host: host, port: port) { + return true + } + try? await Task.sleep(for: .milliseconds(250)) + } + return false + } + + private func canConnect(host: String, port: Int) -> Bool { + let socketFD = socket(AF_INET, SOCK_STREAM, 0) + guard socketFD >= 0 else { return false } + defer { close(socketFD) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.stride) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(UInt16(port).bigEndian) + + let result = host.withCString { cs in + inet_pton(AF_INET, cs, &address.sin_addr) + } + guard result == 1 else { return false } + + var tv = timeval(tv_sec: 1, tv_usec: 0) + setsockopt(socketFD, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) + setsockopt(socketFD, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + + var addr = address + let connectResult = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(socketFD, $0, socklen_t(MemoryLayout.stride)) + } + } + return connectResult == 0 + } + + private func commandExists(_ command: String) -> Bool { + if command.contains("/") { + return FileManager.default.isExecutableFile(atPath: command) + } + + let pathEntries = (ProcessInfo.processInfo.environment["PATH"] ?? "") + .split(separator: ":") + .map(String.init) + + for entry in pathEntries { + let candidate = URL(fileURLWithPath: entry).appendingPathComponent(command).path + if FileManager.default.isExecutableFile(atPath: candidate) { + return true + } + } + return false + } + + private func stopProcess(_ process: inout Process?) { + guard let proc = process else { return } + if proc.isRunning { + proc.terminate() + DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) { + if proc.isRunning { proc.interrupt() } + DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) { + if proc.isRunning { kill(proc.processIdentifier, SIGKILL) } + } + } + } + process = nil + } + + private func openURL(_ raw: String) { + guard let url = URL(string: raw) else { return } + NSWorkspace.shared.open(url) + } +} + +@MainActor +final class AppModel: ObservableObject { + @Published var relays: [RelayRuntime] = [] + @Published var globalWarning: String? + + private let configURL: URL + + init() { + let fm = FileManager.default + let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("ShrimpMenubar", isDirectory: true) + try? fm.createDirectory(at: base, withIntermediateDirectories: true) + configURL = base.appendingPathComponent("config.json") + + let loadResult = Self.loadSettings(at: configURL) + globalWarning = loadResult.warning + relays = loadResult.settings.relays.map { RelayRuntime(config: $0) } + } + + var menuTitle: String { + if relays.contains(where: { $0.bridgeRunning }) { return "Shrimp ✓" } + if relays.contains(where: { $0.loginHelperRunning || $0.loginState == .checking }) { return "Shrimp …" } + return "Shrimp" + } + + var statusIconName: String { + if relays.contains(where: { $0.bridgeRunning }) { return "checkmark.circle.fill" } + if relays.contains(where: { $0.loginHelperRunning || $0.loginState == .checking }) { return "person.crop.circle.badge.clock" } + if relays.contains(where: { $0.loginState == .loginRequired }) { return "person.crop.circle.badge.exclamationmark" } + return "circle.dashed" + } + + func saveConfigs(_ newConfigs: [RelayConfig]) { + let cleaned = newConfigs.filter { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + let finalConfigs = cleaned.isEmpty ? [RelayConfig.template(index: 1)] : cleaned + stopAll() + do { + let settings = AppSettings(relays: finalConfigs) + let data = try JSONEncoder.pretty.encode(settings) + try data.write(to: configURL, options: .atomic) + globalWarning = nil + relays = finalConfigs.map { RelayRuntime(config: $0) } + } catch { + globalWarning = "Failed to save config: \(error.localizedDescription)" + } + } + + func makeNewRelayTemplate(index: Int) -> RelayConfig { + let safeIndex = max(1, index) + let unique = relays.map(\.config.bridgePort).max().map { $0 + 1 } ?? (18789 + safeIndex) + var item = RelayConfig.template(index: safeIndex) + item.id = "relay-\(UUID().uuidString.prefix(8))" + item.name = "relay-\(safeIndex)" + item.bridgePort = unique + item.browserProfileDir = NSString(string: "~/Library/Application Support/ShrimpMenubar/browser-profile-\(item.id)").expandingTildeInPath + item.loginLogPath = NSString(string: "~/.openclaw/logs/\(item.id)-login/out.log").expandingTildeInPath + item.bridgeLogPath = NSString(string: "~/.openclaw/logs/\(item.id)-bridge/out.log").expandingTildeInPath + return item + } + + func stopAll() { + relays.forEach { $0.stopAll() } + } + + func openConfig() { NSWorkspace.shared.open(configURL) } + + private static func loadSettings(at url: URL) -> (settings: AppSettings, warning: String?) { + if let data = try? Data(contentsOf: url) { + do { + let decoded = try JSONDecoder().decode(AppSettings.self, from: data) + if decoded.relays.isEmpty { + let settings = AppSettings.default + if let replacement = try? JSONEncoder.pretty.encode(settings) { + try? replacement.write(to: url, options: .atomic) + } + return (settings, "Config had zero relays; rewrote a default configuration.") + } + return (decoded, nil) + } catch { + if let legacy = try? JSONDecoder().decode(LegacySingleConfig.self, from: data) { + let migrated = AppSettings(relays: [RelayConfig( + id: "relay-1", + name: "shrimp-1", + listenMode: legacy.listenMode, + bridgePort: legacy.bridgePort, + remoteGatewayWSS: legacy.remoteGatewayWSS, + gatewayToken: legacy.gatewayToken, + browserProfileDir: legacy.browserProfileDir, + browserChannel: legacy.browserChannel, + relayScriptPath: legacy.relayScriptPath, + bridgeScriptPath: legacy.bridgeScriptPath, + nodeBinary: legacy.nodeBinary, + loginLogPath: legacy.loginLogPath, + bridgeLogPath: legacy.bridgeLogPath + )]) + if let replacement = try? JSONEncoder.pretty.encode(migrated) { + try? replacement.write(to: url, options: .atomic) + } + return (migrated, "Migrated legacy single-relay config to multi-relay format.") + } + + let settings = AppSettings.default + if let replacement = try? JSONEncoder.pretty.encode(settings) { + try? replacement.write(to: url, options: .atomic) + } + return (settings, "Config decode failed; rewrote a fresh multi-relay config. Error: \(error.localizedDescription)") + } + } + + let settings = AppSettings.default + if let data = try? JSONEncoder.pretty.encode(settings) { + try? data.write(to: url, options: .atomic) + } + return (settings, nil) + } +} + +private extension JSONEncoder { + static var pretty: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } +} diff --git a/tools/shrimp-menubar/Sources/ShrimpMenubar/ShrimpMenubarApp.swift b/tools/shrimp-menubar/Sources/ShrimpMenubar/ShrimpMenubarApp.swift new file mode 100644 index 0000000..6102a41 --- /dev/null +++ b/tools/shrimp-menubar/Sources/ShrimpMenubar/ShrimpMenubarApp.swift @@ -0,0 +1,294 @@ +import SwiftUI +import AppKit + +@main +struct ShrimpMenubarApp: App { + @StateObject private var model = AppModel() + + init() { + NSApplication.shared.setActivationPolicy(.accessory) + } + + var body: some Scene { + MenuBarExtra { + ContentView() + .environmentObject(model) + } label: { + Label(model.menuTitle, systemImage: model.statusIconName) + } + .menuBarExtraStyle(.menu) + } +} + +private struct ContentView: View { + @EnvironmentObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("ShrimpMenubar") + .font(.headline) + + Text("Multi-relay Feishu login + local ws bridge") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + if let warning = model.globalWarning, !warning.isEmpty { + Divider() + Text(warning) + .font(.system(size: 11)) + .foregroundStyle(.orange) + .frame(maxWidth: 360, alignment: .leading) + } + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 12) { + ForEach(model.relays, id: \.id) { relay in + RelaySectionView(relay: relay) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 420) + + Divider() + + HStack { + Button("Settings") { SettingsWindowController.shared.show(model: model) } + Button("Open raw config") { model.openConfig() } + Spacer() + Button("Stop All") { model.stopAll() } + Button("Quit") { + model.stopAll() + NSApplication.shared.terminate(nil) + } + } + } + .padding(12) + .frame(width: 420) + } +} + +private struct FlowButtonRow: View { + @ViewBuilder let content: Content + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack { + content + } + VStack(alignment: .leading, spacing: 6) { + content + } + } + } +} + +private struct RelaySectionView: View { + @ObservedObject var relay: RelayRuntime + + var body: some View { + GroupBox { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label(relay.name, systemImage: relay.statusIconName) + .font(.headline) + Spacer() + Text(relay.localRelayURL) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + } + + statusRow("Listen mode", relay.listenModeText) + statusRow("Feishu login", relay.loginStateText) + statusRow("Login helper", relay.loginHelperStateText) + statusRow("Relay", relay.bridgeStateText) + if let team = relay.teamFeishuURL { + statusRow("Team URL", team) + } + + Text(relay.summaryLine) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + if let detail = relay.lastError, !detail.isEmpty { + Text(detail) + .font(.system(size: 11)) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } + + FlowButtonRow { + Button("Start Relay") { relay.startRelay() } + Button("Stop Relay") { relay.stopRelay() } + Button("Check Login") { + Task { await relay.checkLoginState() } + } + Button("One-click Start") { relay.startAll() } + } + + FlowButtonRow { + Button("Open Feishu Login") { relay.openFeishuLogin() } + Button("Stop Login") { relay.stopLoginHelper() } + } + + FlowButtonRow { + Button("Open Team Feishu") { relay.openTeamFeishu() } + Button("Open Overview") { relay.openOverview() } + Button("Login Log") { relay.openLoginLog() } + Button("Bridge Log") { relay.openBridgeLog() } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } label: { + EmptyView() + } + } + + @ViewBuilder + private func statusRow(_ title: String, _ value: String) -> some View { + HStack(alignment: .top) { + Text(title) + .frame(width: 88, alignment: .leading) + Spacer() + Text(value) + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + } + .font(.system(size: 12)) + } +} + +@MainActor +private final class SettingsWindowController: NSObject, NSWindowDelegate { + static let shared = SettingsWindowController() + + private var window: NSWindow? + + func show(model: AppModel) { + let hosting = NSHostingController(rootView: SettingsView(model: model, onClose: { [weak self] in + self?.window?.close() + })) + + if let window { + window.contentViewController = hosting + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 760, height: 720), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "ShrimpMenubar Settings" + window.isReleasedWhenClosed = false + window.center() + window.contentViewController = hosting + window.delegate = self + self.window = window + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func windowWillClose(_ notification: Notification) { + window?.contentViewController = nil + } +} + +private struct SettingsView: View { + @ObservedObject var model: AppModel + let onClose: () -> Void + @State private var draft: [RelayConfig] + + init(model: AppModel, onClose: @escaping () -> Void) { + self.model = model + self.onClose = onClose + _draft = State(initialValue: model.relays.map(\.config)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Settings") + .font(.title3) + .bold() + Spacer() + Button("Add relay") { + draft.append(model.makeNewRelayTemplate(index: draft.count + 1)) + } + } + + ScrollView { + VStack(alignment: .leading, spacing: 14) { + ForEach($draft) { $relay in + RelayEditorView(relay: $relay, canDelete: draft.count > 1) { + draft.removeAll { $0.id == relay.id } + } + } + } + .padding(.vertical, 4) + } + + HStack { + Button("Open raw config") { model.openConfig() } + Spacer() + Button("Cancel") { onClose() } + Button("Save") { + model.saveConfigs(draft) + onClose() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(16) + .frame(width: 720, height: 660) + } +} + +private struct RelayEditorView: View { + @Binding var relay: RelayConfig + let canDelete: Bool + let onDelete: () -> Void + + var body: some View { + GroupBox { + VStack(alignment: .leading, spacing: 10) { + HStack { + TextField("Relay name", text: $relay.name) + Spacer() + if canDelete { + Button("Delete", role: .destructive, action: onDelete) + } + } + + TextField("Stable id", text: $relay.id) + Picker("Local listen mode", selection: $relay.listenMode) { + ForEach(ListenMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + TextField("Local relay port", value: $relay.bridgePort, format: .number) + TextField("Remote WSS URL", text: $relay.remoteGatewayWSS) + TextField("Gateway token", text: $relay.gatewayToken) + + DisclosureGroup("Advanced") { + TextField("Browser profile dir", text: $relay.browserProfileDir) + TextField("Browser channel (chromium/chrome)", text: $relay.browserChannel) + TextField("Relay script path", text: $relay.relayScriptPath) + TextField("Bridge script path", text: $relay.bridgeScriptPath) + TextField("node binary", text: $relay.nodeBinary) + TextField("Login log path", text: $relay.loginLogPath) + TextField("Bridge log path", text: $relay.bridgeLogPath) + } + } + } label: { + Text(relay.name.isEmpty ? relay.id : relay.name) + .font(.headline) + } + } +} diff --git a/tools/shrimp-menubar/scripts/package-app.sh b/tools/shrimp-menubar/scripts/package-app.sh new file mode 100755 index 0000000..76fdb2d --- /dev/null +++ b/tools/shrimp-menubar/scripts/package-app.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP_NAME="ShrimpMenubar" +BUILD_DIR="$ROOT/.build/arm64-apple-macosx/release" +BIN="$BUILD_DIR/$APP_NAME" +APP_DIR="$ROOT/dist/$APP_NAME.app" +CONTENTS="$APP_DIR/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" +PLIST="$CONTENTS/Info.plist" +ZIP="$ROOT/dist/$APP_NAME-macos-arm64.zip" + +cd "$ROOT" +swift build -c release +rm -rf "$APP_DIR" "$ZIP" +mkdir -p "$MACOS" "$RESOURCES" +cp "$BIN" "$MACOS/$APP_NAME" +cat > "$PLIST" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $APP_NAME + CFBundleIdentifier + ai.clawd.shrimpmenubar + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $APP_NAME + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSUIElement + + NSHighResolutionCapable + + + +PLIST +/usr/bin/zip -qry "$ZIP" "$(basename "$APP_DIR")" -x '*.DS_Store' -x '__MACOSX/*' -x '*/.DS_Store' -j >/dev/null 2>&1 || true +rm -f "$ZIP" +cd "$ROOT/dist" +/usr/bin/zip -qry "$ZIP" "$(basename "$APP_DIR")" -x '*.DS_Store' -x '__MACOSX/*' -x '*/.DS_Store' +shasum -a 256 "$ZIP"