From dde204eef02e1a2ba2a19da56df920b65e6e25ed Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Thu, 5 Feb 2026 20:26:35 +0400 Subject: [PATCH] feat: add settings persistence Add LauncherSettings struct that persists to ~/.openclaw-launcher/settings.json. Settings include: healthCheckInterval, openBrowserOnStart, dockerImage, memoryLimit, cpuLimit, and port. The launcher now loads settings on startup and uses them for Docker container configuration. Settings can be updated via updateSettings(). --- app/macos/Sources/OpenClawLib/Models.swift | 57 +++++++++ .../OpenClawLib/OpenClawLauncher.swift | 40 ++++--- .../Tests/OpenClawTests/ConfigTests.swift | 52 +++++++++ docs/launch/blog-post.md | 109 ++++++++++++++++++ docs/launch/tweet-thread.md | 105 +++++++++++++++++ 5 files changed, 349 insertions(+), 14 deletions(-) create mode 100644 docs/launch/blog-post.md create mode 100644 docs/launch/tweet-thread.md diff --git a/app/macos/Sources/OpenClawLib/Models.swift b/app/macos/Sources/OpenClawLib/Models.swift index ea2e4e3..93038fe 100644 --- a/app/macos/Sources/OpenClawLib/Models.swift +++ b/app/macos/Sources/OpenClawLib/Models.swift @@ -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 diff --git a/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift b/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift index 6f32762..43f2263 100644 --- a/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift +++ b/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift @@ -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? @@ -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 @@ -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 @@ -113,6 +113,7 @@ public class OpenClawLauncher: ObservableObject { self.gatewayRetryCount = gatewayRetryCount self.gatewayRetryDelayNs = gatewayRetryDelayNs self.gatewayTimeoutSecs = gatewayTimeoutSecs + self.settings = settings ?? LauncherSettings.load() } deinit { @@ -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)" } @@ -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() { @@ -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) @@ -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 { @@ -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 @@ -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 --- @@ -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", @@ -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" @@ -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 @@ -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 diff --git a/app/macos/Tests/OpenClawTests/ConfigTests.swift b/app/macos/Tests/OpenClawTests/ConfigTests.swift index f347564..1de86d1 100644 --- a/app/macos/Tests/OpenClawTests/ConfigTests.swift +++ b/app/macos/Tests/OpenClawTests/ConfigTests.swift @@ -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") diff --git a/docs/launch/blog-post.md b/docs/launch/blog-post.md new file mode 100644 index 0000000..12b8848 --- /dev/null +++ b/docs/launch/blog-post.md @@ -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. diff --git a/docs/launch/tweet-thread.md b/docs/launch/tweet-thread.md new file mode 100644 index 0000000..87ed869 --- /dev/null +++ b/docs/launch/tweet-thread.md @@ -0,0 +1,105 @@ +# Tweet Thread: OpenClaw Launcher Launch + +## Images + +| Tweet | Attachment | +|-------|------------| +| Tweet 1 | Video demo OR `dashboard-running.png` | +| Tweet 3 | `setup-progress.png` | + +--- + +**Tweet 1 — Hook + Demo** + +OpenClaw is powerful. The setup? Not so much. + +Today I'm releasing OpenClaw Launcher: + +Double-click → Docker runs in lockdown → browser opens → done. + +No terminal. No npm. No config. + +60 seconds from download to chatting with your AI agent. + +[attach video or dashboard screenshot] + +--- + +**Tweet 2 — Why** + +I wanted to try OpenClaw but: + +→ Setup was a wall (clone, npm, env vars, CLI flags) +→ Giving an AI agent free rein on my machine felt risky + +So I built a launcher that: +• Runs it in isolated Docker +• Read-only filesystem, no root, memory capped +• Localhost only + +The agent works. But it can't escape its box. + +--- + +**Tweet 3 — How it works** + +The launcher handles everything: + +1. Installs Docker Desktop (if needed) +2. Pulls the OpenClaw image +3. Runs container in lockdown mode +4. Opens browser to Control UI + +Then you use OpenClaw to set up OpenClaw. + +Want Telegram? Ask it. Webhooks? Ask it. + +[attach setup-progress screenshot] + +--- + +**Tweet 4 — CTA** + +Try it: + +``` +brew tap anmol1696/openclaw-launcher +brew install --cask openclaw-launcher +``` + +Or download: github.com/Anmol1696/openclaw-launcher + +⚠️ Beta: run `xattr -cr /Applications/OpenClawLauncher.app` once (proper signing coming in days) + +OpenClaw is too useful to be gated behind setup friction. + +--- + +## Alt: Single Tweet Version + +If you want just one tweet: + +--- + +OpenClaw Launcher — run OpenClaw in one click. + +• Isolated Docker container +• Read-only, no root, memory capped +• No terminal, no npm, no config + +Double-click → browser opens → start chatting. + +``` +brew tap anmol1696/openclaw-launcher +brew install --cask openclaw-launcher +``` + +github.com/Anmol1696/openclaw-launcher + +[attach dashboard screenshot or video] + +--- + +## Hashtags (optional, pick 1-2) + +#OpenClaw #AI #MacOS #Docker #AIAgents