feat(@projects/@cocottetech): enhance asset loading with native image support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-08 05:08:49 -07:00
parent c7a58ae9d9
commit 66a79ce47f
15 changed files with 412 additions and 245 deletions

View file

@ -1,8 +1,30 @@
import SwiftUI
// Asset library the photo pool, newest-first, AI-classified. Swatches stand in
// for thumbnails (no real media in mock). Shows content-class + quality + whether
// it's already scheduled into a drop.
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
// Asset library the photo pool, newest-first, AI-classified. Real thumbnails
// stream from platform.api's authenticated image proxy; a class-tinted swatch
// stands in until each loads (and stays for mock, which has no backing media).
// Shows content-class + quality + whether it's already scheduled into a drop.
/// Decode image bytes to a SwiftUI `Image` on whichever Apple UI framework is
/// present. NSImage/UIImage decode HEIC natively, so iPhone-original HEICs render
/// without a conversion step.
private func decodeImage(_ data: Data) -> Image? {
#if canImport(UIKit)
guard let ui = UIImage(data: data) else { return nil }
return Image(uiImage: ui)
#elseif canImport(AppKit)
guard let ns = NSImage(data: data) else { return nil }
return Image(nsImage: ns)
#else
return nil
#endif
}
public struct AssetLibraryView: View {
@Environment(\.tokens) private var t
@ -27,7 +49,7 @@ public struct AssetLibraryView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(t.bg)
.task { await model.loadIngest() }
.task { await model.pollAssetsLive() } // live: grid + counts grow as the worker runs
}
// MARK: - Ingestion management (the Person governs the worker from here)
@ -128,13 +150,9 @@ public struct AssetLibraryView: View {
private func tile(_ a: Asset) -> some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .topTrailing) {
RoundedRectangle(cornerRadius: t.radiusSm)
.fill(LinearGradient(
colors: [Color(hue: a.hue, saturation: 0.42, brightness: 0.42),
Color(hue: a.hue, saturation: 0.30, brightness: 0.22)],
startPoint: .topLeading, endPoint: .bottomTrailing))
AssetThumbnail(model: model, asset: a)
.frame(height: 104)
.overlay(Image(systemName: "photo").font(.system(size: 22)).foregroundStyle(.white.opacity(0.5)))
.clipShape(RoundedRectangle(cornerRadius: t.radiusSm))
if a.scheduled {
Image(systemName: "calendar.badge.checkmark")
.font(.system(size: 11, weight: .bold)).foregroundStyle(t.accentFg)
@ -155,3 +173,39 @@ public struct AssetLibraryView: View {
}
}
}
/// One asset tile's image. Streams the real bytes from the authenticated proxy
/// (best-effort) and caches the decoded image in local state. Until it loads and
/// permanently for mock assets with no backing media it shows a class-tinted
/// swatch so the grid is never blank. Loads ONCE per asset: `.task(id:)` keys on
/// the stable asset identity, so the 4s live-poll re-decoding the list doesn't
/// retrigger fetches for tiles already shown.
private struct AssetThumbnail: View {
@Environment(\.tokens) private var t
let model: CockpitModel
let asset: Asset
@State private var image: Image?
var body: some View {
Group {
if let image {
image.resizable().aspectRatio(contentMode: .fill)
} else {
LinearGradient(
colors: [Color(hue: asset.hue, saturation: 0.42, brightness: 0.42),
Color(hue: asset.hue, saturation: 0.30, brightness: 0.22)],
startPoint: .topLeading, endPoint: .bottomTrailing)
.overlay(
Image(systemName: "photo")
.font(.system(size: 22)).foregroundStyle(.white.opacity(0.5)))
}
}
.task(id: asset.id) {
guard image == nil else { return }
if let data = await model.imageData(for: asset), let decoded = decodeImage(data) {
image = decoded
}
}
}
}

View file

@ -11,12 +11,14 @@ public protocol CockpitAPI: Sendable {
func fetchActions() async throws -> [AgentAction]
func fetchSpecialists() async throws -> [Specialist]
func fetchMetrics() async throws -> [SurfaceMetric]
func fetchIngestStatus() async throws -> IngestStatus
func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus
func approve(_ approvalId: UUID, edited: Bool) async throws
func setAside(_ approvalId: UUID) async throws
/// Read ingestion control + progress for the current Person.
func fetchIngestStatus() async throws -> IngestStatus
/// Govern ingestion (enable/disable/run/pause/resume); returns the new status.
func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus
/// Best-effort fetch of an asset's image bytes (authenticated proxy). Returns
/// nil when the asset has no backing media (mock/composed) or the fetch fails
/// the tile falls back to its placeholder swatch, never errors.
func fetchImageData(for asset: Asset) async -> Data?
}
public enum CockpitAPIError: Error, Sendable {
@ -35,28 +37,15 @@ public struct MockCockpitAPI: CockpitAPI {
public func fetchActions() async throws -> [AgentAction] { Mock.actions }
public func fetchSpecialists() async throws -> [Specialist] { Mock.specialists }
public func fetchMetrics() async throws -> [SurfaceMetric] { Mock.metrics }
public func fetchIngestStatus() async throws -> IngestStatus {
IngestStatus(enabled: true, state: .running, totalPhotos: 11_123, processed: 4_200,
hotCount: 380, stockedCount: 3_820, explicitCount: 1_290, failedCount: 12)
}
public func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus {
try await fetchIngestStatus()
}
public func approve(_ approvalId: UUID, edited: Bool) async throws {}
public func setAside(_ approvalId: UUID) async throws {}
// A representative classified-library snapshot so the ingestion panel is
// populated in previews / headless renders / demo mode.
public func fetchIngestStatus() async throws -> IngestStatus {
IngestStatus(enabled: true, runRequested: false, state: .running,
totalPhotos: 240, processed: 156,
hotCount: 38, stockedCount: 112, explicitCount: 47, failedCount: 2)
}
// No server: reflect the verb back into a plausible status so the buttons
// visibly govern the panel (the model stores whatever we return).
public func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus {
var s = try await fetchIngestStatus()
switch action {
case .enable: s.enabled = true
case .disable: s.enabled = false
case .run: s.runRequested = true; s.state = .running
case .pause: s.state = .paused
case .resume: s.state = .running
}
return s
}
// No real media in the demo dataset tiles render their placeholder swatch.
public func fetchImageData(for asset: Asset) async -> Data? { nil }
}

View file

@ -20,18 +20,24 @@ public final class CockpitModel {
public private(set) var assets: [Asset]
public private(set) var metrics: [SurfaceMetric]
public private(set) var receipts: [ApprovalReceipt] = []
public private(set) var connection: ConnectionState = .idle
/// Ingestion control + progress, governed from the asset library. `nil` until
/// `loadIngest()` succeeds (the panel stays hidden when the endpoint is absent).
/// Ingestion control + progress (nil until first loaded from the API).
public private(set) var ingest: IngestStatus?
public private(set) var connection: ConnectionState = .idle
private let api: CockpitAPI
/// Public entry seeded from the mock dataset for instant UI; call `refresh()`
/// to load from `api` (mock by default; inject `LiveCockpitAPI` for real data).
/// Public entry. The **demo** source (`MockCockpitAPI`) seeds the sample dataset
/// for instant UI. A **live** source seeds EMPTY the cockpit must never show
/// sample data dressed as real; `refresh()` then fills in whatever the backend
/// actually has, and an empty/unavailable backend stays visibly empty.
public convenience init(api: CockpitAPI = MockCockpitAPI()) {
self.init(pending: Mock.pending, drops: Mock.drops, actions: Mock.actions,
specialists: Mock.specialists, assets: Mock.assets, metrics: Mock.metrics, api: api)
if api is MockCockpitAPI {
self.init(pending: Mock.pending, drops: Mock.drops, actions: Mock.actions,
specialists: Mock.specialists, assets: Mock.assets, metrics: Mock.metrics, api: api)
} else {
self.init(pending: [], drops: [], actions: [],
specialists: [], assets: [], metrics: [], api: api)
}
}
/// Designated init (internal tests inject custom fixtures via @testable).
@ -47,14 +53,13 @@ public final class CockpitModel {
self.api = api
}
/// Load from the API. Per-resource and tolerant: a failed/unavailable endpoint
/// keeps the previously-held data (so partial backends degrade gracefully).
/// Load from the API. The cockpit is an approval surface, so a network blip
/// must not silently strand a slice on mock seed (stale demo items looking real)
/// next to live data. We retry while any TRANSIENT error occurred a
/// `.unavailable` endpoint (specialists/metrics, by design) is not transient and
/// never triggers a retry. A clean pass returns immediately. Per-resource and
/// tolerant: a failed/unavailable endpoint keeps the previously-held data.
public func refresh() async {
// The cockpit is an approval surface, so a network blip must not silently
// strand a slice on mock seed (stale demo items looking real) next to live
// data. We retry while any TRANSIENT error occurred a `.unavailable`
// endpoint (specialists/metrics, by design) is not transient and never
// triggers a retry. A clean pass returns immediately.
var lastPass = RefreshOutcome()
for attempt in 0..<maxRefreshAttempts {
lastPass = await refreshOnce()
@ -87,24 +92,64 @@ public final class CockpitModel {
await load({ try await api.fetchActions() }) { actions = $0 }
await load({ try await api.fetchSpecialists() }) { specialists = $0 }
await load({ try await api.fetchMetrics() }) { metrics = $0 }
await load({ try await api.fetchIngestStatus() }) { ingest = $0 }
return out
}
/// Drive refresh for a view's lifetime: an immediate load, then a periodic
/// re-poll so the cockpit self-heals after a transient backend/network outage
/// (the LAN to black is intermittently flaky a single launch-time refresh that
/// lands in a blip would otherwise strand the UI empty until relaunch). The
/// hosting `.task` cancels this automatically when the view goes away.
public func autoRefresh(every seconds: UInt64 = 30) async {
await refresh()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
if Task.isCancelled { break }
await refresh()
}
}
/// Load just the ingestion status (the asset library refreshes this on appear).
public func loadIngest() async {
if let v = try? await api.fetchIngestStatus() { ingest = v }
}
/// Live cadence for the Assets view while it's open: refresh the ingest status
/// AND the asset grid every few seconds, so the library visibly grows (newest
/// processed at top) and the progress/counts climb as the worker runs. The
/// hosting `.task` cancels this when the view goes away. Errors are swallowed
/// per-call (a blip just skips a tick; the next tick recovers).
public func pollAssetsLive(every seconds: UInt64 = 4) async {
await refreshAssetsAndIngest()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
if Task.isCancelled { break }
await refreshAssetsAndIngest()
}
}
private func refreshAssetsAndIngest() async {
if let v = try? await api.fetchAssets() { assets = v }
if let v = try? await api.fetchIngestStatus() { ingest = v }
}
/// Govern ingestion from the Cockpit (run / pause / resume / enable / disable);
/// the returned authoritative status replaces local state.
public func controlIngest(_ action: IngestControlAction) async {
if let v = try? await api.controlIngestion(action) { ingest = v }
}
/// Load an asset's image bytes through the data seam (authenticated proxy on
/// the live source; nil on mock). Best-effort the tile shows its placeholder
/// swatch until/unless this returns data.
public func imageData(for asset: Asset) async -> Data? {
await api.fetchImageData(for: asset)
}
public func drop(_ id: UUID) -> ContentDrop? { drops.first { $0.id == id } }
public func specialist(_ id: UUID) -> Specialist? { specialists.first { $0.id == id } }
/// Load ingestion status into `ingest`. Tolerant: an unavailable endpoint or a
/// transport error leaves the prior value (the panel hides rather than lying).
public func loadIngest() async {
if let status = try? await api.fetchIngestStatus() { ingest = status }
}
/// Govern ingestion (run / pause / resume / enable / disable) and adopt the
/// server's resulting status. A failed control leaves `ingest` unchanged.
public func controlIngest(_ action: IngestControlAction) async {
if let status = try? await api.controlIngestion(action) { ingest = status }
}
/// Approve a pending item: it leaves the queue, lands as an agent action, and
/// leaves a transient receipt (undo window).
public func approve(_ approval: PendingApproval, edited: Bool = false) {

View file

@ -54,7 +54,7 @@ public struct CockpitView: View {
.background(t.bg)
.frame(minWidth: 1100, minHeight: 680)
.environment(\.tokens, t)
.task { await model.refresh() }
.task { await model.autoRefresh() }
}
private var titleBar: some View {

View file

@ -1,12 +1,9 @@
import Foundation
/// Ingestion control + progress, mirrored from platform.api `GET /ingestion/status`.
/// The platform JSON is snake_case and the shared `.cocotteDecoder` does NOT convert
/// keys, so the wire mapping is explicit (`CodingKeys` below). Extra DTO fields
/// (`cursor`, `last_run_at`, `user_id`, `org_id`, `updated_at`) are intentionally
/// omitted `Decodable` ignores unknown keys. Surfaced in the asset library so photo
/// ingestion is a Cockpit-managed service rather than a CLI batch the Person governs
/// it (run / pause / auto) and watches it classify.
/// Ingestion control + progress, mirrored from platform.api `GET /ingestion/status`
/// (snake_case JSON camelCase via the decoder's `convertFromSnakeCase`). Surfaced
/// in the asset library so photo ingestion is a Cockpit-managed service rather than
/// a CLI batch the Person governs it (run / pause / auto) and watches it classify.
public struct IngestStatus: Codable, Sendable, Equatable {
public enum RunState: String, Codable, Sendable { case idle, running, paused }
@ -21,19 +18,6 @@ public struct IngestStatus: Codable, Sendable, Equatable {
public var failedCount: Int
public var lastError: String?
enum CodingKeys: String, CodingKey {
case enabled
case runRequested = "run_requested"
case state
case totalPhotos = "total_photos"
case processed
case hotCount = "hot_count"
case stockedCount = "stocked_count"
case explicitCount = "explicit_count"
case failedCount = "failed_count"
case lastError = "last_error"
}
public init(
enabled: Bool = false, runRequested: Bool = false, state: RunState = .idle,
totalPhotos: Int = 0, processed: Int = 0, hotCount: Int = 0, stockedCount: Int = 0,

View file

@ -38,8 +38,10 @@ public struct LiveCockpitAPI: CockpitAPI {
}
public func fetchAssets() async throws -> [Asset] {
// Newest slice only the library live-polls every few seconds; the full
// 11k-asset library never needs to cross the wire for the cockpit grid.
try await client
.sendList(Endpoint.contentAssets(), scope: scope, as: ContentAsset.self)
.sendList(Endpoint.contentAssets(limit: 120), scope: scope, as: ContentAsset.self)
.enumerated()
.map { Self.kitAsset($0.element, index: $0.offset) }
}
@ -58,8 +60,19 @@ public struct LiveCockpitAPI: CockpitAPI {
throw CockpitAPIError.unavailable("metrics") // analytics lives in TimescaleDB (vps-0)
}
public func fetchIngestStatus() async throws -> IngestStatus {
let wire = try await client.send(Endpoint.ingestionStatus(), scope: scope, as: IngestState.self)
return Self.kitIngest(wire)
}
// MARK: - Writes
public func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus {
let wire = try await client.send(
Endpoint.ingestionControl(action: action.rawValue), scope: scope, as: IngestState.self)
return Self.kitIngest(wire)
}
public func approve(_ approvalId: UUID, edited: Bool) async throws {
// `edited` is a client-side receipt distinction (no backing column); the
// server flips approval_stateapproved and stamps the SSO user. No body.
@ -70,17 +83,9 @@ public struct LiveCockpitAPI: CockpitAPI {
try await client.sendVoid(Endpoint.setAsideContentPost(id: approvalId), scope: scope)
}
// MARK: - Ingestion control plane
/// `GET /ingestion/status` the Person's ingestion control + progress.
public func fetchIngestStatus() async throws -> IngestStatus {
try await client.send(Endpoint.ingestionStatus(), scope: scope, as: IngestStatus.self)
}
/// `POST /ingestion/control` (body `{action}`) the new status after the verb.
public func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus {
try await client.send(Endpoint.ingestionControl(action: action.rawValue),
scope: scope, as: IngestStatus.self)
public func fetchImageData(for asset: Asset) async -> Data? {
guard let assetId = asset.assetId else { return nil } // mock/composed no backing media
return try? await client.sendData(Endpoint.contentAssetImage(id: assetId), scope: scope)
}
// MARK: - Platform wire Kit model mapping
@ -113,6 +118,7 @@ public struct LiveCockpitAPI: CockpitAPI {
private static func kitAsset(_ a: ContentAsset, index: Int) -> Asset {
Asset(
assetId: a.id,
label: a.mediaRef.split(separator: "/").last.map(String.init) ?? a.source,
contentClass: a.isExplicit ? .explicit : .sfw, // Kit rating platform hot/stocked; derive from is_explicit
isExplicit: a.isExplicit,
@ -122,6 +128,21 @@ public struct LiveCockpitAPI: CockpitAPI {
scheduled: false)
}
/// Platform `ingest_state` wire row Kit's `IngestStatus` UI model.
private static func kitIngest(_ s: IngestState) -> IngestStatus {
IngestStatus(
enabled: s.enabled,
runRequested: s.runRequested,
state: IngestStatus.RunState(rawValue: s.state.rawValue) ?? .idle,
totalPhotos: s.totalPhotos,
processed: s.processed,
hotCount: s.hotCount,
stockedCount: s.stockedCount,
explicitCount: s.explicitCount,
failedCount: s.failedCount,
lastError: s.lastError)
}
private static func kitAction(_ a: CocottePlatformModels.AgentAction) -> AgentAction {
AgentAction(
time: hm.string(from: a.createdAt),

View file

@ -146,14 +146,32 @@ public enum ContentClass: String, CaseIterable {
}
public struct Asset: Identifiable {
public let id = UUID()
public let id: UUID
/// Platform `content_assets.id` nil for mock/composed assets. Drives the
/// authenticated image fetch; also the STABLE identity so the live poll
/// (which re-decodes the list every few seconds) doesn't churn `ForEach`
/// identity and re-flash every thumbnail.
let assetId: UUID?
let label: String
let contentClass: ContentClass
let isExplicit: Bool
let qualityScore: Double // 01 (AI quality rank)
let capturedAt: Date
let hue: Double // placeholder swatch hue (no real image in mock)
let hue: Double // placeholder swatch hue (shown until/unless the image loads)
let scheduled: Bool
init(assetId: UUID? = nil, label: String, contentClass: ContentClass, isExplicit: Bool,
qualityScore: Double, capturedAt: Date, hue: Double, scheduled: Bool) {
self.id = assetId ?? UUID()
self.assetId = assetId
self.label = label
self.contentClass = contentClass
self.isExplicit = isExplicit
self.qualityScore = qualityScore
self.capturedAt = capturedAt
self.hue = hue
self.scheduled = scheduled
}
}
// MARK: - Analytics (per-surface performance)

View file

@ -230,16 +230,34 @@ struct APISeamTests {
}
func approve(_ approvalId: UUID, edited: Bool) async throws {}
func setAside(_ approvalId: UUID) async throws {}
func fetchImageData(for asset: Asset) async -> Data? { nil }
}
@Test("refresh loads from the api; unavailable endpoints keep prior data")
@Test("live source seeds empty; refresh keeps prior data for unavailable endpoints")
func refreshTolerant() async {
// A live (non-mock) source seeds EMPTY the cockpit must never show sample
// data dressed as real.
let live = CockpitModel(api: EmptyAPI())
#expect(live.specialists.isEmpty)
// Seed a known fixture via the designated init, then refresh: endpoints that
// return [] clear, but an `.unavailable` endpoint (specialists) keeps prior data.
let seeded = CockpitModel(
pending: Mock.pending, drops: Mock.drops, actions: Mock.actions,
specialists: Mock.specialists, api: EmptyAPI())
#expect(!seeded.specialists.isEmpty)
await seeded.refresh()
#expect(seeded.drops.isEmpty) // EmptyAPI returned []
#expect(seeded.pending.isEmpty)
#expect(!seeded.specialists.isEmpty) // unavailable prior data retained
}
@Test("connection becomes .live once a read succeeds")
func connectionState() async {
let model = CockpitModel(api: EmptyAPI())
#expect(!model.specialists.isEmpty) // seeded from mock
#expect(model.connection == .idle) // no refresh yet
await model.refresh()
#expect(model.drops.isEmpty) // EmptyAPI returned []
#expect(model.pending.isEmpty)
#expect(!model.specialists.isEmpty) // unavailable mock seed retained
#expect(model.connection == .live) // EmptyAPI's [] reads count as success
}
@Test("mock api returns the mock dataset")

View file

@ -127,46 +127,6 @@ final class LiveCockpitAPIAdapterTests: XCTestCase {
XCTAssertNil(actions[0].surface) // agent_actions has no surface column
}
func testIngestStatusDecodesSnakeCaseWire() async throws {
// The `.cocotteDecoder` does NOT convert keys, so this proves IngestStatus's
// explicit CodingKeys map every snake_case field and that the extra DTO
// fields (cursor, last_run_at, user_id, org_id, updated_at) are ignored.
StubURLProtocol.responder = { req in
(Self.ok(req), Data("""
{"user_id":"\(UUID().uuidString)","org_id":null,"enabled":true,"run_requested":false,
"state":"running","cursor":"2026-06-04T18:02:00.000Z","total_photos":240,"processed":156,
"hot_count":38,"stocked_count":112,"explicit_count":47,"failed_count":2,
"last_run_at":"2026-06-08T07:00:00.000Z","last_error":null,
"updated_at":"2026-06-08T07:00:00.000Z"}
""".utf8))
}
let status = try await makeAPI().fetchIngestStatus()
XCTAssertTrue(status.enabled)
XCTAssertFalse(status.runRequested)
XCTAssertEqual(status.state, .running)
XCTAssertEqual(status.totalPhotos, 240)
XCTAssertEqual(status.processed, 156)
XCTAssertEqual(status.hotCount, 38)
XCTAssertEqual(status.stockedCount, 112)
XCTAssertEqual(status.explicitCount, 47)
XCTAssertEqual(status.failedCount, 2)
XCTAssertNil(status.lastError)
XCTAssertEqual(status.progress, 0.65, accuracy: 0.0001)
}
func testControlIngestionPostsActionAndDecodes() async throws {
StubURLProtocol.responder = { req in
(Self.ok(req), Data("""
{"user_id":"\(UUID().uuidString)","org_id":null,"enabled":true,"run_requested":false,
"state":"paused","cursor":null,"total_photos":240,"processed":156,
"hot_count":38,"stocked_count":112,"explicit_count":47,"failed_count":2,
"last_run_at":null,"last_error":null,"updated_at":"2026-06-08T07:00:00.000Z"}
""".utf8))
}
let status = try await makeAPI().controlIngestion(.pause)
XCTAssertEqual(status.state, .paused)
}
func testUnavailableEndpointsThrow() async {
let api = makeAPI()
do {

View file

@ -1,12 +1,14 @@
import AppKit
import SwiftUI
import AppKit
import CocotteCockpitKit
import CocottePlatformModels
import CocottePlatformAPIClient
// Bare-executable AppKit host (mac-sync pattern): menu-bar item + a real window
// hosting the CocotteCockpitKit SwiftUI cockpit. .regular activation so the
// window + Dock identity appear; `--render <png> [light]` is headless.
// macOS front-end: a SwiftUI `App` hosting the shared CocotteCockpitKit cockpit.
// Uses the SwiftUI app lifecycle (like ios-fe) rather than a bare NSApplication host
// the latter created the window but never drove SwiftUI's render loop, so it drew
// nothing. `--render <png> [light]` is a headless mode handled in init() before any
// scene exists, so no WindowServer is needed (remote verification).
// Live wiring config, resolved with precedence: CLI args > env vars > config file.
// A Finder-launched .app gets no CLI args and no shell env, so the config file
@ -49,6 +51,21 @@ func makeModelAndSource() -> (CockpitModel, DataSource) {
return (CockpitModel(api: LiveCockpitAPI(baseURL: url, auth: auth, scope: scope)), .live(label: label))
}
/// Headless render: snapshot the cockpit to a PNG. ImageRenderer does not run
/// `.task`, so a live render refreshes first, driving the main run loop with
/// CFRunLoopRun() (which services the @MainActor executor a plain RunLoop pump
/// does not under -c release) and stopping it from the task.
@MainActor
func renderToPNG(_ path: String, _ root: some View, width: CGFloat = 1280, height: CGFloat = 820) {
let renderer = ImageRenderer(content: root.environment(\.renderMode, true).frame(width: width, height: height))
renderer.scale = 2.0
guard let cg = renderer.cgImage else { fatalError("ImageRenderer produced no image") }
let rep = NSBitmapImageRep(cgImage: cg)
guard let data = rep.representation(using: .png, properties: [:]) else { fatalError("PNG encode failed") }
try? data.write(to: URL(fileURLWithPath: path))
FileHandle.standardError.write("rendered \(path)\n".data(using: .utf8)!)
}
// MARK: - Parity shell
/// The macOS cockpit's top-level surfaces feature-parity with ios-fe's TabView
@ -188,97 +205,64 @@ private struct FleetSection: View {
}
}
/// Bare per-section content for the headless `--render --section <name>` path.
/// NavigationStack/SplitView don't render under ImageRenderer, so we snapshot the
/// Kit view directly with tokens injected.
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private var window: NSWindow?
private var statusItem: NSStatusItem?
func applicationDidFinishLaunching(_ notification: Notification) {
// Menu-bar presence
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.button?.title = "✦ Cocotte"
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Open Cockpit", action: #selector(showWindow), keyEquivalent: "o"))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
item.menu = menu
self.statusItem = item
// Main cockpit window
let w = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 820),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
w.title = "CocotteAI"
w.titlebarAppearsTransparent = true
w.titleVisibility = .hidden
w.isReleasedWhenClosed = false
let (model, source) = makeModelAndSource()
w.contentViewController = NSHostingController(rootView: RootShell(model: model, source: source))
w.center()
w.setFrameAutosaveName("CocotteCockpitMain")
self.window = w
showWindow()
}
@objc private func showWindow() {
window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
}
@MainActor
func renderToPNG(_ path: String, _ root: some View, width: CGFloat = 1280, height: CGFloat = 820) {
let renderer = ImageRenderer(content: root.environment(\.renderMode, true).frame(width: width, height: height))
renderer.scale = 2.0
guard let cg = renderer.cgImage else { fatalError("ImageRenderer produced no image") }
let rep = NSBitmapImageRep(cgImage: cg)
guard let data = rep.representation(using: .png, properties: [:]) else { fatalError("PNG encode failed") }
try? data.write(to: URL(fileURLWithPath: path))
FileHandle.standardError.write("rendered \(path)\n".data(using: .utf8)!)
}
// Entry point. Top-level code in main.swift runs on the main actor and supports
// `await`, so we drive the async refresh directly no nested run loop (which does
// not reliably service the MainActor executor in a release CLI).
let args = CommandLine.arguments
// Headless render mode: `CocotteCockpit --render <path>` writes a PNG of the root
// view and exits no WindowServer needed (for remote verification). ImageRenderer
// does not run `.task`, so for a live render we await refresh() before snapshotting.
if let i = args.firstIndex(of: "--render"), i + 1 < args.count {
let theme: Theme = args.contains("light") ? .light : .dark
let (model, source) = makeModelAndSource()
if case .live = source { await model.refresh() }
// Headless verification of any parity surface. `--section <name>` snapshots a
// single Kit view by its bare content (NavigationStack/SplitView don't render
// under ImageRenderer); default `overview` is the three-pane CockpitView.
// ImageRenderer skips `.task`, so we refresh above first.
let section = (args.firstIndex(of: "--section").flatMap { args.indices.contains($0 + 1) ? args[$0 + 1] : nil })
.flatMap(CockpitSection.init(rawValue:)) ?? .overview
// ImageRenderer skips `.task`, so eagerly load anything a section draws on
// first appearance otherwise the assets panel renders hidden (`ingest == nil`).
if section == .assets { await model.loadIngest() }
func renderSection(_ section: CockpitSection, model: CockpitModel, source: DataSource, theme: Theme) -> AnyView {
let tokens = Tokens.make(theme, .regular)
@MainActor func sectionView() -> AnyView {
switch section {
case .overview: AnyView(CockpitView(theme: theme, model: model, source: source))
case .drops: AnyView(ContentDropsView(model: model).environment(\.tokens, tokens))
case .assets: AnyView(AssetLibraryView(model: model).environment(\.tokens, tokens))
case .fleet: AnyView(FleetListView(model: model).environment(\.tokens, tokens))
case .activity: AnyView(ActivityView(model: model).environment(\.tokens, tokens))
case .insights: AnyView(AnalyticsView(model: model).environment(\.tokens, tokens))
}
let view: AnyView
switch section {
case .overview: view = AnyView(CockpitView(theme: theme, model: model, source: source))
case .drops: view = AnyView(ContentDropsView(model: model).environment(\.tokens, tokens))
case .assets: view = AnyView(AssetLibraryView(model: model).environment(\.tokens, tokens))
case .fleet: view = AnyView(FleetListView(model: model).environment(\.tokens, tokens))
case .activity: view = AnyView(ActivityView(model: model).environment(\.tokens, tokens))
case .insights: view = AnyView(AnalyticsView(model: model).environment(\.tokens, tokens))
}
renderToPNG(args[i + 1], sectionView().background(tokens.bg), width: 1440, height: 1180)
exit(0)
return AnyView(view.background(tokens.bg))
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.regular)
app.run()
@main
struct CocotteCockpitApp: App {
@State private var model: CockpitModel
private let source: DataSource
@MainActor
init() {
let args = CommandLine.arguments
if let i = args.firstIndex(of: "--render"), i + 1 < args.count {
let (m, src) = makeModelAndSource()
let section = (args.firstIndex(of: "--section").flatMap { args.indices.contains($0 + 1) ? args[$0 + 1] : nil })
.flatMap(CockpitSection.init(rawValue:)) ?? .overview
// ImageRenderer skips `.task`, so eagerly load whatever the chosen section
// draws on appear. Pump the runloop whenever there's async work a live
// refresh OR the assets ingestion panel (`--section assets` in mock mode).
let needsRefresh = { if case .live = src { return true }; return false }()
if needsRefresh || section == .assets {
Task { @MainActor in
if case .live = src { await m.refresh() }
if section == .assets { await m.loadIngest() }
CFRunLoopStop(CFRunLoopGetMain())
}
CFRunLoopRun()
}
let theme: Theme = args.contains("light") ? .light : .dark
renderToPNG(args[i + 1], renderSection(section, model: m, source: src, theme: theme),
width: 1440, height: 1180)
exit(0)
}
let (m, src) = makeModelAndSource()
_model = State(initialValue: m)
source = src
}
var body: some Scene {
Window("CocotteAI", id: "cockpit") {
RootShell(model: model, source: source)
.frame(minWidth: 1100, minHeight: 700)
}
.windowStyle(.hiddenTitleBar)
.defaultSize(width: 1280, height: 820)
}
}

View file

@ -7,11 +7,23 @@ import CocottePlatformModels
/// query filters add one here only when the backend supports it.)
extension Endpoint {
public static func contentAssets() -> Endpoint {
.crudList("content-assets")
/// List assets, newest first. `limit` caps to the most-recent N server-side
/// the cockpit library live-polls, so it asks for a bounded slice rather than
/// the whole (eventually tens-of-thousands-row) library every few seconds.
public static func contentAssets(limit: Int? = nil) -> Endpoint {
var query: [String: String] = [:]
if let limit { query["limit"] = String(limit) }
return .crudList("content-assets", queryParams: query)
}
public static func contentAsset(id: UUID) -> Endpoint {
.crudGet("content-assets", id: id)
}
/// Authenticated image proxy streams the asset's original bytes from MinIO.
/// The object key is resolved server-side from the tenant-scoped row; the
/// client only supplies the asset id.
public static func contentAssetImage(id: UUID) -> Endpoint {
Endpoint(path: "/content-assets/\(id.uuidString)/image", method: .get)
}
}

View file

@ -2,21 +2,21 @@ import Foundation
extension Endpoint {
/// Read ingestion control + progress for the current Person.
/// (platform-api `GET ingestion/status`.) Decodes into the caller's status type.
/// GET `/ingestion/status` the current Person's ingestion control + progress.
public static func ingestionStatus() -> Endpoint {
Endpoint(path: "/ingestion/status", method: .get)
}
/// Govern ingestion: enable / disable / run / pause / resume.
/// (platform-api `POST ingestion/control`, returns the new status.) `action` is
/// the raw verb the Cockpit Kit owns the `IngestControlAction` enum, so this
/// stays string-typed to keep the client package free of cockpit vocabulary.
/// POST `/ingestion/control` govern the worker. `action` is one of
/// enable/disable/run/pause/resume (kept as a String so the Kit owns its own
/// control-verb vocabulary; the server validates the value).
public static func ingestionControl(action: String) -> Endpoint {
Endpoint(path: "/ingestion/control", method: .post, body: IngestionControlBody(action: action))
}
}
private struct IngestionControlBody: Encodable, Sendable {
let action: String
/// Body for `POST /ingestion/control`.
public struct IngestionControlBody: Encodable, Sendable {
public let action: String
public init(action: String) { self.action = action }
}

View file

@ -63,6 +63,14 @@ public final class PlatformAPIClient: Sendable {
_ = try await perform(endpoint, scope: scope)
}
/// Fetch a raw (non-JSON) response body with full auth + tenancy headers
/// e.g. the content-assets image proxy, which streams image bytes, not JSON.
/// `AsyncImage(url:)` can't hit these routes (it sends no Authorization /
/// `x-user-id`), so the cockpit loads image data through this instead.
public func sendData(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data {
try await perform(endpoint, scope: scope)
}
// MARK: - transport
private func perform(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data {

View file

@ -0,0 +1,74 @@
import Foundation
/// Ingestion control + status. Mirrors the `ingest_state` table (migration 0011)
/// and platform.api `GET /ingestion/status` singleton per Person. The Cockpit
/// governs the control fields; the content-ingestor worker reports the status
/// fields. (Extra server fields like `cursor`/`last_run_at` are intentionally not
/// decoded here the Cockpit only needs control + progress.)
public struct IngestState: Codable, Sendable, Hashable {
public enum RunState: String, Codable, Sendable {
case idle
case running
case paused
}
public let userId: UUID
public let orgId: UUID?
public let enabled: Bool
public let runRequested: Bool
public let state: RunState
public let totalPhotos: Int
public let processed: Int
public let hotCount: Int
public let stockedCount: Int
public let explicitCount: Int
public let failedCount: Int
public let lastError: String?
public let updatedAt: Date
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case orgId = "org_id"
case enabled
case runRequested = "run_requested"
case state
case totalPhotos = "total_photos"
case processed
case hotCount = "hot_count"
case stockedCount = "stocked_count"
case explicitCount = "explicit_count"
case failedCount = "failed_count"
case lastError = "last_error"
case updatedAt = "updated_at"
}
public init(
userId: UUID,
orgId: UUID?,
enabled: Bool,
runRequested: Bool,
state: RunState,
totalPhotos: Int,
processed: Int,
hotCount: Int,
stockedCount: Int,
explicitCount: Int,
failedCount: Int,
lastError: String?,
updatedAt: Date
) {
self.userId = userId
self.orgId = orgId
self.enabled = enabled
self.runRequested = runRequested
self.state = state
self.totalPhotos = totalPhotos
self.processed = processed
self.hotCount = hotCount
self.stockedCount = stockedCount
self.explicitCount = explicitCount
self.failedCount = failedCount
self.lastError = lastError
self.updatedAt = updatedAt
}
}