Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/target
**/*.rs.bk
Cargo.lock.bak
dist/

# Local node data
/data
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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-* \
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 6 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
1 change: 1 addition & 0 deletions macos-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.build/
20 changes: 20 additions & 0 deletions macos-app/Package.swift
Original file line number Diff line number Diff line change
@@ -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"),
]
),
]
)
30 changes: 30 additions & 0 deletions macos-app/Sources/GitlawbNode/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
149 changes: 149 additions & 0 deletions macos-app/Sources/GitlawbNode/Config.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading