macOS client store and API

This commit is contained in:
Ian Gulliver
2026-10-16 22:46:54 -07:00
parent b28968a0bc
commit a9bec7a7ea
22 changed files with 1508 additions and 251 deletions
+34 -19
View File
@@ -1,4 +1,5 @@
import AppKit
import OpenAPIRuntime
import SwiftUI
import UniformTypeIdentifiers
@@ -35,24 +36,26 @@ func pngFromTIFF(_ data: Data) -> Data? {
@Observable
final class Attachments {
var images: [PendingImage] = []
let server: Server
let spike: SpikeLog
let api: API
let name: String
init(server: Server, name: String) {
self.server = server
init(spike: SpikeLog, api: API, name: String) {
self.spike = spike
self.api = api
self.name = name
}
func add(_ data: Data, from source: String) {
guard let kind = imageType(data), let image = NSImage(data: data) else {
server.log("\(name): \(source) gave \(data.count) bytes that aren't PNG, JPEG, GIF or WebP")
spike.log("\(name): \(source) gave \(data.count) bytes that aren't PNG, JPEG, GIF or WebP")
return
}
if data.count > maxImageBytes {
server.log("\(name): \(source) image is over \(maxImageBytes >> 20) MiB")
spike.log("\(name): \(source) image is over \(maxImageBytes >> 20) MiB")
return
}
server.log("\(name): \(source) attached a \(kind), \(data.count) bytes")
spike.log("\(name): \(source) attached a \(kind), \(data.count) bytes")
images.append(PendingImage(data: data, image: image))
}
@@ -81,7 +84,7 @@ final class Attachments {
}
func add(providers: [NSItemProvider], from source: String) {
server.log("\(name): \(source) with types \(providers.map { $0.registeredTypeIdentifiers })")
spike.log("\(name): \(source) with types \(providers.map { $0.registeredTypeIdentifiers })")
for p in providers {
let types = [UTType.png, .jpeg, .gif, .webP, .tiff, .fileURL].filter { p.hasItemConformingToTypeIdentifier($0.identifier) }
guard let t = types.first else {
@@ -90,7 +93,7 @@ final class Attachments {
_ = p.loadDataRepresentation(for: t) { data, error in
Task { @MainActor in
guard let data else {
self.server.log("\(self.name): \(source) load failed: \(String(describing: error))")
self.spike.log("\(self.name): \(source) load failed: \(String(describing: error))")
return
}
if t == .fileURL, let url = URL(dataRepresentation: data, relativeTo: nil), let file = try? Data(contentsOf: url) {
@@ -108,17 +111,28 @@ final class Attachments {
func send(_ text: String) {
let images = self.images
self.images = []
server.log("\(name): send \(text.debugDescription) with \(images.count) images")
spike.log("\(name): send \(text.debugDescription) with \(images.count) images")
Task {
for img in images {
do {
server.log("\(name): uploaded blob \(try await server.upload(img.data))")
spike.log("\(name): uploaded blob \(try await upload(img.data))")
} catch {
server.log("\(name): upload failed: \(error)")
spike.log("\(name): upload failed: \(errorText(error))")
}
}
}
}
private func upload(_ data: Data) async throws -> String {
let body = HTTPBody(data)
let typed: Operations.UploadBlob.Input.Body = switch imageType(data) {
case "png": .png(body)
case "jpeg": .jpeg(body)
case "gif": .imageGif(body)
default: .imageWebp(body)
}
return try await api.client.uploadBlob(body: typed).created.body.json.id
}
}
struct Thumbnails: View {
@@ -143,13 +157,14 @@ struct Thumbnails: View {
}
struct ComposerSpike: View {
@Environment(Server.self) private var server
@Environment(Store.self) private var store
@Environment(SpikeLog.self) private var spike
var body: some View {
HStack(alignment: .top, spacing: 16) {
SwiftUIComposer(attachments: Attachments(server: server, name: "TextEditor"))
AppKitComposerPanel(attachments: Attachments(server: server, name: "NSTextView"))
RoutedComposer(attachments: Attachments(server: server, name: "Routed"))
SwiftUIComposer(attachments: Attachments(spike: spike, api: store.api, name: "TextEditor"))
AppKitComposerPanel(attachments: Attachments(spike: spike, api: store.api, name: "NSTextView"))
RoutedComposer(attachments: Attachments(spike: spike, api: store.api, name: "Routed"))
}
.padding(8)
}
@@ -215,7 +230,7 @@ struct RoutedComposer: View {
.focusedValue(\.composer, attachments)
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
attachments.server.log("Routed: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)")
attachments.spike.log("Routed: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)")
if press.modifiers.contains(.shift) || marked {
return .ignored
}
@@ -233,7 +248,7 @@ struct RoutedComposer: View {
}
}
.onChange(of: targeted) {
attachments.server.log("Routed: overlay drop targeted \(targeted)")
attachments.spike.log("Routed: overlay drop targeted \(targeted)")
}
}
}
@@ -254,7 +269,7 @@ struct SwiftUIComposer: View {
.border(Color.secondary.opacity(0.4))
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
attachments.server.log("TextEditor: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)")
attachments.spike.log("TextEditor: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)")
if press.modifiers.contains(.shift) {
return .ignored
}
@@ -296,7 +311,7 @@ final class ComposerTextView: NSTextView {
override func keyDown(with event: NSEvent) {
if event.keyCode == 36 || event.keyCode == 76 {
attachments.server.log("NSTextView: keyDown return, modifiers \(event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue), hasMarkedText \(hasMarkedText())")
attachments.spike.log("NSTextView: keyDown return, modifiers \(event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue), hasMarkedText \(hasMarkedText())")
}
super.keyDown(with: event)
}