feat(ios): add ClawdisNode app scaffold
This commit is contained in:
120
apps/ios/Sources/Screen/ScreenController.swift
Normal file
120
apps/ios/Sources/Screen/ScreenController.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import ClawdisNodeKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
final class ScreenController: ObservableObject {
|
||||
let webView: WKWebView
|
||||
|
||||
@Published var mode: ClawdisScreenMode = .web
|
||||
@Published var urlString: String = "https://example.com"
|
||||
@Published var errorText: String?
|
||||
|
||||
init() {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .nonPersistent()
|
||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||
}
|
||||
|
||||
func setMode(_ mode: ClawdisScreenMode) {
|
||||
self.mode = mode
|
||||
self.reload()
|
||||
}
|
||||
|
||||
func navigate(to urlString: String) {
|
||||
self.urlString = urlString
|
||||
self.reload()
|
||||
}
|
||||
|
||||
func reload() {
|
||||
switch self.mode {
|
||||
case .web:
|
||||
guard let url = URL(string: self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
|
||||
self.webView.load(URLRequest(url: url))
|
||||
case .canvas:
|
||||
self.webView.loadHTMLString(Self.canvasScaffoldHTML, baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func eval(javaScript: String) async throws -> String {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
cont.resume(returning: String(describing: result))
|
||||
} else {
|
||||
cont.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
|
||||
let config = WKSnapshotConfiguration()
|
||||
if let maxWidth {
|
||||
config.snapshotWidth = NSNumber(value: Double(maxWidth))
|
||||
}
|
||||
let image: UIImage = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<UIImage, Error>) in
|
||||
self.webView.takeSnapshot(with: config) { image, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image else {
|
||||
cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot failed",
|
||||
]))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
guard let data = image.pngData() else {
|
||||
throw NSError(domain: "Screen", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot encode failed",
|
||||
])
|
||||
}
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
private static let canvasScaffoldHTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Canvas</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html,body { height:100%; margin:0; }
|
||||
body {
|
||||
font: 13px -apple-system, system-ui;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
background:#0b1020;
|
||||
color:#e5e7eb;
|
||||
}
|
||||
.card {
|
||||
max-width: 520px;
|
||||
padding: 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
background: rgba(255,255,255,.06);
|
||||
box-shadow: 0 18px 60px rgba(0,0,0,.35);
|
||||
}
|
||||
.muted { color: rgba(229,231,235,.75); margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div style="font-weight:600; font-size:14px;">Canvas scaffold</div>
|
||||
<div class="muted">Next: agent-driven on-disk workspace.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
74
apps/ios/Sources/Screen/ScreenTab.swift
Normal file
74
apps/ios/Sources/Screen/ScreenTab.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
import ClawdisNodeKit
|
||||
import SwiftUI
|
||||
|
||||
struct ScreenTab: View {
|
||||
@EnvironmentObject private var appModel: NodeAppModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
ScreenWebView(controller: self.appModel.screen)
|
||||
.overlay(alignment: .top) {
|
||||
if let errorText = self.appModel.screen.errorText {
|
||||
Text(errorText)
|
||||
.font(.footnote)
|
||||
.padding(10)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Picker(
|
||||
"Mode",
|
||||
selection: Binding(
|
||||
get: { self.appModel.screen.mode },
|
||||
set: { self.appModel.screen.setMode($0) }))
|
||||
{
|
||||
Text("Web").tag(ClawdisScreenMode.web)
|
||||
Text("Canvas").tag(ClawdisScreenMode.canvas)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
TextField(
|
||||
"URL",
|
||||
text: Binding(
|
||||
get: { self.appModel.screen.urlString },
|
||||
set: { self.appModel.screen.urlString = $0 }))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Go") { self.navigate() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
if self.appModel.isBackgrounded {
|
||||
Text("Screen commands unavailable while backgrounded.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Screen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private func navigate() {
|
||||
if self.appModel.isBackgrounded {
|
||||
self.appModel.screen.errorText = ClawdisNodeError(
|
||||
code: .backgroundUnavailable,
|
||||
message: "NODE_BACKGROUND_UNAVAILABLE: screen commands require foreground").message
|
||||
return
|
||||
}
|
||||
self.appModel.screen.errorText = nil
|
||||
self.appModel.screen.reload()
|
||||
}
|
||||
}
|
||||
15
apps/ios/Sources/Screen/ScreenWebView.swift
Normal file
15
apps/ios/Sources/Screen/ScreenWebView.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import ClawdisNodeKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct ScreenWebView: UIViewRepresentable {
|
||||
@ObservedObject var controller: ScreenController
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
self.controller.webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
// State changes are driven by ScreenController.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user