diff --git a/.gitignore b/.gitignore index 4849830..4054dd1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target **/*.rs.bk Cargo.lock.bak +dist/ # Local node data /data diff --git a/Dockerfile b/Dockerfile index 8e4d42d..2b6ef04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crate # cargo can keep the dummy `fn main() {}` binaries from the cache layer above # and the runtime container exits immediately with code 0. COPY crates/ crates/ +COPY bootstrap-peers.json ./ RUN find crates -name "*.rs" -exec touch {} + && \ rm -f target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb && \ rm -rf target/release/.fingerprint/gitlawb-node-* \ diff --git a/README.md b/README.md index 0369397..b258fef 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,46 @@ crates/ --- +## macOS Menu Bar App + +A native Swift/AppKit menu bar app that manages the Docker Compose stack (node + Postgres) without touching the terminal. + +**Requirements:** macOS 26+, Xcode Command Line Tools (`xcode-select --install`), and a Docker runtime (Docker Desktop, OrbStack, or Colima). + +### Build + +```bash +./scripts/build-macos-app.sh +``` + +The resulting `Gitlawb Node.app` and `.dmg` are placed in `dist/`. + +To codesign for distribution: + +```bash +./scripts/build-macos-app.sh --sign "Developer ID Application: ..." +``` + +### Features + +- Start/Stop the node from the menu bar +- Status indicator (green = running, yellow = starting, red = stopped) +- Settings GUI (ports, Postgres password, operator config) +- Auto-start on login +- Detects Docker Desktop, OrbStack, and Colima automatically + +### Running an unsigned build + +If you built the app locally without a Developer ID, macOS Gatekeeper will block it. To allow it: + +```bash +xattr -cr "dist/Gitlawb Node.app" +``` + +Then open the app normally. Alternatively, go to **System Settings → Privacy & Security** and click **Open Anyway** after the first blocked launch attempt. + +--- + ## Contributing See [`CONTRIBUTING.md`](CONTRIBUTING.md). Security issues: see [`SECURITY.md`](SECURITY.md). diff --git a/docker-compose.yml b/docker-compose.yml index c0ebed4..35a1e1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,15 +23,18 @@ services: postgres: condition: service_healthy ports: - - "7545:7545" # HTTP API + git smart-HTTP - - "7546:7546" # libp2p + - "${GITLAWB_PORT:-7545}:${GITLAWB_PORT:-7545}" # HTTP API + git smart-HTTP + - "${GITLAWB_P2P_PORT:-7546}:${GITLAWB_P2P_PORT:-7546}" # libp2p volumes: - gitlawb-data:/data environment: DATABASE_URL: postgresql://gitlawb:${POSTGRES_PASSWORD:-changeme}@postgres:5432/gitlawb GITLAWB_HOST: 0.0.0.0 + GITLAWB_PORT: ${GITLAWB_PORT:-7545} GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545} - GITLAWB_P2P_PORT: 7546 + GITLAWB_P2P_PORT: ${GITLAWB_P2P_PORT:-7546} + # Sync + GITLAWB_AUTO_SYNC: ${GITLAWB_AUTO_SYNC:-false} # On-chain PoS (optional — leave unset for local/dev) GITLAWB_CHAIN_RPC_URL: ${GITLAWB_CHAIN_RPC_URL:-} GITLAWB_CONTRACT_NODE_STAKING: ${GITLAWB_CONTRACT_NODE_STAKING:-} diff --git a/macos-app/.gitignore b/macos-app/.gitignore new file mode 100644 index 0000000..30bcfa4 --- /dev/null +++ b/macos-app/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/macos-app/Package.swift b/macos-app/Package.swift new file mode 100644 index 0000000..27b54a0 --- /dev/null +++ b/macos-app/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "GitlawbNode", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "GitlawbNode", + path: "Sources/GitlawbNode", + exclude: ["Info.plist"], + resources: [ + .copy("Resources/docker-compose.yml"), + .copy("Resources/MenuBarIcon.png"), + .copy("Resources/MenuBarIcon@2x.png"), + .copy("Resources/AppIcon.icns"), + ] + ), + ] +) diff --git a/macos-app/Sources/GitlawbNode/AppDelegate.swift b/macos-app/Sources/GitlawbNode/AppDelegate.swift new file mode 100644 index 0000000..0c10f3a --- /dev/null +++ b/macos-app/Sources/GitlawbNode/AppDelegate.swift @@ -0,0 +1,30 @@ +import AppKit +import ServiceManagement + +class AppDelegate: NSObject, NSApplicationDelegate { + private var statusBarController: StatusBarController? + private let dockerCompose = DockerCompose() + + func applicationDidFinishLaunching(_ notification: Notification) { + // Hide from Dock — menu bar only + NSApp.setActivationPolicy(.accessory) + + statusBarController = StatusBarController(dockerCompose: dockerCompose) + + // Auto-start node if preference is set + if Config.shared.autoStartOnLaunch { + dockerCompose.start() + } + + // Begin polling status + dockerCompose.startPolling() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return false + } + + func applicationWillTerminate(_ notification: Notification) { + dockerCompose.stopPolling() + } +} diff --git a/macos-app/Sources/GitlawbNode/Config.swift b/macos-app/Sources/GitlawbNode/Config.swift new file mode 100644 index 0000000..ff76723 --- /dev/null +++ b/macos-app/Sources/GitlawbNode/Config.swift @@ -0,0 +1,149 @@ +import Foundation + +/// Persists user settings and generates the .env file for Docker Compose. +class Config { + static let shared = Config() + + // MARK: - Settings + + var httpPort: Int = 7545 + var p2pPort: Int = 7546 + var publicURL: String = "http://localhost:7545" + var postgresPassword: String = "changeme" + var autoStartOnLaunch: Bool = false + var autoStartOnLogin: Bool = false + + // Advanced + var chainRpcURL: String = "" + var contractNodeStaking: String = "" + var operatorPrivateKey: String = "" + var tigrisBucket: String = "" + var autoSync: Bool = false + var repoPathString: String = "" + + // MARK: - Paths + + /// Path to the gitlawb-node repo clone (for local builds). + var repoPath: URL { + if !repoPathString.isEmpty { + return URL(fileURLWithPath: repoPathString) + } + // Use compile-time source path to find the repo root. + // #filePath = .../gitlawb-node/macos-app/Sources/GitlawbNode/Config.swift + // repo root = 4 directories up from the source file + let sourceFile = URL(fileURLWithPath: #filePath) + let repoRoot = sourceFile + .deletingLastPathComponent() // Sources/GitlawbNode/ + .deletingLastPathComponent() // Sources/ + .deletingLastPathComponent() // macos-app/ + .deletingLastPathComponent() // gitlawb-node/ + let compose = repoRoot.appendingPathComponent("docker-compose.yml") + let dockerfile = repoRoot.appendingPathComponent("Dockerfile") + if FileManager.default.fileExists(atPath: compose.path) && + FileManager.default.fileExists(atPath: dockerfile.path) { + return repoRoot + } + return dataDirectory + } + + var dataDirectory: URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return appSupport.appendingPathComponent("GitlawbNode") + } + + private var configFilePath: URL { + dataDirectory.appendingPathComponent("config.json") + } + + // MARK: - Init + + private init() { + load() + } + + // MARK: - Persistence + + func persist() { + let dict: [String: Any] = [ + "httpPort": httpPort, + "p2pPort": p2pPort, + "publicURL": publicURL, + "postgresPassword": postgresPassword, + "autoStartOnLaunch": autoStartOnLaunch, + "autoStartOnLogin": autoStartOnLogin, + "chainRpcURL": chainRpcURL, + "contractNodeStaking": contractNodeStaking, + "operatorPrivateKey": operatorPrivateKey, + "tigrisBucket": tigrisBucket, + "autoSync": autoSync, + "repoPath": repoPathString, + ] + + do { + try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true) + let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: configFilePath, options: .atomic) + } catch { + // Non-fatal — settings won't persist across restarts + } + } + + private func load() { + guard let data = try? Data(contentsOf: configFilePath), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return + } + + httpPort = dict["httpPort"] as? Int ?? httpPort + p2pPort = dict["p2pPort"] as? Int ?? p2pPort + publicURL = dict["publicURL"] as? String ?? publicURL + postgresPassword = dict["postgresPassword"] as? String ?? postgresPassword + autoStartOnLaunch = dict["autoStartOnLaunch"] as? Bool ?? autoStartOnLaunch + autoStartOnLogin = dict["autoStartOnLogin"] as? Bool ?? autoStartOnLogin + chainRpcURL = dict["chainRpcURL"] as? String ?? chainRpcURL + contractNodeStaking = dict["contractNodeStaking"] as? String ?? contractNodeStaking + operatorPrivateKey = dict["operatorPrivateKey"] as? String ?? operatorPrivateKey + tigrisBucket = dict["tigrisBucket"] as? String ?? tigrisBucket + autoSync = dict["autoSync"] as? Bool ?? autoSync + repoPathString = dict["repoPath"] as? String ?? repoPathString + } + + // MARK: - .env File Generation + + /// Writes the .env file that Docker Compose reads. + func writeEnvFile() { + let envPath = dataDirectory.appendingPathComponent(".env") + + var lines = [String]() + lines.append("# Generated by Gitlawb Node app — do not edit manually") + lines.append("POSTGRES_PASSWORD=\(postgresPassword)") + lines.append("GITLAWB_PUBLIC_URL=\(publicURL)") + lines.append("GITLAWB_PORT=\(httpPort)") + lines.append("GITLAWB_P2P_PORT=\(p2pPort)") + lines.append("GITLAWB_AUTO_SYNC=\(autoSync)") + + if !chainRpcURL.isEmpty { + lines.append("GITLAWB_CHAIN_RPC_URL=\(chainRpcURL)") + } + if !contractNodeStaking.isEmpty { + lines.append("GITLAWB_CONTRACT_NODE_STAKING=\(contractNodeStaking)") + } + if !operatorPrivateKey.isEmpty { + lines.append("GITLAWB_OPERATOR_PRIVATE_KEY=\(operatorPrivateKey)") + } + if !tigrisBucket.isEmpty { + lines.append("GITLAWB_TIGRIS_BUCKET=\(tigrisBucket)") + } + + lines.append("") // trailing newline + + let content = lines.joined(separator: "\n") + + do { + try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true) + try content.write(to: envPath, atomically: true, encoding: .utf8) + } catch { + // Non-fatal + } + } +} diff --git a/macos-app/Sources/GitlawbNode/DockerCompose.swift b/macos-app/Sources/GitlawbNode/DockerCompose.swift new file mode 100644 index 0000000..fd1b8a2 --- /dev/null +++ b/macos-app/Sources/GitlawbNode/DockerCompose.swift @@ -0,0 +1,311 @@ +import Foundation + +/// Represents the aggregate state of the Docker Compose stack. +enum NodeStatus: Equatable { + case stopped + case starting + case running + case unhealthy + case error(String) + + var label: String { + switch self { + case .stopped: return "Stopped" + case .starting: return "Starting…" + case .running: return "Running" + case .unhealthy: return "Unhealthy" + case .error(let msg): return "Error: \(msg)" + } + } +} + +/// Manages the Docker Compose lifecycle for the gitlawb node stack. +class DockerCompose { + /// Current status, updated by polling. + private(set) var status: NodeStatus = .stopped { + didSet { + if oldValue != status { + onStatusChange?(status) + } + } + } + + /// Called whenever status changes. + var onStatusChange: ((NodeStatus) -> Void)? + + private var pollTimer: Timer? + private let pollInterval: TimeInterval = 5.0 + + /// Path to the docker-compose.yml — uses the repo's file if available, otherwise generates one. + var composeFilePath: String { + // Prefer the repo's own docker-compose.yml (supports `build: .`) + let repoCompose = Config.shared.repoPath.appendingPathComponent("docker-compose.yml") + if FileManager.default.fileExists(atPath: repoCompose.path) { + return repoCompose.path + } + // Fallback: generate a compose file with pre-built image + let userPath = Config.shared.dataDirectory.appendingPathComponent("docker-compose.yml").path + try? FileManager.default.createDirectory(at: Config.shared.dataDirectory, withIntermediateDirectories: true) + let content = Self.generateComposeFile() + try? content.write(toFile: userPath, atomically: true, encoding: .utf8) + return userPath + } + + /// Project directory for docker compose (needed for `build: .` context). + var projectDirectory: String { + let repoCompose = Config.shared.repoPath.appendingPathComponent("docker-compose.yml") + if FileManager.default.fileExists(atPath: repoCompose.path) { + return Config.shared.repoPath.path + } + return Config.shared.dataDirectory.path + } + + /// Path to the .env file with user configuration. + var envFilePath: String { + Config.shared.dataDirectory.appendingPathComponent(".env").path + } + + // MARK: - Lifecycle + + func start() { + guard let docker = DockerDetector.dockerPath() else { + status = .error("Docker not found") + return + } + + status = .starting + Config.shared.writeEnvFile() + + runAsync(docker: docker, arguments: [ + "compose", + "--project-directory", projectDirectory, + "-f", composeFilePath, + "--env-file", envFilePath, + "up", "-d", + ]) { [weak self] success, output in + DispatchQueue.main.async { + if success { + self?.refreshStatus() + } else { + self?.status = .error(output ?? "Failed to start") + } + } + } + } + + func stop() { + guard let docker = DockerDetector.dockerPath() else { + status = .error("Docker not found") + return + } + + runAsync(docker: docker, arguments: [ + "compose", + "--project-directory", projectDirectory, + "-f", composeFilePath, + "--env-file", envFilePath, + "down", + ]) { [weak self] _, _ in + DispatchQueue.main.async { + self?.status = .stopped + } + } + } + + func refreshStatus() { + guard let docker = DockerDetector.dockerPath() else { + status = .error("Docker not found") + return + } + + let output = runSync(docker: docker, arguments: [ + "compose", + "--project-directory", projectDirectory, + "-f", composeFilePath, + "--env-file", envFilePath, + "ps", "--format", "json", + ]) + + guard let output = output, !output.isEmpty else { + status = .stopped + return + } + + // docker compose ps --format json outputs one JSON object per line + let lines = output.components(separatedBy: .newlines).filter { !$0.isEmpty } + if lines.isEmpty { + status = .stopped + return + } + + var allHealthy = true + var anyRunning = false + + for line in lines { + guard let data = line.data(using: .utf8), + let container = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + continue + } + let state = (container["State"] as? String) ?? "" + let health = (container["Health"] as? String) ?? "" + + if state == "running" { + anyRunning = true + if health == "unhealthy" { + allHealthy = false + } + } + } + + if anyRunning && allHealthy { + status = .running + } else if anyRunning { + status = .unhealthy + } else { + status = .stopped + } + } + + // MARK: - Polling + + func startPolling() { + refreshStatus() + pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + self?.refreshStatus() + } + } + + func stopPolling() { + pollTimer?.invalidate() + pollTimer = nil + } + + // MARK: - Process Helpers + + private func runSync(docker: String, arguments: [String]) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: docker) + process.arguments = arguments + process.environment = processEnvironment() + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + // Read concurrently to avoid pipe buffer deadlock + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(data: data, encoding: .utf8) + guard process.terminationStatus == 0 else { + return output // Return stderr output even on failure + } + return output + } catch { + return nil + } + } + + private func runAsync(docker: String, arguments: [String], completion: @escaping (Bool, String?) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: docker) + process.arguments = arguments + process.environment = self.processEnvironment() + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + // Read all output first to prevent pipe buffer deadlock + // (docker compose build can produce >64KB of output) + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(data: data, encoding: .utf8) + completion(process.terminationStatus == 0, output) + } catch { + completion(false, error.localizedDescription) + } + } + } + + private func processEnvironment() -> [String: String] { + var env = ProcessInfo.processInfo.environment + // Ensure common Docker paths are in PATH + let extraPaths = "/usr/local/bin:/opt/homebrew/bin" + if let existing = env["PATH"] { + env["PATH"] = "\(extraPaths):\(existing)" + } else { + env["PATH"] = extraPaths + } + return env + } + + // MARK: - Logs + + /// Returns recent logs from the Docker Compose stack. + func logs(tail: Int = 200) -> String? { + guard let docker = DockerDetector.dockerPath() else { return nil } + return runSync(docker: docker, arguments: [ + "compose", + "--project-directory", projectDirectory, + "-f", composeFilePath, + "--env-file", envFilePath, + "logs", "--tail", "\(tail)", "--no-color", + ]) + } + + // MARK: - Compose File Generation + + private static func generateComposeFile() -> String { + return """ +# Generated by Gitlawb Node macOS app +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: gitlawb + POSTGRES_USER: gitlawb + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + volumes: + - pg-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitlawb"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + node: + image: ghcr.io/gitlawb/node:latest + depends_on: + postgres: + condition: service_healthy + ports: + - "${GITLAWB_HTTP_PORT:-7545}:7545" + - "${GITLAWB_P2P_PORT:-7546}:7546" + volumes: + - gitlawb-data:/data + environment: + DATABASE_URL: postgresql://gitlawb:${POSTGRES_PASSWORD:-changeme}@postgres:5432/gitlawb + GITLAWB_HOST: 0.0.0.0 + GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545} + GITLAWB_P2P_PORT: 7546 + GITLAWB_CHAIN_RPC_URL: ${GITLAWB_CHAIN_RPC_URL:-} + GITLAWB_CONTRACT_NODE_STAKING: ${GITLAWB_CONTRACT_NODE_STAKING:-} + GITLAWB_OPERATOR_PRIVATE_KEY: ${GITLAWB_OPERATOR_PRIVATE_KEY:-} + GITLAWB_TIGRIS_BUCKET: ${GITLAWB_TIGRIS_BUCKET:-} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AWS_ENDPOINT_URL_S3: ${AWS_ENDPOINT_URL_S3:-} + restart: unless-stopped + +volumes: + pg-data: + gitlawb-data: +""" + } +} diff --git a/macos-app/Sources/GitlawbNode/DockerDetector.swift b/macos-app/Sources/GitlawbNode/DockerDetector.swift new file mode 100644 index 0000000..e5b83e7 --- /dev/null +++ b/macos-app/Sources/GitlawbNode/DockerDetector.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Detects available Docker runtimes on macOS. +struct DockerDetector { + struct DockerRuntime { + let path: String + let name: String + let version: String + } + + /// Well-known locations for docker binaries on macOS. + private static let searchPaths: [String] = [ + "/usr/local/bin/docker", + "/opt/homebrew/bin/docker", + "/Applications/Docker.app/Contents/Resources/bin/docker", + "\(NSHomeDirectory())/.orbstack/bin/docker", + "/Applications/OrbStack.app/Contents/MacOS/xbin/docker", + ] + + /// Finds the first available Docker binary and verifies the daemon is running. + static func detect() -> DockerRuntime? { + // Check PATH first, then well-known locations + let pathResult = run(command: "/usr/bin/which", arguments: ["docker"]) + var candidates = [String]() + if let found = pathResult, !found.isEmpty { + candidates.append(found.trimmingCharacters(in: .whitespacesAndNewlines)) + } + candidates.append(contentsOf: searchPaths) + + for path in candidates { + guard FileManager.default.isExecutableFile(atPath: path) else { continue } + // Verify daemon is responding + if let version = dockerVersion(at: path) { + let name = runtimeName(for: path) + return DockerRuntime(path: path, name: name, version: version) + } + } + return nil + } + + /// Returns the docker binary path or nil if unavailable. + static func dockerPath() -> String? { + detect()?.path + } + + // MARK: - Private + + private static func dockerVersion(at path: String) -> String? { + guard let output = run(command: path, arguments: ["version", "--format", "{{.Server.Version}}"]) else { + return nil + } + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func runtimeName(for path: String) -> String { + if path.contains("OrbStack") || path.contains(".orbstack") { + return "OrbStack" + } else if path.contains("Docker.app") || path == "/usr/local/bin/docker" { + return "Docker Desktop" + } else if path.contains("colima") { + return "Colima" + } else { + return "Docker" + } + } + + private static func run(command: String, arguments: [String]) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: command) + process.arguments = arguments + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } +} diff --git a/macos-app/Sources/GitlawbNode/Info.plist b/macos-app/Sources/GitlawbNode/Info.plist new file mode 100644 index 0000000..a85b66e --- /dev/null +++ b/macos-app/Sources/GitlawbNode/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleName + Gitlawb Node + CFBundleDisplayName + Gitlawb Node + CFBundleIdentifier + com.gitlawb.node-app + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0.0 + CFBundlePackageType + APPL + CFBundleExecutable + GitlawbNode + LSMinimumSystemVersion + 13.0 + CFBundleIconFile + AppIcon + LSUIElement + + NSHighResolutionCapable + + + diff --git a/macos-app/Sources/GitlawbNode/Resources/AppIcon.icns b/macos-app/Sources/GitlawbNode/Resources/AppIcon.icns new file mode 100644 index 0000000..01faf47 Binary files /dev/null and b/macos-app/Sources/GitlawbNode/Resources/AppIcon.icns differ diff --git a/macos-app/Sources/GitlawbNode/Resources/MenuBarIcon.png b/macos-app/Sources/GitlawbNode/Resources/MenuBarIcon.png new file mode 100644 index 0000000..25432e4 Binary files /dev/null and b/macos-app/Sources/GitlawbNode/Resources/MenuBarIcon.png differ diff --git a/macos-app/Sources/GitlawbNode/Resources/MenuBarIcon@2x.png b/macos-app/Sources/GitlawbNode/Resources/MenuBarIcon@2x.png new file mode 100644 index 0000000..a5a96e3 Binary files /dev/null and b/macos-app/Sources/GitlawbNode/Resources/MenuBarIcon@2x.png differ diff --git a/macos-app/Sources/GitlawbNode/Resources/docker-compose.yml b/macos-app/Sources/GitlawbNode/Resources/docker-compose.yml new file mode 100644 index 0000000..35a1e1c --- /dev/null +++ b/macos-app/Sources/GitlawbNode/Resources/docker-compose.yml @@ -0,0 +1,53 @@ +# Run a gitlawb node + Postgres locally. For on-chain PoS operation, supply +# the operator key + contract addresses via a .env file or shell environment. + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: gitlawb + POSTGRES_USER: gitlawb + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + volumes: + - pg-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitlawb"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + node: + build: . + depends_on: + postgres: + condition: service_healthy + ports: + - "${GITLAWB_PORT:-7545}:${GITLAWB_PORT:-7545}" # HTTP API + git smart-HTTP + - "${GITLAWB_P2P_PORT:-7546}:${GITLAWB_P2P_PORT:-7546}" # libp2p + volumes: + - gitlawb-data:/data + environment: + DATABASE_URL: postgresql://gitlawb:${POSTGRES_PASSWORD:-changeme}@postgres:5432/gitlawb + GITLAWB_HOST: 0.0.0.0 + GITLAWB_PORT: ${GITLAWB_PORT:-7545} + GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545} + GITLAWB_P2P_PORT: ${GITLAWB_P2P_PORT:-7546} + # Sync + GITLAWB_AUTO_SYNC: ${GITLAWB_AUTO_SYNC:-false} + # On-chain PoS (optional — leave unset for local/dev) + GITLAWB_CHAIN_RPC_URL: ${GITLAWB_CHAIN_RPC_URL:-} + GITLAWB_CONTRACT_NODE_STAKING: ${GITLAWB_CONTRACT_NODE_STAKING:-} + GITLAWB_OPERATOR_PRIVATE_KEY: ${GITLAWB_OPERATOR_PRIVATE_KEY:-} + GITLAWB_OPERATOR_STRICT_MODE: ${GITLAWB_OPERATOR_STRICT_MODE:-false} + GITLAWB_HEARTBEAT_INTERVAL_HOURS: ${GITLAWB_HEARTBEAT_INTERVAL_HOURS:-20} + # Optional shared storage (leave unset to use local disk only) + GITLAWB_TIGRIS_BUCKET: ${GITLAWB_TIGRIS_BUCKET:-} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AWS_ENDPOINT_URL_S3: ${AWS_ENDPOINT_URL_S3:-} + restart: unless-stopped + +volumes: + pg-data: + gitlawb-data: diff --git a/macos-app/Sources/GitlawbNode/SettingsWindow.swift b/macos-app/Sources/GitlawbNode/SettingsWindow.swift new file mode 100644 index 0000000..6adeade --- /dev/null +++ b/macos-app/Sources/GitlawbNode/SettingsWindow.swift @@ -0,0 +1,106 @@ +import AppKit +import SwiftUI + +/// Hosts the SwiftUI settings view in an NSWindow. +class SettingsWindowController: NSWindowController { + convenience init() { + let hostingController = NSHostingController(rootView: SettingsView()) + let window = NSWindow(contentViewController: hostingController) + window.title = "Gitlawb Node Settings" + window.setContentSize(NSSize(width: 480, height: 280)) + window.styleMask = [.titled, .closable, .miniaturizable] + window.center() + self.init(window: window) + } +} + +struct SettingsView: View { + @State private var httpPort: String = String(Config.shared.httpPort) + @State private var p2pPort: String = String(Config.shared.p2pPort) + @State private var publicURL: String = Config.shared.publicURL + @State private var postgresPassword: String = Config.shared.postgresPassword + @State private var autoStartOnLaunch: Bool = Config.shared.autoStartOnLaunch + + // Advanced (collapsed by default) + @State private var showAdvanced: Bool = false + @State private var chainRpcURL: String = Config.shared.chainRpcURL + @State private var contractNodeStaking: String = Config.shared.contractNodeStaking + @State private var operatorPrivateKey: String = Config.shared.operatorPrivateKey + @State private var tigrisBucket: String = Config.shared.tigrisBucket + @State private var autoSync: Bool = Config.shared.autoSync + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("General") + .font(.headline) + + Form { + TextField("HTTP Port:", text: $httpPort) + .textFieldStyle(.roundedBorder) + TextField("P2P Port:", text: $p2pPort) + .textFieldStyle(.roundedBorder) + TextField("Public URL:", text: $publicURL) + .textFieldStyle(.roundedBorder) + SecureField("Postgres Password:", text: $postgresPassword) + .textFieldStyle(.roundedBorder) + } + .formStyle(.columns) + + Toggle("Auto-start node on app launch", isOn: $autoStartOnLaunch) + VStack(alignment: .leading, spacing: 2) { + Toggle("Sync repos from peers", isOn: $autoSync) + Text("Automatically replicate repositories from other nodes in the network") + .font(.caption) + .foregroundColor(.secondary) + } + + Divider() + + DisclosureGroup("Advanced (Operator & Storage)", isExpanded: $showAdvanced) { + Form { + TextField("Chain RPC URL:", text: $chainRpcURL) + .textFieldStyle(.roundedBorder) + TextField("Staking Contract:", text: $contractNodeStaking) + .textFieldStyle(.roundedBorder) + SecureField("Operator Private Key:", text: $operatorPrivateKey) + .textFieldStyle(.roundedBorder) + TextField("Tigris Bucket:", text: $tigrisBucket) + .textFieldStyle(.roundedBorder) + } + .formStyle(.columns) + .padding(.top, 4) + } + + HStack { + Spacer() + Button("Save") { + save() + } + .keyboardShortcut(.defaultAction) + } + .padding(.top, 8) + } + .padding(20) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 440) + } + + private func save() { + Config.shared.httpPort = Int(httpPort) ?? 7545 + Config.shared.p2pPort = Int(p2pPort) ?? 7546 + Config.shared.publicURL = publicURL + Config.shared.postgresPassword = postgresPassword + Config.shared.autoStartOnLaunch = autoStartOnLaunch + Config.shared.chainRpcURL = chainRpcURL + Config.shared.contractNodeStaking = contractNodeStaking + Config.shared.operatorPrivateKey = operatorPrivateKey + Config.shared.tigrisBucket = tigrisBucket + Config.shared.autoSync = autoSync + + Config.shared.persist() + Config.shared.writeEnvFile() + + // Close the window + NSApp.keyWindow?.close() + } +} diff --git a/macos-app/Sources/GitlawbNode/StatusBarController.swift b/macos-app/Sources/GitlawbNode/StatusBarController.swift new file mode 100644 index 0000000..0bc2351 --- /dev/null +++ b/macos-app/Sources/GitlawbNode/StatusBarController.swift @@ -0,0 +1,249 @@ +import AppKit +import ServiceManagement + +class StatusBarController: NSObject { + private var statusItem: NSStatusItem + private var menu: NSMenu + private let dockerCompose: DockerCompose + + private var startStopItem: NSMenuItem! + private var statusMenuItem: NSMenuItem! + private var settingsWindow: SettingsWindowController? + private var logsWindow: NSWindow? + + init(dockerCompose: DockerCompose) { + self.dockerCompose = dockerCompose + + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + menu = NSMenu() + + super.init() + + setupMenu() + updateIcon(for: .stopped) + + statusItem.menu = menu + + // Listen for status changes + dockerCompose.onStatusChange = { [weak self] status in + self?.updateIcon(for: status) + self?.updateMenuItems(for: status) + } + } + + // MARK: - Menu Setup + + private func setupMenu() { + statusMenuItem = NSMenuItem(title: "Status: Stopped", action: nil, keyEquivalent: "") + statusMenuItem.isEnabled = false + menu.addItem(statusMenuItem) + + menu.addItem(.separator()) + + startStopItem = NSMenuItem(title: "Start Node", action: #selector(toggleNode), keyEquivalent: "s") + startStopItem.target = self + menu.addItem(startStopItem) + + menu.addItem(.separator()) + + let openWebUI = NSMenuItem(title: "Open Web UI", action: #selector(openWebUI), keyEquivalent: "w") + openWebUI.target = self + menu.addItem(openWebUI) + + let viewLogs = NSMenuItem(title: "View Logs…", action: #selector(viewLogs), keyEquivalent: "l") + viewLogs.target = self + menu.addItem(viewLogs) + + let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",") + settingsItem.target = self + menu.addItem(settingsItem) + + menu.addItem(.separator()) + + let autoStartItem = NSMenuItem(title: "Start on Login", action: #selector(toggleAutoStart), keyEquivalent: "") + autoStartItem.target = self + autoStartItem.state = Config.shared.autoStartOnLogin ? .on : .off + menu.addItem(autoStartItem) + + menu.addItem(.separator()) + + let quitItem = NSMenuItem(title: "Quit Gitlawb Node", action: #selector(quit), keyEquivalent: "q") + quitItem.target = self + menu.addItem(quitItem) + } + + // MARK: - Status Updates + + private func updateIcon(for status: NodeStatus) { + guard let button = statusItem.button else { return } + + let dotColor: NSColor + switch status { + case .running: + dotColor = .systemGreen + case .starting, .unhealthy: + dotColor = .systemYellow + case .stopped: + dotColor = .clear + case .error: + dotColor = .systemRed + } + + // Load template image from bundle resources + let iconImage: NSImage + if let bundlePath = Bundle.main.path(forResource: "MenuBarIcon", ofType: "png"), + let img = NSImage(contentsOfFile: bundlePath) { + iconImage = img + } else { + // Fallback: try to load from @2x + if let bundlePath = Bundle.main.path(forResource: "MenuBarIcon@2x", ofType: "png"), + let img = NSImage(contentsOfFile: bundlePath) { + img.size = NSSize(width: 18, height: 18) + iconImage = img + } else { + // Last resort fallback to SF Symbol + iconImage = NSImage(systemSymbolName: "network", accessibilityDescription: "Gitlawb Node") ?? NSImage() + } + } + + // Compose icon with status dot + let size = NSSize(width: 18, height: 18) + let composedImage = NSImage(size: size, flipped: false) { rect in + iconImage.draw(in: rect) + if dotColor != .clear { + let dotSize: CGFloat = 6 + let dotRect = NSRect(x: rect.width - dotSize, y: 0, width: dotSize, height: dotSize) + dotColor.setFill() + NSBezierPath(ovalIn: dotRect).fill() + } + return true + } + composedImage.isTemplate = false // We handle tinting via the dot ourselves + button.image = composedImage + // Use template rendering for the base icon to adapt to light/dark menu bar + // But since we composite with a colored dot, disable template mode on final image + } + + private func updateMenuItems(for status: NodeStatus) { + statusMenuItem.title = "Status: \(status.label)" + + switch status { + case .running, .unhealthy: + startStopItem.title = "Stop Node" + case .starting: + startStopItem.title = "Starting…" + startStopItem.isEnabled = false + return + default: + startStopItem.title = "Start Node" + } + startStopItem.isEnabled = true + } + + // MARK: - Actions + + @objc private func toggleNode() { + switch dockerCompose.status { + case .running, .unhealthy: + dockerCompose.stop() + case .stopped, .error: + // Verify Docker is available before starting + guard DockerDetector.detect() != nil else { + showNoDockerAlert() + return + } + dockerCompose.start() + default: + break + } + } + + @objc private func openWebUI() { + let port = Config.shared.httpPort + if let url = URL(string: "http://localhost:\(port)") { + NSWorkspace.shared.open(url) + } + } + + @objc private func openSettings() { + if settingsWindow == nil { + settingsWindow = SettingsWindowController() + } + settingsWindow?.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + } + + @objc private func viewLogs() { + let logs = dockerCompose.logs() ?? "No logs available. Is the node running?" + let panel = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 500), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + panel.title = "Gitlawb Node Logs" + panel.isReleasedWhenClosed = false + panel.center() + self.logsWindow = panel + + let scrollView = NSScrollView(frame: panel.contentView!.bounds) + scrollView.autoresizingMask = [.width, .height] + scrollView.hasVerticalScroller = true + + let textView = NSTextView(frame: scrollView.bounds) + textView.autoresizingMask = [.width] + textView.isEditable = false + textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + textView.string = logs + textView.textContainerInset = NSSize(width: 8, height: 8) + + scrollView.documentView = textView + panel.contentView = scrollView + + // Scroll to bottom + textView.scrollToEndOfDocument(nil) + + panel.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + @objc private func toggleAutoStart(_ sender: NSMenuItem) { + let newState = !Config.shared.autoStartOnLogin + Config.shared.autoStartOnLogin = newState + sender.state = newState ? .on : .off + + // Register/unregister with macOS login items + let service = SMAppService.mainApp + do { + if newState { + try service.register() + } else { + try service.unregister() + } + } catch { + // Silently fail — user can manage via System Settings + } + } + + @objc private func quit() { + NSApp.terminate(nil) + } + + // MARK: - Alerts + + private func showNoDockerAlert() { + let alert = NSAlert() + alert.messageText = "Docker Not Found" + alert.informativeText = """ + Gitlawb Node requires a Docker runtime to run. + + Install one of the following: + • Docker Desktop (docker.com) + • OrbStack (orbstack.dev) — lightweight alternative + • Colima (github.com/abiosoft/colima) — free CLI-based + """ + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + } +} diff --git a/macos-app/Sources/GitlawbNode/main.swift b/macos-app/Sources/GitlawbNode/main.swift new file mode 100644 index 0000000..b57e54a --- /dev/null +++ b/macos-app/Sources/GitlawbNode/main.swift @@ -0,0 +1,6 @@ +import AppKit + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 0000000..9ca343d --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build the Gitlawb Node macOS menu bar app. +# Usage: ./scripts/build-macos-app.sh [--sign IDENTITY] + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +APP_DIR="$ROOT_DIR/macos-app" +BUILD_DIR="$APP_DIR/.build/release" +OUTPUT_DIR="$ROOT_DIR/dist" +APP_NAME="Gitlawb Node" +APP_BUNDLE="$OUTPUT_DIR/${APP_NAME}.app" + +SIGN_IDENTITY="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --sign) + SIGN_IDENTITY="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +echo "==> Building Gitlawb Node macOS app..." + +# Ensure docker-compose.yml resource is up to date +mkdir -p "$APP_DIR/Sources/GitlawbNode/Resources" +cp "$ROOT_DIR/docker-compose.yml" "$APP_DIR/Sources/GitlawbNode/Resources/docker-compose.yml" + +# Build with Swift Package Manager +cd "$APP_DIR" +swift build -c release + +echo "==> Packaging .app bundle..." + +# Create .app structure +rm -rf "$APP_BUNDLE" +mkdir -p "$APP_BUNDLE/Contents/MacOS" +mkdir -p "$APP_BUNDLE/Contents/Resources" + +# Copy binary +cp "$BUILD_DIR/GitlawbNode" "$APP_BUNDLE/Contents/MacOS/GitlawbNode" + +# Copy Info.plist +cp "$APP_DIR/Sources/GitlawbNode/Info.plist" "$APP_BUNDLE/Contents/Info.plist" + +# Copy bundled resources +cp "$ROOT_DIR/docker-compose.yml" "$APP_BUNDLE/Contents/Resources/docker-compose.yml" + +# Copy app icon +cp "$APP_DIR/Sources/GitlawbNode/Resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns" + +# Copy menu bar icon +cp "$APP_DIR/Sources/GitlawbNode/Resources/MenuBarIcon.png" "$APP_BUNDLE/Contents/Resources/MenuBarIcon.png" +cp "$APP_DIR/Sources/GitlawbNode/Resources/MenuBarIcon@2x.png" "$APP_BUNDLE/Contents/Resources/MenuBarIcon@2x.png" + +# Copy SPM-bundled resources if they exist +if [ -d "$BUILD_DIR/GitlawbNode_GitlawbNode.bundle" ]; then + cp -R "$BUILD_DIR/GitlawbNode_GitlawbNode.bundle" "$APP_BUNDLE/Contents/Resources/" +fi + +echo "==> App bundle created: $APP_BUNDLE" + +# Codesign if identity provided +if [ -n "$SIGN_IDENTITY" ]; then + echo "==> Signing with identity: $SIGN_IDENTITY" + codesign --force --deep --sign "$SIGN_IDENTITY" \ + --options runtime \ + --entitlements /dev/stdin < + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + +EOF + echo "==> Signed." +else + # Remove quarantine attribute so unsigned builds work without Gatekeeper prompts + echo "==> No signing identity provided — removing quarantine attribute for local use" + xattr -cr "$APP_BUNDLE" +fi + +# Create DMG +echo "==> Creating DMG..." +DMG_PATH="$OUTPUT_DIR/GitlawbNode-macOS.dmg" +rm -f "$DMG_PATH" + +# Create a temporary directory for DMG contents +DMG_STAGING="$OUTPUT_DIR/dmg-staging" +rm -rf "$DMG_STAGING" +mkdir -p "$DMG_STAGING" +cp -R "$APP_BUNDLE" "$DMG_STAGING/" +ln -s /Applications "$DMG_STAGING/Applications" + +hdiutil create -volname "Gitlawb Node" \ + -srcfolder "$DMG_STAGING" \ + -ov -format UDZO \ + "$DMG_PATH" + +rm -rf "$DMG_STAGING" + +echo "==> DMG created: $DMG_PATH" + +echo "==> Done!" +echo " App: $APP_BUNDLE" +echo " DMG: $DMG_PATH"