diff --git a/.gitignore b/.gitignore index 37712a6..c2d1ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,12 +30,14 @@ identities/ *.log logs/ tmp/ +dist/ *.tmp *.pid *.out *.err # Python tooling caches +.build/ .pytest_cache/ .mypy_cache/ .ruff_cache/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7c9d4d0 --- /dev/null +++ b/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "OCPDesktop", + platforms: [ + .macOS(.v13) + ], + products: [ + .library(name: "OCPDesktopCore", targets: ["OCPDesktopCore"]), + .executable(name: "OCPDesktop", targets: ["OCPDesktop"]) + ], + targets: [ + .target(name: "OCPDesktopCore"), + .executableTarget( + name: "OCPDesktop", + dependencies: ["OCPDesktopCore"] + ), + .testTarget( + name: "OCPDesktopCoreTests", + dependencies: ["OCPDesktopCore"] + ) + ] +) diff --git a/README.md b/README.md index 1ed9892..cb75dbf 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ **A sovereign, local-first compute fabric for trusted devices.** -[![Tests](https://img.shields.io/badge/tests-187%20passing-00FF88?style=flat-square&labelColor=06090F)](./tests/test_sovereign_mesh.py) -[![Release](https://img.shields.io/badge/release-v0.1.4-F6C177?style=flat-square&labelColor=06090F)](./README.md#current-status) +[![Tests](https://img.shields.io/badge/tests-189%20passing-00FF88?style=flat-square&labelColor=06090F)](./tests/test_sovereign_mesh.py) +[![Release](https://img.shields.io/badge/release-v0.1.6-F6C177?style=flat-square&labelColor=06090F)](./README.md#current-status) [![Version](https://img.shields.io/badge/wire%20version-sovereign--mesh%2Fv1-00D4FF?style=flat-square&labelColor=06090F)](./docs/OCP_STATUS.md) [![Status](https://img.shields.io/badge/status-active%20development-C8A96E?style=flat-square&labelColor=06090F)](./docs/OCP_MASTER_PLAN.md) [![Protocol](https://img.shields.io/badge/protocol-OCP%20v0.1-7BC6FF?style=flat-square&labelColor=06090F)](./docs/OCP_STATUS.md) @@ -131,7 +131,7 @@ Some devices are powerful. Some are private. Some are fragile. Some are approval | `server_control_page.py` | Extracted control-deck renderer for the advanced operator surface | | `server_http_handlers.py` | Grouped HTTP route handlers so `server.py` stays a thin transport host | | `docs/` | Protocol notes, status, and roadmap | -| `tests/test_sovereign_mesh.py` | Regression suite — 187 tests | +| `tests/test_sovereign_mesh.py` | Regression suite — 189 tests | **Key runtime concepts:** @@ -213,6 +213,14 @@ python3 -m ocp_desktop.launcher The launcher keeps state under `~/Library/Application Support/OCP/`, can start a local-only node or LAN-reachable Mesh Mode node, opens the OCP app, and shows the phone link for testing on the same Wi-Fi. +Native SwiftPM Mac app: + +```bash +swift run OCPDesktop +``` + +This native Mission Control shell uses the same OCP server, state paths, operator-token phone links, app-status polling, persisted app-history samples, charts, client-derived route topology, guided setup, and default-worker startup behavior as the Python launcher. + Unsigned macOS beta bundle: ```bash @@ -220,13 +228,22 @@ python3 scripts/build_macos_app.py open dist/OCP.app ``` +Unsigned native SwiftPM beta bundle: + +```bash +python3 scripts/build_swift_macos_app.py +open "dist/OCP Desktop.app" +``` + The beta `.app` requires `python3` to be installed on the Mac. It excludes local state, identities, databases, `.git`, caches, and test artifacts from the bundle. When Mesh Mode is started from the desktop launcher, copied phone links include an operator token in the URL fragment and the browser stores it locally for OCP POST actions. If you start the server manually with `OCP_HOST=0.0.0.0`, set `OCP_OPERATOR_TOKEN` and open `http://HOST_IP:8421/app#ocp_operator_token=YOUR_TOKEN` from the phone. Inside the app: -- `Today` shows mesh strength, Autonomic Mesh status, latest proof, next actions, and a phone link/QR +- `Today` shows mesh strength, Autonomic Mesh status, latest proof, proof timeline, next actions, and a phone link/QR +- `Today` can ask the scheduler to choose the best device and can operator-mediate proof-artifact replication without storing remote tokens +- The native Mac app adds Mission Control pages for overview charts, guided setup, route topology, route health, execution readiness, artifact sync, protocol links, and settings - `Setup` embeds the easy setup flow from `GET /easy` - `Control` embeds the advanced control deck from `GET /control` - `Protocol` links the live manifest, device profile, and HTTP contract from `/mesh/*` @@ -239,7 +256,7 @@ It now also supports: - automatic LAN/share URL detection in the easy page so the phone and spare laptop can see the best local address without manual IP hunting - one-button nearby mesh join with `Connect Everything` - one-button cooperative verification across the whole current mesh with `Test Whole Mesh` -- an auto-open starter script at `python3 scripts/start_ocp_easy.py`, which now also prints detected LAN URLs when the node is network-reachable +- an auto-open starter script at `python3 scripts/start_ocp_easy.py`, which now also prints detected LAN URLs and advertises default worker readiness for full laptop/workstation nodes The control module is phone-friendly, so your phone can act as a real operator console for the mesh. From there you can inspect and act on: @@ -282,21 +299,26 @@ python3 -m unittest tests.test_sovereign_mesh python3 server.py --help ``` -Current baseline: **187 tests passing.** +Current baseline: **189 tests passing.** --- ## Current Status -**Released in v0.1.4** +**Released in v0.1.6** +- Protocol-first app hardening adds schemas for artifact replication auth, execution readiness, worker capacity, setup timeline events, and app/protocol status. +- Native Mission Control adds a SwiftUI sidebar app with charts and a persisted `/mesh/app/history` API for app-status samples. +- Private proof-artifact replication now supports explicit operator-mediated `remote_auth` and records only redacted audit metadata. +- `/mesh/app/status` now exposes protocol, execution-readiness, artifact-sync, and timeline projections so the app and launcher can explain the mesh without scraping UI state. +- Full laptop/workstation nodes started through the easy script or desktop launcher can auto-advertise a default worker so scheduler demos work out of the box. - Autonomic Mesh alpha adds route health, one-button activation, proof repair, helper-safe enlistment, and app-visible summaries. - Desktop Alpha RC adds a Mac beta launcher, unsigned `.app` bundle builder, and a polished `/app` Today surface backed by `GET /mesh/app/status`. - LAN operator hardening now requires signed peer traffic or operator-token authenticated raw mesh mutations from non-loopback clients. - Private artifact content fetches now require operator auth unless the artifact policy is public. - Runtime execution now defaults to explicit environment inheritance, with `inherit_env_allowlist` for deliberate host env pass-through. - The signed envelope implementation now uses dependency-free Ed25519 helpers under `ed25519-sha512-v1`. -- The protocol-kernel refactor and mission-continuity/treaty foundation from v0.1.3 remain intact, with the full regression suite green at 187 tests. +- The protocol-kernel refactor and mission-continuity/treaty foundation from v0.1.3 remain intact, with the full regression suite green at 189 tests. **Implemented in the current runtime** @@ -319,7 +341,7 @@ Current baseline: **187 tests passing.** ## Current Framing - `OCP v0.1` — protocol and spec draft -- `v0.1.4` — current implementation release +- `v0.1.6` — current implementation release - `Sovereign Mesh` — Python-first reference implementation - `sovereign-mesh/v1` — current wire version @@ -342,7 +364,7 @@ OCP is already past "protocol sketch" stage. If it keeps going in this direction ## Related - [Status](./docs/OCP_STATUS.md) -- [v0.1.4 Release Notes](./docs/RELEASE_v0.1.4.md) +- [v0.1.6 Release Notes](./docs/RELEASE_v0.1.6.md) - [7026 Vision](./docs/OCP_7026_VISION.md) - [Quickstart](./docs/QUICKSTART.md) - [Master Plan](./docs/OCP_MASTER_PLAN.md) diff --git a/Sources/OCPDesktop/App/OCPDesktopApp.swift b/Sources/OCPDesktop/App/OCPDesktopApp.swift new file mode 100644 index 0000000..4b9df93 --- /dev/null +++ b/Sources/OCPDesktop/App/OCPDesktopApp.swift @@ -0,0 +1,30 @@ +import SwiftUI + +@main +struct OCPDesktopApp: App { + @StateObject private var model = OCPDesktopModel() + + var body: some Scene { + WindowGroup { + ContentView(model: model) + .frame(minWidth: 1060, minHeight: 720) + } + .commands { + CommandGroup(replacing: .newItem) {} + CommandMenu("Mesh") { + Button("Activate Mesh") { model.activateMesh() } + .keyboardShortcut("a", modifiers: [.command, .shift]) + .disabled(model.isActivating) + Button("Refresh Status") { model.refreshNow() } + .keyboardShortcut("r") + Button("Copy Phone Link") { model.copyPhoneLink() } + .keyboardShortcut("c", modifiers: [.command, .shift]) + Divider() + Button("Start Mesh Mode") { model.startMesh() } + Button("Start Local Only") { model.startLocal() } + Button("Stop Server") { model.stop() } + .keyboardShortcut(".", modifiers: [.command]) + } + } + } +} diff --git a/Sources/OCPDesktop/Models/DesktopSection.swift b/Sources/OCPDesktop/Models/DesktopSection.swift new file mode 100644 index 0000000..1b9e6f4 --- /dev/null +++ b/Sources/OCPDesktop/Models/DesktopSection.swift @@ -0,0 +1,49 @@ +import Foundation + +enum DesktopSection: String, CaseIterable, Identifiable { + case overview + case setup + case routes + case execution + case artifacts + case protocolStatus + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .overview: "Overview" + case .setup: "Setup Doctor" + case .routes: "Routes" + case .execution: "Execution" + case .artifacts: "Artifacts" + case .protocolStatus: "Protocol" + case .settings: "Settings" + } + } + + var detail: String { + switch self { + case .overview: "Mesh command" + case .setup: "One concrete fix" + case .routes: "Route health" + case .execution: "Worker capacity" + case .artifacts: "Proof sync" + case .protocolStatus: "Contract health" + case .settings: "Node profile" + } + } + + var systemImage: String { + switch self { + case .overview: "gauge.with.dots.needle.67percent" + case .setup: "stethoscope" + case .routes: "point.3.connected.trianglepath.dotted" + case .execution: "cpu" + case .artifacts: "shippingbox" + case .protocolStatus: "doc.text.magnifyingglass" + case .settings: "slider.horizontal.3" + } + } +} diff --git a/Sources/OCPDesktop/Services/OCPServerClient.swift b/Sources/OCPDesktop/Services/OCPServerClient.swift new file mode 100644 index 0000000..4d1fb5f --- /dev/null +++ b/Sources/OCPDesktop/Services/OCPServerClient.swift @@ -0,0 +1,53 @@ +import Foundation +import OCPDesktopCore + +struct OCPServerClient { + var baseURL: String + var operatorToken: String + + func fetchStatus() async throws -> AppStatusSnapshot { + try await request(path: "/mesh/app/status", method: "GET", body: nil) + } + + func fetchHistory(limit: Int = 240) async throws -> AppStatusHistory { + try await request(path: "/mesh/app/history?limit=\(limit)", method: "GET", body: nil) + } + + func recordHistorySample(source: String = "swift-desktop") async throws -> AppHistorySampleResponse { + try await request(path: "/mesh/app/history/sample", method: "POST", body: ["source": source]) + } + + func activateMesh() async throws { + let _: AppStatusSnapshotEnvelope = try await request( + path: "/mesh/autonomy/activate", + method: "POST", + body: [ + "mode": "assisted", + "limit": 24, + "run_proof": true, + "repair": true, + "actor_agent_id": "ocp-swift-desktop" + ] + ) + } + + private func request(path: String, method: String, body: [String: Any]?) async throws -> T { + guard let url = URL(string: baseURL + path) else { throw URLError(.badURL) } + var request = URLRequest(url: url) + request.httpMethod = method + if !operatorToken.isEmpty { + request.setValue(operatorToken, forHTTPHeaderField: "X-OCP-Operator-Token") + } + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + return try JSONDecoder().decode(T.self, from: data) + } +} + +private struct AppStatusSnapshotEnvelope: Decodable {} diff --git a/Sources/OCPDesktop/Services/RepoLocator.swift b/Sources/OCPDesktop/Services/RepoLocator.swift new file mode 100644 index 0000000..4beb7a0 --- /dev/null +++ b/Sources/OCPDesktop/Services/RepoLocator.swift @@ -0,0 +1,17 @@ +import Foundation + +enum RepoLocator { + static func defaultRepoRoot() -> URL { + let env = ProcessInfo.processInfo.environment["OCP_REPO_ROOT"] ?? "" + if !env.isEmpty { + return URL(fileURLWithPath: env, isDirectory: true) + } + if let resourceURL = Bundle.main.resourceURL { + let bundled = resourceURL.appendingPathComponent("open-compute-protocol", isDirectory: true) + if FileManager.default.fileExists(atPath: bundled.appendingPathComponent("server.py").path) { + return bundled + } + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + } +} diff --git a/Sources/OCPDesktop/Stores/OCPDesktopModel.swift b/Sources/OCPDesktop/Stores/OCPDesktopModel.swift new file mode 100644 index 0000000..29f01e2 --- /dev/null +++ b/Sources/OCPDesktop/Stores/OCPDesktopModel.swift @@ -0,0 +1,273 @@ +import AppKit +import Foundation +import OCPDesktopCore + +@MainActor +final class OCPDesktopModel: ObservableObject { + @Published var config: LauncherConfig + @Published var statusText = "OCP is stopped." + @Published var appURL = "http://127.0.0.1:8421/" + @Published var phoneURL = "Start Mesh Mode to create a phone link." + @Published var snapshot: AppStatusSnapshot? + @Published var history = AppStatusHistory.empty + @Published var isRunning = false + @Published var isActivating = false + + let repoRoot: URL + let paths: LaunchPaths + + private var process: Process? + private var timer: Timer? + private var currentMode: LaunchMode = .local + private var lastSampleAt = Date.distantPast + private var isSampling = false + + init(repoRoot: URL = RepoLocator.defaultRepoRoot()) { + self.repoRoot = repoRoot + self.paths = LauncherCore.paths() + self.config = LauncherCore.loadConfig(from: paths.configPath) + refreshStaticLinks(mode: .local) + startPolling() + } + + deinit { + timer?.invalidate() + process?.terminate() + } + + var chartPoints: [MissionControlChartPoint] { + MissionControlMetrics.chartPoints(from: history) + } + + var meshScore: Int { + chartPoints.last?.meshScore ?? MissionControlMetrics.meshScore(from: snapshot) + } + + var launchMode: LaunchMode { + currentMode + } + + var topology: TopologyGraph { + MissionControlDeriver.topology(from: snapshot) + } + + var demoState: DemoStripState { + MissionControlDeriver.demoState(snapshot: snapshot, mode: currentMode, phoneURL: phoneURL) + } + + var deviceRoles: [DeviceRoleSummary] { + MissionControlDeriver.deviceRoles(from: snapshot) + } + + var setupGuideSteps: [SetupGuideStep] { + MissionControlDeriver.setupGuideSteps(snapshot: snapshot, mode: currentMode, phoneURL: phoneURL) + } + + var setupLabel: String { + snapshot?.setup?.label ?? snapshot?.setup?.status ?? "Local node ready" + } + + var setupSummary: String { + snapshot?.setup?.operatorSummary ?? snapshot?.setup?.nextFix ?? "Start OCP, open the app, then press Activate Mesh." + } + + var nextFix: String { + snapshot?.setup?.nextFix ?? "Press Activate Mesh to discover nearby devices and prove the mesh." + } + + var executionSummary: String { + snapshot?.executionReadiness?.operatorSummary ?? "No execution readiness yet." + } + + var artifactSummary: String { + snapshot?.artifactSync?.operatorSummary ?? "No artifact sync yet." + } + + var protocolSummary: String { + snapshot?.protocolStatus?.operatorSummary ?? "The live protocol contract is available after OCP starts." + } + + var routeSummary: String { + snapshot?.routeHealth?.operatorSummary ?? "No peer routes have been proven yet." + } + + var serverBaseURL: String { + let host = LauncherCore.displayHostForBrowser(currentMode.host) + return "http://\(host):\(config.port)" + } + + func startLocal() { + start(mode: .local) + } + + func startMesh() { + if config.operatorToken.isEmpty { + config.operatorToken = Self.generateToken() + } + start(mode: .mesh) + } + + func restart() { + let mode = currentMode + stop() + start(mode: mode) + } + + func stop() { + process?.terminate() + process = nil + isRunning = false + statusText = "OCP stopped." + } + + func openApp() { + guard let url = URL(string: appLink(for: currentMode)) else { return } + NSWorkspace.shared.open(url) + } + + func copyPhoneLink() { + let value = phoneURL.hasPrefix("http") ? phoneURL : appLink(for: currentMode) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + statusText = "Copied phone link." + } + + func refreshNow() { + Task { + await pollStatus(forceHistory: true) + } + } + + func activateMesh() { + guard !isActivating else { return } + isActivating = true + statusText = "Activating Mesh: probing routes, planning helpers, and running proof..." + Task { + defer { Task { @MainActor in self.isActivating = false } } + do { + try await client().activateMesh() + statusText = "Activate Mesh completed. Refreshing Mission Control..." + await pollStatus(forceHistory: true) + } catch { + statusText = "Activate Mesh failed: \(error.localizedDescription)" + } + } + } + + func saveConfig() { + let normalized = config.normalized(defaultNodeID: LauncherCore.defaultNodeID()) + config = normalized + do { + try LauncherCore.saveConfig(normalized, to: paths.configPath) + refreshStaticLinks(mode: currentMode) + statusText = "Saved launcher settings." + } catch { + statusText = "Could not save settings: \(error.localizedDescription)" + } + } + + func pollStatus(forceHistory: Bool = false) async { + do { + let next = try await client().fetchStatus() + apply(next) + try await refreshHistory() + if forceHistory || Date().timeIntervalSince(lastSampleAt) >= 15 { + await recordHistorySample() + } + } catch { + if isRunning { + statusText = "OCP is starting or not reachable yet..." + } + } + } + + private func start(mode: LaunchMode) { + stop() + currentMode = mode + let normalized = config.normalized(defaultNodeID: LauncherCore.defaultNodeID()) + config = normalized + do { + try LauncherCore.saveConfig(normalized, to: paths.configPath) + try LauncherCore.ensurePaths(paths) + let plan = LauncherCore.buildPlan(mode: mode, config: normalized, repoRoot: repoRoot) + refreshStaticLinks(mode: mode) + let process = Process() + process.executableURL = URL(fileURLWithPath: plan.command[0]) + process.arguments = Array(plan.command.dropFirst()) + process.currentDirectoryURL = repoRoot + var env = ProcessInfo.processInfo.environment + for (key, value) in plan.environment { + env[key] = value + } + process.environment = env + process.terminationHandler = { [weak self] process in + Task { @MainActor in + self?.isRunning = false + self?.statusText = "OCP stopped with exit code \(process.terminationStatus)." + } + } + try process.run() + self.process = process + isRunning = true + statusText = "Starting OCP in \(mode.rawValue) mode..." + } catch { + statusText = "Could not start OCP: \(error.localizedDescription)" + } + } + + private func apply(_ snapshot: AppStatusSnapshot) { + self.snapshot = snapshot + if let phone = snapshot.setup?.phoneURL, !phone.isEmpty { + phoneURL = tokened(url: phone) + } + statusText = setupSummary + } + + private func refreshHistory() async throws { + history = try await client().fetchHistory(limit: 240) + } + + private func recordHistorySample() async { + guard !isSampling else { return } + isSampling = true + defer { isSampling = false } + do { + _ = try await client().recordHistorySample() + lastSampleAt = Date() + try await refreshHistory() + } catch { + statusText = "Status is live, but history sampling failed: \(error.localizedDescription)" + } + } + + private func refreshStaticLinks(mode: LaunchMode) { + let plan = LauncherCore.buildPlan(mode: mode, config: config, repoRoot: repoRoot) + appURL = tokened(url: plan.appURL) + phoneURL = plan.phoneURLs.first ?? (mode == .mesh ? "No LAN IP found yet. Check Wi-Fi." : "Start Mesh Mode to create a phone link.") + } + + private func startPolling() { + timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.pollStatus() + } + } + } + + private func client() -> OCPServerClient { + OCPServerClient(baseURL: serverBaseURL, operatorToken: config.operatorToken) + } + + private func appLink(for mode: LaunchMode) -> String { + let base = LauncherCore.buildOpenURL(host: mode.host, port: config.port, path: "/") + return tokened(url: base) + } + + private func tokened(url: String) -> String { + LauncherCore.operatorAppURL(baseURL: url, operatorToken: currentMode == .mesh ? config.operatorToken : "") + } + + private static func generateToken() -> String { + "\(UUID().uuidString.lowercased())-\(UUID().uuidString.lowercased())" + } +} diff --git a/Sources/OCPDesktop/Views/ArtifactsView.swift b/Sources/OCPDesktop/Views/ArtifactsView.swift new file mode 100644 index 0000000..05d0aa2 --- /dev/null +++ b/Sources/OCPDesktop/Views/ArtifactsView.swift @@ -0,0 +1,70 @@ +import Charts +import SwiftUI + +struct ArtifactsView: View { + @ObservedObject var model: OCPDesktopModel + var allowMotion: Bool = true + + var body: some View { + let sync = model.snapshot?.artifactSync + let items = sync?.items ?? [] + + MissionScroll(allowMotion: allowMotion) { + PageHeader( + eyebrow: "Artifacts", + title: "\(sync?.verifiedCount ?? 0) verified", + summary: sync?.operatorSummary ?? "Proof artifact sync appears after replication and mirror verification." + ) + + MissionCard { + VStack(alignment: .leading, spacing: 12) { + Text("Verification Trend").sectionLabel() + if model.chartPoints.isEmpty { + EmptyState(text: "Artifact verification history will appear after samples are recorded.") + } else { + Chart(model.chartPoints) { point in + LineMark( + x: .value("Sample", point.sampledAt), + y: .value("Verified artifacts", point.artifactVerifiedCount) + ) + .foregroundStyle(.orange) + .lineStyle(.init(lineWidth: 3)) + BarMark( + x: .value("Sample", point.sampledAt), + y: .value("Pending approvals", point.pendingApprovals) + ) + .foregroundStyle(.purple.opacity(0.35)) + } + .chartXAxis(.hidden) + .frame(height: 180) + } + } + } + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { + MetricCard(title: "Replicated", value: "\(sync?.replicatedCount ?? 0)", detail: "Artifacts copied from peer nodes.", tint: .orange) + MetricCard(title: "Verified", value: "\(sync?.verifiedCount ?? 0)", detail: "Mirrors with digest verification.", tint: .green) + } + + ForEach(items) { item in + MissionCard { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(item.artifactID ?? "Artifact") + .font(.headline) + Spacer() + StatusPill(text: item.verificationStatus ?? "unknown", status: item.verificationStatus ?? "unknown") + } + Text(item.digest ?? "") + .font(.callout.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .textSelection(.enabled) + Text("Source: \(item.sourcePeerID ?? "unknown")") + .foregroundStyle(.secondary) + } + } + } + } + } +} diff --git a/Sources/OCPDesktop/Views/Components.swift b/Sources/OCPDesktop/Views/Components.swift new file mode 100644 index 0000000..3d48915 --- /dev/null +++ b/Sources/OCPDesktop/Views/Components.swift @@ -0,0 +1,1360 @@ +import Charts +import SwiftUI +import OCPDesktopCore + +enum MissionTheme { + static let ink = Color(red: 0.018, green: 0.026, blue: 0.028) + static let deepSea = Color(red: 0.030, green: 0.082, blue: 0.078) + static let desk = Color(red: 0.18, green: 0.10, blue: 0.045) + static let cream = Color(red: 0.95, green: 0.91, blue: 0.78) + static let lamp = Color(red: 1.00, green: 0.70, blue: 0.34) + static let copper = Color(red: 0.96, green: 0.58, blue: 0.24) + static let signal = Color(red: 0.36, green: 0.86, blue: 0.72) + static let mint = Color(red: 0.36, green: 0.96, blue: 0.55) + static let ember = Color(red: 1.00, green: 0.36, blue: 0.30) +} + +struct MissionScroll: View { + var allowMotion: Bool = true + @ViewBuilder var content: () -> Content + @State private var drift = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + content() + } + .padding(26) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(MissionBackground(allowMotion: allowMotion)) + .onAppear { + guard allowMotion else { return } + withAnimation(.easeInOut(duration: 8).repeatForever(autoreverses: true)) { + drift = true + } + } + } +} + +struct MissionCard: View { + var tint: Color = MissionTheme.signal + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill( + LinearGradient( + colors: [ + MissionTheme.cream.opacity(0.11), + MissionTheme.deepSea.opacity(0.55), + Color.black.opacity(0.42) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(Color.black.opacity(0.18)) + LinearGradient( + colors: [tint.opacity(0.18), .clear, MissionTheme.lamp.opacity(0.06)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) + } + ) + .overlay( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .stroke( + LinearGradient( + colors: [tint.opacity(0.32), .white.opacity(0.10), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 1 + ) + ) + .shadow(color: tint.opacity(0.10), radius: 22, x: 0, y: 12) + } +} + +struct MissionBackground: View { + var allowMotion: Bool = true + @State private var phase = false + + var body: some View { + ZStack { + LinearGradient( + colors: [MissionTheme.ink, MissionTheme.deepSea, MissionTheme.ink], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + RadialGradient( + colors: [MissionTheme.signal.opacity(0.34), .clear], + center: .topTrailing, + startRadius: 80, + endRadius: phase && allowMotion ? 640 : 560 + ) + .scaleEffect(phase && allowMotion ? 1.08 : 1.0, anchor: .topTrailing) + RadialGradient( + colors: [MissionTheme.lamp.opacity(0.23), .clear], + center: .bottomLeading, + startRadius: 80, + endRadius: phase && allowMotion ? 590 : 520 + ) + .scaleEffect(phase && allowMotion ? 1.05 : 1.0, anchor: .bottomLeading) + LinearGradient( + colors: [.clear, MissionTheme.desk.opacity(0.48)], + startPoint: .center, + endPoint: .bottom + ) + CinematicConstellation() + .opacity(0.62) + MeshPattern() + .opacity(0.11) + } + .ignoresSafeArea() + .onAppear { + guard allowMotion else { return } + withAnimation(.easeInOut(duration: 7).repeatForever(autoreverses: true)) { + phase = true + } + } + } +} + +struct CinematicConstellation: View { + var body: some View { + Canvas { context, size in + let points = [ + CGPoint(x: size.width * 0.10, y: size.height * 0.58), + CGPoint(x: size.width * 0.24, y: size.height * 0.42), + CGPoint(x: size.width * 0.46, y: size.height * 0.25), + CGPoint(x: size.width * 0.70, y: size.height * 0.31), + CGPoint(x: size.width * 0.88, y: size.height * 0.52), + CGPoint(x: size.width * 0.58, y: size.height * 0.66), + ] + + for index in points.indices { + let start = points[index] + let end = points[(index + 2) % points.count] + var curve = Path() + curve.move(to: start) + curve.addQuadCurve( + to: end, + control: CGPoint(x: (start.x + end.x) / 2, y: min(start.y, end.y) - size.height * 0.22) + ) + context.stroke(curve, with: .color(MissionTheme.signal.opacity(0.30)), lineWidth: 1.1) + } + + for point in points { + context.fill( + Path(ellipseIn: CGRect(x: point.x - 3, y: point.y - 3, width: 6, height: 6)), + with: .color(MissionTheme.mint.opacity(0.88)) + ) + context.fill( + Path(ellipseIn: CGRect(x: point.x - 13, y: point.y - 13, width: 26, height: 26)), + with: .color(MissionTheme.mint.opacity(0.08)) + ) + } + } + } +} + +struct MeshPattern: View { + var body: some View { + Canvas { context, size in + var path = Path() + let spacing: CGFloat = 42 + var x: CGFloat = 0 + while x <= size.width { + path.move(to: CGPoint(x: x, y: 0)) + path.addLine(to: CGPoint(x: x - size.height * 0.45, y: size.height)) + x += spacing + } + var y: CGFloat = 0 + while y <= size.height { + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: size.width, y: y + size.width * 0.16)) + y += spacing + } + context.stroke(path, with: .color(.white.opacity(0.20)), lineWidth: 0.6) + } + } +} + +struct PageHeader: View { + var eyebrow: String + var title: String + var summary: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(eyebrow.uppercased()) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(MissionTheme.signal) + .tracking(2.2) + Text(title) + .font(.system(size: 42, weight: .black, design: .rounded)) + .foregroundStyle( + LinearGradient( + colors: [.primary, MissionTheme.signal.opacity(0.82)], + startPoint: .leading, + endPoint: .trailing + ) + ) + Text(summary) + .font(.title3) + .foregroundStyle(.secondary) + .frame(maxWidth: 820, alignment: .leading) + } + } +} + +struct SovereignHeroHeader: View { + var version: String = "OCP v0.1.6" + var subtitle: String = "Autonomic Mesh Alpha" + var summary: String + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(version) + .font(.system(size: 76, weight: .black, design: .rounded)) + .minimumScaleFactor(0.55) + .lineLimit(1) + .foregroundStyle( + LinearGradient( + colors: [MissionTheme.cream, .white.opacity(0.92)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .shadow(color: MissionTheme.lamp.opacity(0.20), radius: 18, x: 0, y: 8) + VStack(alignment: .leading, spacing: 8) { + Text(subtitle) + .font(.system(size: 26, weight: .bold, design: .monospaced)) + .tracking(1.6) + .foregroundStyle(MissionTheme.mint) + Capsule() + .fill( + LinearGradient( + colors: [MissionTheme.mint, MissionTheme.signal.opacity(0.12), .clear], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: 340, height: 2) + } + Text(summary) + .font(.title3) + .foregroundStyle(MissionTheme.cream.opacity(0.74)) + .frame(maxWidth: 880, alignment: .leading) + } + .padding(.top, 8) + } +} + +struct CinematicOverviewHero: View { + var summary: String + var setupLabel: String + var setupStatus: String + var nextFix: String + var meshScore: Int + var phoneURL: String + var isActivating: Bool + var recoveryLabel: String + var recoverySummary: String + var proofLabel: String + var proofSummary: String + var primaryPeerLabel: String + var primaryPeerSummary: String + var story: [String] + var allowMotion: Bool + var startMesh: () -> Void + var activateMesh: () -> Void + var copyPhoneLink: () -> Void + var openApp: () -> Void + @State private var glow = false + + var body: some View { + ZStack(alignment: .bottom) { + RoundedRectangle(cornerRadius: 34, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color.black.opacity(0.78), + MissionTheme.deepSea.opacity(0.90), + MissionTheme.ink + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + MeshPattern() + .opacity(0.16) + .clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous)) + CinematicConstellation() + .opacity(glow && allowMotion ? 0.82 : 0.52) + .blur(radius: glow && allowMotion ? 0 : 0.4) + LinearGradient( + colors: [.clear, MissionTheme.desk.opacity(0.78)], + startPoint: .center, + endPoint: .bottom + ) + .clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous)) + + VStack(alignment: .leading, spacing: 24) { + HStack(alignment: .top, spacing: 24) { + VStack(alignment: .leading, spacing: 15) { + Text("OCP v0.1.6") + .font(.system(size: 76, weight: .black, design: .rounded)) + .minimumScaleFactor(0.55) + .lineLimit(1) + .foregroundStyle(MissionTheme.cream) + .shadow(color: MissionTheme.lamp.opacity(0.28), radius: 24, x: 0, y: 10) + VStack(alignment: .leading, spacing: 8) { + Text("Autonomic Mesh Alpha") + .font(.system(size: 25, weight: .bold, design: .monospaced)) + .tracking(1.7) + .foregroundStyle(MissionTheme.mint) + Capsule() + .fill( + LinearGradient( + colors: [MissionTheme.mint, MissionTheme.signal.opacity(0.28), .clear], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: 360, height: 2) + } + Text(summary) + .font(.title3.weight(.semibold)) + .foregroundStyle(MissionTheme.cream.opacity(0.76)) + .frame(maxWidth: 720, alignment: .leading) + if !story.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(story.prefix(4).enumerated()), id: \.offset) { _, line in + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(MissionTheme.mint.opacity(0.9)) + .frame(width: 6, height: 6) + .padding(.top, 7) + Text(line) + .font(.callout.weight(.medium)) + .foregroundStyle(MissionTheme.cream.opacity(0.74)) + } + } + } + .padding(.top, 4) + } + } + + Spacer(minLength: 18) + + VStack(alignment: .trailing, spacing: 16) { + StatusPill(text: setupLabel, status: setupStatus) + VStack(alignment: .trailing, spacing: 2) { + Text("\(meshScore)") + .font(.system(size: 58, weight: .black, design: .rounded)) + .foregroundStyle(scoreColor) + Text("mesh score") + .font(.caption.bold()) + .foregroundStyle(MissionTheme.cream.opacity(0.62)) + .textCase(.uppercase) + } + VStack(alignment: .leading, spacing: 12) { + HeroDetailBlock(title: "Recovery", value: recoveryLabel, summary: recoverySummary) + HeroDetailBlock(title: "Proof", value: proofLabel, summary: proofSummary) + HeroDetailBlock(title: "Primary Peer", value: primaryPeerLabel, summary: primaryPeerSummary) + } + Text(nextFix) + .font(.callout.weight(.semibold)) + .foregroundStyle(MissionTheme.cream.opacity(0.72)) + .multilineTextAlignment(.trailing) + .lineLimit(3) + .frame(maxWidth: 300, alignment: .trailing) + } + .padding(18) + .frame(maxWidth: 320) + .background(Color.black.opacity(0.34), in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(MissionTheme.mint.opacity(0.18), lineWidth: 1)) + } + + HeroDeviceScene(allowMotion: allowMotion) + + HStack(spacing: 12) { + Button("Activate Mesh") { + activateMesh() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isActivating) + + Button("Start Mesh Mode") { + startMesh() + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button("Copy Phone Link") { + copyPhoneLink() + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button("Open App") { + openApp() + } + .buttonStyle(.bordered) + .controlSize(.large) + + Spacer() + + Text(phoneLinkCaption) + .font(.caption.monospaced()) + .foregroundStyle(MissionTheme.cream.opacity(0.62)) + .lineLimit(1) + } + } + .padding(34) + } + .frame(minHeight: 520) + .overlay( + RoundedRectangle(cornerRadius: 34, style: .continuous) + .stroke( + LinearGradient( + colors: [MissionTheme.mint.opacity(0.36), MissionTheme.lamp.opacity(0.18), .white.opacity(0.06)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 1 + ) + ) + .shadow(color: MissionTheme.mint.opacity(glow && allowMotion ? 0.22 : 0.12), radius: 34, x: 0, y: 20) + .onAppear { + guard allowMotion else { return } + withAnimation(.easeInOut(duration: 2.8).repeatForever(autoreverses: true)) { + glow = true + } + } + } + + private var phoneLinkCaption: String { + phoneURL.hasPrefix("http") ? phoneURL : "Start Mesh Mode to create the phone link." + } + + private var scoreColor: Color { + if meshScore >= 80 { return MissionTheme.mint } + if meshScore >= 50 { return MissionTheme.copper } + return MissionTheme.ember + } +} + +struct HeroDetailBlock: View { + var title: String + var value: String + var summary: String + + var body: some View { + VStack(alignment: .trailing, spacing: 2) { + Text(title.uppercased()) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .tracking(1.3) + .foregroundStyle(MissionTheme.signal.opacity(0.85)) + Text(value) + .font(.headline.weight(.bold)) + .foregroundStyle(MissionTheme.cream) + .lineLimit(1) + Text(summary) + .font(.caption) + .foregroundStyle(MissionTheme.cream.opacity(0.68)) + .multilineTextAlignment(.trailing) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } +} + +struct HeroDeviceScene: View { + var allowMotion: Bool + @State private var pulse = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill( + LinearGradient( + colors: [Color.black.opacity(0.30), MissionTheme.desk.opacity(0.46)], + startPoint: .top, + endPoint: .bottom + ) + ) + RouteArc(from: CGPoint(x: 0.18, y: 0.62), to: CGPoint(x: 0.50, y: 0.36), glow: pulse && allowMotion) + RouteArc(from: CGPoint(x: 0.50, y: 0.36), to: CGPoint(x: 0.82, y: 0.60), glow: pulse && allowMotion) + RouteArc(from: CGPoint(x: 0.22, y: 0.74), to: CGPoint(x: 0.78, y: 0.74), glow: pulse && allowMotion) + HStack(alignment: .bottom, spacing: 34) { + HeroDevice(title: "Phone", subtitle: "govern", icon: "iphone", tint: MissionTheme.mint) + HeroDevice(title: "Alpha Mac", subtitle: "command", icon: "laptopcomputer", tint: MissionTheme.signal, scale: 1.28) + HeroDevice(title: "Beta Laptop", subtitle: "compute", icon: "macbook", tint: MissionTheme.lamp) + } + .padding(.horizontal, 42) + .padding(.top, 34) + .frame(maxHeight: .infinity, alignment: .center) + + VStack(spacing: 4) { + TrustShieldMark(size: 52, allowMotion: allowMotion) + Text("trusted personal fabric") + .font(.caption.bold()) + .foregroundStyle(MissionTheme.mint.opacity(0.86)) + .tracking(1.2) + .textCase(.uppercase) + } + .offset(y: 66) + } + .frame(height: 245) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 28, style: .continuous).stroke(MissionTheme.mint.opacity(0.14), lineWidth: 1)) + .onAppear { + guard allowMotion else { return } + withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) { + pulse = true + } + } + } +} + +struct HeroDevice: View { + var title: String + var subtitle: String + var icon: String + var tint: Color + var scale: CGFloat = 1 + + var body: some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 50 * scale, weight: .semibold)) + .foregroundStyle(tint) + .shadow(color: tint.opacity(0.48), radius: 18) + Text(title) + .font(.headline.weight(.bold)) + .foregroundStyle(MissionTheme.cream) + Text(subtitle) + .font(.caption.bold()) + .foregroundStyle(tint.opacity(0.84)) + .tracking(1.2) + .textCase(.uppercase) + } + .frame(maxWidth: .infinity) + } +} + +struct CompactSetupGuideCard: View { + var steps: [SetupGuideStep] + var allowMotion: Bool + var startMesh: () -> Void + var copyPhoneLink: () -> Void + var activateMesh: () -> Void + var openSetup: () -> Void + @State private var reveal = false + + var body: some View { + MissionCard(tint: MissionTheme.signal) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text("Guided Path").sectionLabel() + Text("Five small checks from app launch to mesh proof.") + .font(.headline) + } + Spacer() + if let step = activeStep { + Button(step.action) { + perform(step) + } + .buttonStyle(.borderedProminent) + .disabled(step.status == "blocked" || step.status == "complete") + } + } + HStack(spacing: 10) { + ForEach(Array(steps.prefix(5).enumerated()), id: \.element.id) { index, step in + HStack(spacing: 8) { + StatusRing(status: step.status, index: index + 1, allowMotion: allowMotion) + VStack(alignment: .leading, spacing: 2) { + Text(step.title) + .font(.subheadline.bold()) + .lineLimit(1) + Text(step.summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black.opacity(step.status == "active" ? 0.34 : 0.18), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(stepColor(step.status).opacity(0.25), lineWidth: 1)) + .opacity(reveal || !allowMotion ? 1 : 0) + .offset(y: reveal || !allowMotion ? 0 : 8) + .animation(.easeOut(duration: 0.32).delay(Double(index) * 0.04), value: reveal) + } + } + } + } + .onAppear { reveal = true } + } + + private var activeStep: SetupGuideStep? { + steps.first { ["active", "attention"].contains($0.status) } ?? steps.first { $0.status != "complete" } + } + + private func perform(_ step: SetupGuideStep) { + switch step.id { + case "start_mesh": + startMesh() + case "copy_phone_link": + copyPhoneLink() + case "activate_mesh": + activateMesh() + default: + openSetup() + } + } + + private func stepColor(_ status: String) -> Color { + switch status { + case "complete": return MissionTheme.mint + case "active": return MissionTheme.signal + case "attention": return MissionTheme.copper + default: return .secondary + } + } +} + +struct DemoStatusStrip: View { + var state: DemoStripState + var roles: [DeviceRoleSummary] + + var body: some View { + MissionCard(tint: MissionTheme.signal) { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Demo Strip").sectionLabel() + Spacer() + StatusPill(text: state.recoveryLabel, status: state.recoveryLabel) + } + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 190, maximum: 320))], spacing: 12) { + DemoStatusTile(title: "Phone", value: state.phoneLabel, detail: state.phoneSummary, tint: MissionTheme.signal) + DemoStatusTile(title: "Primary Peer", value: state.primaryPeerLabel, detail: state.primaryPeerSummary, tint: MissionTheme.mint) + DemoStatusTile(title: "Proof", value: state.proofLabel, detail: state.proofSummary, tint: MissionTheme.copper) + DemoStatusTile(title: "Recovery", value: state.recoveryLabel, detail: state.recoverySummary, tint: MissionTheme.signal) + } + + if !roles.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("Device Roles").sectionLabel() + RoleBadgeWall(roles: roles) + } + } + + if !state.story.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Mesh Story").sectionLabel() + ForEach(Array(state.story.enumerated()), id: \.offset) { _, line in + Label(line, systemImage: "sparkles") + .foregroundStyle(.secondary) + } + } + } + } + } + } +} + +struct DemoStatusTile: View { + var title: String + var value: String + var detail: String + var tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Text(title).sectionLabel() + Text(value) + .font(.system(size: 24, weight: .black, design: .rounded)) + .foregroundStyle(MissionTheme.cream) + .lineLimit(2) + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(3) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black.opacity(0.18), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(tint.opacity(0.22), lineWidth: 1)) + } +} + +struct RoleBadgeWall: View { + var roles: [DeviceRoleSummary] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(roles) { role in + HStack(alignment: .top, spacing: 12) { + StatusPill(text: roleLabel(role.role), status: role.status) + VStack(alignment: .leading, spacing: 3) { + Text(role.label) + .font(.headline) + Text(role.summary) + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(12) + .background(Color.black.opacity(0.14), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + } + } + + private func roleLabel(_ value: String) -> String { + value.replacingOccurrences(of: "_", with: " ") + } +} + +struct MetricCard: View { + var title: String + var value: String + var detail: String + var tint: Color = .accentColor + + var body: some View { + MissionCard(tint: tint) { + VStack(alignment: .leading, spacing: 8) { + Text(title).sectionLabel() + Text(value) + .font(.system(size: 30, weight: .black, design: .rounded)) + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(4) + GeometryReader { proxy in + Capsule() + .fill(tint.opacity(0.16)) + .overlay(alignment: .leading) { + Capsule() + .fill(tint.gradient) + .frame(width: max(18, proxy.size.width * 0.62)) + } + } + .frame(height: 5) + } + } + } +} + +struct TrustShieldMark: View { + var size: CGFloat = 92 + var allowMotion: Bool = true + @State private var pulse = false + + var body: some View { + ZStack { + Circle() + .stroke(MissionTheme.mint.opacity(0.22), lineWidth: 1) + .frame(width: size * 1.65, height: size * 1.65) + .scaleEffect(pulse && allowMotion ? 1.08 : 0.96) + Circle() + .stroke(MissionTheme.mint.opacity(0.13), lineWidth: 1) + .frame(width: size * 2.18, height: size * 2.18) + .scaleEffect(pulse && allowMotion ? 1.02 : 1.10) + Image(systemName: "shield.checkered") + .font(.system(size: size * 0.72, weight: .semibold)) + .foregroundStyle( + LinearGradient( + colors: [MissionTheme.mint, MissionTheme.signal], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: MissionTheme.mint.opacity(0.55), radius: 24) + } + .frame(width: size * 2.35, height: size * 2.35) + .onAppear { + guard allowMotion else { return } + withAnimation(.easeInOut(duration: 2.7).repeatForever(autoreverses: true)) { + pulse = true + } + } + } +} + +struct SovereignPledgeCard: View { + var body: some View { + MissionCard(tint: MissionTheme.lamp) { + HStack(alignment: .center, spacing: 18) { + Image(systemName: "lock.laptopcomputer") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(MissionTheme.lamp) + VStack(alignment: .leading, spacing: 4) { + Text("Your compute") + Text("Your rules") + Text("Your data") + } + .font(.system(size: 17, weight: .black, design: .monospaced)) + .tracking(1.8) + .textCase(.uppercase) + .foregroundStyle(MissionTheme.cream) + Spacer() + Text("Local-first. Operator-held. Trusted devices only.") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: 260, alignment: .trailing) + } + } + } +} + +struct DeviceStageCard: View { + var allowMotion: Bool + @State private var glow = false + + var body: some View { + MissionCard(tint: MissionTheme.mint) { + VStack(alignment: .leading, spacing: 18) { + HStack { + Text("Trusted Device Stage").sectionLabel() + Spacer() + StatusPill(text: "local-first", status: "strong") + } + ZStack { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(.black.opacity(0.20)) + .frame(height: 210) + RouteArc(from: CGPoint(x: 0.16, y: 0.62), to: CGPoint(x: 0.50, y: 0.38), glow: glow && allowMotion) + RouteArc(from: CGPoint(x: 0.50, y: 0.38), to: CGPoint(x: 0.84, y: 0.62), glow: glow && allowMotion) + HStack(alignment: .bottom, spacing: 30) { + DeviceGlyph(title: "Phone", subtitle: "Govern", icon: "iphone", tint: MissionTheme.mint) + DeviceGlyph(title: "Alpha Mac", subtitle: "Command", icon: "laptopcomputer", tint: MissionTheme.signal, scale: 1.18) + DeviceGlyph(title: "Beta Laptop", subtitle: "Compute", icon: "macbook", tint: MissionTheme.lamp) + } + .padding(.horizontal, 22) + } + } + } + .onAppear { + guard allowMotion else { return } + withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) { + glow = true + } + } + } +} + +struct RouteArc: View { + var from: CGPoint + var to: CGPoint + var glow: Bool + + var body: some View { + GeometryReader { proxy in + Canvas { context, size in + let start = CGPoint(x: proxy.size.width * from.x, y: proxy.size.height * from.y) + let end = CGPoint(x: proxy.size.width * to.x, y: proxy.size.height * to.y) + var path = Path() + path.move(to: start) + path.addQuadCurve( + to: end, + control: CGPoint(x: (start.x + end.x) / 2, y: min(start.y, end.y) - proxy.size.height * 0.25) + ) + context.stroke(path, with: .color(MissionTheme.mint.opacity(glow ? 0.78 : 0.42)), lineWidth: glow ? 2.3 : 1.5) + } + } + } +} + +struct DeviceGlyph: View { + var title: String + var subtitle: String + var icon: String + var tint: Color + var scale: CGFloat = 1 + + var body: some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 46 * scale, weight: .semibold)) + .foregroundStyle(tint) + .shadow(color: tint.opacity(0.38), radius: 14) + Text(title) + .font(.headline) + Text(subtitle) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + .frame(maxWidth: .infinity) + } +} + +struct MeshGauge: View { + var score: Int + var allowMotion: Bool = true + @State private var animatedScore: Double = 0 + @State private var pulse = false + + var body: some View { + ZStack { + Circle() + .fill(scoreColor.opacity(pulse && allowMotion ? 0.13 : 0.07)) + .blur(radius: pulse && allowMotion ? 20 : 12) + Circle() + .stroke(.secondary.opacity(0.15), lineWidth: 18) + Circle() + .trim(from: 0, to: CGFloat(min(100, max(0, animatedScore))) / 100) + .stroke(scoreColor.gradient, style: StrokeStyle(lineWidth: 18, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .shadow(color: scoreColor.opacity(0.50), radius: 16) + ForEach(0..<28, id: \.self) { index in + Capsule() + .fill(index % 4 == 0 ? scoreColor.opacity(0.75) : .secondary.opacity(0.20)) + .frame(width: 2, height: index % 4 == 0 ? 10 : 6) + .offset(y: -112) + .rotationEffect(.degrees(Double(index) * (360 / 28))) + } + VStack(spacing: 4) { + Text("\(Int(round(animatedScore)))") + .font(.system(size: 52, weight: .black, design: .rounded)) + Text("mesh score") + .font(.caption.bold()) + .foregroundStyle(.secondary) + } + } + .frame(width: 220, height: 220) + .accessibilityLabel("Mesh score \(score)") + .onAppear { + animatedScore = allowMotion ? 0 : Double(score) + updateAnimation() + } + .onChange(of: score) { _ in + updateAnimation() + } + } + + private func updateAnimation() { + if allowMotion { + withAnimation(.spring(response: 0.9, dampingFraction: 0.82)) { + animatedScore = Double(score) + } + withAnimation(.easeInOut(duration: 2.8).repeatForever(autoreverses: true)) { + pulse = true + } + } else { + animatedScore = Double(score) + pulse = false + } + } + + private var scoreColor: Color { + if score >= 80 { return MissionTheme.mint } + if score >= 50 { return MissionTheme.copper } + return MissionTheme.ember + } +} + +struct StatusPill: View { + var text: String + var status: String + + var body: some View { + Text(text) + .font(.caption.bold()) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(color.opacity(0.16), in: Capsule()) + .foregroundStyle(color) + .overlay(Capsule().stroke(color.opacity(0.25), lineWidth: 1)) + } + + private var color: Color { + switch status.lowercased().replacingOccurrences(of: " ", with: "_") { + case "ok", "ready", "strong", "completed", "reachable", "fresh", "verified", "active", "healthy", "repaired": + return MissionTheme.mint + case "running", "proving", "queued", "planned", "aging", "repairing": + return MissionTheme.signal + case "warning", "needs_attention", "stale", "attention": + return MissionTheme.copper + case "failed", "unreachable", "cancelled", "blocked": + return MissionTheme.ember + default: + return .secondary + } + } +} + +struct TimelineList: View { + var events: [AppStatusSnapshot.TimelineEvent] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if events.isEmpty { + EmptyState(text: "No setup events yet. Press Activate Mesh to start the proof timeline.") + } else { + ForEach(events) { event in + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon(for: event.kind)) + .font(.title3) + .foregroundStyle(color(for: event.status ?? "info")) + .frame(width: 28) + VStack(alignment: .leading, spacing: 3) { + Text(event.kind.replacingOccurrences(of: "_", with: " ").capitalized) + .font(.headline) + Text(event.summary ?? "OCP recorded a setup event.") + .foregroundStyle(.secondary) + if let peer = event.peerID, !peer.isEmpty { + Text(peer) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + } + } + } + } + + private func icon(for kind: String) -> String { + if kind.contains("route") { return "point.3.connected.trianglepath.dotted" } + if kind.contains("worker") || kind.contains("helper") { return "cpu" } + if kind.contains("artifact") { return "shippingbox" } + if kind.contains("proof") { return "checkmark.seal" } + if kind.contains("fix") { return "wrench.and.screwdriver" } + return "circle.hexagongrid" + } + + private func color(for status: String) -> Color { + switch status.lowercased() { + case "ok", "ready", "completed": MissionTheme.mint + case "failed": MissionTheme.ember + case "warning", "needs_attention": MissionTheme.copper + default: MissionTheme.signal + } + } +} + +struct EmptyState: View { + var text: String + + var body: some View { + Text(text) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 80, alignment: .center) + } +} + +struct SetupGuideCard: View { + var steps: [SetupGuideStep] + var allowMotion: Bool + var startMesh: () -> Void + var copyPhoneLink: () -> Void + var activateMesh: () -> Void + var openSetup: () -> Void + @State private var reveal = false + + var body: some View { + MissionCard(tint: MissionTheme.signal) { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 5) { + Text("Guided Path").sectionLabel() + Text("From this Mac to a proven personal mesh.") + .font(.title3.weight(.bold)) + } + Spacer() + StatusPill(text: "\(steps.filter { $0.status == "complete" }.count)/\(steps.count)", status: steps.allSatisfy { $0.status == "complete" } ? "strong" : "running") + } + + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(steps.enumerated()), id: \.element.id) { index, step in + HStack(alignment: .top, spacing: 12) { + StatusRing(status: step.status, index: index + 1, allowMotion: allowMotion) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(step.title) + .font(.headline) + Spacer() + Button(step.action) { + perform(step) + } + .buttonStyle(.bordered) + .disabled(step.status == "blocked" || step.status == "complete") + } + Text(step.summary) + .foregroundStyle(.secondary) + } + } + .opacity(reveal || !allowMotion ? 1 : 0) + .offset(y: reveal || !allowMotion ? 0 : 10) + .animation(.easeOut(duration: 0.35).delay(Double(index) * 0.06), value: reveal) + } + } + } + } + .onAppear { reveal = true } + } + + private func perform(_ step: SetupGuideStep) { + switch step.id { + case "start_mesh": + startMesh() + case "copy_phone_link": + copyPhoneLink() + case "activate_mesh": + activateMesh() + default: + openSetup() + } + } +} + +struct StatusRing: View { + var status: String + var index: Int + var allowMotion: Bool + @State private var pulse = false + + var body: some View { + ZStack { + Circle() + .stroke(color.opacity(0.18), lineWidth: 3) + Circle() + .trim(from: 0, to: progress) + .stroke(color.gradient, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Text(status == "complete" ? "✓" : "\(index)") + .font(.caption.bold()) + .foregroundStyle(color) + } + .frame(width: 30, height: 30) + .scaleEffect(pulse && allowMotion && status == "active" ? 1.08 : 1.0) + .onAppear { + guard allowMotion, status == "active" else { return } + withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true)) { + pulse = true + } + } + } + + private var progress: CGFloat { + switch status { + case "complete": return 1 + case "active": return 0.66 + case "attention": return 0.50 + default: return 0.18 + } + } + + private var color: Color { + switch status { + case "complete": return MissionTheme.mint + case "active": return MissionTheme.signal + case "attention": return MissionTheme.copper + default: return .secondary + } + } +} + +struct TopologyGraphView: View { + var graph: TopologyGraph + var compact: Bool = false + var allowMotion: Bool = true + @State private var phase: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Route Topology").sectionLabel() + Spacer() + RouteEdgeLegend() + } + if graph.nodes.count <= 1 { + EmptyState(text: "Topology appears after OCP sees this Mac and at least one peer route or worker.") + } else { + Canvas { context, size in + let layout = layout(in: size) + drawEdges(context: &context, layout: layout) + drawNodes(context: &context, layout: layout) + } + .frame(height: compact ? 220 : 360) + .background(.black.opacity(0.10), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .onAppear { + guard allowMotion else { return } + withAnimation(.linear(duration: 2.4).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } + } + } + + private func layout(in size: CGSize) -> [String: CGPoint] { + guard let local = graph.nodes.first else { return [:] } + let center = CGPoint(x: size.width * 0.5, y: size.height * 0.5) + var result = [local.id: center] + let peers = graph.nodes.dropFirst() + let radius = min(size.width, size.height) * (compact ? 0.32 : 0.36) + for (index, node) in peers.enumerated() { + let angle = (Double(index) / Double(max(1, peers.count))) * Double.pi * 2 - Double.pi / 2 + result[node.id] = CGPoint( + x: center.x + CGFloat(cos(angle)) * radius, + y: center.y + CGFloat(sin(angle)) * radius + ) + } + return result + } + + private func drawEdges(context: inout GraphicsContext, layout: [String: CGPoint]) { + for edge in graph.edges { + guard let start = layout[edge.source], let end = layout[edge.target] else { continue } + var path = Path() + path.move(to: start) + path.addLine(to: end) + let color = edgeColor(edge.status, edge.freshness) + context.stroke(path, with: .color(color.opacity(0.58)), style: StrokeStyle(lineWidth: 2, lineCap: .round, dash: edge.freshness == "stale" ? [5, 6] : [])) + + if allowMotion && ["reachable", "ready", "verified", "active"].contains(edge.status.lowercased()) { + let progress = (phase + CGFloat(abs(edge.id.hashValue % 25)) / 25).truncatingRemainder(dividingBy: 1) + let pulsePoint = CGPoint(x: start.x + (end.x - start.x) * progress, y: start.y + (end.y - start.y) * progress) + context.fill(Path(ellipseIn: CGRect(x: pulsePoint.x - 4, y: pulsePoint.y - 4, width: 8, height: 8)), with: .color(color.opacity(0.85))) + } + } + } + + private func drawNodes(context: inout GraphicsContext, layout: [String: CGPoint]) { + for node in graph.nodes { + guard let point = layout[node.id] else { continue } + let isLocal = node.role == "local" + let radius: CGFloat = isLocal ? 30 : 23 + let color = nodeColor(node.status, node.role) + context.fill(Path(ellipseIn: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2)), with: .color(color.opacity(isLocal ? 0.34 : 0.24))) + context.stroke(Path(ellipseIn: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2)), with: .color(color.opacity(0.88)), lineWidth: isLocal ? 3 : 2) + context.draw(Text(node.label).font(.caption.bold()).foregroundColor(.primary), at: CGPoint(x: point.x, y: point.y + radius + 14), anchor: .top) + if !compact { + context.draw(Text(node.subtitle).font(.caption2).foregroundColor(.secondary), at: CGPoint(x: point.x, y: point.y + radius + 30), anchor: .top) + } + } + } + + private func edgeColor(_ status: String, _ freshness: String) -> Color { + if freshness == "stale" { return MissionTheme.copper } + switch status.lowercased() { + case "reachable", "ready", "verified", "active": return MissionTheme.mint + case "failed", "unreachable", "cancelled": return MissionTheme.ember + case "needs_attention", "stale", "attention": return MissionTheme.copper + default: return MissionTheme.signal + } + } + + private func nodeColor(_ status: String, _ role: String) -> Color { + if role == "local" { return MissionTheme.signal } + switch status.lowercased() { + case "reachable", "ready", "verified", "strong", "active": return MissionTheme.mint + case "failed", "unreachable", "cancelled": return MissionTheme.ember + case "needs_attention", "stale", "attention": return MissionTheme.copper + default: return MissionTheme.signal + } + } +} + +struct RouteEdgeLegend: View { + var body: some View { + HStack(spacing: 8) { + legend("fresh", MissionTheme.mint) + legend("stale", MissionTheme.copper) + legend("down", MissionTheme.ember) + } + .font(.caption2.bold()) + .foregroundStyle(.secondary) + } + + private func legend(_ label: String, _ color: Color) -> some View { + HStack(spacing: 4) { + Circle().fill(color).frame(width: 7, height: 7) + Text(label) + } + } +} + +struct MeshScoreChart: View { + var points: [MissionControlChartPoint] + + var body: some View { + if points.isEmpty { + EmptyState(text: "History will appear after the app records a few status samples.") + } else { + Chart(points) { point in + AreaMark( + x: .value("Sample", point.sampledAt), + y: .value("Mesh score", point.meshScore) + ) + .foregroundStyle(MissionTheme.signal.opacity(0.18)) + LineMark( + x: .value("Sample", point.sampledAt), + y: .value("Mesh score", point.meshScore) + ) + .foregroundStyle(MissionTheme.signal) + .lineStyle(.init(lineWidth: 3)) + PointMark( + x: .value("Sample", point.sampledAt), + y: .value("Mesh score", point.meshScore) + ) + .foregroundStyle(MissionTheme.signal.opacity(0.72)) + } + .chartYScale(domain: 0...100) + .chartXAxis(.hidden) + .chartPlotStyle { plot in + plot + .background(.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .frame(height: 180) + } + } +} + +struct RouteHealthChart: View { + var healthy: Int + var total: Int + + var body: some View { + Chart { + BarMark(x: .value("State", "Healthy"), y: .value("Routes", healthy)) + .foregroundStyle(MissionTheme.mint) + BarMark(x: .value("State", "Needs work"), y: .value("Routes", max(0, total - healthy))) + .foregroundStyle(MissionTheme.copper) + } + .chartPlotStyle { plot in + plot + .background(.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .frame(height: 150) + } +} + +extension Text { + func sectionLabel() -> some View { + self + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(MissionTheme.signal.opacity(0.85)) + .tracking(1.6) + .textCase(.uppercase) + } +} diff --git a/Sources/OCPDesktop/Views/ContentView.swift b/Sources/OCPDesktop/Views/ContentView.swift new file mode 100644 index 0000000..34fef3f --- /dev/null +++ b/Sources/OCPDesktop/Views/ContentView.swift @@ -0,0 +1,108 @@ +import SwiftUI + +struct ContentView: View { + @ObservedObject var model: OCPDesktopModel + @SceneStorage("ocp.desktop.selectedSection") private var selectedSectionID = DesktopSection.overview.rawValue + @AppStorage("ocp.desktop.hasSeenGuide") private var hasSeenGuide = false + @AppStorage("ocp.desktop.showGuide") private var showGuide = true + @AppStorage("ocp.desktop.prefersReducedMissionMotion") private var prefersReducedMissionMotion = false + @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion + + private var selection: Binding { + Binding { + DesktopSection(rawValue: selectedSectionID) ?? .overview + } set: { next in + selectedSectionID = (next ?? .overview).rawValue + } + } + + var body: some View { + NavigationSplitView { + SidebarView(selection: selection) + } detail: { + detailView + .navigationTitle((selection.wrappedValue ?? .overview).title) + .toolbar { + ToolbarItemGroup { + Button { + model.startLocal() + } label: { + Label("Local", systemImage: "desktopcomputer") + } + Button { + model.startMesh() + } label: { + Label("Mesh", systemImage: "network") + } + Button { + model.activateMesh() + } label: { + Label("Activate", systemImage: "bolt.circle") + } + .disabled(model.isActivating) + Button { + model.refreshNow() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + Button { + showGuide.toggle() + } label: { + Label(showGuide ? "Hide Guide" : "Show Guide", systemImage: "map") + } + Button { + model.stop() + } label: { + Label("Stop", systemImage: "stop.circle") + } + } + ToolbarItemGroup { + Button { + model.openApp() + } label: { + Label("Open App", systemImage: "safari") + } + Button { + model.copyPhoneLink() + } label: { + Label("Copy Phone Link", systemImage: "iphone") + } + } + } + } + .onAppear { + if !hasSeenGuide { + hasSeenGuide = true + showGuide = true + } + } + .preferredColorScheme(.dark) + .tint(MissionTheme.mint) + } + + @ViewBuilder + private var detailView: some View { + switch selection.wrappedValue ?? .overview { + case .overview: + OverviewView(model: model, showGuide: $showGuide, allowMotion: allowMotion) { + selection.wrappedValue = .setup + } + case .setup: + SetupDoctorView(model: model, allowMotion: allowMotion) + case .routes: + RoutesView(model: model, allowMotion: allowMotion) + case .execution: + ExecutionView(model: model, allowMotion: allowMotion) + case .artifacts: + ArtifactsView(model: model, allowMotion: allowMotion) + case .protocolStatus: + ProtocolView(model: model, allowMotion: allowMotion) + case .settings: + SettingsView(model: model, allowMotion: allowMotion) + } + } + + private var allowMotion: Bool { + !prefersReducedMissionMotion && !accessibilityReduceMotion + } +} diff --git a/Sources/OCPDesktop/Views/ExecutionView.swift b/Sources/OCPDesktop/Views/ExecutionView.swift new file mode 100644 index 0000000..7a41dc5 --- /dev/null +++ b/Sources/OCPDesktop/Views/ExecutionView.swift @@ -0,0 +1,63 @@ +import Charts +import SwiftUI + +struct ExecutionView: View { + @ObservedObject var model: OCPDesktopModel + var allowMotion: Bool = true + + var body: some View { + let readiness = model.snapshot?.executionReadiness + let targets = readiness?.targets ?? [] + let demo = model.demoState + + MissionScroll(allowMotion: allowMotion) { + PageHeader( + eyebrow: "Execution", + title: readiness?.status?.replacingOccurrences(of: "_", with: " ").capitalized ?? "No readiness yet", + summary: readiness?.operatorSummary ?? "Worker readiness appears after OCP starts and advertises local or remote capacity." + ) + + DemoStatusStrip(state: demo, roles: model.deviceRoles) + + MissionCard { + VStack(alignment: .leading, spacing: 12) { + Text("Ready Targets").sectionLabel() + if targets.isEmpty { + EmptyState(text: "No execution targets are visible yet.") + } else { + Chart(targets) { target in + BarMark( + x: .value("Target", target.displayName ?? target.peerID ?? "Peer"), + y: .value("Workers", target.workerCount ?? 0) + ) + .foregroundStyle((target.status ?? "") == "ready" ? .green : .orange) + } + .frame(height: 190) + } + } + } + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { + ForEach(targets) { target in + MissionCard { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(target.displayName ?? target.peerID ?? "Target") + .font(.headline) + Spacer() + StatusPill(text: target.status ?? "unknown", status: target.status ?? "unknown") + } + Text("\(target.workerCount ?? 0) worker(s)") + .foregroundStyle(.secondary) + ForEach(target.reasons ?? [], id: \.self) { reason in + Label(reason, systemImage: "checkmark.circle") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + } + } + } + } +} diff --git a/Sources/OCPDesktop/Views/OverviewView.swift b/Sources/OCPDesktop/Views/OverviewView.swift new file mode 100644 index 0000000..0d11b21 --- /dev/null +++ b/Sources/OCPDesktop/Views/OverviewView.swift @@ -0,0 +1,122 @@ +import Charts +import SwiftUI +import OCPDesktopCore + +struct OverviewView: View { + @ObservedObject var model: OCPDesktopModel + @Binding var showGuide: Bool + var allowMotion: Bool + var openSetup: () -> Void + + var body: some View { + let demo = model.demoState + MissionScroll(allowMotion: allowMotion) { + CinematicOverviewHero( + summary: model.setupSummary, + setupLabel: model.setupLabel, + setupStatus: model.snapshot?.setup?.status ?? "ready", + nextFix: model.nextFix, + meshScore: model.meshScore, + phoneURL: model.phoneURL, + isActivating: model.isActivating, + recoveryLabel: demo.recoveryLabel, + recoverySummary: demo.recoverySummary, + proofLabel: demo.proofLabel, + proofSummary: demo.proofSummary, + primaryPeerLabel: demo.primaryPeerLabel, + primaryPeerSummary: demo.primaryPeerSummary, + story: demo.story, + allowMotion: allowMotion, + startMesh: { model.startMesh() }, + activateMesh: { model.activateMesh() }, + copyPhoneLink: { model.copyPhoneLink() }, + openApp: { model.openApp() } + ) + + if showGuide { + CompactSetupGuideCard( + steps: model.setupGuideSteps, + allowMotion: allowMotion, + startMesh: { model.startMesh() }, + copyPhoneLink: { model.copyPhoneLink() }, + activateMesh: { model.activateMesh() }, + openSetup: openSetup + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + DemoStatusStrip(state: demo, roles: model.deviceRoles) + + HStack(alignment: .top, spacing: 18) { + MissionCard(tint: MissionTheme.signal) { + TopologyGraphView(graph: model.topology, compact: true, allowMotion: allowMotion) + } + + MissionCard(tint: MissionTheme.mint) { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Mesh Score History").sectionLabel() + Spacer() + Text("\(model.history.count) samples") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + MeshScoreChart(points: model.chartPoints) + } + } + } + + HStack(alignment: .top, spacing: 18) { + MissionCard(tint: MissionTheme.copper) { + HStack(alignment: .center, spacing: 20) { + MeshGauge(score: model.meshScore, allowMotion: allowMotion) + .scaleEffect(0.72) + .frame(width: 170, height: 170) + VStack(alignment: .leading, spacing: 10) { + Text("Mission Status").sectionLabel() + Text(model.nextFix) + .font(.system(size: 25, weight: .black, design: .rounded)) + .lineLimit(3) + Text(model.statusText) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + } + SovereignPledgeCard() + .frame(maxWidth: 430) + } + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { + MetricCard( + title: "Routes", + value: "\(model.snapshot?.meshQuality?.healthyRoutes ?? 0)/\(model.snapshot?.meshQuality?.routeCount ?? 0)", + detail: model.routeSummary, + tint: .green + ) + MetricCard( + title: "Execution", + value: "\(model.snapshot?.executionReadiness?.targets?.filter { $0.status == "ready" }.count ?? 0) ready", + detail: model.executionSummary, + tint: .cyan + ) + MetricCard( + title: "Artifacts", + value: "\(model.snapshot?.artifactSync?.verifiedCount ?? 0) verified", + detail: model.artifactSummary, + tint: .orange + ) + } + + MissionCard(tint: MissionTheme.copper) { + VStack(alignment: .leading, spacing: 12) { + Text("Next Actions").sectionLabel() + ForEach(Array((model.snapshot?.nextActions ?? ["Start OCP and press Activate Mesh."]).enumerated()), id: \.offset) { _, action in + Label(action, systemImage: "arrow.right.circle") + .foregroundStyle(.secondary) + } + } + } + } + } +} diff --git a/Sources/OCPDesktop/Views/ProtocolView.swift b/Sources/OCPDesktop/Views/ProtocolView.swift new file mode 100644 index 0000000..3fed8a4 --- /dev/null +++ b/Sources/OCPDesktop/Views/ProtocolView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct ProtocolView: View { + @ObservedObject var model: OCPDesktopModel + var allowMotion: Bool = true + + var body: some View { + let protocolStatus = model.snapshot?.protocolStatus + + MissionScroll(allowMotion: allowMotion) { + PageHeader( + eyebrow: "Protocol", + title: "OCP \(protocolStatus?.release ?? "0.1")", + summary: model.protocolSummary + ) + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { + MetricCard(title: "Wire", value: protocolStatus?.version ?? "sovereign-mesh/v1", detail: "Current protocol version.", tint: .cyan) + MetricCard(title: "Schemas", value: protocolStatus?.schemaVersion ?? "pending", detail: "Live schema registry version.", tint: .green) + MetricCard(title: "Contract", value: protocolStatus?.contractURL ?? "/mesh/contract", detail: "HTTP contract route.", tint: .orange) + } + + MissionCard { + VStack(alignment: .leading, spacing: 12) { + Text("Live Links").sectionLabel() + Link("Open Contract", destination: URL(string: model.serverBaseURL + "/mesh/contract")!) + Link("Open Manifest", destination: URL(string: model.serverBaseURL + "/mesh/manifest")!) + Link("Open App Status", destination: URL(string: model.serverBaseURL + "/mesh/app/status")!) + Link("Open App History", destination: URL(string: model.serverBaseURL + "/mesh/app/history")!) + } + } + } + } +} diff --git a/Sources/OCPDesktop/Views/RoutesView.swift b/Sources/OCPDesktop/Views/RoutesView.swift new file mode 100644 index 0000000..54d4b82 --- /dev/null +++ b/Sources/OCPDesktop/Views/RoutesView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct RoutesView: View { + @ObservedObject var model: OCPDesktopModel + var allowMotion: Bool + + var body: some View { + let health = model.snapshot?.routeHealth + let routes = health?.routes ?? [] + let demo = model.demoState + + MissionScroll(allowMotion: allowMotion) { + PageHeader( + eyebrow: "Route Health", + title: "\(health?.healthy ?? 0) fresh route(s)", + summary: health?.operatorSummary ?? "Route health appears after peer discovery and Autonomic Mesh activation." + ) + + DemoStatusStrip(state: demo, roles: model.deviceRoles) + + MissionCard(tint: MissionTheme.signal) { + TopologyGraphView(graph: model.topology, allowMotion: allowMotion) + } + + MissionCard { + VStack(alignment: .leading, spacing: 12) { + Text("Reachability").sectionLabel() + RouteHealthChart(healthy: health?.healthy ?? 0, total: health?.count ?? 0) + } + } + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { + ForEach(routes) { route in + MissionCard { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(route.displayName ?? route.peerID ?? "Peer") + .font(.headline) + Spacer() + StatusPill(text: route.status ?? "unknown", status: route.status ?? "unknown") + } + Text(route.bestRoute ?? "No working route recorded.") + .font(.callout.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + Text(route.operatorSummary ?? "Press Activate Mesh to probe this route.") + .foregroundStyle(.secondary) + StatusPill(text: route.freshness ?? "unknown", status: route.freshness ?? "unknown") + } + } + } + } + + if routes.isEmpty { + MissionCard { + EmptyState(text: "No peer routes yet. Start Mesh Mode, connect another device, then press Activate Mesh.") + } + } + } + } +} diff --git a/Sources/OCPDesktop/Views/SettingsView.swift b/Sources/OCPDesktop/Views/SettingsView.swift new file mode 100644 index 0000000..2049885 --- /dev/null +++ b/Sources/OCPDesktop/Views/SettingsView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var model: OCPDesktopModel + var allowMotion: Bool = true + @AppStorage("ocp.desktop.showGuide") private var showGuide = true + @AppStorage("ocp.desktop.prefersReducedMissionMotion") private var prefersReducedMissionMotion = false + + var body: some View { + MissionScroll(allowMotion: allowMotion) { + PageHeader( + eyebrow: "Settings", + title: "Node profile", + summary: "These settings feed the Python OCP server launched by the native Mac app." + ) + + MissionCard { + VStack(alignment: .leading, spacing: 14) { + Text("Profile").sectionLabel() + HStack(spacing: 12) { + labeledField("Display", text: $model.config.displayName) + labeledField("Node ID", text: $model.config.nodeID) + VStack(alignment: .leading, spacing: 6) { + Text("Port").sectionLabel() + TextField("Port", value: $model.config.port, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 110) + } + } + HStack(spacing: 12) { + labeledField("Device", text: $model.config.deviceClass) + labeledField("Form", text: $model.config.formFactor) + } + Button("Save Settings") { model.saveConfig() } + .buttonStyle(.borderedProminent) + } + } + + MissionCard { + VStack(alignment: .leading, spacing: 10) { + Text("Local State").sectionLabel() + pathRow("Config", model.paths.configPath.path) + pathRow("Database", model.paths.databasePath.path) + pathRow("Identity", model.paths.identityDirectory.path) + pathRow("Workspace", model.paths.workspaceDirectory.path) + pathRow("Repo", model.repoRoot.path) + } + } + + MissionCard { + VStack(alignment: .leading, spacing: 10) { + Text("Links").sectionLabel() + Text("App: \(model.appURL)") + .textSelection(.enabled) + Text("Phone: \(model.phoneURL)") + .textSelection(.enabled) + HStack { + Button("Open App") { model.openApp() } + Button("Copy Phone Link") { model.copyPhoneLink() } + } + } + } + + MissionCard(tint: MissionTheme.signal) { + VStack(alignment: .leading, spacing: 12) { + Text("Experience").sectionLabel() + Toggle("Show guided path in Overview", isOn: $showGuide) + Toggle("Reduce Mission Control motion", isOn: $prefersReducedMissionMotion) + Text("The app also respects the system Reduce Motion accessibility setting.") + .foregroundStyle(.secondary) + } + } + } + } + + private func labeledField(_ label: String, text: Binding) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).sectionLabel() + TextField(label, text: text) + .textFieldStyle(.roundedBorder) + } + } + + private func pathRow(_ label: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.headline) + .frame(width: 90, alignment: .leading) + Text(value) + .font(.callout.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } +} diff --git a/Sources/OCPDesktop/Views/SetupDoctorView.swift b/Sources/OCPDesktop/Views/SetupDoctorView.swift new file mode 100644 index 0000000..1b04278 --- /dev/null +++ b/Sources/OCPDesktop/Views/SetupDoctorView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct SetupDoctorView: View { + @ObservedObject var model: OCPDesktopModel + var allowMotion: Bool + + var body: some View { + let demo = model.demoState + + MissionScroll(allowMotion: allowMotion) { + PageHeader( + eyebrow: "Setup Doctor", + title: model.setupLabel, + summary: model.setupSummary + ) + + DemoStatusStrip(state: demo, roles: model.deviceRoles) + + SetupGuideCard( + steps: model.setupGuideSteps, + allowMotion: allowMotion, + startMesh: { model.startMesh() }, + copyPhoneLink: { model.copyPhoneLink() }, + activateMesh: { model.activateMesh() }, + openSetup: { model.refreshNow() } + ) + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { + MetricCard(title: "Known Peers", value: "\(model.snapshot?.setup?.knownPeerCount ?? 0)", detail: "Devices OCP can currently reason about.", tint: .cyan) + MetricCard(title: "Healthy Routes", value: "\(model.snapshot?.setup?.healthyRouteCount ?? 0)", detail: "Fresh route proofs available for dispatch.", tint: .green) + MetricCard(title: "Latest Proof", value: model.snapshot?.setup?.latestProofStatus ?? "none", detail: model.snapshot?.latestProof?.summary ?? "No whole-mesh proof summary yet.", tint: .purple) + MetricCard(title: "Approvals", value: "\(model.snapshot?.approvals?.pendingCount ?? 0)", detail: "Pending operator attention.", tint: .orange) + } + + MissionCard { + VStack(alignment: .leading, spacing: 12) { + Text("One Concrete Fix").sectionLabel() + Text(model.nextFix) + .font(.title3.weight(.semibold)) + if let blocking = model.snapshot?.setup?.blockingIssue, !blocking.isEmpty { + Label(blocking, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + Text("Phone: \(model.phoneURL)") + .font(.callout) + .foregroundStyle(.secondary) + .textSelection(.enabled) + HStack { + Button("Activate Mesh") { model.activateMesh() } + .buttonStyle(.borderedProminent) + Button("Copy Phone Link") { model.copyPhoneLink() } + } + } + } + + MissionCard { + VStack(alignment: .leading, spacing: 14) { + Text("Proof Timeline").sectionLabel() + TimelineList(events: model.snapshot?.setup?.timeline ?? []) + } + } + } + } +} diff --git a/Sources/OCPDesktop/Views/SidebarView.swift b/Sources/OCPDesktop/Views/SidebarView.swift new file mode 100644 index 0000000..44b0b7a --- /dev/null +++ b/Sources/OCPDesktop/Views/SidebarView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct SidebarView: View { + @Binding var selection: DesktopSection? + + var body: some View { + List(selection: $selection) { + Section("Mission Control") { + ForEach(DesktopSection.allCases) { section in + HStack(spacing: 10) { + Image(systemName: section.systemImage) + .foregroundStyle(section == selection ? MissionTheme.signal : .secondary) + .frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(section.title) + .lineLimit(1) + .fontWeight(section == selection ? .semibold : .regular) + Text(section.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .tag(section) + } + } + } + .listStyle(.sidebar) + } +} diff --git a/Sources/OCPDesktopCore/LauncherCore.swift b/Sources/OCPDesktopCore/LauncherCore.swift new file mode 100644 index 0000000..a3d2fc0 --- /dev/null +++ b/Sources/OCPDesktopCore/LauncherCore.swift @@ -0,0 +1,694 @@ +import Foundation +import Darwin + +public enum LaunchMode: String, Codable, CaseIterable, Sendable { + case local + case mesh + + public var host: String { + switch self { + case .local: + return "127.0.0.1" + case .mesh: + return "0.0.0.0" + } + } +} + +public struct LauncherConfig: Codable, Equatable, Sendable { + public var port: Int + public var nodeID: String + public var displayName: String + public var deviceClass: String + public var formFactor: String + public var operatorToken: String + + public init( + port: Int = 8421, + nodeID: String = "", + displayName: String = "OCP Node", + deviceClass: String = "full", + formFactor: String = "workstation", + operatorToken: String = "" + ) { + self.port = port + self.nodeID = nodeID + self.displayName = displayName + self.deviceClass = deviceClass + self.formFactor = formFactor + self.operatorToken = operatorToken + } + + public func normalized(defaultNodeID: String) -> LauncherConfig { + LauncherConfig( + port: max(1, port), + nodeID: nodeID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? defaultNodeID : nodeID.trimmingCharacters(in: .whitespacesAndNewlines), + displayName: displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "OCP Node" : displayName.trimmingCharacters(in: .whitespacesAndNewlines), + deviceClass: deviceClass.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "full" : deviceClass.trimmingCharacters(in: .whitespacesAndNewlines), + formFactor: formFactor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "workstation" : formFactor.trimmingCharacters(in: .whitespacesAndNewlines), + operatorToken: operatorToken.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } +} + +public struct LaunchPaths: Equatable, Sendable { + public var supportDirectory: URL + public var configPath: URL + public var stateDirectory: URL + public var databasePath: URL + public var identityDirectory: URL + public var workspaceDirectory: URL +} + +public struct LaunchPlan: Equatable, Sendable { + public var mode: LaunchMode + public var host: String + public var port: Int + public var command: [String] + public var environment: [String: String] + public var appURL: String + public var manifestURL: String + public var phoneURLs: [String] + public var repoRoot: URL + public var paths: LaunchPaths +} + +public struct AppStatusSnapshot: Decodable, Equatable, Sendable { + public struct Node: Decodable, Equatable, Sendable { + public var nodeID: String? + public var displayName: String? + public var deviceClass: String? + public var formFactor: String? + + enum CodingKeys: String, CodingKey { + case nodeID = "node_id" + case displayName = "display_name" + case deviceClass = "device_class" + case formFactor = "form_factor" + } + } + + public struct MeshQuality: Decodable, Equatable, Sendable { + public var status: String? + public var label: String? + public var peerCount: Int? + public var routeCount: Int? + public var healthyRoutes: Int? + public var operatorSummary: String? + + enum CodingKeys: String, CodingKey { + case status + case label + case peerCount = "peer_count" + case routeCount = "route_count" + case healthyRoutes = "healthy_routes" + case operatorSummary = "operator_summary" + } + } + + public struct TimelineEvent: Decodable, Equatable, Identifiable, Sendable { + public var kind: String + public var status: String? + public var summary: String? + public var peerID: String? + public var createdAt: String? + + public var id: String { + "\(createdAt ?? "")-\(kind)-\(peerID ?? "")-\(summary ?? "")" + } + + enum CodingKeys: String, CodingKey { + case kind + case status + case summary + case peerID = "peer_id" + case createdAt = "created_at" + } + } + + public struct Setup: Decodable, Equatable, Sendable { + public struct PrimaryPeer: Decodable, Equatable, Sendable { + public var peerID: String? + public var displayName: String? + public var role: String? + public var status: String? + public var route: String? + public var summary: String? + + enum CodingKeys: String, CodingKey { + case peerID = "peer_id" + case displayName = "display_name" + case role + case status + case route + case summary + } + } + + public struct DeviceRole: Decodable, Equatable, Identifiable, Sendable { + public var peerID: String? + public var displayName: String? + public var role: String? + public var status: String? + public var summary: String? + + public var id: String { peerID ?? displayName ?? role ?? "device-role" } + + enum CodingKeys: String, CodingKey { + case peerID = "peer_id" + case displayName = "display_name" + case role + case status + case summary + } + } + + public var status: String? + public var label: String? + public var phoneURL: String? + public var knownPeerCount: Int? + public var healthyRouteCount: Int? + public var routeCount: Int? + public var latestProofStatus: String? + public var recoveryState: String? + public var primaryPeer: PrimaryPeer? + public var deviceRoles: [DeviceRole]? + public var blockingIssue: String? + public var blockerCode: String? + public var nextFix: String? + public var operatorSummary: String? + public var story: [String]? + public var timeline: [TimelineEvent]? + + enum CodingKeys: String, CodingKey { + case status + case label + case phoneURL = "phone_url" + case knownPeerCount = "known_peer_count" + case healthyRouteCount = "healthy_route_count" + case routeCount = "route_count" + case latestProofStatus = "latest_proof_status" + case recoveryState = "recovery_state" + case primaryPeer = "primary_peer" + case deviceRoles = "device_roles" + case blockingIssue = "blocking_issue" + case blockerCode = "blocker_code" + case nextFix = "next_fix" + case operatorSummary = "operator_summary" + case story + case timeline + } + } + + public struct Route: Decodable, Equatable, Identifiable, Sendable { + public var peerID: String? + public var displayName: String? + public var status: String? + public var freshness: String? + public var bestRoute: String? + public var operatorSummary: String? + + public var id: String { peerID ?? displayName ?? bestRoute ?? "route" } + + enum CodingKeys: String, CodingKey { + case peerID = "peer_id" + case displayName = "display_name" + case status + case freshness + case bestRoute = "best_route" + case operatorSummary = "operator_summary" + } + } + + public struct RouteHealth: Decodable, Equatable, Sendable { + public var count: Int? + public var healthy: Int? + public var routes: [Route]? + public var operatorSummary: String? + + enum CodingKeys: String, CodingKey { + case count + case healthy + case routes + case operatorSummary = "operator_summary" + } + } + + public struct ExecutionTarget: Decodable, Equatable, Identifiable, Sendable { + public var peerID: String? + public var displayName: String? + public var role: String? + public var status: String? + public var workerCount: Int? + public var routeStatus: String? + public var routeFreshness: String? + public var reasons: [String]? + + public var id: String { peerID ?? displayName ?? "target" } + + enum CodingKeys: String, CodingKey { + case peerID = "peer_id" + case displayName = "display_name" + case role + case status + case workerCount = "worker_count" + case routeStatus = "route_status" + case routeFreshness = "route_freshness" + case reasons + } + } + + public struct LocalExecution: Decodable, Equatable, Sendable { + public var workerCount: Int? + public var readyWorkerCount: Int? + + enum CodingKeys: String, CodingKey { + case workerCount = "worker_count" + case readyWorkerCount = "ready_worker_count" + } + } + + public struct ExecutionReadiness: Decodable, Equatable, Sendable { + public var status: String? + public var local: LocalExecution? + public var targets: [ExecutionTarget]? + public var operatorSummary: String? + + enum CodingKeys: String, CodingKey { + case status + case local + case targets + case operatorSummary = "operator_summary" + } + } + + public struct ArtifactSync: Decodable, Equatable, Sendable { + public var status: String? + public var replicatedCount: Int? + public var verifiedCount: Int? + public var latestSyncedAt: String? + public var items: [ArtifactSyncItem]? + public var operatorSummary: String? + + enum CodingKeys: String, CodingKey { + case status + case replicatedCount = "replicated_count" + case verifiedCount = "verified_count" + case latestSyncedAt = "latest_synced_at" + case items + case operatorSummary = "operator_summary" + } + } + + public struct ArtifactSyncItem: Decodable, Equatable, Identifiable, Sendable { + public var artifactID: String? + public var digest: String? + public var sourcePeerID: String? + public var verificationStatus: String? + public var pinned: Bool? + public var syncedAt: String? + + public var id: String { artifactID ?? digest ?? "artifact" } + + enum CodingKeys: String, CodingKey { + case artifactID = "artifact_id" + case digest + case sourcePeerID = "source_peer_id" + case verificationStatus = "verification_status" + case pinned + case syncedAt = "synced_at" + } + } + + public struct ProtocolStatus: Decodable, Equatable, Sendable { + public var release: String? + public var version: String? + public var schemaVersion: String? + public var contractURL: String? + public var operatorSummary: String? + + enum CodingKeys: String, CodingKey { + case release + case version + case schemaVersion = "schema_version" + case contractURL = "contract_url" + case operatorSummary = "operator_summary" + } + } + + public struct LatestProof: Decodable, Equatable, Sendable { + public var status: String? + public var title: String? + public var summary: String? + public var missionID: String? + + enum CodingKeys: String, CodingKey { + case status + case title + case summary + case missionID = "mission_id" + } + } + + public struct Approvals: Decodable, Equatable, Sendable { + public var pendingCount: Int? + + enum CodingKeys: String, CodingKey { + case pendingCount = "pending_count" + } + } + + public var status: String? + public var node: Node? + public var meshQuality: MeshQuality? + public var setup: Setup? + public var routeHealth: RouteHealth? + public var executionReadiness: ExecutionReadiness? + public var artifactSync: ArtifactSync? + public var protocolStatus: ProtocolStatus? + public var latestProof: LatestProof? + public var approvals: Approvals? + public var nextActions: [String]? + public var generatedAt: String? + + enum CodingKeys: String, CodingKey { + case status + case node + case meshQuality = "mesh_quality" + case setup + case routeHealth = "route_health" + case executionReadiness = "execution_readiness" + case artifactSync = "artifact_sync" + case protocolStatus = "protocol" + case latestProof = "latest_proof" + case approvals + case nextActions = "next_actions" + case generatedAt = "generated_at" + } +} + +public struct AppStatusSample: Decodable, Equatable, Identifiable, Sendable { + public var id: String + public var sampledAt: String + public var nodeID: String + public var setupStatus: String + public var meshScore: Int + public var knownPeerCount: Int + public var routeCount: Int + public var healthyRouteCount: Int + public var latestProofStatus: String + public var executionReadyTargets: Int + public var localReadyWorkers: Int + public var artifactVerifiedCount: Int + public var pendingApprovals: Int + + enum CodingKeys: String, CodingKey { + case id + case sampledAt = "sampled_at" + case nodeID = "node_id" + case setupStatus = "setup_status" + case meshScore = "mesh_score" + case knownPeerCount = "known_peer_count" + case routeCount = "route_count" + case healthyRouteCount = "healthy_route_count" + case latestProofStatus = "latest_proof_status" + case executionReadyTargets = "execution_ready_targets" + case localReadyWorkers = "local_ready_workers" + case artifactVerifiedCount = "artifact_verified_count" + case pendingApprovals = "pending_approvals" + } +} + +public struct AppStatusHistory: Decodable, Equatable, Sendable { + public var status: String + public var count: Int + public var limit: Int? + public var samples: [AppStatusSample] + public var generatedAt: String? + + public static let empty = AppStatusHistory(status: "ok", count: 0, limit: 0, samples: [], generatedAt: nil) + + enum CodingKeys: String, CodingKey { + case status + case count + case limit + case samples + case generatedAt = "generated_at" + } +} + +public struct AppHistorySampleResponse: Decodable, Equatable, Sendable { + public var status: String + public var sample: AppStatusSample + public var retentionLimit: Int? + + enum CodingKeys: String, CodingKey { + case status + case sample + case retentionLimit = "retention_limit" + } +} + +public struct MissionControlChartPoint: Equatable, Identifiable, Sendable { + public var id: String + public var sampledAt: String + public var meshScore: Int + public var healthyRouteCount: Int + public var routeCount: Int + public var executionReadyTargets: Int + public var artifactVerifiedCount: Int + public var pendingApprovals: Int +} + +public enum MissionControlMetrics { + public static func chartPoints(from history: AppStatusHistory) -> [MissionControlChartPoint] { + history.samples.map { + MissionControlChartPoint( + id: $0.id, + sampledAt: $0.sampledAt, + meshScore: $0.meshScore, + healthyRouteCount: $0.healthyRouteCount, + routeCount: $0.routeCount, + executionReadyTargets: $0.executionReadyTargets, + artifactVerifiedCount: $0.artifactVerifiedCount, + pendingApprovals: $0.pendingApprovals + ) + } + } + + public static func meshScore(from snapshot: AppStatusSnapshot?) -> Int { + guard let snapshot else { return 0 } + let routeCount = snapshot.meshQuality?.routeCount ?? snapshot.setup?.routeCount ?? 0 + let healthyRoutes = snapshot.meshQuality?.healthyRoutes ?? snapshot.setup?.healthyRouteCount ?? 0 + let peerCount = snapshot.meshQuality?.peerCount ?? snapshot.setup?.knownPeerCount ?? 0 + let proofStatus = (snapshot.latestProof?.status ?? snapshot.setup?.latestProofStatus ?? "").lowercased() + let setupStatus = (snapshot.setup?.status ?? "").lowercased() + + var score = routeCount > 0 ? Int(round((Double(healthyRoutes) / Double(max(1, routeCount))) * 70)) : (peerCount > 0 ? 30 : 12) + if proofStatus == "completed" { + score += 20 + } else if ["planned", "queued", "running", "accepted"].contains(proofStatus) { + score += 8 + } else if ["failed", "needs_attention", "cancelled"].contains(proofStatus) { + score -= 12 + } + + if setupStatus == "strong" { + score += 10 + } else if setupStatus == "ready" { + score += 5 + } else if ["needs_attention", "local_only"].contains(setupStatus) { + score -= 5 + } + return min(100, max(0, score)) + } +} + +public enum LauncherCore { + public static func supportDirectory(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { + home + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("OCP", isDirectory: true) + } + + public static func paths(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> LaunchPaths { + let support = supportDirectory(home: home) + let state = support.appendingPathComponent("state", isDirectory: true) + return LaunchPaths( + supportDirectory: support, + configPath: support.appendingPathComponent("launcher.json"), + stateDirectory: state, + databasePath: state.appendingPathComponent("ocp.db"), + identityDirectory: state.appendingPathComponent("identity", isDirectory: true), + workspaceDirectory: state.appendingPathComponent("workspace", isDirectory: true) + ) + } + + public static func ensurePaths(_ paths: LaunchPaths) throws { + try FileManager.default.createDirectory(at: paths.supportDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: paths.stateDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: paths.databasePath.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: paths.identityDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: paths.workspaceDirectory, withIntermediateDirectories: true) + } + + public static func slugify(_ value: String) -> String { + var result = "" + var lastDash = false + for scalar in value.lowercased().unicodeScalars { + if CharacterSet.alphanumerics.contains(scalar) { + result.unicodeScalars.append(scalar) + lastDash = false + } else if !lastDash { + result.append("-") + lastDash = true + } + } + return result.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } + + public static func defaultNodeID(hostname: String = Host.current().localizedName ?? "ocp") -> String { + let token = slugify(hostname).isEmpty ? "ocp" : slugify(hostname) + return "\(token)-node" + } + + public static func defaultWorkerID(nodeID: String) -> String { + let token = slugify(nodeID).isEmpty ? "ocp" : slugify(nodeID) + return "\(token)-default-worker" + } + + public static func autoWorkerEnabled(deviceClass: String, formFactor: String) -> Bool { + let device = deviceClass.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let form = formFactor.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return device == "full" && !["phone", "watch", "tablet"].contains(form) + } + + public static func displayHostForBrowser(_ host: String) -> String { + ["", "0.0.0.0", "::", "[::]"].contains(host.trimmingCharacters(in: .whitespacesAndNewlines)) ? "127.0.0.1" : host + } + + public static func buildOpenURL(host: String, port: Int, path: String = "/") -> String { + let route = path.hasPrefix("/") ? path : "/\(path)" + return "http://\(displayHostForBrowser(host)):\(port)\(route.isEmpty ? "/" : route)" + } + + public static func operatorAppURL(baseURL: String, operatorToken: String, path: String = "/app") -> String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !trimmed.isEmpty else { return "" } + let route = path.hasPrefix("/") ? path : "/\(path)" + let url = trimmed.hasSuffix(route) ? trimmed : "\(trimmed)\(route)" + guard !operatorToken.isEmpty else { return url } + let encoded = operatorToken.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? operatorToken + return "\(url)#ocp_operator_token=\(encoded)" + } + + public static func localIPv4Addresses() -> [String] { + var addresses: Set = [] + var ifaddr: UnsafeMutablePointer? + guard getifaddrs(&ifaddr) == 0, let first = ifaddr else { return [] } + defer { freeifaddrs(ifaddr) } + + var cursor: UnsafeMutablePointer? = first + while let interface = cursor?.pointee { + defer { cursor = interface.ifa_next } + guard interface.ifa_addr.pointee.sa_family == UInt8(AF_INET) else { continue } + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + interface.ifa_addr, + socklen_t(interface.ifa_addr.pointee.sa_len), + &hostname, + socklen_t(hostname.count), + nil, + 0, + NI_NUMERICHOST + ) + guard result == 0 else { continue } + let address = String(cString: hostname) + if !address.hasPrefix("127.") && address != "0.0.0.0" { + addresses.insert(address) + } + } + return addresses.sorted() + } + + public static func shareURLs(host: String, port: Int, addresses: [String]? = nil) -> [String] { + let token = host.trimmingCharacters(in: .whitespacesAndNewlines) + if token == "0.0.0.0" || token.isEmpty { + return (addresses ?? localIPv4Addresses()).map { "http://\($0):\(port)/" } + } + if token == "localhost" || token.hasPrefix("127.") { + return [] + } + return ["http://\(token):\(port)/"] + } + + public static func loadConfig(from url: URL, hostname: String = Host.current().localizedName ?? "ocp") -> LauncherConfig { + let fallback = LauncherConfig().normalized(defaultNodeID: defaultNodeID(hostname: hostname)) + guard let data = try? Data(contentsOf: url) else { return fallback } + let decoded = (try? JSONDecoder().decode(LauncherConfig.self, from: data)) ?? fallback + return decoded.normalized(defaultNodeID: defaultNodeID(hostname: hostname)) + } + + public static func saveConfig(_ config: LauncherConfig, to url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONEncoder.pretty.encode(config) + try data.write(to: url, options: .atomic) + } + + public static func buildPlan( + mode: LaunchMode, + config rawConfig: LauncherConfig, + repoRoot: URL, + pythonExecutable: String = "/usr/bin/env", + home: URL = FileManager.default.homeDirectoryForCurrentUser, + addresses: [String]? = nil + ) -> LaunchPlan { + let launchPaths = paths(home: home) + let config = rawConfig.normalized(defaultNodeID: defaultNodeID()) + let host = mode.host + let command = [ + pythonExecutable, + "python3", + repoRoot.appendingPathComponent("server.py").path, + "--host", host, + "--port", String(config.port), + "--db-path", launchPaths.databasePath.path, + "--workspace-root", launchPaths.workspaceDirectory.path, + "--identity-dir", launchPaths.identityDirectory.path, + "--node-id", config.nodeID, + "--display-name", config.displayName, + "--device-class", config.deviceClass, + "--form-factor", config.formFactor + ] + let shares = shareURLs(host: host, port: config.port, addresses: addresses) + var environment: [String: String] = [:] + if mode == .mesh && !config.operatorToken.isEmpty { + environment["OCP_OPERATOR_TOKEN"] = config.operatorToken + } + if autoWorkerEnabled(deviceClass: config.deviceClass, formFactor: config.formFactor) { + environment["OCP_AUTO_REGISTER_WORKER"] = "1" + environment["OCP_AUTO_WORKER_ID"] = defaultWorkerID(nodeID: config.nodeID) + } + return LaunchPlan( + mode: mode, + host: host, + port: config.port, + command: command, + environment: environment, + appURL: buildOpenURL(host: host, port: config.port, path: "/"), + manifestURL: buildOpenURL(host: host, port: config.port, path: "/mesh/manifest"), + phoneURLs: shares.map { operatorAppURL(baseURL: $0, operatorToken: mode == .mesh ? config.operatorToken : "") }, + repoRoot: repoRoot, + paths: launchPaths + ) + } +} + +private extension JSONEncoder { + static var pretty: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } +} diff --git a/Sources/OCPDesktopCore/MissionControlDerivedModels.swift b/Sources/OCPDesktopCore/MissionControlDerivedModels.swift new file mode 100644 index 0000000..d3de4cd --- /dev/null +++ b/Sources/OCPDesktopCore/MissionControlDerivedModels.swift @@ -0,0 +1,387 @@ +import Foundation + +public struct TopologyNode: Equatable, Identifiable, Sendable { + public var id: String + public var label: String + public var role: String + public var status: String + public var subtitle: String + + public init(id: String, label: String, role: String, status: String, subtitle: String = "") { + self.id = id + self.label = label + self.role = role + self.status = status + self.subtitle = subtitle + } +} + +public struct TopologyEdge: Equatable, Identifiable, Sendable { + public var source: String + public var target: String + public var status: String + public var freshness: String + public var label: String + + public var id: String { "\(source)->\(target):\(label)" } + + public init(source: String, target: String, status: String, freshness: String = "", label: String = "") { + self.source = source + self.target = target + self.status = status + self.freshness = freshness + self.label = label + } +} + +public struct TopologyGraph: Equatable, Sendable { + public var nodes: [TopologyNode] + public var edges: [TopologyEdge] + + public static let empty = TopologyGraph(nodes: [], edges: []) +} + +public struct SetupGuideStep: Equatable, Identifiable, Sendable { + public var id: String + public var title: String + public var summary: String + public var status: String + public var action: String + + public init(id: String, title: String, summary: String, status: String, action: String) { + self.id = id + self.title = title + self.summary = summary + self.status = status + self.action = action + } +} + +public struct DeviceRoleSummary: Equatable, Identifiable, Sendable { + public var id: String + public var label: String + public var role: String + public var status: String + public var summary: String + + public init(id: String, label: String, role: String, status: String, summary: String) { + self.id = id + self.label = label + self.role = role + self.status = status + self.summary = summary + } +} + +public struct DemoStripState: Equatable, Sendable { + public var phoneLabel: String + public var phoneSummary: String + public var primaryPeerLabel: String + public var primaryPeerSummary: String + public var proofLabel: String + public var proofSummary: String + public var recoveryLabel: String + public var recoverySummary: String + public var story: [String] + + public init( + phoneLabel: String, + phoneSummary: String, + primaryPeerLabel: String, + primaryPeerSummary: String, + proofLabel: String, + proofSummary: String, + recoveryLabel: String, + recoverySummary: String, + story: [String] + ) { + self.phoneLabel = phoneLabel + self.phoneSummary = phoneSummary + self.primaryPeerLabel = primaryPeerLabel + self.primaryPeerSummary = primaryPeerSummary + self.proofLabel = proofLabel + self.proofSummary = proofSummary + self.recoveryLabel = recoveryLabel + self.recoverySummary = recoverySummary + self.story = story + } +} + +public enum MissionControlDeriver { + public static func topology(from snapshot: AppStatusSnapshot?) -> TopologyGraph { + guard let snapshot else { + return TopologyGraph( + nodes: [TopologyNode(id: "local", label: "This Mac", role: "local", status: "local_only", subtitle: "Start OCP to map the mesh.")], + edges: [] + ) + } + let localID = clean(snapshot.node?.nodeID) ?? "local" + let setupRoles: [String: AppStatusSnapshot.Setup.DeviceRole] = Dictionary( + uniqueKeysWithValues: (snapshot.setup?.deviceRoles ?? []).compactMap { role in + guard let peerID = clean(role.peerID ?? role.displayName) else { return nil } + return (peerID, role) + } + ) + var nodes: [String: TopologyNode] = [ + localID: TopologyNode( + id: localID, + label: clean(snapshot.node?.displayName) ?? clean(snapshot.node?.nodeID) ?? "This Mac", + role: topologyRole(from: setupRoles[localID]?.role, fallback: "local"), + status: snapshot.setup?.status ?? "ready", + subtitle: snapshot.node?.formFactor ?? "OCP node" + ) + ] + var edges: [TopologyEdge] = [] + + for route in snapshot.routeHealth?.routes ?? [] { + guard let peerID = clean(route.peerID ?? route.displayName) else { continue } + let setupRole = setupRoles[peerID] + nodes[peerID] = TopologyNode( + id: peerID, + label: clean(route.displayName) ?? peerID, + role: topologyRole(from: setupRole?.role, fallback: "peer"), + status: setupRole?.status ?? route.status ?? "unknown", + subtitle: clean(setupRole?.summary) ?? route.freshness ?? "route" + ) + edges.append( + TopologyEdge( + source: localID, + target: peerID, + status: route.status ?? "unknown", + freshness: route.freshness ?? "", + label: route.bestRoute ?? "route" + ) + ) + } + + for target in snapshot.executionReadiness?.targets ?? [] { + guard let peerID = clean(target.peerID ?? target.displayName) else { continue } + let existing = nodes[peerID] + let setupRole = setupRoles[peerID] + nodes[peerID] = TopologyNode( + id: peerID, + label: clean(target.displayName) ?? existing?.label ?? peerID, + role: topologyRole(from: setupRole?.role, fallback: target.role == "local" ? "local" : "worker"), + status: setupRole?.status ?? target.status ?? existing?.status ?? "unknown", + subtitle: clean(setupRole?.summary) ?? "\(target.workerCount ?? 0) worker(s)" + ) + if peerID != localID && !edges.contains(where: { $0.source == localID && $0.target == peerID }) { + edges.append(TopologyEdge(source: localID, target: peerID, status: target.status ?? "unknown", label: "execution")) + } + } + + for item in snapshot.artifactSync?.items ?? [] { + guard let sourcePeer = clean(item.sourcePeerID), sourcePeer != localID else { continue } + let existing = nodes[sourcePeer] + let setupRole = setupRoles[sourcePeer] + nodes[sourcePeer] = TopologyNode( + id: sourcePeer, + label: existing?.label ?? sourcePeer, + role: topologyRole(from: setupRole?.role, fallback: existing?.role == "worker" ? "worker" : "artifact"), + status: setupRole?.status ?? item.verificationStatus ?? existing?.status ?? "unknown", + subtitle: clean(setupRole?.summary) ?? "artifact source" + ) + edges.append( + TopologyEdge( + source: sourcePeer, + target: localID, + status: item.verificationStatus ?? "unknown", + label: "artifact" + ) + ) + } + + return TopologyGraph( + nodes: nodes.values.sorted { lhs, rhs in + if lhs.id == localID { return true } + if rhs.id == localID { return false } + return lhs.label.localizedCaseInsensitiveCompare(rhs.label) == .orderedAscending + }, + edges: edges + ) + } + + public static func deviceRoles(from snapshot: AppStatusSnapshot?) -> [DeviceRoleSummary] { + guard let snapshot else { + return [ + DeviceRoleSummary( + id: "local", + label: "This Mac", + role: "local_command", + status: "local_only", + summary: "Start OCP and switch to Mesh Mode to add trusted devices." + ) + ] + } + + let serverRoles = snapshot.setup?.deviceRoles ?? [] + if !serverRoles.isEmpty { + return serverRoles.map { + DeviceRoleSummary( + id: clean($0.peerID ?? $0.displayName) ?? UUID().uuidString, + label: clean($0.displayName) ?? clean($0.peerID) ?? "Peer", + role: clean($0.role) ?? "peer", + status: clean($0.status) ?? "unknown", + summary: clean($0.summary) ?? "Role available." + ) + } + } + + var roles: [DeviceRoleSummary] = [ + DeviceRoleSummary( + id: clean(snapshot.node?.nodeID) ?? "local", + label: clean(snapshot.node?.displayName) ?? "This Mac", + role: "local_command", + status: clean(snapshot.setup?.status) ?? "ready", + summary: "This Mac is the local command node." + ) + ] + + for target in snapshot.executionReadiness?.targets ?? [] { + guard let peerID = clean(target.peerID ?? target.displayName), + clean(target.role)?.lowercased() != "local", + clean(target.status)?.lowercased() == "ready" + else { continue } + roles.append( + DeviceRoleSummary( + id: peerID, + label: clean(target.displayName) ?? peerID, + role: "compute", + status: clean(target.status) ?? "ready", + summary: "\(clean(target.displayName) ?? peerID) is ready for compute work." + ) + ) + } + + return roles + } + + public static func demoState(snapshot: AppStatusSnapshot?, mode: LaunchMode, phoneURL: String) -> DemoStripState { + let setup = snapshot?.setup + let latestProof = snapshot?.latestProof + let phoneReady = mode == .mesh && phoneURL.hasPrefix("http") + let primaryPeer = setup?.primaryPeer + let story = (setup?.story ?? []).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + let recoveryState = clean(setup?.recoveryState) ?? clean(setup?.status) ?? "ready" + let blockerCode = clean(setup?.blockerCode) ?? "" + let proofStatus = clean(latestProof?.status ?? setup?.latestProofStatus) ?? "none" + + let phoneLabel: String + let phoneSummary: String + if phoneReady { + phoneLabel = "Phone link ready" + phoneSummary = "Your same-Wi-Fi phone link is live." + } else if mode == .mesh { + phoneLabel = "Mesh mode" + phoneSummary = "LAN mode is on, but OCP still needs a phone link." + } else { + phoneLabel = "Local only" + phoneSummary = "Start Mesh Mode to make a phone link." + } + + let primaryPeerLabel = clean(primaryPeer?.displayName) ?? "No remote peer yet" + let primaryPeerSummary = clean(primaryPeer?.summary) ?? "Connect another trusted device to build the mesh." + + let proofLabel = humanize(proofStatus) + let proofSummary = clean(latestProof?.summary) + ?? (proofStatus == "none" ? "No whole-mesh proof has run yet." : "OCP is tracking the latest whole-mesh proof.") + + let recoveryLabel = humanize(recoveryState) + let recoverySummary: String + switch recoveryState { + case "repairing": + recoverySummary = "OCP is repairing routes and retrying proof work." + case "repaired": + recoverySummary = "A route repair succeeded and the mesh recovered." + case "needs_attention": + recoverySummary = blockerCode.isEmpty ? (clean(setup?.nextFix) ?? "One concrete fix is waiting.") : "\(humanize(blockerCode)) detected." + default: + recoverySummary = clean(setup?.nextFix) ?? "The current mesh path is healthy." + } + + return DemoStripState( + phoneLabel: phoneLabel, + phoneSummary: phoneSummary, + primaryPeerLabel: primaryPeerLabel, + primaryPeerSummary: primaryPeerSummary, + proofLabel: proofLabel, + proofSummary: proofSummary, + recoveryLabel: recoveryLabel, + recoverySummary: recoverySummary, + story: story.isEmpty ? [clean(setup?.operatorSummary) ?? "Activate Mesh to start the demo."] : story + ) + } + + public static func setupGuideSteps(snapshot: AppStatusSnapshot?, mode: LaunchMode, phoneURL: String) -> [SetupGuideStep] { + let setupStatus = (snapshot?.setup?.status ?? "").lowercased() + let recoveryState = (snapshot?.setup?.recoveryState ?? "").lowercased() + let routeCount = snapshot?.setup?.routeCount ?? snapshot?.meshQuality?.routeCount ?? 0 + let healthyRoutes = snapshot?.setup?.healthyRouteCount ?? snapshot?.meshQuality?.healthyRoutes ?? 0 + let proofStatus = (snapshot?.setup?.latestProofStatus ?? snapshot?.latestProof?.status ?? "").lowercased() + let phoneReady = mode == .mesh && phoneURL.hasPrefix("http") + let strong = setupStatus == "strong" + + return [ + SetupGuideStep( + id: "start_mesh", + title: "Start Mesh Mode", + summary: mode == .mesh ? "This Mac is listening for trusted devices on your LAN." : "Bind OCP to the LAN so your phone and spare laptop can reach it.", + status: mode == .mesh ? "complete" : "active", + action: "Start Mesh Mode" + ), + SetupGuideStep( + id: "copy_phone_link", + title: "Copy Phone Link", + summary: phoneReady ? "A tokened phone link is ready for the same Wi-Fi." : "Mesh Mode creates the LAN phone link.", + status: phoneReady ? "complete" : (mode == .mesh ? "active" : "blocked"), + action: "Copy Phone Link" + ), + SetupGuideStep( + id: "connect_device", + title: "Connect Device", + summary: routeCount > 0 ? "\(healthyRoutes)/\(routeCount) peer route(s) are fresh." : "Open the phone or laptop link and connect a nearby OCP node.", + status: routeCount > 0 ? (healthyRoutes == routeCount ? "complete" : "attention") : (phoneReady ? "active" : "blocked"), + action: "Open Setup Doctor" + ), + SetupGuideStep( + id: "activate_mesh", + title: "Activate Mesh", + summary: proofStatus.isEmpty || proofStatus == "none" ? "Run discovery, repair, helper planning, and proof." : "Latest proof: \(proofStatus.replacingOccurrences(of: "_", with: " ")).", + status: ["completed"].contains(proofStatus) ? "complete" : ((["planned", "queued", "running", "accepted"].contains(proofStatus) || recoveryState == "repairing") ? "active" : (routeCount > 0 ? "active" : "blocked")), + action: "Activate Mesh" + ), + SetupGuideStep( + id: "verify_strong", + title: "Verify Strong", + summary: strong ? "The mesh has proven routes and a completed proof." : "OCP will mark the mesh strong after route proof and whole-mesh proof complete.", + status: strong ? "complete" : ((["failed", "needs_attention", "cancelled"].contains(proofStatus) || setupStatus == "needs_attention" || recoveryState == "needs_attention") ? "attention" : "blocked"), + action: "Review Next Fix" + ), + ] + } + + private static func topologyRole(from setupRole: String?, fallback: String) -> String { + switch clean(setupRole)?.lowercased() { + case "local_command": + return "local" + case "approval_only": + return "approval" + case "artifact_source": + return "artifact" + case let role?: + return role + default: + return fallback + } + } + + private static func humanize(_ value: String) -> String { + let token = clean(value) ?? "unknown" + return token.replacingOccurrences(of: "_", with: " ").capitalized + } + + private static func clean(_ value: String?) -> String? { + let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/docs/OCP_STATUS.md b/docs/OCP_STATUS.md index fa103a3..6eb1bbf 100644 --- a/docs/OCP_STATUS.md +++ b/docs/OCP_STATUS.md @@ -12,7 +12,7 @@ Related planning docs: ## Current framing - `OCP v0.1` = protocol/spec draft -- `v0.1.4` = current Desktop Alpha RC implementation release +- `v0.1.6` = current protocol-first Desktop Alpha implementation release - `Sovereign Mesh` = current Python-first reference implementation - `sovereign-mesh/v1` = current wire version @@ -58,6 +58,8 @@ Related planning docs: - Pinning-aware artifact policy so replicated artifacts can be held durably across purge windows - Graph-aware replication for OCI result bundles and checkpoint-linked attempt artifact sets - Pull-through sync of linked subject, config, attestation, log, and checkpoint artifacts from bundle roots +- Operator-mediated private artifact replication through explicit redacted `remote_auth` so proof artifacts can move without storing remote operator tokens +- Fresh route proof is required before remote artifact replication, with explicit base URLs probed before use - First-class device profiles for `full`, `light`, `micro`, and `relay` nodes with durable local profile state - Device-profile-aware peer manifests, stream snapshots, and peer registry surfaces for phones, watches, relays, and heavier compute nodes - Device-aware scheduler inputs for preferred and required device classes, stable-network requirements, battery avoidance, and artifact-mirror-capable placement @@ -73,8 +75,15 @@ Related planning docs: - First operator-grade `Connect Devices` flow in the control deck with nearby scan, one-click connect, built-in reachability diagnostics, and one-click test missions - New unified OCP app shell at `GET /` and `GET /app` so phone and desktop operators get setup, control, and protocol inspection in one surface - App home now includes a `Today` panel with mesh strength, Autonomic Mesh activation, latest proof state, next actions, phone link/QR, and route-health summaries +- App home now includes a Proof Timeline plus “Run on Best Device” and “Replicate Proof Artifact” actions backed by scheduler and artifact services - Compact app status API at `GET /mesh/app/status` so product surfaces can render operator state without scraping the advanced cockpit +- App status now exposes protocol schema status, execution readiness, artifact sync, and setup timeline projections for launchers and phone UI +- App history now persists normalized app-status samples for native Mission Control charts at `GET /mesh/app/history` and `POST /mesh/app/history/sample` - Mac-first beta desktop launcher with Local Only and Mesh Mode starts, Application Support state defaults, live status, app opening, and phone/LAN link copy +- Native SwiftPM Mac app target `OCPDesktop` with a SwiftUI Mission Control shell around the existing OCP server +- Native Mission Control pages cover overview charts, guided setup, client-derived route topology, route health, execution readiness, artifact sync, protocol links, and settings +- Unsigned native SwiftPM bundle builder at `python3 scripts/build_swift_macos_app.py` +- Easy startup and the Mac launcher can auto-advertise a default full-node worker for laptop/workstation demos - Unsigned macOS beta bundle builder at `python3 scripts/build_macos_app.py` that excludes local state, identities, databases, git metadata, caches, and test artifacts - Plain-language easy setup remains available at `GET /easy` so first-run pairing can stay friendly while `/control` remains the advanced cockpit module - Easy setup share-link copy and plain troubleshooting guidance so nearby pairing can fall back to “copy this link to the other computer” instead of terminal instructions @@ -177,6 +186,8 @@ Related planning docs: - Unified OCP app: `GET /` - Installable app shell: `GET /app` - App status: `GET /mesh/app/status` +- App history: `GET /mesh/app/history` +- App history sample: `POST /mesh/app/history/sample` - App manifest: `GET /app.webmanifest` - Autonomic status: `GET /mesh/autonomy/status` - Autonomic activation: `POST /mesh/autonomy/activate` @@ -244,6 +255,7 @@ Related planning docs: - Helper autonomy run: `POST /mesh/helpers/autonomy/run` `/mesh/contract` now exposes the grouped route contract, reusable protocol schema registry, and schema refs used by the first lightweight ingress validation path. +The contract includes additive schemas for operator-mediated artifact replication, route proof freshness, execution readiness, worker capacity, and setup timeline events. - Secret list: `GET /mesh/secrets` - Secret put: `POST /mesh/secrets/put` - Worker heartbeat: `POST /mesh/workers/{worker_id}/heartbeat` @@ -255,8 +267,8 @@ Related planning docs: - Artifact list: `GET /mesh/artifacts` - Artifact fetch: `GET /mesh/artifacts/{artifact_id}` - Artifact publish: `POST /mesh/artifacts/publish` -- Artifact replicate: `POST /mesh/artifacts/replicate` -- Artifact graph replicate: `POST /mesh/artifacts/replicate-graph` +- Artifact replicate: `POST /mesh/artifacts/replicate` with optional `remote_auth: {type: "operator_token", token: "..."}` for explicit private remote pulls +- Artifact graph replicate: `POST /mesh/artifacts/replicate-graph` with the same redacted operator-mediated auth shape - Artifact pin: `POST /mesh/artifacts/pin` - Artifact mirror verify: `POST /mesh/artifacts/verify-mirror` - Artifact purge: `POST /mesh/artifacts/purge` @@ -265,7 +277,7 @@ Related planning docs: ## Recommended next OCP builds 1. Add signed/notarized packaging, tray presence, startup defaults, and deeper firewall prompts after the unsigned Mac beta launcher proves the flow. -2. Add a mission launch helper in the control surface so operators can create single-job or cooperative missions without dropping to raw JSON. +2. Replace operator-mediated artifact pulls with signed scoped delegation grants once the capability-law layer is ready. ## Broader roadmap @@ -280,4 +292,4 @@ python3 -m unittest tests.test_sovereign_mesh ``` Current standalone baseline: -- `tests.test_sovereign_mesh`: 187 tests passing +- `tests.test_sovereign_mesh`: 189 tests passing diff --git a/docs/OPEN_COMPUTE_PROTOCOL_v0.1.md b/docs/OPEN_COMPUTE_PROTOCOL_v0.1.md index 3d6e275..effd30d 100644 --- a/docs/OPEN_COMPUTE_PROTOCOL_v0.1.md +++ b/docs/OPEN_COMPUTE_PROTOCOL_v0.1.md @@ -149,8 +149,12 @@ The current reference implementation exposes the protocol under `/mesh/*`. | `/mesh/jobs/{job_id}/cancel` | POST | Cancel job | | `/mesh/artifacts/publish` | POST | Publish artifact | | `/mesh/artifacts/{artifact_id}` | GET | Fetch artifact subject to policy | +| `/mesh/artifacts/replicate` | POST | Pull and verify an artifact from a trusted peer | +| `/mesh/artifacts/replicate-graph` | POST | Pull a bundle/checkpoint graph from a trusted peer | | `/mesh/agents/handoff` | POST | Send explicit delegation packet | | `/mesh/app/status` | GET | Operator/app-facing status projection | +| `/mesh/app/history` | GET | Operator/app-facing persisted status history for local charts | +| `/mesh/app/history/sample` | POST | Record one redacted app-status sample for chart history | | `/mesh/autonomy/status` | GET | Current Autonomic Mesh posture | | `/mesh/autonomy/activate` | POST | Assisted discovery, route probing, helper planning, and proof activation | | `/mesh/routes/health` | GET | Known route-candidate health projection | @@ -181,6 +185,16 @@ Artifacts are immutable references to payloads or outputs with: The v0.1 reference implementation verifies digests and refuses downloads that violate the artifact policy. +Private remote artifact pulls are operator-mediated in v0.1.6. A caller may include: + +```json +{ + "remote_auth": {"type": "operator_token", "token": "remote-node-token"} +} +``` + +The token is used only for the outbound content fetch, is redacted from responses and events, and is not stored in artifact metadata. Signed scoped delegation grants are reserved for a future capability-law layer. + ## Agent Federation Agent federation is the first proof target of OCP v0.1. @@ -207,6 +221,8 @@ The working implementation in this repo intentionally stays pragmatic: - Golem is treated as a provider lane, not a trust authority - the standalone OCP store remains the local source of truth for mesh runtime state - app/autonomy routes are operator-facing control surfaces, not consensus or settlement surfaces +- `/mesh/app/status` is an operator/app projection that includes setup timeline, execution readiness, artifact sync, route health, and protocol status +- `/mesh/app/history` and `/mesh/app/history/sample` are local operator/app chart surfaces, not consensus-critical protocol messages ## Planned OCP v0.2 Themes diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 71986fd..42067c0 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -31,8 +31,14 @@ Mac beta app launcher: python3 -m ocp_desktop.launcher ``` +Native SwiftPM Mac Mission Control app: + +```bash +swift run OCPDesktop +``` + Use `Start Local Only` for a private node or `Start Mesh Mode` when you want your phone or spare laptop on the same Wi-Fi to connect. The launcher stores its beta app state under `~/Library/Application Support/OCP/`. -In Mesh Mode, `Copy Phone Link` includes a private operator token in the URL fragment so the phone app can safely run OCP actions such as `Activate Autonomic Mesh`. +In Mesh Mode, `Copy Phone Link` includes a private operator token in the URL fragment so the phone app can safely run OCP actions such as `Activate Mesh`. Build the unsigned Mac beta bundle: @@ -41,6 +47,13 @@ python3 scripts/build_macos_app.py open dist/OCP.app ``` +Build the unsigned native SwiftPM beta bundle: + +```bash +python3 scripts/build_swift_macos_app.py +open "dist/OCP Desktop.app" +``` + If you prefer the shell starter instead: ```bash @@ -120,6 +133,8 @@ On Windows PowerShell, set environment variables before starting Python: $env:OCP_HOST="0.0.0.0"; $env:OCP_NODE_ID="beta-node"; $env:OCP_DISPLAY_NAME="Beta"; python scripts/start_ocp_easy.py ``` +Do not add a trailing `#` after flags in PowerShell. For example, use `--no-open-browser`, not `--no-open-browser#`. + If you are testing the UI from another machine and the deck is empty, seed activity against the LAN URL: ```bash @@ -146,8 +161,22 @@ On machine two: OCP_HOST=0.0.0.0 OCP_PORT=8422 OCP_NODE_ID=beta-node OCP_DISPLAY_NAME=Beta python3 scripts/start_ocp_easy.py ``` -Then open `http://HOST_IP:8421/` on each machine, choose the `Setup` tab, use `Connect Everything`, then `Test Whole Mesh`. -For the polished flow, open `/app` from the phone and press `Activate Autonomic Mesh`; it will scan, probe routes, plan safe helpers, run a whole-mesh proof, and explain what happened. +Then open `http://HOST_IP:8421/` on each machine, choose the `Setup Details` tab, use `Connect Everything`, then `Test Whole Mesh`. +For the polished flow, open `/app` from the phone and press `Activate Mesh`; it will scan, probe routes, plan safe helpers, run a whole-mesh proof, and explain what happened. +Full laptop/workstation nodes started with `scripts/start_ocp_easy.py` or the Mac launcher advertise a default worker automatically, so the app can also show execution readiness and run the scheduler-backed `Run on Best Device` demo. The native Mac app records local app-status samples through `/mesh/app/history/sample` and renders charts from `/mesh/app/history`. + +Private proof artifacts stay protected. To replicate a private artifact from another node, use the app’s `Replicate Proof Artifact` action or send `remote_auth` explicitly: + +```json +{ + "peer_id": "beta-node", + "artifact_id": "REMOTE_ARTIFACT_ID", + "pin": true, + "remote_auth": {"type": "operator_token", "token": "BETA_OPERATOR_TOKEN"} +} +``` + +OCP uses that remote token only for the outbound fetch, records redacted audit metadata, verifies the digest, and does not store or echo the token. If scan does not immediately find the other machine, use `Copy My Easy Link` on one computer and paste that address into the manual connect box on the other one. You can also scan the QR code from the easy page on the other device and open the pairing link that way. @@ -158,6 +187,9 @@ You can also scan the QR code from the easy page on the other device and open th - Personal Mirror can integrate with it, but is not required to run it. - The main operator app is `/`. - The app status API is `/mesh/app/status`. +- The app status API includes setup timeline, execution readiness, artifact sync, and protocol status. +- The app history APIs are `GET /mesh/app/history` and `POST /mesh/app/history/sample`; they are local operator/app-facing chart surfaces. - Autonomic Mesh APIs are `/mesh/autonomy/status`, `/mesh/autonomy/activate`, `/mesh/routes/health`, and `/mesh/routes/probe`. +- Artifact replication APIs are `/mesh/artifacts/replicate` and `/mesh/artifacts/replicate-graph`; private remote pulls require explicit operator-mediated auth for now. - The easy setup module remains at `/easy`. - The advanced deck module remains at `/control`. diff --git a/docs/RELEASE_v0.1.6.md b/docs/RELEASE_v0.1.6.md new file mode 100644 index 0000000..d05c9ef --- /dev/null +++ b/docs/RELEASE_v0.1.6.md @@ -0,0 +1,42 @@ +# OCP v0.1.6 Release Notes + +`v0.1.6` is the protocol-first Desktop Alpha tranche. It keeps the local-first HTTP/PWA architecture and SQLite substrate intact while making artifact mobility, execution readiness, and setup explanations more explicit. + +## Highlights + +- Protocol contract coverage now includes artifact replication auth, route proof freshness, execution readiness, worker capacity, and setup timeline events. +- Private artifact replication supports explicit `remote_auth: {type: "operator_token", token: "..."}` for operator-mediated pulls from trusted peers. +- Remote operator tokens are used only in memory for the outbound content fetch and are redacted from responses, events, and artifact sync metadata. +- `/mesh/app/status` now exposes `protocol`, `execution_readiness`, `artifact_sync`, and `setup.timeline`. +- `/mesh/app/history` and `/mesh/app/history/sample` persist redacted app-status samples for native Mission Control charts. +- `/app` adds a Proof Timeline plus `Run on Best Device` and `Replicate Proof Artifact` actions. +- The easy launcher and Mac launcher can auto-advertise a default worker for full laptop/workstation nodes. +- A native SwiftPM Mac app target, `OCPDesktop`, now wraps the existing OCP server with a SwiftUI Mission Control shell, sidebar pages, charts, guided setup, route topology, and setup/protocol detail views. +- `python3 scripts/build_swift_macos_app.py` builds an unsigned native SwiftPM `.app` beta bundle. + +## Compatibility Notes + +- Existing `/mesh/*`, `/app`, `/easy`, and `/control` routes remain compatible. +- Artifact replication without `remote_auth` still works for public content and already-local CAS hits. +- Private remote content requires either public artifact policy or explicit operator-mediated remote auth. +- Signed scoped capability grants are not implemented yet; they are the intended future replacement for operator-token-mediated private pulls. + +## Verification + +Expected release gates: + +```bash +git diff --check +python3 scripts/check_protocol_conformance.py +python3 -m unittest tests.test_sovereign_mesh -q +python3 server.py --help +./scripts/start_ocp.sh --help +python3 scripts/start_ocp_easy.py --help +python3 scripts/build_macos_app.py --help +python3 scripts/build_swift_macos_app.py --help +swift build +swift test +python3 -m ocp_desktop.launcher --plan local +``` + +Current standalone baseline: `tests.test_sovereign_mesh` has 189 tests. diff --git a/mesh/sovereign.py b/mesh/sovereign.py index cf14c1b..56ae73b 100644 --- a/mesh/sovereign.py +++ b/mesh/sovereign.py @@ -1049,17 +1049,23 @@ def execute_job(self, job_kind: str, payload: dict, policy: dict) -> dict: class MeshPeerClient: - def __init__(self, base_url: str, *, timeout: float = 8.0): + def __init__(self, base_url: str, *, timeout: float = 8.0, operator_token: Optional[str] = None): self.base_url = (base_url or "").rstrip("/") self.timeout = float(timeout) + self.operator_token = operator_token def _operator_token(self) -> str: + if self.operator_token is not None: + return str(self.operator_token or "").strip() return ( os.environ.get("OCP_OPERATOR_TOKEN") or os.environ.get("OCP_CONTROL_TOKEN") or "" ).strip() + def with_operator_token(self, operator_token: str) -> "MeshPeerClient": + return MeshPeerClient(self.base_url, timeout=self.timeout, operator_token=operator_token) + def _request_json(self, method: str, path: str, payload: Optional[dict] = None, params: Optional[dict] = None) -> dict: url = self.base_url + path if params: @@ -1340,12 +1346,18 @@ def replicate_artifact( artifact_id: str = "", digest: str = "", pin: bool = False, + base_url: str = "", + remote_auth: Optional[dict] = None, ) -> dict: payload = {"peer_id": peer_id, "pin": bool(pin)} if artifact_id: payload["artifact_id"] = artifact_id if digest: payload["digest"] = digest + if base_url: + payload["base_url"] = base_url + if remote_auth: + payload["remote_auth"] = dict(remote_auth) return self._request_json("POST", "/mesh/artifacts/replicate", payload=payload) def replicate_artifact_graph( @@ -1355,12 +1367,18 @@ def replicate_artifact_graph( artifact_id: str = "", digest: str = "", pin: bool = False, + base_url: str = "", + remote_auth: Optional[dict] = None, ) -> dict: payload = {"peer_id": peer_id, "pin": bool(pin)} if artifact_id: payload["artifact_id"] = artifact_id if digest: payload["digest"] = digest + if base_url: + payload["base_url"] = base_url + if remote_auth: + payload["remote_auth"] = dict(remote_auth) return self._request_json("POST", "/mesh/artifacts/replicate-graph", payload=payload) def set_artifact_pin(self, artifact_id: str, *, pinned: bool = True, reason: str = "operator_pin") -> dict: @@ -6579,6 +6597,7 @@ def replicate_artifact_from_peer( base_url: Optional[str] = None, request_id: Optional[str] = None, pin: bool = False, + remote_auth: Optional[dict] = None, ) -> dict: return self.artifacts.replicate_artifact_from_peer( peer_id, @@ -6588,6 +6607,7 @@ def replicate_artifact_from_peer( base_url=base_url, request_id=request_id, pin=pin, + remote_auth=remote_auth, ) def replicate_artifact_graph_from_peer( @@ -6600,6 +6620,7 @@ def replicate_artifact_graph_from_peer( base_url: Optional[str] = None, request_id: Optional[str] = None, pin: bool = False, + remote_auth: Optional[dict] = None, ) -> dict: return self.artifacts.replicate_artifact_graph_from_peer( peer_id, @@ -6609,6 +6630,7 @@ def replicate_artifact_graph_from_peer( base_url=base_url, request_id=request_id, pin=pin, + remote_auth=remote_auth, ) def set_artifact_pin(self, artifact_id: str, *, pinned: bool = True, reason: str = "operator_pin") -> dict: diff --git a/mesh_artifacts/service.py b/mesh_artifacts/service.py index fa1e83b..a25bcc0 100644 --- a/mesh_artifacts/service.py +++ b/mesh_artifacts/service.py @@ -459,6 +459,79 @@ def resolve_remote_artifact( remote_artifact = remote_client.get_artifact(artifact_token, peer_id=self.mesh.node_id, include_content=include_content) return remote_client, remote_artifact, artifact_token + def _remote_auth_summary(self, remote_auth: Optional[dict]) -> dict: + auth = dict(remote_auth or {}) + auth_type = str(auth.get("type") or "").strip().lower() + if not auth_type: + return {"type": "none", "status": "not_used"} + if auth_type == "operator_token": + if not str(auth.get("token") or "").strip(): + raise self.mesh.MeshPolicyError("remote_auth.operator_token requires a token") + return {"type": "operator_token", "status": "used", "redacted": True} + if auth_type in {"signed_delegation", "capability_grant"}: + raise self.mesh.MeshPolicyError("signed artifact delegation is reserved for a future protocol layer") + raise self.mesh.MeshPolicyError(f"unsupported remote_auth type: {auth_type}") + + def _client_with_remote_auth(self, client, remote_auth: Optional[dict]): + auth = dict(remote_auth or {}) + auth_type = str(auth.get("type") or "").strip().lower() + if not auth_type: + if hasattr(client, "with_operator_token"): + return client.with_operator_token("") + return client + if auth_type == "operator_token": + token = str(auth.get("token") or "").strip() + if hasattr(client, "with_operator_token"): + return client.with_operator_token(token) + raise self.mesh.MeshPolicyError("remote operator token auth requires an HTTP peer client") + self._remote_auth_summary(auth) + return client + + def _fresh_route_for_replication(self, peer_id: str, *, base_url: Optional[str] = None, client=None) -> dict: + if client is not None: + return {"status": "skipped", "reason": "in_process_client"} + peer_token = str(peer_id or "").strip() + explicit_base_url = str(base_url or "").strip() + if explicit_base_url: + probe = self.mesh.probe_routes(peer_id=peer_token, base_url=explicit_base_url, timeout=3.0, limit=1) + if int(probe.get("reachable") or 0) <= 0: + raise self.mesh.MeshPolicyError("fresh reachable route required before artifact replication") + return { + "status": "fresh", + "best_route": probe.get("best_route") or explicit_base_url, + "source": "explicit_probe", + "checked_at": probe.get("generated_at") or "", + } + + routes = self.mesh.routes_health(limit=50) + for route in list(routes.get("routes") or []): + route = dict(route or {}) + if str(route.get("peer_id") or "").strip() != peer_token: + continue + if ( + str(route.get("status") or "").strip().lower() == "reachable" + and str(route.get("freshness") or "").strip().lower() == "fresh" + and str(route.get("best_route") or route.get("last_reachable_base_url") or "").strip() + ): + return { + "status": "fresh", + "best_route": str(route.get("best_route") or route.get("last_reachable_base_url") or "").strip(), + "source": "route_health", + "checked_at": route.get("checked_at") or "", + "last_success_at": route.get("last_success_at") or "", + } + break + + probe = self.mesh.probe_routes(peer_id=peer_token, timeout=3.0, limit=4) + if int(probe.get("reachable") or 0) <= 0: + raise self.mesh.MeshPolicyError("fresh reachable route required before artifact replication") + return { + "status": "fresh", + "best_route": probe.get("best_route") or "", + "source": "probe", + "checked_at": probe.get("generated_at") or "", + } + def artifact_json_payload(self, artifact: dict) -> dict: payload_bytes = b"" if str(artifact.get("content_base64") or "").strip(): @@ -646,6 +719,7 @@ def replicate_artifact_from_peer( base_url: Optional[str] = None, request_id: Optional[str] = None, pin: bool = False, + remote_auth: Optional[dict] = None, ) -> dict: peer_token = (peer_id or "").strip() if not peer_token: @@ -671,11 +745,19 @@ def replicate_artifact_from_peer( "source": {"peer_id": peer_token, "artifact_id": artifact_token, "digest": digest_token}, } + route_proof = self._fresh_route_for_replication(peer_token, base_url=base_url, client=client) + if not base_url and route_proof.get("best_route"): + base_url = str(route_proof.get("best_route") or "").strip() + auth_summary = self._remote_auth_summary(remote_auth) + remote_client_override = client + if remote_client_override is None and base_url: + remote_client_override, _ = self.mesh._resolve_peer_client(peer_token, base_url=base_url) + remote_client_override = self._client_with_remote_auth(remote_client_override, remote_auth) remote_client, remote_artifact, artifact_token = self.resolve_remote_artifact( peer_token, artifact_id=artifact_token, digest=digest_token, - client=client, + client=remote_client_override, base_url=base_url, include_content=False, ) @@ -698,9 +780,11 @@ def replicate_artifact_from_peer( "status": "already_present", "artifact": local_hit, "source": {"peer_id": peer_token, "artifact_id": artifact_token, "digest": remote_digest}, + "route_proof": route_proof, } - remote_artifact = remote_client.get_artifact(artifact_token, peer_id=self.mesh.node_id, include_content=True) + content_client = self._client_with_remote_auth(remote_client, remote_auth) + remote_artifact = content_client.get_artifact(artifact_token, peer_id=self.mesh.node_id, include_content=True) content_base64 = str(remote_artifact.get("content_base64") or "").strip() if not content_base64: raise self.mesh.MeshPolicyError("remote artifact content missing") @@ -736,6 +820,8 @@ def replicate_artifact_from_peer( "source_peer_id": peer_token, "source_artifact_id": source_artifact_id, "source_digest": remote_digest, + "route_proof": route_proof, + "remote_auth": auth_summary, "synced_at": self._utcnow(), "verified_at": verification["checked_at"], "verification_status": verification["status"], @@ -754,6 +840,8 @@ def replicate_artifact_from_peer( "digest": replicated["digest"], "source_artifact_id": source_artifact_id, "pinned": bool(pin), + "route_proof": route_proof, + "remote_auth": auth_summary, "treaty_requirements": list(governance.get("treaty_requirements") or []), }, ) @@ -762,6 +850,8 @@ def replicate_artifact_from_peer( "artifact": replicated, "source": {"peer_id": peer_token, "artifact_id": source_artifact_id, "digest": remote_digest}, "verification": verification, + "route_proof": route_proof, + "remote_auth": auth_summary, } if governance.get("treaty_requirements"): response["governance"] = governance @@ -777,20 +867,30 @@ def replicate_artifact_graph_from_peer( base_url: Optional[str] = None, request_id: Optional[str] = None, pin: bool = False, + remote_auth: Optional[dict] = None, ) -> dict: peer_token = (peer_id or "").strip() if not peer_token: raise self.mesh.MeshPolicyError("peer_id is required") + route_proof = self._fresh_route_for_replication(peer_token, base_url=base_url, client=client) + if not base_url and route_proof.get("best_route"): + base_url = str(route_proof.get("best_route") or "").strip() + auth_summary = self._remote_auth_summary(remote_auth) + remote_client_override = client + if remote_client_override is None and base_url: + remote_client_override, _ = self.mesh._resolve_peer_client(peer_token, base_url=base_url) + remote_client_override = self._client_with_remote_auth(remote_client_override, remote_auth) remote_client, remote_root, resolved_artifact_id = self.resolve_remote_artifact( peer_token, artifact_id=artifact_id, digest=digest, - client=client, + client=remote_client_override, base_url=base_url, include_content=False, ) root_governance = self.ensure_artifact_replication_allowed(peer_token, remote_root) - remote_root = remote_client.get_artifact(resolved_artifact_id, peer_id=self.mesh.node_id, include_content=True) + content_client = self._client_with_remote_auth(remote_client, remote_auth) + remote_root = content_client.get_artifact(resolved_artifact_id, peer_id=self.mesh.node_id, include_content=True) root = self.replicate_artifact_from_peer( peer_token, artifact_id=resolved_artifact_id, @@ -798,9 +898,10 @@ def replicate_artifact_graph_from_peer( client=remote_client, request_id=request_id, pin=pin, + remote_auth=remote_auth, ) pending = self.artifact_graph_targets(remote_root) - pending.extend(self.artifact_attempt_graph_targets(peer_token, remote_client=remote_client, artifact=remote_root)) + pending.extend(self.artifact_attempt_graph_targets(peer_token, remote_client=content_client, artifact=remote_root)) seen: set[tuple[str, str]] = { (str(root["artifact"].get("id") or "").strip(), str(root["artifact"].get("digest") or "").strip().lower()) } @@ -821,6 +922,7 @@ def replicate_artifact_graph_from_peer( client=remote_client, request_id=request_id, pin=pin, + remote_auth=remote_auth, ) replicated_children.append( { @@ -840,6 +942,8 @@ def replicate_artifact_graph_from_peer( "root_digest": (root.get("artifact") or {}).get("digest", ""), "replicated_count": len(replicated_children) + 1, "pinned": bool(pin), + "route_proof": route_proof, + "remote_auth": auth_summary, }, ) response = { @@ -852,6 +956,8 @@ def replicate_artifact_graph_from_peer( "count": len(replicated_children) + 1, "linked": replicated_children, }, + "route_proof": route_proof, + "remote_auth": auth_summary, } if root_governance.get("treaty_requirements"): response["governance"] = root_governance diff --git a/mesh_autonomy/service.py b/mesh_autonomy/service.py index 5aa8379..e117afa 100644 --- a/mesh_autonomy/service.py +++ b/mesh_autonomy/service.py @@ -654,11 +654,20 @@ def _repair_routes(self, peer_ids: list[str], *, timeout: float, request_id: str repairs.append(probe) best_route = str(probe.get("best_route") or "").strip() if best_route: + self._action( + actions, + "route_repaired", + "ok", + f"Repaired route for {peer_id} at {best_route}.", + peer_id=peer_id, + details={"base_url": best_route, "probe": probe}, + request_id=request_id, + ) try: sync = self.mesh.sync_peer(peer_id, base_url=best_route, limit=20, refresh_manifest=True) self._action( actions, - "route_synced", + "peer_synced", "ok", f"Synced {peer_id} through repaired route.", peer_id=peer_id, @@ -781,32 +790,78 @@ def activate( proof: dict[str, Any] = {} if run_proof: try: - proof = self._run_whole_mesh_proof(include_local=True, limit=limit, request_id=f"{request_token}-proof") - result["proof"] = proof - mission = dict(proof.get("mission") or {}) - mission_status = str(mission.get("status") or proof.get("status") or "unknown") self._action( actions, - "proof_completed" if mission_status in {"completed", "planned", "accepted"} else "fix_needed", - "ok" if mission_status in {"completed", "planned", "accepted"} else "warning", - f"Whole-mesh proof launched with status {mission_status}.", - details={"mission_id": mission.get("id"), "mission_status": mission_status}, + "proof_started", + "running", + "Starting whole-mesh proof.", + details={"request_id": f"{request_token}-proof"}, request_id=request_token, ) + proof = self._run_whole_mesh_proof(include_local=True, limit=limit, request_id=f"{request_token}-proof") + result["proof"] = proof + mission = dict(proof.get("mission") or {}) + mission_status = str(mission.get("status") or proof.get("status") or "unknown").strip().lower() + if mission_status in {"completed", "ok"}: + self._action( + actions, + "proof_completed", + "ok", + f"Whole-mesh proof completed with status {mission_status}.", + details={"mission_id": mission.get("id"), "mission_status": mission_status}, + request_id=request_token, + ) + elif mission_status in {"planned", "queued", "running", "accepted"}: + self._action( + actions, + "proof_started", + "ok", + f"Whole-mesh proof is in flight with status {mission_status}.", + details={"mission_id": mission.get("id"), "mission_status": mission_status}, + request_id=request_token, + ) + else: + self._action( + actions, + "fix_needed", + "warning", + f"Whole-mesh proof needs attention with status {mission_status}.", + details={"mission_id": mission.get("id"), "mission_status": mission_status}, + request_id=request_token, + ) if repair and self._proof_failed_due_transport(proof): self._action(actions, "fix_needed", "running", "Proof hit a transport timeout; probing routes once before retry.", request_id=request_token) result["repairs"] = self._repair_routes(peer_ids, timeout=timeout, request_id=request_token, actions=actions) - proof = self._run_whole_mesh_proof(include_local=True, limit=limit, request_id=f"{request_token}-proof-retry") - result["proof_retry"] = proof - retry_mission = dict(proof.get("mission") or {}) self._action( actions, - "proof_completed", - "ok", - f"Retried whole-mesh proof with status {retry_mission.get('status') or proof.get('status') or 'unknown'}.", - details={"mission_id": retry_mission.get("id"), "mission_status": retry_mission.get("status")}, + "proof_started", + "running", + "Retrying whole-mesh proof after route repair.", + details={"request_id": f"{request_token}-proof-retry"}, request_id=request_token, ) + proof = self._run_whole_mesh_proof(include_local=True, limit=limit, request_id=f"{request_token}-proof-retry") + result["proof_retry"] = proof + retry_mission = dict(proof.get("mission") or {}) + retry_status = str(retry_mission.get("status") or proof.get("status") or "unknown").strip().lower() + if retry_status in {"completed", "ok"}: + self._action( + actions, + "proof_completed", + "ok", + f"Retried whole-mesh proof completed with status {retry_status}.", + details={"mission_id": retry_mission.get("id"), "mission_status": retry_status}, + request_id=request_token, + ) + else: + self._action( + actions, + "fix_needed", + "warning", + f"Retried whole-mesh proof still needs attention with status {retry_status}.", + details={"mission_id": retry_mission.get("id"), "mission_status": retry_status}, + request_id=request_token, + ) except Exception as exc: result["proof_error"] = str(exc) self._action(actions, "fix_needed", "warning", f"Whole-mesh proof needs attention: {exc}", details={"error": str(exc)}, request_id=request_token) @@ -948,10 +1003,40 @@ def _evaluate_and_enlist_helpers( except Exception as exc: skipped.append({"peer_id": peer_id, "reason": str(exc)}) self._action(actions, "fix_needed", "warning", f"Could not enlist {peer_id}: {exc}", peer_id=peer_id, details={"error": str(exc)}, request_id=request_id) + elif trust == "partner" and device_class == "full": + try: + state = self.mesh.enlist_helper( + peer_id, + mode="on_demand", + role=role, + reason="autonomic_mesh_partner_activation", + source="autonomy", + ) + enlisted.append({"peer_id": peer_id, "state": state}) + self._action( + actions, + "partner_auto_enlisted", + "ok", + f"Auto-enlisted partner peer {candidate.get('display_name') or peer_id} after route proof succeeded.", + peer_id=peer_id, + details={"role": role}, + request_id=request_id, + ) + except Exception as exc: + skipped.append({"peer_id": peer_id, "reason": str(exc)}) + self._action( + actions, + "fix_needed", + "warning", + f"Could not auto-enlist partner peer {peer_id}: {exc}", + peer_id=peer_id, + details={"error": str(exc)}, + request_id=request_id, + ) elif trust == "partner": approval = self.mesh.create_approval_request( title=f"Allow {candidate.get('display_name') or peer_id} to help this mesh?", - summary="Autonomic Mesh found a partner peer that could help, but partner devices need approval before helper enlistment.", + summary="This partner peer is reachable, but OCP still wants approval before auto-using a non-full helper device.", action_type="autonomic.helper.enlist", severity="normal", request_id=f"{request_id}-helper-{peer_id}", @@ -994,11 +1079,14 @@ def _activation_outcome(self, result: dict, actions: list[dict[str, Any]]) -> tu proof_status = str(mission.get("status") or proof.get("status") or "").strip().lower() warnings = [action for action in actions if action.get("status") in {"warning", "blocked"}] approvals = list(((result.get("helpers") or {}).get("approvals") or [])) + repaired = any(action.get("kind") in {"route_repaired", "peer_synced"} for action in actions) if result.get("proof_error") and healthy == 0: return "needs_attention", "Autonomic Mesh found peers, but proof execution still needs attention." if approvals: return "approval_requested", "Mesh routes are prepared; one or more partner helpers need approval before OCP can use them." if route_count and healthy == route_count and (not proof or proof_status in {"completed", "planned", "accepted", "ok"}): + if repaired: + return "completed", f"Mesh repaired: {healthy} route(s) proven again and the whole-mesh proof recovered." return "completed", f"Mesh is strong: {healthy} route(s) proven, helpers evaluated, and whole-mesh proof launched." if healthy: return "partial", f"Mesh is partly healthy: {healthy} route(s) work, but {len(warnings)} item(s) need attention." diff --git a/mesh_protocol/conformance.py b/mesh_protocol/conformance.py index c4b75d3..9edc960 100644 --- a/mesh_protocol/conformance.py +++ b/mesh_protocol/conformance.py @@ -110,6 +110,35 @@ def build_protocol_conformance_snapshot() -> dict[str, Any]: "created_at": "2026-01-01T00:00:00Z", }, ), + _fixture_entry( + "artifact-replicate-request-operator-mediated", + schema_ref="ArtifactReplicateRequest", + purpose="Explicit operator-mediated artifact pull request without persisting remote credentials.", + value={ + "peer_id": "beta-node", + "artifact_id": "artifact-fixture", + "pin": True, + "remote_auth": {"type": "operator_token", "token": "fixture-token"}, + }, + ), + _fixture_entry( + "artifact-replicate-response-redacted-auth", + schema_ref="ArtifactReplicateResponse", + purpose="Artifact replication response with route proof and redacted remote auth metadata.", + value={ + "status": "replicated", + "artifact": { + "id": "artifact-local", + "digest": "abc123", + "media_type": "application/json", + "artifact_kind": "bundle", + }, + "source": {"peer_id": "beta-node", "artifact_id": "artifact-fixture", "digest": "abc123"}, + "verification": {"status": "verified", "verified": True}, + "route_proof": {"status": "fresh", "best_route": "http://192.168.1.22:8421"}, + "remote_auth": {"type": "operator_token", "status": "used", "redacted": True}, + }, + ), _fixture_entry( "continuity-restore-request", schema_ref="ContinuityRestorePlanRequest", @@ -225,9 +254,54 @@ def build_protocol_conformance_snapshot() -> dict[str, Any]: "healthy_route_count": 1, "route_count": 1, "latest_proof_status": "completed", + "recovery_state": "healthy", + "primary_peer": { + "peer_id": "beta-node", + "display_name": "Beta", + "role": "compute", + "status": "ready", + "route": "http://192.168.1.22:8421", + "summary": "Beta is best for compute right now.", + }, + "device_roles": [ + { + "peer_id": "alpha-node", + "display_name": "Alpha", + "role": "local_command", + "status": "ready", + "summary": "This Mac is the local command node.", + }, + { + "peer_id": "beta-node", + "display_name": "Beta", + "role": "compute", + "status": "ready", + "summary": "Beta is ready for compute work.", + }, + ], "blocking_issue": "", + "blocker_code": "", "next_fix": "No fix needed. The current mesh proof completed.", "operator_summary": "Mesh is strong. Devices have proven routes and the latest proof completed.", + "story": [ + "Mesh is strong.", + "Beta is best for compute right now.", + "Whole-mesh proof completed.", + ], + "timeline": [ + { + "kind": "proof_completed", + "status": "ok", + "summary": "Whole-mesh proof completed.", + "created_at": "2026-01-01T00:00:00Z", + } + ], + }, + "protocol": { + "release": "0.1", + "version": "sovereign-mesh/v1", + "schema_version": SCHEMA_VERSION, + "contract_url": "/mesh/contract", }, "autonomy": {"status": "ok", "mode": "assisted", "operator_summary": "Mesh is strong."}, "route_health": { @@ -242,6 +316,30 @@ def build_protocol_conformance_snapshot() -> dict[str, Any]: } ], }, + "execution_readiness": { + "status": "ready", + "local": {"worker_count": 1, "ready_worker_count": 1}, + "targets": [{"peer_id": "alpha-node", "status": "ready", "reasons": ["local worker registered"]}], + "worker_capacity": [ + { + "worker_id": "alpha-default-worker", + "peer_id": "alpha-node", + "status": "active", + "capabilities": ["worker-runtime", "shell"], + "resources": {"cpu": 1}, + "max_concurrent_jobs": 1, + "available_slots": 1, + } + ], + "operator_summary": "Execution is ready.", + }, + "artifact_sync": { + "status": "verified", + "replicated_count": 1, + "verified_count": 1, + "items": [], + "operator_summary": "1 replicated artifact(s) verified.", + }, "latest_proof": { "status": "completed", "mission_id": "mission-fixture", diff --git a/mesh_protocol/schemas.py b/mesh_protocol/schemas.py index 46c66a6..ef2343d 100644 --- a/mesh_protocol/schemas.py +++ b/mesh_protocol/schemas.py @@ -159,6 +159,53 @@ "descriptor": {"$ref": "#/schemas/ArtifactDescriptor"}, }, }, + "ArtifactReplicationAuth": { + "type": "object", + "description": "Explicit remote-content authorization. Tokens are request-only and must never be persisted or echoed.", + "properties": { + "type": {"type": "string"}, + "token": {"type": "string"}, + "redacted": {"type": "boolean"}, + "status": {"type": "string"}, + }, + }, + "ArtifactReplicateRequest": { + "type": "object", + "properties": { + "peer_id": {"type": "string"}, + "artifact_id": {"type": "string"}, + "digest": {"type": "string"}, + "base_url": {"type": "string"}, + "pin": {"type": "boolean"}, + "remote_auth": {"$ref": "#/schemas/ArtifactReplicationAuth"}, + }, + }, + "ArtifactReplicateResponse": { + "type": "object", + "required": ["status"], + "properties": { + "status": {"type": "string"}, + "artifact": {"$ref": "#/schemas/Artifact"}, + "source": {"type": "object"}, + "verification": {"type": "object"}, + "route_proof": {"type": "object"}, + "remote_auth": {"$ref": "#/schemas/ArtifactReplicationAuth"}, + "governance": {"type": "object"}, + }, + }, + "ArtifactGraphReplicateResponse": { + "type": "object", + "required": ["status"], + "properties": { + "status": {"type": "string"}, + "root": {"$ref": "#/schemas/ArtifactReplicateResponse"}, + "artifacts": {"type": "array", "items": {"$ref": "#/schemas/Artifact"}}, + "graph": {"type": "object"}, + "route_proof": {"type": "object"}, + "remote_auth": {"$ref": "#/schemas/ArtifactReplicationAuth"}, + "governance": {"type": "object"}, + }, + }, "MissionContinuitySummary": { "type": "object", "required": ["mission_id", "continuity"], @@ -320,6 +367,19 @@ "generated_at": {"type": "string"}, }, }, + "RouteProofFreshness": { + "type": "object", + "properties": { + "status": {"type": "string"}, + "peer_id": {"type": "string"}, + "best_route": {"type": "string"}, + "freshness": {"type": "string"}, + "checked_at": {"type": "string"}, + "last_success_at": {"type": "string"}, + "source": {"type": "string"}, + "operator_summary": {"type": "string"}, + }, + }, "RouteProbeRequest": { "type": "object", "properties": { @@ -409,6 +469,88 @@ "generated_at": {"type": "string"}, }, }, + "WorkerCapacity": { + "type": "object", + "properties": { + "worker_id": {"type": "string"}, + "peer_id": {"type": "string"}, + "status": {"type": "string"}, + "capabilities": {"type": "array", "items": {"type": "string"}}, + "resources": {"type": "object"}, + "max_concurrent_jobs": {"type": "integer"}, + "available_slots": {"type": "integer"}, + "operator_summary": {"type": "string"}, + }, + }, + "ExecutionReadiness": { + "type": "object", + "properties": { + "status": {"type": "string"}, + "local": {"type": "object"}, + "targets": {"type": "array", "items": {"type": "object"}}, + "worker_capacity": {"type": "array", "items": {"$ref": "#/schemas/WorkerCapacity"}}, + "operator_summary": {"type": "string"}, + }, + }, + "SetupTimelineEvent": { + "type": "object", + "required": ["kind", "status", "summary"], + "properties": { + "kind": {"type": "string"}, + "status": {"type": "string"}, + "summary": {"type": "string"}, + "peer_id": {"type": "string"}, + "created_at": {"type": "string"}, + "details": {"type": "object"}, + }, + }, + "AppStatusSample": { + "type": "object", + "description": "Operator/app-facing normalized app status point for local charts.", + "required": ["id", "sampled_at", "node_id", "mesh_score"], + "properties": { + "id": {"type": "string"}, + "sampled_at": {"type": "string"}, + "node_id": {"type": "string"}, + "setup_status": {"type": "string"}, + "mesh_score": {"type": "integer"}, + "known_peer_count": {"type": "integer"}, + "route_count": {"type": "integer"}, + "healthy_route_count": {"type": "integer"}, + "latest_proof_status": {"type": "string"}, + "execution_ready_targets": {"type": "integer"}, + "local_ready_workers": {"type": "integer"}, + "artifact_verified_count": {"type": "integer"}, + "pending_approvals": {"type": "integer"}, + "payload": {"type": "object"}, + }, + }, + "AppStatusHistory": { + "type": "object", + "required": ["status", "count", "samples"], + "properties": { + "status": {"type": "string"}, + "count": {"type": "integer"}, + "limit": {"type": "integer"}, + "samples": {"type": "array", "items": {"$ref": "#/schemas/AppStatusSample"}}, + "generated_at": {"type": "string"}, + }, + }, + "AppHistorySampleRequest": { + "type": "object", + "properties": { + "source": {"type": "string"}, + }, + }, + "AppHistorySampleResponse": { + "type": "object", + "required": ["status", "sample"], + "properties": { + "status": {"type": "string"}, + "sample": {"$ref": "#/schemas/AppStatusSample"}, + "retention_limit": {"type": "integer"}, + }, + }, "AppStatus": { "type": "object", "description": "Operator-facing compact status for the installable OCP app home.", @@ -440,6 +582,7 @@ "operator_summary": {"type": "string"}, }, }, + "protocol": {"type": "object"}, "setup": { "type": "object", "properties": { @@ -453,13 +596,43 @@ "healthy_route_count": {"type": "integer"}, "route_count": {"type": "integer"}, "latest_proof_status": {"type": "string"}, + "recovery_state": {"type": "string"}, + "primary_peer": { + "type": "object", + "properties": { + "peer_id": {"type": "string"}, + "display_name": {"type": "string"}, + "role": {"type": "string"}, + "status": {"type": "string"}, + "route": {"type": "string"}, + "summary": {"type": "string"}, + }, + }, + "device_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "peer_id": {"type": "string"}, + "display_name": {"type": "string"}, + "role": {"type": "string"}, + "status": {"type": "string"}, + "summary": {"type": "string"}, + }, + }, + }, "blocking_issue": {"type": "string"}, + "blocker_code": {"type": "string"}, "next_fix": {"type": "string"}, "operator_summary": {"type": "string"}, + "story": {"type": "array", "items": {"type": "string"}}, + "timeline": {"type": "array", "items": {"$ref": "#/schemas/SetupTimelineEvent"}}, }, }, "autonomy": {"type": "object"}, "route_health": {"$ref": "#/schemas/RouteHealthList"}, + "execution_readiness": {"$ref": "#/schemas/ExecutionReadiness"}, + "artifact_sync": {"type": "object"}, "latest_proof": {"type": "object"}, "approvals": {"type": "object"}, "next_actions": {"type": "array", "items": {"type": "string"}}, diff --git a/mesh_state/schema.py b/mesh_state/schema.py index b76927f..54fc7df 100644 --- a/mesh_state/schema.py +++ b/mesh_state/schema.py @@ -320,6 +320,22 @@ created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS mesh_app_status_samples ( + id TEXT PRIMARY KEY, + sampled_at TEXT DEFAULT CURRENT_TIMESTAMP, + node_id TEXT NOT NULL, + setup_status TEXT DEFAULT '', + mesh_score INTEGER DEFAULT 0, + known_peer_count INTEGER DEFAULT 0, + route_count INTEGER DEFAULT 0, + healthy_route_count INTEGER DEFAULT 0, + latest_proof_status TEXT DEFAULT '', + execution_ready_targets INTEGER DEFAULT 0, + local_ready_workers INTEGER DEFAULT 0, + artifact_verified_count INTEGER DEFAULT 0, + pending_approvals INTEGER DEFAULT 0, + payload TEXT DEFAULT '{}' +); CREATE INDEX IF NOT EXISTS idx_mesh_events_created ON mesh_events(created_at DESC); CREATE INDEX IF NOT EXISTS idx_mesh_remote_events_peer_created ON mesh_remote_events(peer_id, remote_seq DESC); CREATE INDEX IF NOT EXISTS idx_mesh_leases_peer_status ON mesh_leases(peer_id, status); @@ -339,6 +355,7 @@ CREATE INDEX IF NOT EXISTS idx_mesh_scheduler_decisions_created ON mesh_scheduler_decisions(created_at DESC); CREATE INDEX IF NOT EXISTS idx_mesh_offload_preferences_updated ON mesh_offload_preferences(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_mesh_autonomy_runs_created ON mesh_autonomy_runs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_mesh_app_status_samples_node_sampled ON mesh_app_status_samples(node_id, sampled_at DESC); """ diff --git a/ocp_desktop/launcher.py b/ocp_desktop/launcher.py index 9a1bbc7..d7d158d 100644 --- a/ocp_desktop/launcher.py +++ b/ocp_desktop/launcher.py @@ -150,13 +150,21 @@ def fetch_app_status(plan: LaunchPlan, *, operator_token: str = "", timeout: flo def launcher_status_message(payload: dict[str, Any]) -> str: setup = dict((payload or {}).get("setup") or {}) + execution = dict((payload or {}).get("execution_readiness") or {}) + artifact_sync = dict((payload or {}).get("artifact_sync") or {}) status = str(setup.get("status") or "").strip().lower() + suffix_parts = [] + if execution.get("status"): + suffix_parts.append(str(execution.get("operator_summary") or "").strip()) + if int(artifact_sync.get("verified_count") or 0): + suffix_parts.append(str(artifact_sync.get("operator_summary") or "").strip()) + suffix = " " + " ".join(part for part in suffix_parts if part) if suffix_parts else "" if status == "strong": - return "Mesh is strong. Latest proof completed." + return "Mesh is strong. Latest proof completed." + suffix if status == "proving": return "OCP is proving the mesh now..." if status == "ready": - return "OCP is ready for phone setup. Open the phone link below and press Activate Mesh." + return "OCP is ready for phone setup. Open the phone link below and press Activate Mesh." + suffix if status == "local_only": return "OCP is running local-only. Start Mesh Mode to use your phone or spare laptop." if status == "needs_attention": @@ -274,6 +282,9 @@ def _start(self, mode: str) -> None: operator_token = self._operator_token_for_mode(plan.mode) if operator_token: env["OCP_OPERATOR_TOKEN"] = operator_token + if ocp_startup.auto_worker_enabled(plan.profile.device_class, plan.profile.form_factor): + env.setdefault("OCP_AUTO_REGISTER_WORKER", "1") + env.setdefault("OCP_AUTO_WORKER_ID", ocp_startup.default_worker_id(plan.profile.node_id)) self.process = subprocess.Popen(plan.command, cwd=str(self.repo_root), env=env) self.status.set(f"Starting OCP in {plan.mode} mode...") self._render_links(plan) diff --git a/ocp_desktop/macos_app.py b/ocp_desktop/macos_app.py index 17abfc5..ee2d286 100644 --- a/ocp_desktop/macos_app.py +++ b/ocp_desktop/macos_app.py @@ -11,6 +11,7 @@ EXCLUDED_DIR_NAMES = { ".git", + ".build", ".local", ".mypy_cache", ".pytest_cache", diff --git a/ocp_startup.py b/ocp_startup.py index 1de29be..cb2720a 100644 --- a/ocp_startup.py +++ b/ocp_startup.py @@ -144,6 +144,16 @@ def operator_app_url(base_url: str, operator_token: str = "", *, path: str = "/a return f"{url}#ocp_operator_token={urllib.parse.quote(token, safe='')}" +def auto_worker_enabled(device_class: str, form_factor: str) -> bool: + device = str(device_class or "").strip().lower() + form = str(form_factor or "").strip().lower() + return device == "full" and form not in {"phone", "watch", "tablet"} + + +def default_worker_id(node_id: str) -> str: + return f"{slugify(node_id or default_node_id()) or 'ocp'}-default-worker" + + def health_url(host: str, port: int) -> str: return build_open_url(host, port, "/mesh/manifest") @@ -325,9 +335,11 @@ def write_json_file(path: Path, payload: dict) -> None: "default_launcher_support_dir", "default_node_id", "default_repo_state_dir", + "default_worker_id", "discover_local_ipv4_addresses", "display_host_for_browser", "ensure_state_paths", + "auto_worker_enabled", "health_url", "is_loopback_host", "is_wildcard_host", diff --git a/scripts/build_swift_macos_app.py b/scripts/build_swift_macos_app.py new file mode 100755 index 0000000..c51b279 --- /dev/null +++ b/scripts/build_swift_macos_app.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Build an unsigned native SwiftPM OCP Desktop.app bundle.""" + +from __future__ import annotations + +import argparse +import plistlib +import shutil +import stat +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from ocp_desktop.macos_app import should_exclude + + +DEFAULT_APP_NAME = "OCP Desktop" +DEFAULT_BUNDLE_ID = "org.opencomputeprotocol.ocpdesktop" + + +def _copy_repo(repo_root: Path, destination: Path) -> None: + for source in Path(repo_root).iterdir(): + if should_exclude(source, repo_root): + continue + target = destination / source.name + if source.is_dir(): + shutil.copytree( + source, + target, + ignore=lambda directory, names: [ + name for name in names if should_exclude(Path(directory) / name, repo_root) + ], + ) + else: + shutil.copy2(source, target) + + +def _write_info_plist(contents_dir: Path, *, app_name: str, bundle_id: str) -> None: + plist = { + "CFBundleDevelopmentRegion": "en", + "CFBundleDisplayName": app_name, + "CFBundleExecutable": "OCPDesktop", + "CFBundleIdentifier": bundle_id, + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": app_name, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "0.1.6", + "CFBundleVersion": "1", + "LSMinimumSystemVersion": "13.0", + "NSHighResolutionCapable": True, + } + with (contents_dir / "Info.plist").open("wb") as handle: + plistlib.dump(plist, handle) + + +def build_swift_macos_app( + repo_root: Path, + *, + dist_dir: Path | None = None, + app_name: str = DEFAULT_APP_NAME, + bundle_id: str = DEFAULT_BUNDLE_ID, +) -> dict[str, str]: + root = Path(repo_root).resolve() + subprocess.run(["swift", "build", "-c", "release", "--product", "OCPDesktop"], cwd=root, check=True) + + executable = root / ".build" / "release" / "OCPDesktop" + if not executable.exists(): + raise FileNotFoundError(f"Swift build did not produce {executable}") + + output_dir = Path(dist_dir).resolve() if dist_dir else root / "dist" + app_dir = output_dir / f"{app_name}.app" + contents_dir = app_dir / "Contents" + macos_dir = contents_dir / "MacOS" + resources_dir = contents_dir / "Resources" + bundled_repo = resources_dir / "open-compute-protocol" + + if app_dir.exists(): + shutil.rmtree(app_dir) + macos_dir.mkdir(parents=True, exist_ok=True) + bundled_repo.mkdir(parents=True, exist_ok=True) + + _write_info_plist(contents_dir, app_name=app_name, bundle_id=bundle_id) + shutil.copy2(executable, macos_dir / "OCPDesktop") + current = (macos_dir / "OCPDesktop").stat().st_mode + (macos_dir / "OCPDesktop").chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + _copy_repo(root, bundled_repo) + + return { + "status": "ok", + "app_path": str(app_dir), + "executable": str(macos_dir / "OCPDesktop"), + "bundled_repo": str(bundled_repo), + "note": "Unsigned native SwiftPM beta bundle. Requires python3 to run the OCP server.", + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Build an unsigned native SwiftPM OCP Desktop.app bundle.") + parser.add_argument("--repo-root", default=str(REPO_ROOT)) + parser.add_argument("--dist-dir", default="") + parser.add_argument("--app-name", default=DEFAULT_APP_NAME) + parser.add_argument("--bundle-id", default=DEFAULT_BUNDLE_ID) + args = parser.parse_args(argv) + result = build_swift_macos_app( + Path(args.repo_root), + dist_dir=Path(args.dist_dir) if args.dist_dir else None, + app_name=args.app_name, + bundle_id=args.bundle_id, + ) + for key, value in result.items(): + print(f"{key}: {value}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/start_ocp_easy.py b/scripts/start_ocp_easy.py index 1d0223d..cb123fa 100755 --- a/scripts/start_ocp_easy.py +++ b/scripts/start_ocp_easy.py @@ -141,7 +141,14 @@ def main() -> int: print("To share OCP with your phone or another laptop:") print(f" OCP_HOST=0.0.0.0 python3 {Path(__file__).name}") - child = subprocess.Popen(command, cwd=str(repo_root)) + env = os.environ.copy() + if ocp_startup.auto_worker_enabled(args.device_class, args.form_factor): + env.setdefault("OCP_AUTO_REGISTER_WORKER", "1") + env.setdefault("OCP_AUTO_WORKER_ID", ocp_startup.default_worker_id(args.node_id)) + print() + print("Execution readiness:") + print(f" default worker: {env['OCP_AUTO_WORKER_ID']}") + child = subprocess.Popen(command, cwd=str(repo_root), env=env) try: if not args.no_open_browser and wait_for_manifest(args.host, args.port, args.open_timeout): webbrowser.open(open_url) diff --git a/server.py b/server.py index 26fc799..05c5fb2 100644 --- a/server.py +++ b/server.py @@ -7,12 +7,14 @@ import argparse import errno import json +import os from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, urlparse from mesh import SovereignMesh from mesh.sovereign import _normalize_base_url, _preferred_local_base_url +import ocp_startup from runtime import OCPRegistry, OCPStore from server_app import build_app_manifest as _build_app_manifest, build_app_page as _build_app_page from server_app_status import build_app_status as _build_app_status @@ -230,6 +232,21 @@ def _bootstrap_mesh(args) -> SovereignMesh: or None, ) mesh.network_bind_host = args.host + if str(os.environ.get("OCP_AUTO_REGISTER_WORKER") or "").strip().lower() in {"1", "true", "yes", "on"}: + worker_id = ( + os.environ.get("OCP_AUTO_WORKER_ID") + or ocp_startup.default_worker_id(args.node_id or mesh.node_id) + ) + mesh.register_worker( + worker_id=worker_id, + agent_id=args.agent_id, + capabilities=["worker-runtime", "shell", "python"], + resources={"cpu": 1}, + labels=["default", "launcher"], + max_concurrent_jobs=1, + metadata={"source": "ocp_startup", "auto_registered": True}, + status="active", + ) server_context["mesh"] = mesh server_context["runtime"] = {"lattice": lattice, "registry": registry} server_context["ready"] = True diff --git a/server_app.py b/server_app.py index ce3cf32..3bd2db3 100644 --- a/server_app.py +++ b/server_app.py @@ -32,8 +32,8 @@ def build_app_manifest(mesh: SovereignMesh) -> dict[str, Any]: "start_url": "/app", "scope": "/", "display": "standalone", - "background_color": "#f7f0e6", - "theme_color": "#112437", + "background_color": "#071217", + "theme_color": "#071217", "categories": ["productivity", "utilities"], } @@ -55,7 +55,7 @@ def build_app_page(mesh: SovereignMesh) -> str: - + @@ -64,15 +64,15 @@ def build_app_page(mesh: SovereignMesh) -> str: OCP App