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
57 changes: 57 additions & 0 deletions app/macos/Sources/OpenClawLib/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,63 @@ public protocol ShellExecutor: Sendable {
func run(_ args: [String]) async throws -> ShellResult
}

// MARK: - Settings

public struct LauncherSettings: Codable, Equatable {
public var healthCheckInterval: TimeInterval
public var openBrowserOnStart: Bool
public var dockerImage: String
public var memoryLimit: String
public var cpuLimit: Double
public var port: Int

public init(
healthCheckInterval: TimeInterval = 5.0,
openBrowserOnStart: Bool = true,
dockerImage: String = "ghcr.io/openclaw/openclaw:latest",
memoryLimit: String = "2g",
cpuLimit: Double = 2.0,
port: Int = 18789
) {
self.healthCheckInterval = healthCheckInterval
self.openBrowserOnStart = openBrowserOnStart
self.dockerImage = dockerImage
self.memoryLimit = memoryLimit
self.cpuLimit = cpuLimit
self.port = port
}

public static var settingsURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw-launcher")
.appendingPathComponent("settings.json")
}

public static func load() -> LauncherSettings {
guard FileManager.default.fileExists(atPath: settingsURL.path),
let data = try? Data(contentsOf: settingsURL),
let settings = try? JSONDecoder().decode(LauncherSettings.self, from: data)
else {
return LauncherSettings()
}
return settings
}

public func save() throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(self)

// Ensure directory exists
let dir = LauncherSettings.settingsURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)

try data.write(to: LauncherSettings.settingsURL)
}
}

// MARK: - Errors

public enum LauncherError: LocalizedError {
case dockerNotRunning
case dockerNotInstalled
Expand Down
40 changes: 26 additions & 14 deletions app/macos/Sources/OpenClawLib/OpenClawLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public class OpenClawLauncher: ObservableObject {
@Published public var showResetConfirm: Bool = false
@Published public var needsDockerInstall: Bool = false
@Published public var authExpiredBanner: String?
@Published public var settings: LauncherSettings

private var isFirstRun = false
private var currentPKCE: AnthropicOAuth.PKCE?
Expand All @@ -75,8 +76,6 @@ public class OpenClawLauncher: ObservableObject {
private var uptimeTimer: Timer?

private let containerName = "openclaw"
private let imageName = "ghcr.io/openclaw/openclaw:latest"
private let port: Int = 18789
private var hasStarted = false
private let shellExecutor: ShellExecutor

Expand All @@ -103,7 +102,8 @@ public class OpenClawLauncher: ObservableObject {
dockerRetryDelayNs: UInt64 = 2_000_000_000,
gatewayRetryCount: Int = 30,
gatewayRetryDelayNs: UInt64 = 2_000_000_000, // 2 seconds between retries
gatewayTimeoutSecs: TimeInterval = 5
gatewayTimeoutSecs: TimeInterval = 5,
settings: LauncherSettings? = nil
) {
self.shellExecutor = shell
self.stateDir = stateDir ?? FileManager.default.homeDirectoryForCurrentUser
Expand All @@ -113,6 +113,7 @@ public class OpenClawLauncher: ObservableObject {
self.gatewayRetryCount = gatewayRetryCount
self.gatewayRetryDelayNs = gatewayRetryDelayNs
self.gatewayTimeoutSecs = gatewayTimeoutSecs
self.settings = settings ?? LauncherSettings.load()
}

deinit {
Expand Down Expand Up @@ -419,7 +420,7 @@ public class OpenClawLauncher: ObservableObject {
addStep(.done, "Opened Control UI in browser")
return
}
var urlString = "http://localhost:\(port)/openclaw"
var urlString = "http://localhost:\(settings.port)/openclaw"
if let token = gatewayToken {
urlString += "?token=\(token)"
}
Expand All @@ -446,6 +447,17 @@ public class OpenClawLauncher: ObservableObject {
}
}

// MARK: - Settings

public func updateSettings(_ newSettings: LauncherSettings) {
settings = newSettings
do {
try settings.save()
} catch {
addStep(.warning, "Failed to save settings: \(error.localizedDescription)")
}
}

// MARK: - Reset & Cleanup

public func resetEverything() {
Expand Down Expand Up @@ -626,7 +638,7 @@ public class OpenClawLauncher: ObservableObject {
gatewayToken = token

// Write .env
let envContent = "OPENCLAW_GATEWAY_TOKEN=\(token)\nOPENCLAW_PORT=\(port)\n"
let envContent = "OPENCLAW_GATEWAY_TOKEN=\(token)\nOPENCLAW_PORT=\(settings.port)\n"
try envContent.write(to: envFile, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: envFile.path)

Expand Down Expand Up @@ -671,7 +683,7 @@ public class OpenClawLauncher: ObservableObject {
addStep(.running, "Pulling latest image... this may take a moment")
pullProgressText = nil

let pull = try await shell("docker", "pull", imageName)
let pull = try await shell("docker", "pull", settings.dockerImage)
pullProgressText = nil

if pull.exitCode == 0 {
Expand All @@ -680,7 +692,7 @@ public class OpenClawLauncher: ObservableObject {
}

// Pull failed — check if we have a local copy to fall back on
let inspect = try? await shell("docker", "image", "inspect", imageName)
let inspect = try? await shell("docker", "image", "inspect", settings.dockerImage)
if inspect?.exitCode == 0 {
addStep(.warning, "Couldn't check for updates (offline?). Using cached image.")
return
Expand Down Expand Up @@ -723,9 +735,9 @@ public class OpenClawLauncher: ObservableObject {
"--tmpfs", "/home/node/.npm:rw,size=64m", // npm might need this

// --- Resource limits ---
"--memory", "2g", // max 2GB RAM
"--memory-swap", "2g", // no swap
"--cpus", "2.0", // max 2 CPU cores
"--memory", settings.memoryLimit, // max RAM
"--memory-swap", settings.memoryLimit, // no swap
"--cpus", String(settings.cpuLimit), // max CPU cores
"--pids-limit", "256", // prevent fork bombs

// --- Security ---
Expand All @@ -734,7 +746,7 @@ public class OpenClawLauncher: ObservableObject {
"--security-opt", "no-new-privileges:true", // prevent privilege escalation

// --- Network ---
"-p", "127.0.0.1:\(port):18789", // LOCALHOST ONLY — not exposed to network
"-p", "127.0.0.1:\(settings.port):18789", // LOCALHOST ONLY — not exposed to network

// --- Persistent state (mounted writable) ---
"-v", "\(configDir.path):/home/node/.openclaw",
Expand All @@ -750,7 +762,7 @@ public class OpenClawLauncher: ObservableObject {
"--restart", "unless-stopped",

// --- Image ---
imageName,
settings.dockerImage,

// --- CMD override (upstream default is just `node dist/index.js`) ---
"node", "dist/index.js", "gateway", "--bind", "lan", "--port", "18789"
Expand All @@ -769,7 +781,7 @@ public class OpenClawLauncher: ObservableObject {
addStep(.running, "Waiting for Gateway to be ready...")
logger.info("waitForGateway: starting (max \(self.gatewayRetryCount) attempts)")

let url = URL(string: "http://127.0.0.1:\(port)/openclaw/")!
let url = URL(string: "http://127.0.0.1:\(settings.port)/openclaw/")!
var request = URLRequest(url: url)
request.timeoutInterval = 5 // 5 second timeout per attempt

Expand Down Expand Up @@ -828,7 +840,7 @@ public class OpenClawLauncher: ObservableObject {
}

private func checkGatewayHealth() async {
let url = URL(string: "http://127.0.0.1:\(port)/openclaw/")!
let url = URL(string: "http://127.0.0.1:\(settings.port)/openclaw/")!
var request = URLRequest(url: url)
request.timeoutInterval = 5

Expand Down
52 changes: 52 additions & 0 deletions app/macos/Tests/OpenClawTests/ConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,58 @@ final class ConfigTests: XCTestCase {
XCTAssertNotNil(agents["defaults"])
}

// MARK: - LauncherSettings Tests

func testSettingsDefaults() {
let settings = LauncherSettings()
XCTAssertEqual(settings.healthCheckInterval, 5.0)
XCTAssertEqual(settings.openBrowserOnStart, true)
XCTAssertEqual(settings.dockerImage, "ghcr.io/openclaw/openclaw:latest")
XCTAssertEqual(settings.memoryLimit, "2g")
XCTAssertEqual(settings.cpuLimit, 2.0)
XCTAssertEqual(settings.port, 18789)
}

func testSettingsEncodeDecode() throws {
let settings = LauncherSettings(
healthCheckInterval: 10.0,
openBrowserOnStart: false,
dockerImage: "custom/image:v1",
memoryLimit: "4g",
cpuLimit: 4.0,
port: 19000
)

let encoder = JSONEncoder()
let data = try encoder.encode(settings)

let decoder = JSONDecoder()
let decoded = try decoder.decode(LauncherSettings.self, from: data)

XCTAssertEqual(settings, decoded)
}

func testSettingsSaveLoad() throws {
// Create custom settings
var settings = LauncherSettings()
settings.port = 12345
settings.memoryLimit = "8g"

// Save to temp file
let settingsFile = tempDir.appendingPathComponent("settings.json")
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(settings)
try data.write(to: settingsFile)

// Load back
let loadedData = try Data(contentsOf: settingsFile)
let loaded = try JSONDecoder().decode(LauncherSettings.self, from: loadedData)

XCTAssertEqual(loaded.port, 12345)
XCTAssertEqual(loaded.memoryLimit, "8g")
}

func testMigration() throws {
let oldDir = tempDir.appendingPathComponent("old-state")
let newDir = tempDir.appendingPathComponent("new-state")
Expand Down
109 changes: 109 additions & 0 deletions docs/launch/blog-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Introducing OpenClaw Launcher: OpenClaw in One Click

**TL;DR:** Double-click an app, get OpenClaw running in an isolated Docker container, chat in your browser. No terminal. No npm. No config files.

![OpenClaw Launcher Dashboard](images/dashboard-running.png)
*OpenClaw running in lockdown mode—one click to get here*

---

## The Problem

OpenClaw is powerful. The setup? Not so much.

If you've tried to get OpenClaw running, you know the drill:
- Clone the repo
- Install Node.js (right version?)
- `npm install` (hope nothing breaks)
- Configure environment variables
- Set up API keys
- Figure out the CLI flags
- Cross your fingers

For developers, this is Tuesday. For everyone else, it's a wall.

And here's the thing: **OpenClaw is evolving fast**. New features land weekly. But if you can't get past the setup, you're left out. I felt this myself—and I suspect many others do too.

## The Security Question

There's another concern that doesn't get talked about enough: **what exactly is this AI agent doing on my machine?**

OpenClaw is an autonomous agent. It can run commands, modify files, make API calls. That's the whole point—but it's also why you might hesitate before giving it free rein on your laptop.

I wanted to try OpenClaw, but I wanted guardrails. A sandbox. Something isolated from my actual system.

Docker was the obvious answer.

## The Solution: OpenClaw Launcher

OpenClaw Launcher is a native macOS app that:

1. **Installs Docker Desktop** if you don't have it
2. **Pulls the OpenClaw image** automatically
3. **Runs it in lockdown mode**—read-only filesystem, no root, memory limits, localhost-only
4. **Opens the Control UI** in your browser

That's it. Double-click → Docker runs → browser opens → done.

No terminal. No Node.js. No PATH issues. No CLI.

![Setup Progress](images/setup-progress.png)
*The app handles Docker, image pulling, and gateway startup automatically*

### What "Lockdown Mode" Means

The container runs with maximum restrictions:

| Security Feature | What It Does |
|-----------------|--------------|
| `--read-only` | Container can't modify its own filesystem |
| `--cap-drop ALL` | Zero Linux capabilities |
| `--no-new-privileges` | Can't escalate permissions |
| `--memory 2g` | Can't consume all your RAM |
| `--pids-limit 256` | Can't fork bomb |
| `127.0.0.1` binding | Not accessible from your network |

The agent runs in a box. It can still do its job—but it can't escape.

## The Workflow

Once the launcher starts OpenClaw, here's what happens:

1. Browser opens to the Control UI
2. Paste the gateway token (auto-generated, shown in the app)
3. Sign in with your AI provider (Anthropic, OpenAI, etc.)
4. Start chatting

Want to connect Telegram? Ask OpenClaw to set it up. Want to configure webhooks? Ask OpenClaw. The whole point is that **you use OpenClaw to configure OpenClaw**.

The WebUI is your interface. The agent does the work.

## Beta Launch

Full transparency: this is a beta release.

The app works, but it's not yet notarized with Apple. That means macOS will show a security warning on first launch. You'll need to run one command to bypass it:

```bash
xattr -cr /Applications/OpenClawLauncher.app
```

This is temporary. Apple Developer enrollment is pending, and v1 will be properly signed and notarized—no terminal commands needed.

## Try It

**Homebrew:**
```bash
brew tap anmol1696/openclaw-launcher
brew install --cask openclaw-launcher
xattr -cr /Applications/OpenClawLauncher.app # beta only
```

**Direct download:**
[OpenClawLauncher.dmg](https://github.com/Anmol1696/openclaw-launcher/releases/latest/download/OpenClawLauncher.dmg)

---

OpenClaw is too useful to be gated behind setup friction. The Launcher removes that gate.

Give it a shot.
Loading