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/.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 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..3f9e1f9a --- /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 timeOfDay.isEmpty ? "Day \(day)" : "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..6016c952 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift @@ -0,0 +1,28 @@ +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 url: URL + var healthPath: String + var status: EndpointStatus + + var port: Int { + URLComponents(url: url, resolvingAgainstBaseURL: false)?.port ?? 0 + } + + var dashboardURL: URL { + url.appendingPathComponent("dashboard") + } + + var monitorURL: URL { + url.appendingPathComponent("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..c944ff69 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift @@ -0,0 +1,288 @@ +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() + private let maxLogCharacters = 120_000 + + 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() + + 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 + } + 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", + url: baseURL, + 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)") + } + + 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, + runId: runId, + port: port, + companions: companions, + repoPath: repoURL, + preferences: preferences + ) + + providerProcess?.terminate() + providerProcess = nil + runningProvider = nil + 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 baseURL = URL(string: "http://127.0.0.1:\(port)")! + let dashboard = baseURL.appendingPathComponent("dashboard") + viewerEndpoint = LocalEndpoint( + name: "Provider viewer", + url: baseURL, + healthPath: "/state", + status: .starting + ) + append("\(request.message) pid \(managed.pid)", stream: .provider) + return dashboard + } + + func stopProvider() { + providerProcess?.terminate() + providerProcess = nil + runningProvider = nil + stopProviderViewerEndpointIfNeeded() + } + + 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 { + if self?.providerProcess === managed { + self?.providerProcess = nil + self?.runningProvider = nil + self?.stopProviderViewerEndpointIfNeeded() + } + } else if self?.viewerProcess === managed { + self?.viewerProcess = nil + if var endpoint = self?.viewerEndpoint { + endpoint.status = .stopped + self?.viewerEndpoint = endpoint + } + } + } + } + + 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" + 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 } + 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) + 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..d5dde3cb --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/CampaignStore.swift @@ -0,0 +1,173 @@ +import Foundation + +@MainActor +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) + reloadTask = 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 } + 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) + } + } + } + + 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, + 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 + Self.loadSnapshot( + snapshotURL: campaignURL.appendingPathComponent("snapshot.json"), + stateRoot: runURL, + source: source + ) + } + } + } + + private nonisolated static 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(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 = Self.currentLocationName(root) + let party = Self.partyNames(root) + let lastUpdate = Self.campaignRecency(snapshotURL: snapshotURL) + let isLive = Date().timeIntervalSince(lastUpdate) < 120 + let provider = Self.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 nonisolated static 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 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(Self.nonEmpty) ?? id + } + } + + private nonisolated static 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 nonisolated static func campaignRecency(snapshotURL: URL) -> Date { + var best = Self.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 = Self.fileDate(log) + if date > best { + best = date + } + } + return best + } + + private nonisolated static func fileDate(_ url: URL) -> Date { + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + return values?.contentModificationDate ?? .distantPast + } + + 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/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..005f7285 --- /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 nil + } + + 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..7ef9a3ef --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/ProviderAdapters.swift @@ -0,0 +1,281 @@ +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 { + guard let adapter = adapters[kind] else { + preconditionFailure("No ProviderAdapter registered for \(kind.rawValue)") + } + return adapter + } + + 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..a159d04f --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift @@ -0,0 +1,41 @@ +import Foundation + +enum RepositoryLocator { + static func defaultRepoPath() -> String? { + 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 + var cursor = bundleURL.deletingLastPathComponent() + for _ in 0..<8 { + if looksLikeRepo(cursor) { + return cursor.path + } + cursor.deleteLastPathComponent() + } + + let homeRepo = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("repos/ClawDnD") + if looksLikeRepo(homeRepo) { + return homeRepo.path + } + + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + if looksLikeRepo(cwd) { + return cwd.path + } + + return nil + } + + 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..e5dfbc7d --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/MonitorView.swift @@ -0,0 +1,68 @@ +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? + @State private var webViewErrorMessage: 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 { + 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") + } + } + .alert("Monitor could not start", isPresented: alertBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(alertMessage ?? "") + } + } + + private func openMonitor() { + do { + webViewErrorMessage = nil + let dashboard = try processService.startViewer( + repoPath: repoPath, + preferredPort: preferredPort, + stateDir: stateDir + ) + webURL = processService.viewerEndpoint?.monitorURL + ?? dashboard.deletingLastPathComponent().appendingPathComponent("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..26e9909d --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift @@ -0,0 +1,189 @@ +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? + @State private var webViewErrorMessage: 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 { + if let webViewErrorMessage { + WebViewErrorView(message: webViewErrorMessage) { + self.webViewErrorMessage = nil + } + } else { + WebView(url: webURL, navigationError: $webViewErrorMessage) + } + } 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 { + webViewErrorMessage = nil + webURL = try processService.startViewer( + repoPath: repoPath, + preferredPort: preferredPort, + stateDir: stateDir + ) + } catch { + alertMessage = error.localizedDescription + } + } + + private func startProvider() { + do { + webViewErrorMessage = nil + 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 let runIDFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter + }() + + private static func newRunID() -> String { + return "play-\(runIDFormatter.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..3f03d033 --- /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..46a4e0a6 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/SettingsView.swift @@ -0,0 +1,199 @@ +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 + + @State private var preferredPortText = "" + + var body: some View { + Form { + Section("Workspace") { + ValidatedTextField("Repo path", text: $repoPath, error: repoPathError) + TextField("State directory", text: $stateDir) + 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) + } + } + } + + 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") { + 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") { + 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) + } + } + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift new file mode 100644 index 00000000..72a04451 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift @@ -0,0 +1,82 @@ +import SwiftUI +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) + } +} 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.