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"