302 lines
9.4 KiB
Swift
302 lines
9.4 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import Network
|
|
import Observation
|
|
import SwiftUI
|
|
import WebKit
|
|
|
|
enum Page: Equatable {
|
|
case file(taskId: String, path: String)
|
|
case preview(taskId: String, port: Int)
|
|
|
|
var taskId: String {
|
|
switch self {
|
|
case .file(let taskId, _), .preview(let taskId, _): taskId
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func title(_ store: Store) -> String {
|
|
switch self {
|
|
case .file(_, let path): (path as NSString).lastPathComponent
|
|
case .preview(let taskId, let port): store.forwards.name(taskId, port) ?? "port \(port)"
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func url(_ store: Store) -> URL? {
|
|
switch self {
|
|
case .file(let taskId, let path): store.pages.url(taskId, path)
|
|
case .preview(let taskId, let port): store.forwards.url(taskId, port)
|
|
}
|
|
}
|
|
}
|
|
|
|
private let pageExts: Set<String> = ["html", "htm"]
|
|
|
|
func isPage(_ path: String) -> Bool {
|
|
pageExts.contains((path as NSString).pathExtension.lowercased())
|
|
}
|
|
|
|
extension Store {
|
|
func openPage(_ page: Page) {
|
|
sheet = Sheet(owner: .task(page.taskId), view: .page(page))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class Pages {
|
|
private(set) var port: Int?
|
|
@ObservationIgnored var served: [String: [String: Data]] = [:]
|
|
@ObservationIgnored private var listener: PageListener?
|
|
|
|
init(api: API) {
|
|
listener = PageListener(api: api, ready: { [weak self] port in
|
|
Task { @MainActor in
|
|
self?.port = port
|
|
}
|
|
}, served: { [weak self] taskId, path, body in
|
|
Task { @MainActor in
|
|
self?.served[taskId, default: [:]][path] = body
|
|
}
|
|
})
|
|
}
|
|
|
|
func url(_ taskId: String, _ path: String) -> URL? {
|
|
guard let port else {
|
|
return nil
|
|
}
|
|
var c = URLComponents()
|
|
c.scheme = "http"
|
|
c.host = "127.0.0.1"
|
|
c.port = port
|
|
c.path = "/\(taskId)/\(path)"
|
|
return c.url
|
|
}
|
|
|
|
func close() {
|
|
listener?.close()
|
|
listener = nil
|
|
port = nil
|
|
}
|
|
|
|
static func fetch(_ api: API, _ taskId: String, _ path: String) async -> (status: Int, type: String, body: Data) {
|
|
do {
|
|
let (body, response) = try await api.session.data(from: api.url("/api/tasks/\(taskId)/raw", [URLQueryItem(name: "path", value: path)]))
|
|
let http = response as! HTTPURLResponse
|
|
return (http.statusCode, http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream", body)
|
|
} catch {
|
|
return (502, "text/plain; charset=utf-8", Data(errorText(error).utf8))
|
|
}
|
|
}
|
|
}
|
|
|
|
private let headEnd = Data("\r\n\r\n".utf8)
|
|
private let maxHead = 64 << 10
|
|
|
|
private final class PageListener: @unchecked Sendable {
|
|
private let api: API
|
|
private let queue = DispatchQueue(label: "run.fc.byre.pages")
|
|
private let served: @Sendable (String, String, Data) -> Void
|
|
private var listener: NWListener?
|
|
|
|
init(api: API, ready: @escaping @Sendable (Int) -> Void, served: @escaping @Sendable (String, String, Data) -> Void) {
|
|
self.api = api
|
|
self.served = served
|
|
let params = NWParameters.tcp
|
|
params.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
|
|
let listener: NWListener
|
|
do {
|
|
listener = try NWListener(using: params)
|
|
} catch {
|
|
log.error("page listener: \(String(describing: error), privacy: .public)")
|
|
return
|
|
}
|
|
self.listener = listener
|
|
listener.stateUpdateHandler = { [weak listener] state in
|
|
switch state {
|
|
case .ready:
|
|
if let port = listener?.port {
|
|
ready(Int(port.rawValue))
|
|
}
|
|
case .failed(let error):
|
|
log.error("page listener: \(String(describing: error), privacy: .public)")
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
listener.newConnectionHandler = { [weak self] conn in
|
|
guard let self else {
|
|
conn.cancel()
|
|
return
|
|
}
|
|
conn.start(queue: self.queue)
|
|
self.read(conn, Data())
|
|
}
|
|
listener.start(queue: queue)
|
|
}
|
|
|
|
func close() {
|
|
queue.async {
|
|
self.listener?.newConnectionHandler = nil
|
|
self.listener?.cancel()
|
|
self.listener = nil
|
|
}
|
|
}
|
|
|
|
private func read(_ conn: NWConnection, _ buf: Data) {
|
|
conn.receive(minimumIncompleteLength: 1, maximumLength: maxHead) { data, _, complete, error in
|
|
var buf = buf
|
|
if let data {
|
|
buf.append(data)
|
|
}
|
|
if let end = buf.range(of: headEnd) {
|
|
self.serve(conn, String(decoding: buf[..<end.lowerBound], as: UTF8.self))
|
|
return
|
|
}
|
|
if complete || error != nil || buf.count > maxHead {
|
|
conn.cancel()
|
|
return
|
|
}
|
|
self.read(conn, buf)
|
|
}
|
|
}
|
|
|
|
private func serve(_ conn: NWConnection, _ head: String) {
|
|
let parts = head.components(separatedBy: "\r\n")[0].split(separator: " ")
|
|
guard parts.count == 3, parts[0] == "GET", let path = URLComponents(string: String(parts[1]))?.path else {
|
|
respond(conn, 400, "text/plain; charset=utf-8", Data("bad request".utf8))
|
|
return
|
|
}
|
|
let route = path.drop(while: { $0 == "/" }).split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
|
|
guard route.count == 2, !route[1].isEmpty else {
|
|
respond(conn, 404, "text/plain; charset=utf-8", Data("not found".utf8))
|
|
return
|
|
}
|
|
let taskId = String(route[0])
|
|
let file = route[1].hasSuffix("/") ? "\(route[1])index.html" : String(route[1])
|
|
let api = api
|
|
Task {
|
|
let (status, type, body) = await Pages.fetch(api, taskId, file)
|
|
self.served(taskId, file, body)
|
|
self.respond(conn, status, type, body)
|
|
}
|
|
}
|
|
|
|
private func respond(_ conn: NWConnection, _ status: Int, _ type: String, _ body: Data) {
|
|
let head = "HTTP/1.1 \(status) \(HTTPURLResponse.localizedString(forStatusCode: status))\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n"
|
|
conn.send(content: Data(head.utf8) + body, isComplete: true, completion: .contentProcessed { _ in
|
|
conn.cancel()
|
|
})
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
private final class PageWatch {
|
|
private(set) var reloads = 0
|
|
@ObservationIgnored private var timer: Task<Void, Never>?
|
|
|
|
func check(_ api: API, _ pages: Pages, _ taskId: String) {
|
|
timer?.cancel()
|
|
timer = Task {
|
|
try? await Task.sleep(for: .milliseconds(1500))
|
|
var changed = false
|
|
for (path, shown) in pages.served[taskId] ?? [:] {
|
|
let fresh = await Pages.fetch(api, taskId, path).body
|
|
if Task.isCancelled {
|
|
return
|
|
}
|
|
if fresh != shown {
|
|
pages.served[taskId]?[path] = fresh
|
|
changed = true
|
|
}
|
|
}
|
|
if changed {
|
|
reloads += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
timer?.cancel()
|
|
timer = nil
|
|
}
|
|
}
|
|
|
|
struct PageView: View {
|
|
let page: Page
|
|
@Environment(Store.self) private var store
|
|
@ViewState private var watch = PageWatch()
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let url = page.url(store) {
|
|
WebPage(url: url, reloads: watch.reloads)
|
|
} else {
|
|
EmptyText(text: "Loading…")
|
|
}
|
|
}
|
|
.task(id: page) {
|
|
guard case .file(let taskId, _) = page else {
|
|
return
|
|
}
|
|
let watch = watch
|
|
let api = store.api
|
|
let pages = store.pages
|
|
let off = store.selectedFeed?.appended.add { events in
|
|
if events.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
|
|
watch.check(api, pages, taskId)
|
|
}
|
|
}
|
|
await untilCancelled()
|
|
off?()
|
|
watch.stop()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WebPage: NSViewRepresentable {
|
|
let url: URL
|
|
let reloads: Int
|
|
|
|
final class Coordinator: NSObject, WKUIDelegate {
|
|
var url: URL?
|
|
var reloads = 0
|
|
|
|
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
|
if let url = action.request.url {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
func makeNSView(context: Context) -> WKWebView {
|
|
let config = WKWebViewConfiguration()
|
|
config.websiteDataStore = .nonPersistent()
|
|
let view = WKWebView(frame: .zero, configuration: config)
|
|
view.uiDelegate = context.coordinator
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ view: WKWebView, context: Context) {
|
|
let c = context.coordinator
|
|
if c.url != url {
|
|
c.url = url
|
|
c.reloads = reloads
|
|
view.load(URLRequest(url: url))
|
|
return
|
|
}
|
|
if c.reloads != reloads {
|
|
c.reloads = reloads
|
|
view.reload()
|
|
}
|
|
}
|
|
}
|