From 959d8f1ffa713516308be3f190984db7cd6774ea Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 25 May 2026 18:24:23 +0700 Subject: [PATCH 1/6] feat(macos): add native ClawDnD app shell --- .codex/environments/environment.toml | 11 + .gitignore | 5 + clawdnd-play.command | 14 +- macos/ClawDnDApp/Package.swift | 16 + macos/ClawDnDApp/RELEASE_CHECKLIST.md | 31 ++ .../Sources/ClawDnDApp/App/ClawDnDApp.swift | 34 +++ .../ClawDnDApp/Models/AppSection.swift | 34 +++ .../ClawDnDApp/Models/CampaignSummary.swift | 41 +++ .../ClawDnDApp/Models/DependencyStatus.swift | 10 + .../ClawDnDApp/Models/LocalEndpoint.swift | 25 ++ .../ClawDnDApp/Models/ProviderModels.swift | 81 +++++ .../Services/AppProcessService.swift | 251 ++++++++++++++++ .../ClawDnDApp/Services/CampaignStore.swift | 161 ++++++++++ .../Services/DependencyChecker.swift | 17 ++ .../ClawDnDApp/Services/Diagnostics.swift | 11 + .../ClawDnDApp/Services/PortFinder.swift | 41 +++ .../Services/ProviderAdapters.swift | 278 ++++++++++++++++++ .../Services/RepositoryLocator.swift | 38 +++ .../Sources/ClawDnDApp/Services/Shell.swift | 27 ++ .../ClawDnDApp/Views/CampaignsView.swift | 177 +++++++++++ .../ClawDnDApp/Views/EmptyStateView.swift | 18 ++ .../Sources/ClawDnDApp/Views/LogsView.swift | 51 ++++ .../ClawDnDApp/Views/MonitorView.swift | 60 ++++ .../Sources/ClawDnDApp/Views/PlayView.swift | 174 +++++++++++ .../ClawDnDApp/Views/ProvidersView.swift | 124 ++++++++ .../Sources/ClawDnDApp/Views/RootView.swift | 153 ++++++++++ .../ClawDnDApp/Views/SettingsView.swift | 56 ++++ .../Sources/ClawDnDApp/Views/WebView.swift | 21 ++ script/build_and_run.sh | 123 ++++++++ scripts/launch_common.sh | 78 +++++ scripts/play.sh | 13 + scripts/play_party.sh | 22 +- 32 files changed, 2194 insertions(+), 2 deletions(-) create mode 100644 .codex/environments/environment.toml create mode 100644 macos/ClawDnDApp/Package.swift create mode 100644 macos/ClawDnDApp/RELEASE_CHECKLIST.md create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Models/AppSection.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Models/CampaignSummary.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Models/DependencyStatus.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Models/ProviderModels.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Services/DependencyChecker.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Services/Diagnostics.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Services/PortFinder.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Services/ProviderAdapters.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Services/Shell.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/CampaignsView.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/EmptyStateView.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/LogsView.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/ProvidersView.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/SettingsView.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift create mode 100755 script/build_and_run.sh create mode 100644 scripts/launch_common.sh diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 00000000..91fda2e2 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "ClawDnD" + +[setup] +script = "" + +[[actions]] +name = "Run" +icon = "run" +command = "./script/build_and_run.sh" diff --git a/.gitignore b/.gitignore index 298cdfe7..a3a0b8f9 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,11 @@ venv/ .uv/ *.egg-info/ +# Native macOS app build outputs +/dist/ +/macos/ClawDnDApp/.build/ +/macos/ClawDnDApp/.swiftpm/ + # TTS models / audio caches (large, regenerated locally) *.onnx *.pt diff --git a/clawdnd-play.command b/clawdnd-play.command index 4ce89541..7ee45c98 100755 --- a/clawdnd-play.command +++ b/clawdnd-play.command @@ -25,4 +25,16 @@ cd "$(dirname "$0")" || exit 1 # play_party.sh == solo play.sh when no companion spec is given, and adds the opt-in party # when one is (via the 4th arg or $CLAWDND_PLAY_COMPANIONS). Routing through it keeps the # double-click solo experience identical while enabling companions for those who want them. -exec "$PWD/scripts/play_party.sh" "$@" +"$PWD/scripts/play_party.sh" "$@" +status=$? +if [ "$status" -ne 0 ] && [ "$status" -ne 130 ]; then + echo + echo "ClawDnD did not start cleanly (exit $status)." + echo "The message above should say what was missing or which port was busy." + if [ -t 0 ]; then + echo + echo "Press Return to close this window." + read -r _ + fi +fi +exit "$status" diff --git a/macos/ClawDnDApp/Package.swift b/macos/ClawDnDApp/Package.swift new file mode 100644 index 00000000..881e6fc1 --- /dev/null +++ b/macos/ClawDnDApp/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "ClawDnDApp", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "ClawDnDApp", targets: ["ClawDnDApp"]) + ], + targets: [ + .executableTarget(name: "ClawDnDApp") + ] +) diff --git a/macos/ClawDnDApp/RELEASE_CHECKLIST.md b/macos/ClawDnDApp/RELEASE_CHECKLIST.md new file mode 100644 index 00000000..16010b5f --- /dev/null +++ b/macos/ClawDnDApp/RELEASE_CHECKLIST.md @@ -0,0 +1,31 @@ +# ClawDnD Native macOS Release Checklist + +The v0.3 macOS lane starts with a locally signed development app. Notarization is +release-trust work after the local shell, provider bridge, and dashboard hosting +are stable. + +## Local build + +```bash +./script/build_and_run.sh --verify +``` + +## Signing state + +```bash +security find-identity -p codesigning -v +codesign --verify --deep --strict dist/ClawDnD.app +spctl -a -vv dist/ClawDnD.app +``` + +The build script ad-hoc signs the local app bundle when `codesign` is available. +Gatekeeper assessment may still reject the app until a Developer ID certificate, +hardened runtime, and notarization flow are configured. + +## Distribution blockers to track separately + +- Developer ID signing identity. +- Hardened runtime entitlements. +- Notarization profile and CI secret handling. +- User-facing update channel. +- Copyright/private world seed exclusion from packaged artifacts. diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift new file mode 100644 index 00000000..80bc4eaa --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift @@ -0,0 +1,34 @@ +import AppKit +import SwiftUI + +@main +struct ClawDnDApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var processService = AppProcessService() + @StateObject private var campaignStore = CampaignStore() + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(processService) + .environmentObject(campaignStore) + .frame(minWidth: 1120, minHeight: 720) + } + .commands { + CommandGroup(replacing: .newItem) {} + CommandGroup(after: .appInfo) { + Button("Copy Diagnostics") { + Diagnostics.copy(processService: processService) + } + .keyboardShortcut("d", modifiers: [.command, .shift]) + } + } + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/AppSection.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/AppSection.swift new file mode 100644 index 00000000..b6ae130f --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/AppSection.swift @@ -0,0 +1,34 @@ +import Foundation + +enum AppSection: String, CaseIterable, Identifiable { + case play + case campaigns + case monitor + case providers + case settings + case logs + + var id: String { rawValue } + + var title: String { + switch self { + case .play: "Play" + case .campaigns: "Campaigns" + case .monitor: "Monitor" + case .providers: "Providers" + case .settings: "Settings" + case .logs: "Logs" + } + } + + var symbolName: String { + switch self { + case .play: "play.circle" + case .campaigns: "books.vertical" + case .monitor: "waveform.path.ecg.rectangle" + case .providers: "person.2.wave.2" + case .settings: "gearshape" + case .logs: "doc.text.magnifyingglass" + } + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/CampaignSummary.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/CampaignSummary.swift new file mode 100644 index 00000000..d39a4550 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/CampaignSummary.swift @@ -0,0 +1,41 @@ +import Foundation + +enum CampaignSource: String, Codable { + case play + case qa +} + +struct CampaignSummary: Identifiable, Equatable { + let id: String + let runID: String + let source: CampaignSource + let snapshotPath: URL + let stateRoot: URL + let title: String + let world: String + let day: Int? + let timeOfDay: String + let location: String + let party: [String] + let provider: String + let lastUpdate: Date + let isLive: Bool + + var sourceLabel: String { + switch source { + case .play: "Play" + case .qa: "QA" + } + } + + var partyLabel: String { + party.isEmpty ? "No party yet" : party.joined(separator: ", ") + } + + var dayLabel: String { + if let day { + return "Day \(day), \(timeOfDay)" + } + return timeOfDay.isEmpty ? "Unknown time" : timeOfDay + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/DependencyStatus.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/DependencyStatus.swift new file mode 100644 index 00000000..ab435724 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/DependencyStatus.swift @@ -0,0 +1,10 @@ +import Foundation + +struct DependencyStatus: Identifiable, Equatable { + var id: String { command } + let command: String + let requiredFor: String + let path: String? + + var isInstalled: Bool { path != nil } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift new file mode 100644 index 00000000..b127556d --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift @@ -0,0 +1,25 @@ +import Foundation + +enum EndpointStatus: String, Equatable { + case stopped + case starting + case running + case failed +} + +struct LocalEndpoint: Identifiable, Equatable { + let id = UUID() + var name: String + var port: Int + var url: URL + var healthPath: String + var status: EndpointStatus + + var dashboardURL: URL { + URL(string: "http://127.0.0.1:\(port)/dashboard")! + } + + var monitorURL: URL { + URL(string: "http://127.0.0.1:\(port)/monitor")! + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/ProviderModels.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/ProviderModels.swift new file mode 100644 index 00000000..10021e36 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/ProviderModels.swift @@ -0,0 +1,81 @@ +import Foundation + +enum ProviderKind: String, CaseIterable, Identifiable { + case claude + case codex + case openclaw + + var id: String { rawValue } + + var displayName: String { + switch self { + case .claude: "Claude" + case .codex: "Codex" + case .openclaw: "OpenClaw" + } + } + + var symbolName: String { + switch self { + case .claude: "sparkles" + case .codex: "terminal" + case .openclaw: "link" + } + } +} + +enum ProviderAvailability: String, Equatable { + case installed + case configured + case missing + case error +} + +struct ProviderStatus: Identifiable, Equatable { + var id: String { kind.rawValue } + let kind: ProviderKind + let availability: ProviderAvailability + let detail: String + let detectedPath: String? + + var isLaunchable: Bool { + availability == .configured || (kind == .claude && availability == .installed) + } +} + +struct ProviderRun: Identifiable, Equatable { + let id: String + let provider: ProviderKind + let processID: Int32? + let message: String + let startedAt: Date +} + +struct ProviderPreferences { + let codexCommand: String + let openClawCommand: String + let budget: String + let sessionBudget: String + let maxTurns: String +} + +struct ProviderLaunchRequest { + let name: String + let executable: String + let arguments: [String] + let environment: [String: String] + let workingDirectory: URL + let message: String +} + +enum ProviderError: LocalizedError { + case missingDependency(String) + case configuration(String) + + var errorDescription: String? { + switch self { + case .missingDependency(let message), .configuration(let message): + message + } + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift new file mode 100644 index 00000000..38e7c442 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift @@ -0,0 +1,251 @@ +import Foundation + +@MainActor +final class AppProcessService: ObservableObject { + @Published var viewerEndpoint: LocalEndpoint? + @Published var runningProvider: ProviderKind? + @Published var activeCampaignID: String? + @Published var dependencies: [DependencyStatus] = DependencyChecker.check() + @Published var supervisorLog: String = "" + @Published var providerLog: String = "" + @Published var lastError: String? + + private var viewerProcess: ManagedProcess? + private var providerProcess: ManagedProcess? + private let registry = ProviderRegistry() + + var diagnostics: String { + """ + ClawDnD Native App Diagnostics + Viewer: \(viewerEndpoint?.url.absoluteString ?? "stopped") + Viewer status: \(viewerEndpoint?.status.rawValue ?? "stopped") + Active campaign: \(activeCampaignID ?? "none") + Running provider: \(runningProvider?.rawValue ?? "none") + Last error: \(lastError ?? "none") + + Dependencies: + \(dependencies.map { "\($0.command): \($0.path ?? "missing")" }.joined(separator: "\n")) + + Supervisor log: + \(supervisorLog) + + Provider log: + \(providerLog) + """ + } + + func refreshDependencies() { + dependencies = DependencyChecker.check() + } + + func providerStatuses(repoPath: String, preferences: ProviderPreferences) -> [ProviderStatus] { + registry.detectAll(repoPath: URL(fileURLWithPath: repoPath), preferences: preferences) + } + + func startViewer( + repoPath: String, + preferredPort: Int, + stateDir: String, + campaignID: String? = nil + ) throws -> URL { + let repoURL = URL(fileURLWithPath: repoPath) + guard RepositoryLocator.looksLikeRepo(repoURL) else { + try throwAndRecord("Repo path is not a ClawDnD checkout: \(repoPath)") + } + guard Shell.which("python3") != nil else { + try throwAndRecord("python3 is missing. Install Python 3 before launching the viewer.") + } + + stopViewer() + + let port = PortFinder.firstFreePort(startingAt: preferredPort) + let dashboard = URL(string: "http://127.0.0.1:\(port)/dashboard")! + var env: [String: String] = [:] + if !stateDir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + env["CLAWDND_STATE_DIR"] = (stateDir as NSString).expandingTildeInPath + } + let args = ["python3", "viewer/server.py", campaignID ?? "", String(port)] + let managed = try launchManagedProcess( + name: "viewer", + executable: "/usr/bin/env", + arguments: args, + workingDirectory: repoURL, + environment: env, + stream: .supervisor + ) + viewerProcess = managed + activeCampaignID = campaignID + viewerEndpoint = LocalEndpoint( + name: "Viewer", + port: port, + url: dashboard, + healthPath: "/state", + status: .running + ) + append("Started viewer pid \(managed.pid) on \(dashboard.absoluteString)", stream: .supervisor) + return dashboard + } + + func stopViewer() { + viewerProcess?.terminate() + viewerProcess = nil + if var endpoint = viewerEndpoint { + endpoint.status = .stopped + viewerEndpoint = endpoint + } + } + + func startProviderSession( + kind: ProviderKind, + repoPath: String, + world: String, + runId: String, + preferredPort: Int, + companions: String, + preferences: ProviderPreferences + ) throws -> URL { + let repoURL = URL(fileURLWithPath: repoPath) + guard RepositoryLocator.looksLikeRepo(repoURL) else { + try throwAndRecord("Repo path is not a ClawDnD checkout: \(repoPath)") + } + + let port = PortFinder.firstFreePort(startingAt: preferredPort) + let adapter = registry.adapter(for: kind) + let request = try adapter.startSession( + world: world, + runId: runId, + port: port, + companions: companions, + repoPath: repoURL, + preferences: preferences + ) + + providerProcess?.terminate() + providerLog = "" + let managed = try launchManagedProcess( + name: request.name, + executable: request.executable, + arguments: request.arguments, + workingDirectory: request.workingDirectory, + environment: request.environment, + stream: .provider + ) + providerProcess = managed + runningProvider = kind + let dashboard = URL(string: "http://127.0.0.1:\(port)/dashboard")! + viewerEndpoint = LocalEndpoint( + name: "Provider viewer", + port: port, + url: dashboard, + healthPath: "/state", + status: .starting + ) + append("\(request.message) pid \(managed.pid)", stream: .provider) + return dashboard + } + + func stopProvider() { + providerProcess?.terminate() + providerProcess = nil + runningProvider = nil + } + + private func launchManagedProcess( + name: String, + executable: String, + arguments: [String], + workingDirectory: URL, + environment: [String: String], + stream: LogStream + ) throws -> ManagedProcess { + var mergedEnvironment = ProcessInfo.processInfo.environment + environment.forEach { key, value in mergedEnvironment[key] = value } + + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.currentDirectoryURL = workingDirectory + process.environment = mergedEnvironment + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + let managed = ManagedProcess(name: name, process: process) + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + let text = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self) + Task { @MainActor in + self?.append(text, stream: stream, prefix: name) + } + } + + process.terminationHandler = { [weak self, weak managed] process in + Task { @MainActor in + managed?.close() + self?.append("\(name) exited with status \(process.terminationStatus)", stream: stream) + if stream == .provider { + self?.runningProvider = nil + } + } + } + + do { + try process.run() + return managed + } catch { + let message = "\(name) failed to launch: \(error.localizedDescription)" + lastError = message + append(message, stream: stream) + throw error + } + } + + private func append(_ text: String, stream: LogStream, prefix: String? = nil) { + let line = prefix.map { "[\($0)] \(text)" } ?? text + switch stream { + case .supervisor: + supervisorLog += line.hasSuffix("\n") ? line : line + "\n" + case .provider: + providerLog += line.hasSuffix("\n") ? line : line + "\n" + } + } + + private func throwAndRecord(_ message: String) throws -> Never { + lastError = message + append(message, stream: .supervisor) + throw ProviderError.configuration(message) + } +} + +private enum LogStream { + case supervisor + case provider +} + +final class ManagedProcess { + let name: String + let process: Process + + init(name: String, process: Process) { + self.name = name + self.process = process + } + + var pid: Int32 { process.processIdentifier } + + func terminate() { + guard process.isRunning else { + close() + return + } + process.terminate() + close() + } + + func close() { + (process.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil + (process.standardError as? Pipe)?.fileHandleForReading.readabilityHandler = nil + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift new file mode 100644 index 00000000..21cb664b --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift @@ -0,0 +1,161 @@ +import Foundation + +@MainActor +final class CampaignStore: ObservableObject { + @Published var campaigns: [CampaignSummary] = [] + @Published var lastError: String? + + func reload(repoPath: String) { + let repoURL = URL(fileURLWithPath: repoPath) + do { + let play = try loadCampaigns( + root: repoURL.appendingPathComponent("play-state"), + source: .play + ) + let qa = try loadCampaigns( + root: repoURL.appendingPathComponent("qa/state"), + source: .qa + ) + campaigns = (play + qa).sorted { $0.lastUpdate > $1.lastUpdate } + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + private func loadCampaigns(root: URL, source: CampaignSource) throws -> [CampaignSummary] { + guard FileManager.default.fileExists(atPath: root.path) else { return [] } + let runDirectories = try FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + + return runDirectories.flatMap { runURL -> [CampaignSummary] in + let campaignsDir = runURL.appendingPathComponent("campaigns") + guard FileManager.default.fileExists(atPath: campaignsDir.path) else { return [] } + let campaignDirectories = (try? FileManager.default.contentsOfDirectory( + at: campaignsDir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + return campaignDirectories.compactMap { campaignURL in + loadSnapshot( + snapshotURL: campaignURL.appendingPathComponent("snapshot.json"), + stateRoot: runURL, + source: source + ) + } + } + } + + private func loadSnapshot( + snapshotURL: URL, + stateRoot: URL, + source: CampaignSource + ) -> CampaignSummary? { + guard let data = try? Data(contentsOf: snapshotURL), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let id = root["id"] as? String else { + return nil + } + + let runID = stateRoot.lastPathComponent + let title = (root["title"] as? String).flatMap(nonEmpty) ?? id + let world = (root["world_id"] as? String).flatMap(nonEmpty) ?? "unknown" + let timeOfDay = (root["time_of_day"] as? String).flatMap(nonEmpty) ?? "" + let day = root["day"] as? Int + let location = currentLocationName(root) + let party = partyNames(root) + let lastUpdate = campaignRecency(snapshotURL: snapshotURL) + let isLive = Date().timeIntervalSince(lastUpdate) < 120 + let provider = inferProvider(stateRoot: stateRoot, source: source) + + return CampaignSummary( + id: id, + runID: runID, + source: source, + snapshotPath: snapshotURL, + stateRoot: stateRoot, + title: title, + world: world, + day: day, + timeOfDay: timeOfDay, + location: location, + party: party, + provider: provider, + lastUpdate: lastUpdate, + isLive: isLive + ) + } + + private func currentLocationName(_ root: [String: Any]) -> String { + guard let locID = root["current_location_id"] as? String else { + return "Unknown location" + } + if let locations = root["locations"] as? [String: Any], + let location = locations[locID] as? [String: Any], + let name = location["name"] as? String, + !name.isEmpty { + return name + } + return locID + } + + private func partyNames(_ root: [String: Any]) -> [String] { + guard let party = root["party"] as? [String], + let characters = root["characters"] as? [String: Any] else { + return [] + } + return party.compactMap { id in + guard let character = characters[id] as? [String: Any] else { return id } + return (character["name"] as? String).flatMap(nonEmpty) ?? id + } + } + + private func inferProvider(stateRoot: URL, source: CampaignSource) -> String { + switch source { + case .qa: + return "QA" + case .play: + if FileManager.default.fileExists(atPath: stateRoot.appendingPathComponent("companion_0.mcp.json").path) { + return "Claude party" + } + if FileManager.default.fileExists(atPath: stateRoot.appendingPathComponent("dm.mcp.json").path) { + return "Claude" + } + return "Local" + } + } + + private func campaignRecency(snapshotURL: URL) -> Date { + var best = fileDate(snapshotURL) + let sessionsURL = snapshotURL + .deletingLastPathComponent() + .appendingPathComponent("sessions") + guard let logs = try? FileManager.default.contentsOfDirectory( + at: sessionsURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return best + } + for log in logs where log.pathExtension == "jsonl" { + let date = fileDate(log) + if date > best { + best = date + } + } + return best + } + + private func fileDate(_ url: URL) -> Date { + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + return values?.contentModificationDate ?? .distantPast + } + + private func nonEmpty(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/DependencyChecker.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/DependencyChecker.swift new file mode 100644 index 00000000..7038c34b --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/DependencyChecker.swift @@ -0,0 +1,17 @@ +import Foundation + +enum DependencyChecker { + static let required: [(String, String)] = [ + ("python3", "viewer"), + ("claude", "Claude provider"), + ("uv", "engine/rules/voice servers"), + ("jq", "play scripts"), + ("curl", "viewer health checks") + ] + + static func check() -> [DependencyStatus] { + required.map { command, use in + DependencyStatus(command: command, requiredFor: use, path: Shell.which(command)) + } + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/Diagnostics.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/Diagnostics.swift new file mode 100644 index 00000000..3ea16562 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/Diagnostics.swift @@ -0,0 +1,11 @@ +import AppKit +import Foundation + +enum Diagnostics { + @MainActor + static func copy(processService: AppProcessService) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(processService.diagnostics, forType: .string) + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/PortFinder.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/PortFinder.swift new file mode 100644 index 00000000..946fc638 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/PortFinder.swift @@ -0,0 +1,41 @@ +import Darwin +import Foundation + +enum PortFinder { + static func firstFreePort(startingAt preferredPort: Int) -> Int { + let start = max(1, min(preferredPort, 65535)) + if isAvailable(start) { + return start + } + let nearbyEnd = min(start + 40, 65535) + if start < nearbyEnd { + for port in (start + 1)...nearbyEnd where isAvailable(port) { + return port + } + } + for port in 8765...8805 where isAvailable(port) { + return port + } + return start + } + + static func isAvailable(_ port: Int) -> Bool { + guard (1...65535).contains(port) else { return false } + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(port).bigEndian + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let result = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/ProviderAdapters.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/ProviderAdapters.swift new file mode 100644 index 00000000..6e57f8e8 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/ProviderAdapters.swift @@ -0,0 +1,278 @@ +import Foundation + +protocol ProviderAdapter { + var kind: ProviderKind { get } + + func detect(repoPath: URL, preferences: ProviderPreferences) -> ProviderStatus + func startSession( + world: String, + runId: String, + port: Int, + companions: String, + repoPath: URL, + preferences: ProviderPreferences + ) throws -> ProviderLaunchRequest + func stop(runId: String) + func tailLogs(runId: String) -> String +} + +struct ClaudeProvider: ProviderAdapter { + let kind: ProviderKind = .claude + + func detect(repoPath: URL, preferences: ProviderPreferences) -> ProviderStatus { + guard let claudePath = Shell.which("claude") else { + return ProviderStatus( + kind: kind, + availability: .missing, + detail: "Install the Claude CLI to run the existing plugin play path.", + detectedPath: nil + ) + } + let plugin = repoPath.appendingPathComponent(".claude-plugin/plugin.json").path + guard FileManager.default.fileExists(atPath: plugin) else { + return ProviderStatus( + kind: kind, + availability: .error, + detail: "Claude CLI is installed, but .claude-plugin/plugin.json is missing from this repo.", + detectedPath: claudePath + ) + } + return ProviderStatus( + kind: kind, + availability: .installed, + detail: "Ready. Uses scripts/play_party.sh and the existing Claude plugin path.", + detectedPath: claudePath + ) + } + + func startSession( + world: String, + runId: String, + port: Int, + companions: String, + repoPath: URL, + preferences: ProviderPreferences + ) throws -> ProviderLaunchRequest { + guard Shell.which("claude") != nil else { + throw ProviderError.missingDependency("Claude CLI is missing. Install claude, then start the session again.") + } + + var args = ["scripts/play_party.sh", world, runId, String(port)] + if !companions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(companions) + } + + return ProviderLaunchRequest( + name: "Claude game", + executable: "/usr/bin/env", + arguments: ["bash"] + args, + environment: budgetEnvironment(preferences), + workingDirectory: repoPath, + message: "Claude session starting on port \(port)." + ) + } + + func stop(runId: String) {} + + func tailLogs(runId: String) -> String { + "Claude logs are captured through the app supervisor and play-state//dm*.jsonl." + } +} + +struct CodexProvider: ProviderAdapter { + let kind: ProviderKind = .codex + + func detect(repoPath: URL, preferences: ProviderPreferences) -> ProviderStatus { + let cli = Shell.which("codex") + let appExists = Shell.fileExists("/Applications/Codex.app") + let homeConfig = Shell.fileExists("~/.codex") + + if preferences.codexCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let cli { + return ProviderStatus( + kind: kind, + availability: .installed, + detail: "Codex CLI found, but no ClawDnD launch command is configured yet.", + detectedPath: cli + ) + } + if appExists || homeConfig { + return ProviderStatus( + kind: kind, + availability: .installed, + detail: "Codex app/config detected, but no ClawDnD launch command is configured yet.", + detectedPath: appExists ? "/Applications/Codex.app" : "~/.codex" + ) + } + return ProviderStatus( + kind: kind, + availability: .missing, + detail: "Codex was not found. Install Codex or configure a provider command.", + detectedPath: nil + ) + } + + return ProviderStatus( + kind: kind, + availability: .configured, + detail: "Configured. The app will launch your command with ClawDnD provider environment variables.", + detectedPath: cli + ) + } + + func startSession( + world: String, + runId: String, + port: Int, + companions: String, + repoPath: URL, + preferences: ProviderPreferences + ) throws -> ProviderLaunchRequest { + let command = preferences.codexCommand.trimmingCharacters(in: .whitespacesAndNewlines) + guard !command.isEmpty else { + throw ProviderError.configuration("Codex provider is detected but not launch-configured. Set a Codex provider command in Settings.") + } + return ProviderLaunchRequest( + name: "Codex game", + executable: "/bin/zsh", + arguments: ["-lc", command], + environment: providerEnvironment( + kind: kind, + world: world, + runId: runId, + port: port, + companions: companions, + preferences: preferences + ), + workingDirectory: repoPath, + message: "Codex provider command launched on port \(port)." + ) + } + + func stop(runId: String) {} + + func tailLogs(runId: String) -> String { + "Codex provider output is captured through the app supervisor." + } +} + +struct OpenClawProvider: ProviderAdapter { + let kind: ProviderKind = .openclaw + + func detect(repoPath: URL, preferences: ProviderPreferences) -> ProviderStatus { + let cli = Shell.which("openclaw") + let config = Shell.fileExists("~/.openclaw") + if preferences.openClawCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let cli { + return ProviderStatus( + kind: kind, + availability: .installed, + detail: "OpenClaw CLI found. Configure a local ClawDnD launch command before starting sessions.", + detectedPath: cli + ) + } + if config { + return ProviderStatus( + kind: kind, + availability: .installed, + detail: "OpenClaw config detected. Configure a local launch command to enable game starts.", + detectedPath: "~/.openclaw" + ) + } + return ProviderStatus( + kind: kind, + availability: .missing, + detail: "OpenClaw was not found. This adapter fails closed until a valid local command exists.", + detectedPath: nil + ) + } + return ProviderStatus( + kind: kind, + availability: .configured, + detail: "Configured. The app will launch your command with ClawDnD provider environment variables.", + detectedPath: cli + ) + } + + func startSession( + world: String, + runId: String, + port: Int, + companions: String, + repoPath: URL, + preferences: ProviderPreferences + ) throws -> ProviderLaunchRequest { + let command = preferences.openClawCommand.trimmingCharacters(in: .whitespacesAndNewlines) + guard !command.isEmpty else { + throw ProviderError.configuration("OpenClaw provider is not launch-configured. Set an OpenClaw provider command in Settings.") + } + return ProviderLaunchRequest( + name: "OpenClaw game", + executable: "/bin/zsh", + arguments: ["-lc", command], + environment: providerEnvironment( + kind: kind, + world: world, + runId: runId, + port: port, + companions: companions, + preferences: preferences + ), + workingDirectory: repoPath, + message: "OpenClaw provider command launched on port \(port)." + ) + } + + func stop(runId: String) {} + + func tailLogs(runId: String) -> String { + "OpenClaw provider output is captured through the app supervisor." + } +} + +struct ProviderRegistry { + private let adapters: [ProviderKind: ProviderAdapter] = [ + .claude: ClaudeProvider(), + .codex: CodexProvider(), + .openclaw: OpenClawProvider() + ] + + func adapter(for kind: ProviderKind) -> ProviderAdapter { + adapters[kind] ?? ClaudeProvider() + } + + func detectAll(repoPath: URL, preferences: ProviderPreferences) -> [ProviderStatus] { + ProviderKind.allCases.map { adapter(for: $0).detect(repoPath: repoPath, preferences: preferences) } + } +} + +private func budgetEnvironment(_ preferences: ProviderPreferences) -> [String: String] { + var env: [String: String] = [:] + if !preferences.budget.isEmpty { + env["CLAWDND_PLAY_BUDGET"] = preferences.budget + } + if !preferences.sessionBudget.isEmpty { + env["CLAWDND_PLAY_SESSION_BUDGET"] = preferences.sessionBudget + } + if !preferences.maxTurns.isEmpty { + env["CLAWDND_PLAY_MAX_TURNS"] = preferences.maxTurns + } + return env +} + +private func providerEnvironment( + kind: ProviderKind, + world: String, + runId: String, + port: Int, + companions: String, + preferences: ProviderPreferences +) -> [String: String] { + var env = budgetEnvironment(preferences) + env["CLAWDND_PROVIDER"] = kind.rawValue + env["CLAWDND_WORLD"] = world + env["CLAWDND_RUN_ID"] = runId + env["CLAWDND_PLAY_PORT"] = String(port) + env["CLAWDND_PLAY_COMPANIONS"] = companions + return env +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift new file mode 100644 index 00000000..b4432e7f --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift @@ -0,0 +1,38 @@ +import Foundation + +enum RepositoryLocator { + static func defaultRepoPath() -> String { + if let env = ProcessInfo.processInfo.environment["CLAWDND_REPO_ROOT"], + looksLikeRepo(URL(fileURLWithPath: env)) { + return env + } + + let bundleURL = Bundle.main.bundleURL + var cursor = bundleURL.deletingLastPathComponent() + for _ in 0..<8 { + if looksLikeRepo(cursor) { + return cursor.path + } + cursor.deleteLastPathComponent() + } + + let lexar = URL(fileURLWithPath: "/Volumes/LEXAR/repos/ClawDnD") + if looksLikeRepo(lexar) { + return lexar.path + } + + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + if looksLikeRepo(cwd) { + return cwd.path + } + + return lexar.path + } + + static func looksLikeRepo(_ url: URL) -> Bool { + let viewer = url.appendingPathComponent("viewer/server.py").path + let plugin = url.appendingPathComponent(".claude-plugin/plugin.json").path + return FileManager.default.fileExists(atPath: viewer) + && FileManager.default.fileExists(atPath: plugin) + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/Shell.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/Shell.swift new file mode 100644 index 00000000..f91026b4 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/Shell.swift @@ -0,0 +1,27 @@ +import Foundation + +enum Shell { + static func which(_ command: String) -> String? { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = [command] + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + } catch { + return nil + } + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let path = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return path?.isEmpty == false ? path : nil + } + + static func fileExists(_ path: String) -> Bool { + FileManager.default.fileExists(atPath: (path as NSString).expandingTildeInPath) + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/CampaignsView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/CampaignsView.swift new file mode 100644 index 00000000..7875991e --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/CampaignsView.swift @@ -0,0 +1,177 @@ +import SwiftUI + +struct CampaignsView: View { + @EnvironmentObject private var processService: AppProcessService + @EnvironmentObject private var campaignStore: CampaignStore + + @Binding var repoPath: String + @Binding var preferredPort: Int + @Binding var webURL: URL? + + @State private var selectedCampaignID: CampaignSummary.ID? + @State private var alertMessage: String? + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading) { + Text("Campaigns") + .font(.title2.weight(.semibold)) + Text("Read-only snapshots from play-state and qa/state.") + .foregroundStyle(.secondary) + } + Spacer() + Button { + campaignStore.reload(repoPath: repoPath) + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + } + .padding(16) + Divider() + + if campaignStore.campaigns.isEmpty { + EmptyStateView(title: "No Campaigns Found", symbolName: "books.vertical") + } else { + HSplitView { + List(selection: $selectedCampaignID) { + ForEach(campaignStore.campaigns) { campaign in + CampaignRow(campaign: campaign) + .tag(campaign.id) + .contextMenu { + Button("Open in Dashboard") { + open(campaign) + } + } + } + } + .frame(minWidth: 360, idealWidth: 460) + + CampaignDetail( + campaign: campaignStore.campaigns.first { $0.id == selectedCampaignID }, + openAction: { campaign in open(campaign) } + ) + .frame(minWidth: 420) + } + } + } + .onAppear { + campaignStore.reload(repoPath: repoPath) + selectedCampaignID = selectedCampaignID ?? campaignStore.campaigns.first?.id + } + .alert("Campaign could not open", isPresented: alertBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(alertMessage ?? "") + } + } + + private func open(_ campaign: CampaignSummary) { + do { + webURL = try processService.startViewer( + repoPath: repoPath, + preferredPort: preferredPort, + stateDir: campaign.stateRoot.path, + campaignID: campaign.id + ) + } catch { + alertMessage = error.localizedDescription + } + } + + private var alertBinding: Binding { + Binding(get: { alertMessage != nil }, set: { if !$0 { alertMessage = nil } }) + } +} + +struct CampaignRow: View { + let campaign: CampaignSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(campaign.title) + .font(.headline) + .lineLimit(1) + Spacer() + if campaign.isLive { + Text("Live") + .font(.caption.weight(.semibold)) + .foregroundStyle(.green) + } + } + HStack(spacing: 8) { + Label(campaign.sourceLabel, systemImage: campaign.source == .play ? "play.circle" : "testtube.2") + Label(campaign.world, systemImage: "map") + Label(campaign.dayLabel, systemImage: "clock") + } + .font(.caption) + .foregroundStyle(.secondary) + Text(campaign.location) + .font(.caption) + .lineLimit(1) + } + .padding(.vertical, 6) + } +} + +struct CampaignDetail: View { + let campaign: CampaignSummary? + let openAction: (CampaignSummary) -> Void + + var body: some View { + if let campaign { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text(campaign.title) + .font(.title2.weight(.semibold)) + Text(campaign.id) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + Button { + openAction(campaign) + } label: { + Label("Open", systemImage: "rectangle.on.rectangle") + } + .buttonStyle(.borderedProminent) + } + + Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 10) { + detailRow("Source", campaign.sourceLabel) + detailRow("Run", campaign.runID) + detailRow("World", campaign.world) + detailRow("Time", campaign.dayLabel) + detailRow("Location", campaign.location) + detailRow("Provider", campaign.provider) + detailRow("Updated", campaign.lastUpdate.formatted(date: .abbreviated, time: .standard)) + detailRow("State root", campaign.stateRoot.path) + } + Divider() + VStack(alignment: .leading, spacing: 8) { + Text("Party") + .font(.headline) + Text(campaign.partyLabel) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Spacer() + } + .padding(20) + } else { + EmptyStateView(title: "Select a Campaign", symbolName: "books.vertical") + } + } + + private func detailRow(_ label: String, _ value: String) -> some View { + GridRow { + Text(label) + .foregroundStyle(.secondary) + Text(value) + .textSelection(.enabled) + .lineLimit(2) + } + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/EmptyStateView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/EmptyStateView.swift new file mode 100644 index 00000000..f41ed220 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/EmptyStateView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct EmptyStateView: View { + let title: String + let symbolName: String + + var body: some View { + VStack(spacing: 14) { + Image(systemName: symbolName) + .font(.system(size: 44)) + .foregroundStyle(.secondary) + Text(title) + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/LogsView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/LogsView.swift new file mode 100644 index 00000000..a6a4f3e3 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/LogsView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct LogsView: View { + @EnvironmentObject private var processService: AppProcessService + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading) { + Text("Logs") + .font(.title2.weight(.semibold)) + Text("Supervisor, provider, and last-error diagnostics.") + .foregroundStyle(.secondary) + } + Spacer() + Button { + Diagnostics.copy(processService: processService) + } label: { + Label("Copy Diagnostics", systemImage: "doc.on.doc") + } + } + .padding(16) + Divider() + TabView { + LogText(title: "Viewer", text: processService.supervisorLog) + .tabItem { Text("Viewer") } + LogText(title: "Provider", text: processService.providerLog) + .tabItem { Text("Provider") } + LogText(title: "Diagnostics", text: processService.diagnostics) + .tabItem { Text("Diagnostics") } + } + .padding(12) + } + } +} + +struct LogText: View { + let title: String + let text: String + + var body: some View { + ScrollView { + Text(text.isEmpty ? "\(title) log is empty." : text) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(12) + } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift new file mode 100644 index 00000000..28d893f2 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct MonitorView: View { + @EnvironmentObject private var processService: AppProcessService + @Binding var repoPath: String + @Binding var preferredPort: Int + @Binding var stateDir: String + @Binding var webURL: URL? + @State private var alertMessage: String? + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading) { + Text("Monitor") + .font(.title2.weight(.semibold)) + Text("Read-only dashboard monitor for local play and QA snapshots.") + .foregroundStyle(.secondary) + } + Spacer() + Button { + openMonitor() + } label: { + Label("Open Monitor", systemImage: "waveform.path.ecg.rectangle") + } + .buttonStyle(.borderedProminent) + } + .padding(16) + Divider() + if let webURL { + WebView(url: webURL) + } else { + EmptyStateView(title: "No Monitor Open", symbolName: "waveform.path.ecg.rectangle") + } + } + .alert("Monitor could not start", isPresented: alertBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(alertMessage ?? "") + } + } + + private func openMonitor() { + do { + let dashboard = try processService.startViewer( + repoPath: repoPath, + preferredPort: preferredPort, + stateDir: stateDir + ) + let port = processService.viewerEndpoint?.port ?? URLComponents(url: dashboard, resolvingAgainstBaseURL: false)?.port ?? preferredPort + webURL = URL(string: "http://127.0.0.1:\(port)/monitor") + } catch { + alertMessage = error.localizedDescription + } + } + + private var alertBinding: Binding { + Binding(get: { alertMessage != nil }, set: { if !$0 { alertMessage = nil } }) + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift new file mode 100644 index 00000000..5a485233 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift @@ -0,0 +1,174 @@ +import SwiftUI + +struct PlayView: View { + @EnvironmentObject private var processService: AppProcessService + + @Binding var repoPath: String + @Binding var preferredPort: Int + @Binding var stateDir: String + @Binding var selectedProviderRaw: String + @Binding var defaultWorld: String + @Binding var codexProviderCommand: String + @Binding var openClawProviderCommand: String + @Binding var budget: String + @Binding var sessionBudget: String + @Binding var maxTurns: String + @Binding var webURL: URL? + + @State private var runID: String = PlayView.newRunID() + @State private var companions: String = "" + @State private var alertMessage: String? + + var body: some View { + VStack(spacing: 0) { + controlBar + Divider() + webSurface + } + .alert("ClawDnD could not start", isPresented: alertBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(alertMessage ?? "") + } + } + + private var controlBar: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text("Play") + .font(.title2.weight(.semibold)) + dependencySummary + } + Spacer() + Picker("Provider", selection: $selectedProviderRaw) { + ForEach(ProviderKind.allCases) { provider in + Label(provider.displayName, systemImage: provider.symbolName) + .tag(provider.rawValue) + } + } + .pickerStyle(.segmented) + .frame(width: 320) + } + + HStack(spacing: 10) { + TextField("World", text: $defaultWorld) + .textFieldStyle(.roundedBorder) + .frame(width: 180) + TextField("Run ID", text: $runID) + .textFieldStyle(.roundedBorder) + .frame(width: 240) + TextField("Companions", text: $companions) + .textFieldStyle(.roundedBorder) + Button { + startViewer() + } label: { + Label("Open Dashboard", systemImage: "rectangle.on.rectangle") + } + Button { + startProvider() + } label: { + Label("Start Game", systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + Button { + processService.stopProvider() + processService.stopViewer() + } label: { + Label("Stop", systemImage: "stop.fill") + } + } + } + .padding(16) + .background(.thinMaterial) + } + + @ViewBuilder + private var dependencySummary: some View { + let missing = processService.dependencies.filter { !$0.isInstalled } + if missing.isEmpty { + Label("Dependencies ready", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + HStack(spacing: 6) { + Label("Missing", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(missing.map(\.command).joined(separator: ", ")) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var webSurface: some View { + if let webURL { + WebView(url: webURL) + } else { + VStack(spacing: 18) { + Image(systemName: "gamecontroller") + .font(.system(size: 54, weight: .regular)) + .foregroundStyle(.secondary) + Text("Start a viewer or game session") + .font(.title3.weight(.semibold)) + Text(repoPath) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func startViewer() { + do { + webURL = try processService.startViewer( + repoPath: repoPath, + preferredPort: preferredPort, + stateDir: stateDir + ) + } catch { + alertMessage = error.localizedDescription + } + } + + private func startProvider() { + do { + let provider = ProviderKind(rawValue: selectedProviderRaw) ?? .claude + let cleanRunID = runID.trimmingCharacters(in: .whitespacesAndNewlines) + webURL = try processService.startProviderSession( + kind: provider, + repoPath: repoPath, + world: defaultWorld, + runId: cleanRunID.isEmpty ? Self.newRunID() : cleanRunID, + preferredPort: preferredPort, + companions: companions, + preferences: providerPreferences + ) + } catch { + alertMessage = error.localizedDescription + } + } + + private var providerPreferences: ProviderPreferences { + ProviderPreferences( + codexCommand: codexProviderCommand, + openClawCommand: openClawProviderCommand, + budget: budget, + sessionBudget: sessionBudget, + maxTurns: maxTurns + ) + } + + private var alertBinding: Binding { + Binding( + get: { alertMessage != nil }, + set: { if !$0 { alertMessage = nil } } + ) + } + + private static func newRunID() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + return "play-\(formatter.string(from: Date()))" + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/ProvidersView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/ProvidersView.swift new file mode 100644 index 00000000..2f8322f8 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/ProvidersView.swift @@ -0,0 +1,124 @@ +import SwiftUI + +struct ProvidersView: View { + @EnvironmentObject private var processService: AppProcessService + + @Binding var repoPath: String + @Binding var codexProviderCommand: String + @Binding var openClawProviderCommand: String + @Binding var budget: String + @Binding var sessionBudget: String + @Binding var maxTurns: String + + @State private var statuses: [ProviderStatus] = [] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + VStack(alignment: .leading) { + Text("Providers") + .font(.title2.weight(.semibold)) + Text("Launch adapters can only start processes that speak through ClawDnD's existing engine/player paths.") + .foregroundStyle(.secondary) + } + Spacer() + Button { + refresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + } + .padding(16) + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 14) { + ForEach(statuses) { status in + ProviderCard(status: status) + } + + GroupBox("Configured provider commands") { + VStack(alignment: .leading, spacing: 10) { + TextField("Codex launch command", text: $codexProviderCommand) + .textFieldStyle(.roundedBorder) + TextField("OpenClaw launch command", text: $openClawProviderCommand) + .textFieldStyle(.roundedBorder) + Text("Configured commands receive CLAWDND_PROVIDER, CLAWDND_WORLD, CLAWDND_RUN_ID, CLAWDND_PLAY_PORT, and CLAWDND_PLAY_COMPANIONS. They must route moves through existing engine/player contracts.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + GroupBox("Session caps") { + HStack { + TextField("Per-turn budget", text: $budget) + TextField("Session budget", text: $sessionBudget) + TextField("Max turns", text: $maxTurns) + } + .textFieldStyle(.roundedBorder) + } + } + .padding(16) + } + } + .onAppear(perform: refresh) + .onChange(of: codexProviderCommand) { _ in refresh() } + .onChange(of: openClawProviderCommand) { _ in refresh() } + } + + private func refresh() { + statuses = processService.providerStatuses( + repoPath: repoPath, + preferences: ProviderPreferences( + codexCommand: codexProviderCommand, + openClawCommand: openClawProviderCommand, + budget: budget, + sessionBudget: sessionBudget, + maxTurns: maxTurns + ) + ) + } +} + +struct ProviderCard: View { + let status: ProviderStatus + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: status.kind.symbolName) + .font(.title2) + .frame(width: 28) + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(status.kind.displayName) + .font(.headline) + Text(status.availability.rawValue.capitalized) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(statusColor.opacity(0.16), in: Capsule()) + .foregroundStyle(statusColor) + } + Text(status.detail) + .foregroundStyle(.secondary) + if let detectedPath = status.detectedPath { + Text(detectedPath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + Spacer() + } + .padding(14) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + + private var statusColor: Color { + switch status.availability { + case .configured, .installed: .green + case .missing: .orange + case .error: .red + } + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift new file mode 100644 index 00000000..3d9df2b0 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +struct RootView: View { + @EnvironmentObject private var processService: AppProcessService + @EnvironmentObject private var campaignStore: CampaignStore + + @AppStorage("repoPath") private var repoPath: String = RepositoryLocator.defaultRepoPath() + @AppStorage("preferredPort") private var preferredPort: Int = 8765 + @AppStorage("stateDir") private var stateDir: String = "" + @AppStorage("selectedProvider") private var selectedProviderRaw: String = ProviderKind.claude.rawValue + @AppStorage("defaultWorld") private var defaultWorld: String = "baldurs-gate" + @AppStorage("codexProviderCommand") private var codexProviderCommand: String = "" + @AppStorage("openClawProviderCommand") private var openClawProviderCommand: String = "" + @AppStorage("budget") private var budget: String = "1.50" + @AppStorage("sessionBudget") private var sessionBudget: String = "15.00" + @AppStorage("maxTurns") private var maxTurns: String = "40" + @AppStorage("voiceBackend") private var voiceBackend: String = "null" + + @State private var selection: AppSection? = .play + @State private var webURL: URL? + + var body: some View { + NavigationSplitView { + SidebarView(selection: $selection) + } detail: { + VStack(spacing: 0) { + detailView + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + StatusStrip(repoPath: repoPath, stateDir: stateDir) + .environmentObject(processService) + } + } + .onAppear(perform: refresh) + .onChange(of: repoPath) { _ in refresh() } + } + + @ViewBuilder + private var detailView: some View { + switch selection ?? .play { + case .play: + PlayView( + repoPath: $repoPath, + preferredPort: $preferredPort, + stateDir: $stateDir, + selectedProviderRaw: $selectedProviderRaw, + defaultWorld: $defaultWorld, + codexProviderCommand: $codexProviderCommand, + openClawProviderCommand: $openClawProviderCommand, + budget: $budget, + sessionBudget: $sessionBudget, + maxTurns: $maxTurns, + webURL: $webURL + ) + case .campaigns: + CampaignsView( + repoPath: $repoPath, + preferredPort: $preferredPort, + webURL: $webURL + ) + case .monitor: + MonitorView( + repoPath: $repoPath, + preferredPort: $preferredPort, + stateDir: $stateDir, + webURL: $webURL + ) + case .providers: + ProvidersView( + repoPath: $repoPath, + codexProviderCommand: $codexProviderCommand, + openClawProviderCommand: $openClawProviderCommand, + budget: $budget, + sessionBudget: $sessionBudget, + maxTurns: $maxTurns + ) + case .settings: + SettingsView( + repoPath: $repoPath, + preferredPort: $preferredPort, + stateDir: $stateDir, + selectedProviderRaw: $selectedProviderRaw, + defaultWorld: $defaultWorld, + codexProviderCommand: $codexProviderCommand, + openClawProviderCommand: $openClawProviderCommand, + budget: $budget, + sessionBudget: $sessionBudget, + maxTurns: $maxTurns, + voiceBackend: $voiceBackend + ) + case .logs: + LogsView() + } + } + + private func refresh() { + processService.refreshDependencies() + campaignStore.reload(repoPath: repoPath) + } +} + +struct SidebarView: View { + @Binding var selection: AppSection? + + var body: some View { + List(AppSection.allCases, selection: $selection) { section in + Label(section.title, systemImage: section.symbolName) + .tag(section) + } + .navigationSplitViewColumnWidth(min: 180, ideal: 210) + .toolbar { + ToolbarItem(placement: .principal) { + Text("ClawDnD") + .font(.headline) + } + } + } +} + +struct StatusStrip: View { + @EnvironmentObject private var processService: AppProcessService + let repoPath: String + let stateDir: String + + var body: some View { + HStack(spacing: 14) { + statusItem("Viewer", processService.viewerEndpoint.map { "\($0.port) \($0.status.rawValue)" } ?? "stopped") + statusItem("State", stateDir.isEmpty ? "default" : URL(fileURLWithPath: stateDir).lastPathComponent) + statusItem("Campaign", processService.activeCampaignID ?? "auto") + statusItem("Provider", processService.runningProvider?.displayName ?? "none") + if let lastError = processService.lastError { + Label(lastError, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + .lineLimit(1) + } else { + Spacer(minLength: 8) + } + } + .font(.caption) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.bar) + } + + private func statusItem(_ label: String, _ value: String) -> some View { + HStack(spacing: 4) { + Text(label) + .foregroundStyle(.secondary) + Text(value) + .lineLimit(1) + } + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/SettingsView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/SettingsView.swift new file mode 100644 index 00000000..f218ba1c --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/SettingsView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct SettingsView: View { + @Binding var repoPath: String + @Binding var preferredPort: Int + @Binding var stateDir: String + @Binding var selectedProviderRaw: String + @Binding var defaultWorld: String + @Binding var codexProviderCommand: String + @Binding var openClawProviderCommand: String + @Binding var budget: String + @Binding var sessionBudget: String + @Binding var maxTurns: String + @Binding var voiceBackend: String + + var body: some View { + Form { + Section("Workspace") { + TextField("Repo path", text: $repoPath) + TextField("State directory", text: $stateDir) + Stepper(value: $preferredPort, in: 1024...65535) { + Text("Preferred port: \(preferredPort)") + } + } + + Section("Defaults") { + TextField("Default world", text: $defaultWorld) + Picker("Preferred provider", selection: $selectedProviderRaw) { + ForEach(ProviderKind.allCases) { provider in + Text(provider.displayName).tag(provider.rawValue) + } + } + .pickerStyle(.segmented) + Picker("Voice backend", selection: $voiceBackend) { + Text("Null").tag("null") + Text("Kokoro").tag("kokoro") + Text("ElevenLabs").tag("elevenlabs") + } + .pickerStyle(.segmented) + } + + Section("Caps") { + TextField("Per-turn budget", text: $budget) + TextField("Session budget", text: $sessionBudget) + TextField("Max turns", text: $maxTurns) + } + + Section("Provider commands") { + TextField("Codex command", text: $codexProviderCommand) + TextField("OpenClaw command", text: $openClawProviderCommand) + } + } + .formStyle(.grouped) + .padding(20) + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift new file mode 100644 index 00000000..a72bfc3c --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift @@ -0,0 +1,21 @@ +import SwiftUI +import WebKit + +struct WebView: NSViewRepresentable { + let url: URL? + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + let view = WKWebView(frame: .zero, configuration: configuration) + view.allowsBackForwardNavigationGestures = true + return view + } + + func updateNSView(_ view: WKWebView, context: Context) { + guard let url else { return } + if view.url != url { + view.load(URLRequest(url: url)) + } + } +} diff --git a/script/build_and_run.sh b/script/build_and_run.sh new file mode 100755 index 00000000..b4f51c00 --- /dev/null +++ b/script/build_and_run.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-run}" +APP_NAME="ClawDnDApp" +DISPLAY_NAME="ClawDnD" +BUNDLE_ID="dev.clawdnd.app" +MIN_SYSTEM_VERSION="13.0" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PACKAGE_DIR="$ROOT_DIR/macos/ClawDnDApp" +DIST_DIR="$ROOT_DIR/dist" +APP_BUNDLE="$DIST_DIR/$DISPLAY_NAME.app" +APP_CONTENTS="$APP_BUNDLE/Contents" +APP_MACOS="$APP_CONTENTS/MacOS" +APP_BINARY="$APP_MACOS/$APP_NAME" +INFO_PLIST="$APP_CONTENTS/Info.plist" + +usage() { + echo "usage: $0 [run|--verify|--debug|--logs|--telemetry|--release-check]" >&2 +} + +stop_existing() { + pkill -x "$APP_NAME" >/dev/null 2>&1 || true +} + +build_bundle() { + swift build --package-path "$PACKAGE_DIR" + local bin_path + bin_path="$(swift build --package-path "$PACKAGE_DIR" --show-bin-path)/$APP_NAME" + + rm -rf "$APP_BUNDLE" + mkdir -p "$APP_MACOS" + cp "$bin_path" "$APP_BINARY" + chmod +x "$APP_BINARY" + + cat >"$INFO_PLIST" < + + + + CFBundleExecutable + $APP_NAME + CFBundleIdentifier + $BUNDLE_ID + CFBundleName + $DISPLAY_NAME + CFBundleDisplayName + $DISPLAY_NAME + CFBundlePackageType + APPL + LSMinimumSystemVersion + $MIN_SYSTEM_VERSION + NSPrincipalClass + NSApplication + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + +PLIST + + if command -v codesign >/dev/null 2>&1; then + codesign --force --sign - "$APP_BUNDLE" >/dev/null 2>&1 || true + fi +} + +open_app() { + CLAWDND_REPO_ROOT="$ROOT_DIR" /usr/bin/open -n "$APP_BUNDLE" +} + +release_check() { + build_bundle + echo "Bundle: $APP_BUNDLE" + echo + echo "Codesign identities:" + security find-identity -p codesigning -v || true + echo + echo "codesign --verify --deep --strict:" + codesign --verify --deep --strict "$APP_BUNDLE" + echo + echo "spctl -a -vv:" + spctl -a -vv "$APP_BUNDLE" || true +} + +stop_existing + +case "$MODE" in + run) + build_bundle + open_app + ;; + --verify|verify) + build_bundle + open_app + sleep 2 + pgrep -x "$APP_NAME" >/dev/null + echo "$DISPLAY_NAME launched from $APP_BUNDLE" + ;; + --debug|debug) + build_bundle + lldb -- "$APP_BINARY" + ;; + --logs|logs) + build_bundle + open_app + /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\"" + ;; + --telemetry|telemetry) + build_bundle + open_app + /usr/bin/log stream --info --style compact --predicate "subsystem == \"$BUNDLE_ID\"" + ;; + --release-check|release-check) + release_check + ;; + *) + usage + exit 2 + ;; +esac diff --git a/scripts/launch_common.sh b/scripts/launch_common.sh new file mode 100644 index 00000000..0482ecd7 --- /dev/null +++ b/scripts/launch_common.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Shared launcher helpers for the double-clickable local play scripts. +# +# Keep this dependency-free: these helpers run before uv/Claude/Python environments +# are provisioned, so failures need to be clear in a plain macOS Terminal window. + +clawdnd_missing_commands() { + local missing=() cmd + for cmd in "$@"; do + command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") + done + if [ "${#missing[@]}" -gt 0 ]; then + echo "ClawDnD cannot start yet: missing command(s): ${missing[*]}" >&2 + echo >&2 + echo "Install the missing tools, then double-click the launcher again." >&2 + echo "Required for dashboard play: python3, claude, uv, jq, curl." >&2 + return 1 + fi +} + +clawdnd_port_available() { + local port="$1" + case "$port" in + ''|*[!0-9]*) + return 1 + ;; + esac + if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + return 1 + fi + python3 - "$port" <<'PY' +import socket +import sys + +port = int(sys.argv[1]) +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +try: + sock.bind(("127.0.0.1", port)) +except OSError: + sys.exit(1) +finally: + sock.close() +PY +} + +clawdnd_choose_port() { + local requested="$1" explicit="${2:-0}" p + case "$requested" in + ''|*[!0-9]*) + echo "Invalid dashboard port: $requested" >&2 + return 1 + ;; + esac + if [ "$requested" -lt 1 ] || [ "$requested" -gt 65535 ]; then + echo "Dashboard port must be between 1 and 65535: $requested" >&2 + return 1 + fi + if clawdnd_port_available "$requested"; then + printf '%s\n' "$requested" + return 0 + fi + if [ "$explicit" = "1" ]; then + echo "Port $requested is already in use." >&2 + echo "Close the existing ClawDnD window, or run with another port:" >&2 + echo " ./clawdnd-play.command baldurs-gate '' 8766" >&2 + return 1 + fi + for p in $(seq $((requested + 1)) $((requested + 40))); do + if clawdnd_port_available "$p"; then + echo "Port $requested is already in use; using $p instead." >&2 + printf '%s\n' "$p" + return 0 + fi + done + echo "Could not find a free local dashboard port near $requested." >&2 + echo "Close an existing viewer/monitor, or pass a port explicitly." >&2 + return 1 +} diff --git a/scripts/play.sh b/scripts/play.sh index d679e15a..51db278a 100755 --- a/scripts/play.sh +++ b/scripts/play.sh @@ -24,6 +24,14 @@ # CLAWDND_PLAY_MAX_TURNS hard cap on DM turns (default 40) set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$ROOT" || exit 1 +COMMON="$ROOT/scripts/launch_common.sh" +if [ -f "$COMMON" ]; then + # shellcheck source=launch_common.sh + . "$COMMON" +fi +if declare -F clawdnd_missing_commands >/dev/null 2>&1; then + clawdnd_missing_commands python3 claude uv jq curl || exit 127 +fi # Shared beat-driver helpers: the C soft clock-tick backstop + the A beat-aware runbooks — # the SAME implementation the QA duo loop sources, so the human-paced and QA loops can't drift. # shellcheck source=../qa/lib_beat_driver.sh @@ -31,6 +39,11 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$ROOT" || exit 1 WORLD="${1:-baldurs-gate}" RUN="${2:-play-$(date +%Y%m%d-%H%M%S)}" PORT="${3:-${CLAWDND_PLAY_PORT:-8765}}" +PORT_EXPLICIT=0 +[ -n "${3:-}" ] || [ -n "${CLAWDND_PLAY_PORT:-}" ] && PORT_EXPLICIT=1 +if declare -F clawdnd_choose_port >/dev/null 2>&1; then + PORT="$(clawdnd_choose_port "$PORT" "$PORT_EXPLICIT")" || exit 1 +fi BUDGET="${CLAWDND_PLAY_BUDGET:-1.50}" # per DM turn SESSION_BUDGET="${CLAWDND_PLAY_SESSION_BUDGET:-15.00}" # aggregate ceiling for the whole session MAX_TURNS="${CLAWDND_PLAY_MAX_TURNS:-40}" # hard turn cap (worst case = MAX_TURNS×BUDGET) diff --git a/scripts/play_party.sh b/scripts/play_party.sh index f3b9b6bd..4982ee97 100755 --- a/scripts/play_party.sh +++ b/scripts/play_party.sh @@ -46,9 +46,19 @@ # CLAWDND_PLAY_MAX_TURNS hard cap on agent turns (DM + companions)(default 40) set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$ROOT" || exit 1 +COMMON="$ROOT/scripts/launch_common.sh" +if [ -f "$COMMON" ]; then + # shellcheck source=launch_common.sh + . "$COMMON" +fi +if declare -F clawdnd_missing_commands >/dev/null 2>&1; then + clawdnd_missing_commands python3 claude uv jq curl || exit 127 +fi WORLD="${1:-baldurs-gate}" RUN="${2:-play-$(date +%Y%m%d-%H%M%S)}" PORT="${3:-${CLAWDND_PLAY_PORT:-8765}}" +PORT_EXPLICIT=0 +[ -n "${3:-}" ] || [ -n "${CLAWDND_PLAY_PORT:-}" ] && PORT_EXPLICIT=1 COMPANION_SPEC="${4:-${CLAWDND_PLAY_COMPANIONS:-}}" # Model knobs (default sonnet → unchanged behavior). The DM model is the structural-adherence # lever (decision §3); the actor model drives the companion facade agents. The solo path below @@ -61,7 +71,14 @@ CLAWDND_ACTOR_MODEL="${CLAWDND_ACTOR_MODEL:-sonnet}" # replaces this process, so a solo launch is indistinguishable from running play.sh # directly — no ensemble code path, no extra cost, no behavior drift. if [ -z "${COMPANION_SPEC//[[:space:]]/}" ]; then - exec "$ROOT/scripts/play.sh" "$WORLD" "$RUN" "$PORT" + # Preserve which arguments the user actually supplied. That keeps the common double-click + # path as an implicit/default-port launch, so play.sh can pick a clean fallback port instead + # of treating the internally-filled 8765 as a hard user request. + ARGS=() + [ "$#" -ge 1 ] && ARGS+=("$1") + [ "$#" -ge 2 ] && ARGS+=("$2") + [ "$#" -ge 3 ] && ARGS+=("$3") + exec "$ROOT/scripts/play.sh" "${ARGS[@]}" fi # =========================================================================== @@ -73,6 +90,9 @@ fi BUDGET="${CLAWDND_PLAY_BUDGET:-1.50}" # per agent turn (DM or companion) SESSION_BUDGET="${CLAWDND_PLAY_SESSION_BUDGET:-15.00}" # aggregate ceiling for the whole session MAX_TURNS="${CLAWDND_PLAY_MAX_TURNS:-40}" # hard cap on agent turns (DM + companions) +if declare -F clawdnd_choose_port >/dev/null 2>&1; then + PORT="$(clawdnd_choose_port "$PORT" "$PORT_EXPLICIT")" || exit 1 +fi AGENT_TURNS=0 # Product play state under play-state/ (git-ignored), same layout as play.sh. From 057e7faf94743b390ed58a950751e59d69fcd4c1 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 25 May 2026 18:36:25 +0700 Subject: [PATCH 2/6] fix(macos): address native shell review feedback --- .../ClawDnDApp/Models/CampaignSummary.swift | 2 +- .../ClawDnDApp/Models/LocalEndpoint.swift | 9 ++- .../Services/AppProcessService.swift | 44 +++++++++--- .../ClawDnDApp/Services/CampaignStore.swift | 71 ++++++++++--------- .../ClawDnDApp/Services/PortFinder.swift | 4 +- .../Services/ProviderAdapters.swift | 5 +- .../Services/RepositoryLocator.swift | 17 +++-- .../ClawDnDApp/Views/MonitorView.swift | 3 +- .../Sources/ClawDnDApp/Views/PlayView.swift | 8 ++- 9 files changed, 104 insertions(+), 59 deletions(-) diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/CampaignSummary.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/CampaignSummary.swift index d39a4550..3f9e1f9a 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/CampaignSummary.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/CampaignSummary.swift @@ -34,7 +34,7 @@ struct CampaignSummary: Identifiable, Equatable { var dayLabel: String { if let day { - return "Day \(day), \(timeOfDay)" + return timeOfDay.isEmpty ? "Day \(day)" : "Day \(day), \(timeOfDay)" } return timeOfDay.isEmpty ? "Unknown time" : timeOfDay } diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift index b127556d..6016c952 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift @@ -10,16 +10,19 @@ enum EndpointStatus: String, Equatable { struct LocalEndpoint: Identifiable, Equatable { let id = UUID() var name: String - var port: Int var url: URL var healthPath: String var status: EndpointStatus + var port: Int { + URLComponents(url: url, resolvingAgainstBaseURL: false)?.port ?? 0 + } + var dashboardURL: URL { - URL(string: "http://127.0.0.1:\(port)/dashboard")! + url.appendingPathComponent("dashboard") } var monitorURL: URL { - URL(string: "http://127.0.0.1:\(port)/monitor")! + url.appendingPathComponent("monitor") } } diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift index 38e7c442..91eecd7a 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift @@ -13,6 +13,7 @@ final class AppProcessService: ObservableObject { private var viewerProcess: ManagedProcess? private var providerProcess: ManagedProcess? private let registry = ProviderRegistry() + private let maxLogCharacters = 120_000 var diagnostics: String { """ @@ -58,8 +59,11 @@ final class AppProcessService: ObservableObject { stopViewer() - let port = PortFinder.firstFreePort(startingAt: preferredPort) - let dashboard = URL(string: "http://127.0.0.1:\(port)/dashboard")! + guard let port = PortFinder.firstFreePort(startingAt: preferredPort) else { + try throwAndRecord("Could not find a free viewer port near \(preferredPort).") + } + let baseURL = URL(string: "http://127.0.0.1:\(port)")! + let dashboard = baseURL.appendingPathComponent("dashboard") var env: [String: String] = [:] if !stateDir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { env["CLAWDND_STATE_DIR"] = (stateDir as NSString).expandingTildeInPath @@ -77,8 +81,7 @@ final class AppProcessService: ObservableObject { activeCampaignID = campaignID viewerEndpoint = LocalEndpoint( name: "Viewer", - port: port, - url: dashboard, + url: baseURL, healthPath: "/state", status: .running ) @@ -109,7 +112,9 @@ final class AppProcessService: ObservableObject { try throwAndRecord("Repo path is not a ClawDnD checkout: \(repoPath)") } - let port = PortFinder.firstFreePort(startingAt: preferredPort) + guard let port = PortFinder.firstFreePort(startingAt: preferredPort) else { + try throwAndRecord("Could not find a free provider viewer port near \(preferredPort).") + } let adapter = registry.adapter(for: kind) let request = try adapter.startSession( world: world, @@ -121,6 +126,8 @@ final class AppProcessService: ObservableObject { ) providerProcess?.terminate() + providerProcess = nil + runningProvider = nil providerLog = "" let managed = try launchManagedProcess( name: request.name, @@ -132,11 +139,11 @@ final class AppProcessService: ObservableObject { ) providerProcess = managed runningProvider = kind - let dashboard = URL(string: "http://127.0.0.1:\(port)/dashboard")! + let baseURL = URL(string: "http://127.0.0.1:\(port)")! + let dashboard = baseURL.appendingPathComponent("dashboard") viewerEndpoint = LocalEndpoint( name: "Provider viewer", - port: port, - url: dashboard, + url: baseURL, healthPath: "/state", status: .starting ) @@ -186,7 +193,16 @@ final class AppProcessService: ObservableObject { managed?.close() self?.append("\(name) exited with status \(process.terminationStatus)", stream: stream) if stream == .provider { - self?.runningProvider = nil + if self?.providerProcess === managed { + self?.providerProcess = nil + self?.runningProvider = nil + } + } else if self?.viewerProcess === managed { + self?.viewerProcess = nil + if var endpoint = self?.viewerEndpoint { + endpoint.status = .stopped + self?.viewerEndpoint = endpoint + } } } } @@ -207,8 +223,18 @@ final class AppProcessService: ObservableObject { switch stream { case .supervisor: supervisorLog += line.hasSuffix("\n") ? line : line + "\n" + trimLogIfNeeded(&supervisorLog) case .provider: providerLog += line.hasSuffix("\n") ? line : line + "\n" + trimLogIfNeeded(&providerLog) + } + } + + private func trimLogIfNeeded(_ log: inout String) { + guard log.count > maxLogCharacters else { return } + log.removeFirst(log.count - maxLogCharacters) + if let newline = log.firstIndex(of: "\n") { + log.removeSubrange(...newline) } } diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift index 21cb664b..3f6ae4d9 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift @@ -7,23 +7,30 @@ final class CampaignStore: ObservableObject { func reload(repoPath: String) { let repoURL = URL(fileURLWithPath: repoPath) - do { - let play = try loadCampaigns( - root: repoURL.appendingPathComponent("play-state"), - source: .play - ) - let qa = try loadCampaigns( - root: repoURL.appendingPathComponent("qa/state"), - source: .qa - ) - campaigns = (play + qa).sorted { $0.lastUpdate > $1.lastUpdate } - lastError = nil - } catch { - lastError = error.localizedDescription + Task.detached(priority: .userInitiated) { [weak self] in + do { + let play = try Self.loadCampaigns( + root: repoURL.appendingPathComponent("play-state"), + source: .play + ) + let qa = try Self.loadCampaigns( + root: repoURL.appendingPathComponent("qa/state"), + source: .qa + ) + let merged = (play + qa).sorted { $0.lastUpdate > $1.lastUpdate } + await self?.finishReload(campaigns: merged, lastError: nil) + } catch { + await self?.finishReload(campaigns: [], lastError: error.localizedDescription) + } } } - private func loadCampaigns(root: URL, source: CampaignSource) throws -> [CampaignSummary] { + private func finishReload(campaigns: [CampaignSummary], lastError: String?) { + self.campaigns = campaigns + self.lastError = lastError + } + + private nonisolated static func loadCampaigns(root: URL, source: CampaignSource) throws -> [CampaignSummary] { guard FileManager.default.fileExists(atPath: root.path) else { return [] } let runDirectories = try FileManager.default.contentsOfDirectory( at: root, @@ -40,7 +47,7 @@ final class CampaignStore: ObservableObject { options: [.skipsHiddenFiles] )) ?? [] return campaignDirectories.compactMap { campaignURL in - loadSnapshot( + Self.loadSnapshot( snapshotURL: campaignURL.appendingPathComponent("snapshot.json"), stateRoot: runURL, source: source @@ -49,7 +56,7 @@ final class CampaignStore: ObservableObject { } } - private func loadSnapshot( + private nonisolated static func loadSnapshot( snapshotURL: URL, stateRoot: URL, source: CampaignSource @@ -61,15 +68,15 @@ final class CampaignStore: ObservableObject { } let runID = stateRoot.lastPathComponent - let title = (root["title"] as? String).flatMap(nonEmpty) ?? id - let world = (root["world_id"] as? String).flatMap(nonEmpty) ?? "unknown" - let timeOfDay = (root["time_of_day"] as? String).flatMap(nonEmpty) ?? "" + let title = (root["title"] as? String).flatMap(Self.nonEmpty) ?? id + let world = (root["world_id"] as? String).flatMap(Self.nonEmpty) ?? "unknown" + let timeOfDay = (root["time_of_day"] as? String).flatMap(Self.nonEmpty) ?? "" let day = root["day"] as? Int - let location = currentLocationName(root) - let party = partyNames(root) - let lastUpdate = campaignRecency(snapshotURL: snapshotURL) + let location = Self.currentLocationName(root) + let party = Self.partyNames(root) + let lastUpdate = Self.campaignRecency(snapshotURL: snapshotURL) let isLive = Date().timeIntervalSince(lastUpdate) < 120 - let provider = inferProvider(stateRoot: stateRoot, source: source) + let provider = Self.inferProvider(stateRoot: stateRoot, source: source) return CampaignSummary( id: id, @@ -89,7 +96,7 @@ final class CampaignStore: ObservableObject { ) } - private func currentLocationName(_ root: [String: Any]) -> String { + private nonisolated static func currentLocationName(_ root: [String: Any]) -> String { guard let locID = root["current_location_id"] as? String else { return "Unknown location" } @@ -102,18 +109,18 @@ final class CampaignStore: ObservableObject { return locID } - private func partyNames(_ root: [String: Any]) -> [String] { + private nonisolated static func partyNames(_ root: [String: Any]) -> [String] { guard let party = root["party"] as? [String], let characters = root["characters"] as? [String: Any] else { return [] } return party.compactMap { id in guard let character = characters[id] as? [String: Any] else { return id } - return (character["name"] as? String).flatMap(nonEmpty) ?? id + return (character["name"] as? String).flatMap(Self.nonEmpty) ?? id } } - private func inferProvider(stateRoot: URL, source: CampaignSource) -> String { + private nonisolated static func inferProvider(stateRoot: URL, source: CampaignSource) -> String { switch source { case .qa: return "QA" @@ -128,8 +135,8 @@ final class CampaignStore: ObservableObject { } } - private func campaignRecency(snapshotURL: URL) -> Date { - var best = fileDate(snapshotURL) + private nonisolated static func campaignRecency(snapshotURL: URL) -> Date { + var best = Self.fileDate(snapshotURL) let sessionsURL = snapshotURL .deletingLastPathComponent() .appendingPathComponent("sessions") @@ -141,7 +148,7 @@ final class CampaignStore: ObservableObject { return best } for log in logs where log.pathExtension == "jsonl" { - let date = fileDate(log) + let date = Self.fileDate(log) if date > best { best = date } @@ -149,12 +156,12 @@ final class CampaignStore: ObservableObject { return best } - private func fileDate(_ url: URL) -> Date { + private nonisolated static func fileDate(_ url: URL) -> Date { let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) return values?.contentModificationDate ?? .distantPast } - private func nonEmpty(_ value: String) -> String? { + private nonisolated static func nonEmpty(_ value: String) -> String? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/PortFinder.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/PortFinder.swift index 946fc638..005f7285 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/PortFinder.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/PortFinder.swift @@ -2,7 +2,7 @@ import Darwin import Foundation enum PortFinder { - static func firstFreePort(startingAt preferredPort: Int) -> Int { + static func firstFreePort(startingAt preferredPort: Int) -> Int? { let start = max(1, min(preferredPort, 65535)) if isAvailable(start) { return start @@ -16,7 +16,7 @@ enum PortFinder { for port in 8765...8805 where isAvailable(port) { return port } - return start + return nil } static func isAvailable(_ port: Int) -> Bool { diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/ProviderAdapters.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/ProviderAdapters.swift index 6e57f8e8..7ef9a3ef 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/ProviderAdapters.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/ProviderAdapters.swift @@ -238,7 +238,10 @@ struct ProviderRegistry { ] func adapter(for kind: ProviderKind) -> ProviderAdapter { - adapters[kind] ?? ClaudeProvider() + guard let adapter = adapters[kind] else { + preconditionFailure("No ProviderAdapter registered for \(kind.rawValue)") + } + return adapter } func detectAll(repoPath: URL, preferences: ProviderPreferences) -> [ProviderStatus] { diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift index b4432e7f..680e4d9b 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift @@ -2,9 +2,11 @@ import Foundation enum RepositoryLocator { static func defaultRepoPath() -> String { - if let env = ProcessInfo.processInfo.environment["CLAWDND_REPO_ROOT"], - looksLikeRepo(URL(fileURLWithPath: env)) { - return env + if let env = ProcessInfo.processInfo.environment["CLAWDND_REPO_ROOT"] { + let expanded = (env as NSString).expandingTildeInPath + if looksLikeRepo(URL(fileURLWithPath: expanded)) { + return expanded + } } let bundleURL = Bundle.main.bundleURL @@ -16,9 +18,10 @@ enum RepositoryLocator { cursor.deleteLastPathComponent() } - let lexar = URL(fileURLWithPath: "/Volumes/LEXAR/repos/ClawDnD") - if looksLikeRepo(lexar) { - return lexar.path + let homeRepo = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("repos/ClawDnD") + if looksLikeRepo(homeRepo) { + return homeRepo.path } let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) @@ -26,7 +29,7 @@ enum RepositoryLocator { return cwd.path } - return lexar.path + return "" } static func looksLikeRepo(_ url: URL) -> Bool { diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift index 28d893f2..525623f0 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift @@ -47,8 +47,7 @@ struct MonitorView: View { preferredPort: preferredPort, stateDir: stateDir ) - let port = processService.viewerEndpoint?.port ?? URLComponents(url: dashboard, resolvingAgainstBaseURL: false)?.port ?? preferredPort - webURL = URL(string: "http://127.0.0.1:\(port)/monitor") + webURL = processService.viewerEndpoint?.monitorURL ?? dashboard } catch { alertMessage = error.localizedDescription } diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift index 5a485233..567f7f0e 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift @@ -166,9 +166,13 @@ struct PlayView: View { ) } - private static func newRunID() -> String { + private static let runIDFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd-HHmmss" - return "play-\(formatter.string(from: Date()))" + return formatter + }() + + private static func newRunID() -> String { + return "play-\(runIDFormatter.string(from: Date()))" } } From 8134133d6654db4e35ab06e372403de188e9c696 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 25 May 2026 18:54:51 +0700 Subject: [PATCH 3/6] fix(macos): close native shell review gaps --- .../ClawDnDApp/Services/AppProcessService.swift | 17 ++++++++++++++--- .../ClawDnDApp/Services/CampaignStore.swift | 7 ++++++- .../ClawDnDApp/Services/RepositoryLocator.swift | 4 ++-- .../Sources/ClawDnDApp/Views/MonitorView.swift | 3 ++- .../Sources/ClawDnDApp/Views/PlayView.swift | 2 ++ .../Sources/ClawDnDApp/Views/RootView.swift | 2 +- 6 files changed, 27 insertions(+), 8 deletions(-) diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift index 91eecd7a..c944ff69 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift @@ -155,6 +155,7 @@ final class AppProcessService: ObservableObject { providerProcess?.terminate() providerProcess = nil runningProvider = nil + stopProviderViewerEndpointIfNeeded() } private func launchManagedProcess( @@ -196,6 +197,7 @@ final class AppProcessService: ObservableObject { if self?.providerProcess === managed { self?.providerProcess = nil self?.runningProvider = nil + self?.stopProviderViewerEndpointIfNeeded() } } else if self?.viewerProcess === managed { self?.viewerProcess = nil @@ -232,12 +234,21 @@ final class AppProcessService: ObservableObject { private func trimLogIfNeeded(_ log: inout String) { guard log.count > maxLogCharacters else { return } - log.removeFirst(log.count - maxLogCharacters) - if let newline = log.firstIndex(of: "\n") { - log.removeSubrange(...newline) + let suffix = String(log.suffix(maxLogCharacters)) + if let newline = suffix.firstIndex(of: "\n"), + suffix.index(after: newline) < suffix.endIndex { + log = String(suffix[suffix.index(after: newline)...]) + } else { + log = suffix } } + private func stopProviderViewerEndpointIfNeeded() { + guard var endpoint = viewerEndpoint, endpoint.name == "Provider viewer" else { return } + endpoint.status = .stopped + viewerEndpoint = endpoint + } + private func throwAndRecord(_ message: String) throws -> Never { lastError = message append(message, stream: .supervisor) diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift index 3f6ae4d9..d5dde3cb 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift @@ -5,9 +5,12 @@ final class CampaignStore: ObservableObject { @Published var campaigns: [CampaignSummary] = [] @Published var lastError: String? + private var reloadTask: Task? + func reload(repoPath: String) { + reloadTask?.cancel() let repoURL = URL(fileURLWithPath: repoPath) - Task.detached(priority: .userInitiated) { [weak self] in + reloadTask = Task.detached(priority: .userInitiated) { [weak self] in do { let play = try Self.loadCampaigns( root: repoURL.appendingPathComponent("play-state"), @@ -18,8 +21,10 @@ final class CampaignStore: ObservableObject { source: .qa ) let merged = (play + qa).sorted { $0.lastUpdate > $1.lastUpdate } + guard !Task.isCancelled else { return } await self?.finishReload(campaigns: merged, lastError: nil) } catch { + guard !Task.isCancelled else { return } await self?.finishReload(campaigns: [], lastError: error.localizedDescription) } } diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift index 680e4d9b..a159d04f 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift @@ -1,7 +1,7 @@ import Foundation enum RepositoryLocator { - static func defaultRepoPath() -> String { + static func defaultRepoPath() -> String? { if let env = ProcessInfo.processInfo.environment["CLAWDND_REPO_ROOT"] { let expanded = (env as NSString).expandingTildeInPath if looksLikeRepo(URL(fileURLWithPath: expanded)) { @@ -29,7 +29,7 @@ enum RepositoryLocator { return cwd.path } - return "" + return nil } static func looksLikeRepo(_ url: URL) -> Bool { diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift index 525623f0..4a3c9e97 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift @@ -47,7 +47,8 @@ struct MonitorView: View { preferredPort: preferredPort, stateDir: stateDir ) - webURL = processService.viewerEndpoint?.monitorURL ?? dashboard + webURL = processService.viewerEndpoint?.monitorURL + ?? dashboard.deletingLastPathComponent().appendingPathComponent("monitor") } catch { alertMessage = error.localizedDescription } diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift index 567f7f0e..eab85caa 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift @@ -168,6 +168,8 @@ struct PlayView: View { private static let runIDFormatter: DateFormatter = { let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) formatter.dateFormat = "yyyyMMdd-HHmmss" return formatter }() diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift index 3d9df2b0..3f03d033 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift @@ -4,7 +4,7 @@ struct RootView: View { @EnvironmentObject private var processService: AppProcessService @EnvironmentObject private var campaignStore: CampaignStore - @AppStorage("repoPath") private var repoPath: String = RepositoryLocator.defaultRepoPath() + @AppStorage("repoPath") private var repoPath: String = RepositoryLocator.defaultRepoPath() ?? "" @AppStorage("preferredPort") private var preferredPort: Int = 8765 @AppStorage("stateDir") private var stateDir: String = "" @AppStorage("selectedProvider") private var selectedProviderRaw: String = ProviderKind.claude.rawValue From c827ff066d52e0a3fd3e7aff09d65f067e9ff6e1 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 25 May 2026 19:00:00 +0700 Subject: [PATCH 4/6] fix(macos): validate native app settings --- .../ClawDnDApp/Views/SettingsView.swift | 159 +++++++++++++++++- 1 file changed, 151 insertions(+), 8 deletions(-) diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/SettingsView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/SettingsView.swift index f218ba1c..46a4e0a6 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/SettingsView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/SettingsView.swift @@ -13,13 +13,24 @@ struct SettingsView: View { @Binding var maxTurns: String @Binding var voiceBackend: String + @State private var preferredPortText = "" + var body: some View { Form { Section("Workspace") { - TextField("Repo path", text: $repoPath) + ValidatedTextField("Repo path", text: $repoPath, error: repoPathError) TextField("State directory", text: $stateDir) - Stepper(value: $preferredPort, in: 1024...65535) { - Text("Preferred port: \(preferredPort)") + VStack(alignment: .leading, spacing: 6) { + HStack { + ValidatedTextField("Preferred port", text: $preferredPortText, error: preferredPortError) + Stepper("", value: $preferredPort, in: 1024...65535) + .labelsHidden() + } + if preferredPortError == nil { + Text("Preferred port: \(preferredPort)") + .font(.caption) + .foregroundStyle(.secondary) + } } } @@ -40,17 +51,149 @@ struct SettingsView: View { } Section("Caps") { - TextField("Per-turn budget", text: $budget) - TextField("Session budget", text: $sessionBudget) - TextField("Max turns", text: $maxTurns) + ValidatedTextField("Per-turn budget", text: $budget, error: positiveDecimalError(for: budget, label: "Per-turn budget")) + ValidatedTextField("Session budget", text: $sessionBudget, error: positiveDecimalError(for: sessionBudget, label: "Session budget")) + ValidatedTextField("Max turns", text: $maxTurns, error: positiveIntegerError(for: maxTurns, label: "Max turns")) } Section("Provider commands") { - TextField("Codex command", text: $codexProviderCommand) - TextField("OpenClaw command", text: $openClawProviderCommand) + ValidatedTextField("Codex command", text: $codexProviderCommand, error: commandError(for: codexProviderCommand, label: "Codex command")) + ValidatedTextField("OpenClaw command", text: $openClawProviderCommand, error: commandError(for: openClawProviderCommand, label: "OpenClaw command")) } } .formStyle(.grouped) .padding(20) + .onAppear { + preferredPortText = String(preferredPort) + } + .onChange(of: preferredPort) { newValue in + if preferredPortText != String(newValue) { + preferredPortText = String(newValue) + } + } + .onChange(of: preferredPortText) { newValue in + if let port = Self.validPort(from: newValue) { + preferredPort = port + } + } + } + + private var repoPathError: String? { + let expanded = repoPath.trimmingCharacters(in: .whitespacesAndNewlines) as NSString + let path = expanded.expandingTildeInPath + guard !path.isEmpty else { + return "Choose a ClawDnD checkout folder." + } + + guard path.hasPrefix("/") else { + return "Use a full path, for example /Volumes/LEXAR/repos/ClawDnD." + } + + guard RepositoryLocator.looksLikeRepo(URL(fileURLWithPath: path)) else { + return "This folder does not look like a ClawDnD checkout." + } + + return nil + } + + private var preferredPortError: String? { + let value = preferredPortText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + return "Enter a port from 1024 to 65535." + } + + guard Int(value) != nil else { + return "Port must be a whole number." + } + + guard Self.validPort(from: value) != nil else { + return "Port must be between 1024 and 65535." + } + + return nil + } + + private func positiveDecimalError(for rawValue: String, label: String) -> String? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + return nil + } + + guard let number = Decimal(string: value), number > 0 else { + return "\(label) must be a positive number." + } + + return nil + } + + private func positiveIntegerError(for rawValue: String, label: String) -> String? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + return nil + } + + guard let number = Int(value), number > 0 else { + return "\(label) must be a positive whole number." + } + + return nil + } + + private func commandError(for rawValue: String, label: String) -> String? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + return nil + } + + guard value.rangeOfCharacter(from: .newlines) == nil else { + return "\(label) must be one shell command line." + } + + guard value.rangeOfCharacter(from: .controlCharacters) == nil else { + return "\(label) contains an unsupported control character." + } + + return nil + } + + private static func validPort(from rawValue: String) -> Int? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard let port = Int(value), (1024...65535).contains(port) else { + return nil + } + + return port + } +} + +private struct ValidatedTextField: View { + let title: String + @Binding var text: String + let error: String? + + init(_ title: String, text: Binding, error: String?) { + self.title = title + _text = text + self.error = error + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + TextField(title, text: $text) + .textFieldStyle(.roundedBorder) + .overlay { + if error != nil { + RoundedRectangle(cornerRadius: 5) + .stroke(.red, lineWidth: 1) + } + } + + if let error { + Label(error, systemImage: "exclamationmark.circle.fill") + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } } } From 55b97f2519258fe90d993d845850606f0351e444 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 25 May 2026 19:00:32 +0700 Subject: [PATCH 5/6] ci(macos): validate native app build --- .github/workflows/macos-swift.yml | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/macos-swift.yml diff --git a/.github/workflows/macos-swift.yml b/.github/workflows/macos-swift.yml new file mode 100644 index 00000000..efe7cc60 --- /dev/null +++ b/.github/workflows/macos-swift.yml @@ -0,0 +1,40 @@ +name: macOS Swift + +on: + push: + branches: + - macos/native-app-shell + - macos/swift-ci + paths: + - ".github/workflows/*.yml" + - "macos/ClawDnDApp/**" + - "script/build_and_run.sh" + - "scripts/*.sh" + - "scripts/**/*.sh" + pull_request: + branches: + - macos/native-app-shell + paths: + - ".github/workflows/*.yml" + - "macos/ClawDnDApp/**" + - "script/build_and_run.sh" + - "scripts/*.sh" + - "scripts/**/*.sh" + workflow_dispatch: + +jobs: + swift-build: + name: SwiftPM build + runs-on: macos-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Validate launcher script syntax + shell: bash + run: | + bash -n script/build_and_run.sh + find scripts -type f -name "*.sh" -print0 | xargs -0 -n 1 bash -n + + - name: Build native app + run: swift build --package-path macos/ClawDnDApp From 280224d2d18b1de0c89ffd516996d7032e26c926 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 25 May 2026 19:00:44 +0700 Subject: [PATCH 6/6] fix(macos): report embedded dashboard load failures --- .../ClawDnDApp/Views/MonitorView.swift | 10 ++- .../Sources/ClawDnDApp/Views/PlayView.swift | 11 +++- .../Sources/ClawDnDApp/Views/WebView.swift | 61 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift index 4a3c9e97..e5dfbc7d 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift @@ -7,6 +7,7 @@ struct MonitorView: View { @Binding var stateDir: String @Binding var webURL: URL? @State private var alertMessage: String? + @State private var webViewErrorMessage: String? var body: some View { VStack(spacing: 0) { @@ -28,7 +29,13 @@ struct MonitorView: View { .padding(16) Divider() if let webURL { - WebView(url: webURL) + if let webViewErrorMessage { + WebViewErrorView(message: webViewErrorMessage) { + self.webViewErrorMessage = nil + } + } else { + WebView(url: webURL, navigationError: $webViewErrorMessage) + } } else { EmptyStateView(title: "No Monitor Open", symbolName: "waveform.path.ecg.rectangle") } @@ -42,6 +49,7 @@ struct MonitorView: View { private func openMonitor() { do { + webViewErrorMessage = nil let dashboard = try processService.startViewer( repoPath: repoPath, preferredPort: preferredPort, diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift index eab85caa..26e9909d 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift @@ -18,6 +18,7 @@ struct PlayView: View { @State private var runID: String = PlayView.newRunID() @State private var companions: String = "" @State private var alertMessage: String? + @State private var webViewErrorMessage: String? var body: some View { VStack(spacing: 0) { @@ -102,7 +103,13 @@ struct PlayView: View { @ViewBuilder private var webSurface: some View { if let webURL { - WebView(url: webURL) + if let webViewErrorMessage { + WebViewErrorView(message: webViewErrorMessage) { + self.webViewErrorMessage = nil + } + } else { + WebView(url: webURL, navigationError: $webViewErrorMessage) + } } else { VStack(spacing: 18) { Image(systemName: "gamecontroller") @@ -121,6 +128,7 @@ struct PlayView: View { private func startViewer() { do { + webViewErrorMessage = nil webURL = try processService.startViewer( repoPath: repoPath, preferredPort: preferredPort, @@ -133,6 +141,7 @@ struct PlayView: View { private func startProvider() { do { + webViewErrorMessage = nil let provider = ProviderKind(rawValue: selectedProviderRaw) ?? .claude let cleanRunID = runID.trimmingCharacters(in: .whitespacesAndNewlines) webURL = try processService.startProviderSession( diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift index a72bfc3c..72a04451 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift @@ -3,19 +3,80 @@ import WebKit struct WebView: NSViewRepresentable { let url: URL? + @Binding var navigationError: String? func makeNSView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.preferences.javaScriptCanOpenWindowsAutomatically = true let view = WKWebView(frame: .zero, configuration: configuration) view.allowsBackForwardNavigationGestures = true + view.navigationDelegate = context.coordinator return view } func updateNSView(_ view: WKWebView, context: Context) { guard let url else { return } if view.url != url { + navigationError = nil view.load(URLRequest(url: url)) } } + + func makeCoordinator() -> Coordinator { + Coordinator(navigationError: $navigationError) + } + + final class Coordinator: NSObject, WKNavigationDelegate { + private let navigationError: Binding + + init(navigationError: Binding) { + self.navigationError = navigationError + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + navigationError.wrappedValue = nil + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + report(error) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + report(error) + } + + private func report(_ error: Error) { + let nsError = error as NSError + guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return } + navigationError.wrappedValue = "Dashboard failed to load: \(error.localizedDescription)" + } + } +} + +struct WebViewErrorView: View { + let message: String + let retry: () -> Void + + var body: some View { + VStack(spacing: 14) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 42, weight: .regular)) + .foregroundStyle(.orange) + Text("Dashboard Unavailable") + .font(.title3.weight(.semibold)) + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(3) + .frame(maxWidth: 520) + Button { + retry() + } label: { + Label("Retry", systemImage: "arrow.clockwise") + } + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } }