Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc235fc312 | ||
|
|
249f97d1ed | ||
|
|
3e9310d6cd | ||
|
|
9051c5891e | ||
|
|
56d94e6974 | ||
|
|
e6a96bea47 | ||
|
|
cf82e37c36 | ||
|
|
4fb3e0500a | ||
|
|
9c7d51429e | ||
|
|
c1985443fd | ||
|
|
17a27fd312 | ||
|
|
557ffdbe35 | ||
|
|
e9bfe34850 | ||
|
|
1a4540d386 | ||
|
|
a0c4b1e061 | ||
|
|
e275ba8d2e | ||
|
|
db7eeee07b | ||
|
|
84d5f24f5f | ||
|
|
42948b70e3 | ||
|
|
28d3bd03b2 |
No files matched your search
@@ -17,6 +17,9 @@ apps/ios/*.xcodeproj/
|
||||
apps/ios/*.xcworkspace/
|
||||
apps/ios/.swiftpm/
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
|
||||
# fastlane (iOS)
|
||||
apps/ios/fastlane/README.md
|
||||
apps/ios/fastlane/report.xml
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks. Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.steipete.clawdis.node.bridge.BridgeDiscovery
|
||||
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
||||
@@ -183,8 +182,6 @@ class NodeRuntime(context: Context) {
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = "dev",
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = Build.MODEL,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
@@ -207,8 +204,6 @@ class NodeRuntime(context: Context) {
|
||||
token = authToken,
|
||||
platform = "Android",
|
||||
version = "dev",
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = Build.MODEL,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
object BonjourEscapes {
|
||||
fun decode(input: String): String {
|
||||
if (input.isEmpty()) return input
|
||||
|
||||
val out = StringBuilder(input.length)
|
||||
var i = 0
|
||||
while (i < input.length) {
|
||||
if (input[i] == '\\' && i + 3 < input.length) {
|
||||
val d0 = input[i + 1]
|
||||
val d1 = input[i + 2]
|
||||
val d2 = input[i + 3]
|
||||
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
|
||||
val value =
|
||||
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
|
||||
if (value in 0..0x10FFFF) {
|
||||
out.appendCodePoint(value)
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.append(input[i])
|
||||
i += 1
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import java.net.InetAddress
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xbill.DNS.ExtendedResolver
|
||||
import org.xbill.DNS.Lookup
|
||||
import org.xbill.DNS.PTRRecord
|
||||
import org.xbill.DNS.SRVRecord
|
||||
@@ -25,6 +28,7 @@ class BridgeDiscovery(
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val serviceType = "_clawdis-bridge._tcp."
|
||||
private val wideAreaDomain = "clawdis.internal."
|
||||
|
||||
@@ -100,8 +104,10 @@ class BridgeDiscovery(
|
||||
val port = resolved.port
|
||||
if (port <= 0) return
|
||||
|
||||
val displayName = txt(resolved, "displayName") ?: resolved.serviceName
|
||||
val id = stableId(resolved.serviceName, "local.")
|
||||
val rawServiceName = resolved.serviceName
|
||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||
publish()
|
||||
}
|
||||
@@ -133,13 +139,15 @@ class BridgeDiscovery(
|
||||
}
|
||||
|
||||
private suspend fun refreshUnicast(domain: String) {
|
||||
val resolver = createUnicastResolver()
|
||||
val ptrName = "${serviceType}${domain}"
|
||||
val ptrRecords = lookup(ptrName, Type.PTR).mapNotNull { it as? PTRRecord }
|
||||
val ptrRecords = lookup(ptrName, Type.PTR, resolver).mapNotNull { it as? PTRRecord }
|
||||
|
||||
val next = LinkedHashMap<String, BridgeEndpoint>()
|
||||
for (ptr in ptrRecords) {
|
||||
val instanceFqdn = ptr.target.toString()
|
||||
val srv = lookup(instanceFqdn, Type.SRV).firstOrNull { it is SRVRecord } as? SRVRecord ?: continue
|
||||
val srv =
|
||||
lookup(instanceFqdn, Type.SRV, resolver).firstOrNull { it is SRVRecord } as? SRVRecord ?: continue
|
||||
val port = srv.port
|
||||
if (port <= 0) continue
|
||||
|
||||
@@ -152,9 +160,9 @@ class BridgeDiscovery(
|
||||
null
|
||||
} ?: continue
|
||||
|
||||
val txt = lookup(instanceFqdn, Type.TXT).mapNotNull { it as? TXTRecord }
|
||||
val instanceName = decodeInstanceName(instanceFqdn, domain)
|
||||
val displayName = txtValue(txt, "displayName") ?: instanceName
|
||||
val txt = lookup(instanceFqdn, Type.TXT, resolver).mapNotNull { it as? TXTRecord }
|
||||
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||
}
|
||||
@@ -179,15 +187,41 @@ class BridgeDiscovery(
|
||||
return raw.removeSuffix(".")
|
||||
}
|
||||
|
||||
private fun lookup(name: String, type: Int): List<org.xbill.DNS.Record> {
|
||||
private fun lookup(name: String, type: Int, resolver: org.xbill.DNS.Resolver?): List<org.xbill.DNS.Record> {
|
||||
return try {
|
||||
val out = Lookup(name, type).run() ?: return emptyList()
|
||||
val lookup = Lookup(name, type)
|
||||
if (resolver != null) {
|
||||
lookup.setResolver(resolver)
|
||||
lookup.setCache(null)
|
||||
}
|
||||
val out = lookup.run() ?: return emptyList()
|
||||
out.toList()
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createUnicastResolver(): org.xbill.DNS.Resolver? {
|
||||
val cm = connectivity ?: return null
|
||||
val net = cm.activeNetwork ?: return null
|
||||
val dnsServers = cm.getLinkProperties(net)?.dnsServers ?: return null
|
||||
val addrs =
|
||||
dnsServers
|
||||
.mapNotNull { it.hostAddress }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
if (addrs.isEmpty()) return null
|
||||
|
||||
return try {
|
||||
ExtendedResolver(addrs.toTypedArray()).apply {
|
||||
setTimeout(Duration.ofMillis(1500))
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun txtValue(records: List<TXTRecord>, key: String): String? {
|
||||
val prefix = "$key="
|
||||
for (r in records) {
|
||||
|
||||
@@ -24,8 +24,6 @@ class BridgePairingClient {
|
||||
val token: String?,
|
||||
val platform: String?,
|
||||
val version: String?,
|
||||
val deviceFamily: String?,
|
||||
val modelIdentifier: String?,
|
||||
)
|
||||
|
||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||
@@ -57,8 +55,6 @@ class BridgePairingClient {
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -80,8 +76,6 @@ class BridgePairingClient {
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -39,8 +39,6 @@ class BridgeSession(
|
||||
val token: String?,
|
||||
val platform: String?,
|
||||
val version: String?,
|
||||
val deviceFamily: String?,
|
||||
val modelIdentifier: String?,
|
||||
)
|
||||
|
||||
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?)
|
||||
@@ -193,8 +191,6 @@ class BridgeSession(
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class BonjourEscapesTest {
|
||||
@Test
|
||||
fun decodeNoop() {
|
||||
assertEquals("", BonjourEscapes.decode(""))
|
||||
assertEquals("hello", BonjourEscapes.decode("hello"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeDecodesDecimalEscapes() {
|
||||
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway"))
|
||||
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -46,8 +46,6 @@ class BridgePairingClientTest {
|
||||
token = "token-123",
|
||||
platform = "Android",
|
||||
version = "test",
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = "SM-X000",
|
||||
),
|
||||
)
|
||||
assertTrue(res.ok)
|
||||
@@ -93,8 +91,6 @@ class BridgePairingClientTest {
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = "test",
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = "SM-X000",
|
||||
),
|
||||
)
|
||||
assertTrue(res.ok)
|
||||
@@ -102,3 +98,4 @@ class BridgePairingClientTest {
|
||||
server.await()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import ClawdisKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AnthropicAuthControls: View {
|
||||
let connectionMode: AppState.ConnectionMode
|
||||
|
||||
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
|
||||
@State private var pkce: AnthropicOAuth.PKCE?
|
||||
@State private var code: String = ""
|
||||
@State private var busy = false
|
||||
@State private var statusText: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.connectionMode == .remote {
|
||||
Text("Gateway runs remotely; OAuth must be created on the gateway host where Pi runs.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(self.oauthStatus.shortDescription)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
|
||||
|
||||
Button("Refresh") {
|
||||
self.refresh()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Text(PiOAuthStore.oauthURL().path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
self.startOAuth()
|
||||
} label: {
|
||||
if self.busy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.connectionMode == .remote || self.busy)
|
||||
|
||||
if self.pkce != nil {
|
||||
Button("Cancel") {
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy)
|
||||
}
|
||||
}
|
||||
|
||||
if self.pkce != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paste `code#state`")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("code#state", text: self.$code)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.busy)
|
||||
|
||||
Button("Connect") {
|
||||
Task { await self.finishOAuth() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy || self.connectionMode == .remote || self.code
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
|
||||
}
|
||||
|
||||
private func startOAuth() {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard !self.busy else { return }
|
||||
self.busy = true
|
||||
defer { self.busy = false }
|
||||
|
||||
do {
|
||||
let pkce = try AnthropicOAuth.generatePKCE()
|
||||
self.pkce = pkce
|
||||
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
||||
NSWorkspace.shared.open(url)
|
||||
self.statusText = "Browser opened. After approving, paste the `code#state` value here."
|
||||
} catch {
|
||||
self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func finishOAuth() async {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard !self.busy else { return }
|
||||
guard let pkce = self.pkce else { return }
|
||||
self.busy = true
|
||||
defer { self.busy = false }
|
||||
|
||||
let trimmed = self.code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
|
||||
let code = splits.first ?? ""
|
||||
let state = splits.count > 1 ? splits[1] : ""
|
||||
|
||||
do {
|
||||
let creds = try await AnthropicOAuth.exchangeCode(code: code, state: state, verifier: pkce.verifier)
|
||||
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refresh()
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = "Connected. Pi can now use Claude via OAuth."
|
||||
} catch {
|
||||
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,52 @@ struct AnthropicOAuthCredentials: Codable {
|
||||
let expires: Int64
|
||||
}
|
||||
|
||||
enum AnthropicAuthMode: Equatable {
|
||||
case oauthFile
|
||||
case oauthEnv
|
||||
case apiKeyEnv
|
||||
case missing
|
||||
|
||||
var shortLabel: String {
|
||||
switch self {
|
||||
case .oauthFile: "OAuth (Pi token file)"
|
||||
case .oauthEnv: "OAuth (env var)"
|
||||
case .apiKeyEnv: "API key (env var)"
|
||||
case .missing: "Missing credentials"
|
||||
}
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
switch self {
|
||||
case .missing: false
|
||||
case .oauthFile, .oauthEnv, .apiKeyEnv: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicAuthResolver {
|
||||
static func resolve(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||
{
|
||||
if oauthStatus.isConnected { return .oauthFile }
|
||||
|
||||
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return .oauthEnv
|
||||
}
|
||||
|
||||
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!key.isEmpty
|
||||
{
|
||||
return .apiKeyEnv
|
||||
}
|
||||
|
||||
return .missing
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicOAuth {
|
||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "anthropic-oauth")
|
||||
|
||||
@@ -107,6 +153,7 @@ enum AnthropicOAuth {
|
||||
enum PiOAuthStore {
|
||||
static let oauthFilename = "oauth.json"
|
||||
private static let providerKey = "anthropic"
|
||||
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
|
||||
|
||||
enum AnthropicOAuthStatus: Equatable {
|
||||
case missingFile
|
||||
@@ -123,18 +170,26 @@ enum PiOAuthStore {
|
||||
|
||||
var shortDescription: String {
|
||||
switch self {
|
||||
case .missingFile: "oauth.json not found"
|
||||
case .unreadableFile: "oauth.json not readable"
|
||||
case .invalidJSON: "oauth.json invalid"
|
||||
case .missingProviderEntry: "oauth.json has no anthropic entry"
|
||||
case .missingTokens: "anthropic entry missing tokens"
|
||||
case .connected: "OAuth credentials found"
|
||||
case .missingFile: "Pi OAuth token file not found"
|
||||
case .unreadableFile: "Pi OAuth token file not readable"
|
||||
case .invalidJSON: "Pi OAuth token file invalid"
|
||||
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
|
||||
case .missingTokens: "Anthropic entry missing tokens"
|
||||
case .connected: "Pi OAuth credentials found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func oauthDir() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
let expanded = NSString(string: override).expandingTildeInPath
|
||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||
}
|
||||
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".pi", isDirectory: true)
|
||||
.appendingPathComponent("agent", isDirectory: true)
|
||||
}
|
||||
|
||||
@@ -332,14 +332,7 @@ final class AppState {
|
||||
self.voiceWakeGlobalSyncTask?.cancel()
|
||||
self.voiceWakeGlobalSyncTask = Task { [sanitized] in
|
||||
try? await Task.sleep(nanoseconds: 650_000_000)
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.request(
|
||||
method: "voicewake.set",
|
||||
params: ["triggers": AnyCodable(sanitized)],
|
||||
timeoutMs: 10000)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
await GatewayConnection.shared.voiceWakeSetTriggers(sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,13 +167,13 @@ actor BridgeServer {
|
||||
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "node-\(nodeId)"
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: text,
|
||||
thinking: "low",
|
||||
sessionKey: sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: "last")
|
||||
channel: .last))
|
||||
|
||||
case "agent.request":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
@@ -191,15 +191,15 @@ actor BridgeServer {
|
||||
?? "node-\(nodeId)"
|
||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let channel = link.channel?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: link.deliver,
|
||||
to: to,
|
||||
channel: channel ?? "last")
|
||||
channel: channel))
|
||||
|
||||
default:
|
||||
break
|
||||
@@ -328,39 +328,35 @@ actor BridgeServer {
|
||||
}
|
||||
|
||||
private func beaconPresence(nodeId: String, reason: String) async {
|
||||
do {
|
||||
let paired = await self.store?.find(nodeId: nodeId)
|
||||
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? nodeId
|
||||
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let ip = await self.connections[nodeId]?.remoteAddress()
|
||||
let paired = await self.store?.find(nodeId: nodeId)
|
||||
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? nodeId
|
||||
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let ip = await self.connections[nodeId]?.remoteAddress()
|
||||
|
||||
var tags: [String] = ["node", "ios"]
|
||||
if let platform { tags.append(platform) }
|
||||
var tags: [String] = ["node", "ios"]
|
||||
if let platform { tags.append(platform) }
|
||||
|
||||
let summary = [
|
||||
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
|
||||
platform.map { "platform \($0)" },
|
||||
version.map { "app \($0)" },
|
||||
"mode node",
|
||||
"reason \(reason)",
|
||||
].compactMap(\.self).joined(separator: " · ")
|
||||
let summary = [
|
||||
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
|
||||
platform.map { "platform \($0)" },
|
||||
version.map { "app \($0)" },
|
||||
"mode node",
|
||||
"reason \(reason)",
|
||||
].compactMap(\.self).joined(separator: " · ")
|
||||
|
||||
var params: [String: Any] = [
|
||||
"text": summary,
|
||||
"instanceId": nodeId,
|
||||
"host": host,
|
||||
"mode": "node",
|
||||
"reason": reason,
|
||||
"tags": tags,
|
||||
]
|
||||
if let ip { params["ip"] = ip }
|
||||
if let version { params["version"] = version }
|
||||
_ = try await GatewayConnection.shared.controlRequest(method: "system-event", params: params)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
var params: [String: AnyCodable] = [
|
||||
"text": AnyCodable(summary),
|
||||
"instanceId": AnyCodable(nodeId),
|
||||
"host": AnyCodable(host),
|
||||
"mode": AnyCodable("node"),
|
||||
"reason": AnyCodable(reason),
|
||||
"tags": AnyCodable(tags),
|
||||
]
|
||||
if let ip { params["ip"] = AnyCodable(ip) }
|
||||
if let version { params["version"] = AnyCodable(version) }
|
||||
await GatewayConnection.shared.sendSystemEvent(params)
|
||||
}
|
||||
|
||||
private func startPresenceTask(nodeId: String) {
|
||||
@@ -469,10 +465,3 @@ enum BridgePairingApprover {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
fileprivate var nonEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class CanvasManager {
|
||||
static let shared = CanvasManager()
|
||||
|
||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasManager")
|
||||
|
||||
private var panelController: CanvasWindowController?
|
||||
private var panelSessionKey: String?
|
||||
|
||||
@@ -24,6 +27,8 @@ final class CanvasManager {
|
||||
}
|
||||
|
||||
func showDetailed(sessionKey: String, target: String? = nil, placement: CanvasPlacement? = nil) throws -> CanvasShowResult {
|
||||
Self.logger.debug(
|
||||
"showDetailed start session=\(sessionKey, privacy: .public) target=\(target ?? "", privacy: .public) placement=\(placement != nil)")
|
||||
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
|
||||
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedTarget = target?
|
||||
@@ -31,6 +36,7 @@ final class CanvasManager {
|
||||
.nonEmpty
|
||||
|
||||
if let controller = self.panelController, self.panelSessionKey == session {
|
||||
Self.logger.debug("showDetailed reuse existing session=\(session, privacy: .public)")
|
||||
controller.onVisibilityChanged = { [weak self] visible in
|
||||
self?.onPanelVisibilityChanged?(visible)
|
||||
}
|
||||
@@ -54,15 +60,19 @@ final class CanvasManager {
|
||||
url: nil)
|
||||
}
|
||||
|
||||
Self.logger.debug("showDetailed creating new session=\(session, privacy: .public)")
|
||||
self.panelController?.close()
|
||||
self.panelController = nil
|
||||
self.panelSessionKey = nil
|
||||
|
||||
Self.logger.debug("showDetailed ensure canvas root dir")
|
||||
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
|
||||
Self.logger.debug("showDetailed init CanvasWindowController")
|
||||
let controller = try CanvasWindowController(
|
||||
sessionKey: session,
|
||||
root: Self.canvasRoot,
|
||||
presentation: .panel(anchorProvider: anchorProvider))
|
||||
Self.logger.debug("showDetailed CanvasWindowController init done")
|
||||
controller.onVisibilityChanged = { [weak self] visible in
|
||||
self?.onPanelVisibilityChanged?(visible)
|
||||
}
|
||||
@@ -72,7 +82,9 @@ final class CanvasManager {
|
||||
|
||||
// New session: default to "/" so the user sees either the welcome page or `index.html`.
|
||||
let effectiveTarget = normalizedTarget ?? "/"
|
||||
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
|
||||
controller.showCanvas(path: effectiveTarget)
|
||||
Self.logger.debug("showDetailed showCanvas done")
|
||||
|
||||
return self.makeShowResult(
|
||||
directory: controller.directoryPath,
|
||||
@@ -215,9 +227,3 @@ final class CanvasManager {
|
||||
return FileManager.default.fileExists(atPath: index.path)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
@@ -50,18 +50,104 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
self.root = root
|
||||
self.presentation = presentation
|
||||
|
||||
canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)")
|
||||
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
||||
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
|
||||
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
||||
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
|
||||
|
||||
self.schemeHandler = CanvasSchemeHandler(root: root)
|
||||
canvasWindowLogger.debug("CanvasWindowController init scheme handler ready")
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.userContentController = WKUserContentController()
|
||||
config.preferences.isElementFullscreenEnabled = true
|
||||
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
||||
canvasWindowLogger.debug("CanvasWindowController init config ready")
|
||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
|
||||
canvasWindowLogger.debug("CanvasWindowController init scheme handler installed")
|
||||
|
||||
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
|
||||
//
|
||||
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
|
||||
// (includes the app-generated key so it won't prompt).
|
||||
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
|
||||
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
|
||||
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
|
||||
let bridgeScript = """
|
||||
(() => {
|
||||
try {
|
||||
if (location.protocol !== '\(CanvasScheme.scheme):') return;
|
||||
if (globalThis.__clawdisA2UIBridgeInstalled) return;
|
||||
globalThis.__clawdisA2UIBridgeInstalled = true;
|
||||
|
||||
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
|
||||
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
|
||||
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
|
||||
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
|
||||
|
||||
globalThis.addEventListener('a2uiaction', (evt) => {
|
||||
try {
|
||||
const payload = evt?.detail ?? evt?.payload ?? null;
|
||||
if (!payload || payload.eventType !== 'a2ui.action') return;
|
||||
|
||||
const action = payload.action ?? null;
|
||||
const name = action?.name ?? '';
|
||||
if (!name) return;
|
||||
|
||||
const context = Array.isArray(action?.context) ? action.context : [];
|
||||
const userAction = {
|
||||
id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())),
|
||||
name,
|
||||
surfaceId: payload.surfaceId ?? 'main',
|
||||
sourceComponentId: payload.sourceComponentId ?? '',
|
||||
dataContextPath: payload.dataContextPath ?? '',
|
||||
timestamp: new Date().toISOString(),
|
||||
...(context.length ? { context } : {}),
|
||||
};
|
||||
|
||||
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
||||
|
||||
// If the bundled A2UI shell is present, let it forward actions so we keep its richer
|
||||
// context resolution (data model path lookups, surface detection, etc.).
|
||||
const hasBundledA2UIHost = !!globalThis.clawdisA2UI || !!document.querySelector('clawdis-a2ui-host');
|
||||
if (hasBundledA2UIHost && handler?.postMessage) return;
|
||||
|
||||
// Otherwise, forward directly when possible.
|
||||
if (!hasBundledA2UIHost && handler?.postMessage) {
|
||||
handler.postMessage({ userAction });
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
|
||||
const message =
|
||||
'CANVAS_A2UI action=' + userAction.name +
|
||||
' session=' + sessionKey +
|
||||
' surface=' + userAction.surfaceId +
|
||||
' component=' + (userAction.sourceComponentId || '-') +
|
||||
' host=' + machineName.replace(/\\s+/g, '_') +
|
||||
' instance=' + instanceId +
|
||||
ctx +
|
||||
' default=update_canvas';
|
||||
const params = new URLSearchParams();
|
||||
params.set('message', message);
|
||||
params.set('sessionKey', sessionKey);
|
||||
params.set('thinking', 'low');
|
||||
params.set('deliver', 'false');
|
||||
params.set('channel', 'last');
|
||||
params.set('key', deepLinkKey);
|
||||
location.href = 'clawdis://agent?' + params.toString();
|
||||
} catch {}
|
||||
}, true);
|
||||
} catch {}
|
||||
})();
|
||||
"""
|
||||
config.userContentController.addUserScript(
|
||||
WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed")
|
||||
|
||||
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
|
||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||
self.webView.setValue(false, forKey: "drawsBackground")
|
||||
|
||||
@@ -93,6 +179,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
|
||||
self.container = HoverChromeContainerView(containing: self.webView)
|
||||
let window = Self.makeWindow(for: presentation, contentView: self.container)
|
||||
canvasWindowLogger.debug("CanvasWindowController init makeWindow done")
|
||||
super.init(window: window)
|
||||
|
||||
let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey)
|
||||
@@ -106,6 +193,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
self.watcher.start()
|
||||
canvasWindowLogger.debug("CanvasWindowController init done")
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -141,10 +229,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
func hideCanvas() {
|
||||
if case .panel = self.presentation {
|
||||
self.persistFrameIfPanel()
|
||||
self.window?.orderOut(nil)
|
||||
} else {
|
||||
self.close()
|
||||
}
|
||||
self.window?.orderOut(nil)
|
||||
self.onVisibilityChanged?(false)
|
||||
}
|
||||
|
||||
@@ -258,16 +344,17 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
||||
switch presentation {
|
||||
case .window:
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Clawdis Canvas"
|
||||
window.contentView = contentView
|
||||
window.center()
|
||||
window.minSize = NSSize(width: 880, height: 680)
|
||||
return window
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Clawdis Canvas"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.contentView = contentView
|
||||
window.center()
|
||||
window.minSize = NSSize(width: 880, height: 680)
|
||||
return window
|
||||
|
||||
case .panel:
|
||||
let panel = CanvasPanel(
|
||||
@@ -409,6 +496,17 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
let scheme = url.scheme?.lowercased()
|
||||
|
||||
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
|
||||
if scheme == "clawdis" {
|
||||
if self.webView.url?.scheme == CanvasScheme.scheme {
|
||||
Task { await DeepLinkHandler.shared.handle(url: url) }
|
||||
} else {
|
||||
canvasWindowLogger.debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)")
|
||||
}
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep web content inside the panel when reasonable.
|
||||
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
|
||||
if scheme == CanvasScheme.scheme
|
||||
@@ -466,6 +564,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
private static func jsStringLiteral(_ value: String) -> String {
|
||||
let data = try? JSONEncoder().encode(value)
|
||||
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
|
||||
}
|
||||
|
||||
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
|
||||
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
|
||||
}
|
||||
@@ -504,9 +607,6 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
||||
webView.url?.scheme == CanvasScheme.scheme
|
||||
else { return }
|
||||
|
||||
let path = webView.url?.path ?? ""
|
||||
guard path == "/" || path.isEmpty || path.hasPrefix("/__clawdis__/a2ui") else { return }
|
||||
|
||||
let body: [String: Any] = {
|
||||
if let dict = message.body as? [String: Any] { return dict }
|
||||
if let dict = message.body as? [AnyHashable: Any] {
|
||||
@@ -533,36 +633,82 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
||||
guard !userAction.isEmpty else { return }
|
||||
|
||||
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
|
||||
|
||||
let json: String = {
|
||||
if let data = try? JSONSerialization.data(withJSONObject: userAction, options: [.prettyPrinted, .sortedKeys]),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
{
|
||||
return str
|
||||
}
|
||||
return ""
|
||||
}()
|
||||
let actionId =
|
||||
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? UUID().uuidString
|
||||
|
||||
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
|
||||
|
||||
let text = json.isEmpty
|
||||
? "A2UI action: \(name)"
|
||||
: "A2UI action: \(name)\n\n```json\n\(json)\n```"
|
||||
let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
|
||||
let sourceComponentId = (userAction["sourceComponentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
||||
let host = Self.sanitizeTagValue(InstanceIdentity.displayName)
|
||||
let instanceId = InstanceIdentity.instanceId.lowercased()
|
||||
let contextJSON = Self.compactJSON(userAction["context"])
|
||||
let contextSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
|
||||
|
||||
Task {
|
||||
let result = await GatewayConnection.shared.sendAgent(
|
||||
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
|
||||
let text =
|
||||
"CANVAS_A2UI action=\(Self.sanitizeTagValue(name)) session=\(Self.sanitizeTagValue(self.sessionKey)) surface=\(Self.sanitizeTagValue(surfaceId)) component=\(Self.sanitizeTagValue(sourceComponentId)) host=\(host) instance=\(instanceId)\(contextSuffix) default=update_canvas"
|
||||
|
||||
Task { [weak webView] in
|
||||
if AppStateStore.shared.connectionMode == .local {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
}
|
||||
|
||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: text,
|
||||
thinking: nil,
|
||||
sessionKey: self.sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: "webchat")
|
||||
channel: .last,
|
||||
idempotencyKey: actionId))
|
||||
|
||||
await MainActor.run {
|
||||
guard let webView else { return }
|
||||
let js = Self.jsDispatchA2UIActionStatus(actionId: actionId, ok: result.ok, error: result.error)
|
||||
webView.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
if !result.ok {
|
||||
canvasWindowLogger.error(
|
||||
"A2UI action send failed name=\(name, privacy: .public) error=\(result.error ?? "unknown", privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func sanitizeTagValue(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
||||
let normalized = trimmed.replacingOccurrences(of: " ", with: "_")
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:")
|
||||
let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
private static func compactJSON(_ obj: Any?) -> String? {
|
||||
guard let obj else { return nil }
|
||||
guard JSONSerialization.isValidJSONObject(obj) else { return nil }
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
return str
|
||||
}
|
||||
|
||||
private static func jsDispatchA2UIActionStatus(actionId: String, ok: Bool, error: String?) -> String {
|
||||
let payload: [String: Any] = [
|
||||
"id": actionId,
|
||||
"ok": ok,
|
||||
"error": error ?? "",
|
||||
]
|
||||
let json: String = {
|
||||
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
{
|
||||
return str
|
||||
}
|
||||
return "{\"id\":\"\(actionId)\",\"ok\":\(ok ? "true" : "false"),\"error\":\"\"}"
|
||||
}()
|
||||
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: \(json) }));"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hover chrome container
|
||||
@@ -681,8 +827,8 @@ private final class HoverChromeContainerView: NSView {
|
||||
v.layer?.cornerRadius = 10
|
||||
v.layer?.masksToBounds = true
|
||||
v.layer?.borderWidth = 1
|
||||
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor
|
||||
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.22).cgColor
|
||||
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor
|
||||
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor
|
||||
v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor
|
||||
v.layer?.shadowOpacity = 0.35
|
||||
v.layer?.shadowRadius = 8
|
||||
@@ -691,7 +837,7 @@ private final class HoverChromeContainerView: NSView {
|
||||
}()
|
||||
|
||||
private let closeButton: NSButton = {
|
||||
let cfg = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
|
||||
let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold)
|
||||
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")?
|
||||
.withSymbolConfiguration(cfg)
|
||||
?? NSImage(size: NSSize(width: 18, height: 18))
|
||||
@@ -699,7 +845,7 @@ private final class HoverChromeContainerView: NSView {
|
||||
btn.isBordered = false
|
||||
btn.bezelStyle = .regularSquare
|
||||
btn.imageScaling = .scaleProportionallyDown
|
||||
btn.contentTintColor = NSColor.labelColor
|
||||
btn.contentTintColor = NSColor.white.withAlphaComponent(0.92)
|
||||
btn.toolTip = "Close"
|
||||
return btn
|
||||
}()
|
||||
@@ -745,8 +891,8 @@ private final class HoverChromeContainerView: NSView {
|
||||
|
||||
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
|
||||
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
||||
self.closeButton.widthAnchor.constraint(equalToConstant: 20),
|
||||
self.closeButton.heightAnchor.constraint(equalToConstant: 20),
|
||||
self.closeButton.widthAnchor.constraint(equalToConstant: 16),
|
||||
self.closeButton.heightAnchor.constraint(equalToConstant: 16),
|
||||
|
||||
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
@MainActor
|
||||
struct ConfigSettings: View {
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let state = AppStateStore.shared
|
||||
private let labelColumnWidth: CGFloat = 120
|
||||
private static let browserAttachOnlyHelp =
|
||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||
@@ -31,204 +32,7 @@ struct ConfigSettings: View {
|
||||
@State private var browserAttachOnly: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Clawdis CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
GroupBox("Agent") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Picker("Model", selection: self.$configModel) {
|
||||
ForEach(self.models) { choice in
|
||||
Text("\(choice.name) — \(choice.provider.uppercased())")
|
||||
.tag(choice.id)
|
||||
}
|
||||
Text("Manual entry…").tag("__custom__")
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.configModel) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
|
||||
if self.configModel == "__custom__" {
|
||||
TextField("Enter model ID", text: self.$customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.customModel) { _, newValue in
|
||||
self.configModel = newValue
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let modelError {
|
||||
Text(modelError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
GroupBox("Heartbeat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Schedule")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { self.heartbeatMinutes ?? 10 },
|
||||
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||
in: 0...720)
|
||||
{
|
||||
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||
.frame(width: 150, alignment: .leading)
|
||||
}
|
||||
.help("Set to 0 to disable automatic heartbeats")
|
||||
|
||||
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.heartbeatBody) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
.help("Message body sent on each heartbeat")
|
||||
}
|
||||
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
GroupBox("Web Chat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$webChatEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Port")
|
||||
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 100)
|
||||
.disabled(!self.webChatEnabled)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
"""
|
||||
Mac app connects to the gateway’s loopback web chat on this port.
|
||||
Remote mode uses SSH -L to forward it.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
GroupBox("Browser (clawd)") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$browserEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Control URL")
|
||||
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Browser path")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let label = self.browserPathLabel {
|
||||
Text(label)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} else {
|
||||
Text("—")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Accent")
|
||||
HStack(spacing: 8) {
|
||||
TextField("#FF4500", text: self.$browserColorHex)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
|
||||
Circle()
|
||||
.fill(self.browserColor)
|
||||
.frame(width: 12, height: 12)
|
||||
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
Text("lobster-orange")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$browserAttachOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
||||
.help(Self.browserAttachOnlyHelp)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(Self.browserProfileNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
ScrollView { self.content }
|
||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
@@ -245,6 +49,252 @@ struct ConfigSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.agentSection
|
||||
self.heartbeatSection
|
||||
self.webChatSection
|
||||
self.browserSection
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
Text("Clawdis CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var agentSection: some View {
|
||||
GroupBox("Agent") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
self.modelPicker
|
||||
self.customModelField
|
||||
self.modelMetaLabels
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var modelPicker: some View {
|
||||
Picker("Model", selection: self.$configModel) {
|
||||
ForEach(self.models) { choice in
|
||||
Text("\(choice.name) — \(choice.provider.uppercased())")
|
||||
.tag(choice.id)
|
||||
}
|
||||
Text("Manual entry…").tag("__custom__")
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.configModel) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var customModelField: some View {
|
||||
if self.configModel == "__custom__" {
|
||||
TextField("Enter model ID", text: self.$customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.customModel) { _, newValue in
|
||||
self.configModel = newValue
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modelMetaLabels: some View {
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let authMode = self.selectedAnthropicAuthMode {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(authMode.isConfigured ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("Anthropic auth: \(authMode.shortLabel)")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
|
||||
.help(self.anthropicAuthHelpText)
|
||||
|
||||
AnthropicAuthControls(connectionMode: self.state.connectionMode)
|
||||
}
|
||||
|
||||
if let modelError {
|
||||
Text(modelError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var anthropicAuthHelpText: String {
|
||||
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
|
||||
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
|
||||
}
|
||||
|
||||
private var heartbeatSection: some View {
|
||||
GroupBox("Heartbeat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Schedule")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { self.heartbeatMinutes ?? 10 },
|
||||
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||
in: 0...720)
|
||||
{
|
||||
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||
.frame(width: 150, alignment: .leading)
|
||||
}
|
||||
.help("Set to 0 to disable automatic heartbeats")
|
||||
|
||||
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.heartbeatBody) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
.help("Message body sent on each heartbeat")
|
||||
}
|
||||
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var webChatSection: some View {
|
||||
GroupBox("Web Chat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$webChatEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Port")
|
||||
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 100)
|
||||
.disabled(!self.webChatEnabled)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
"""
|
||||
Mac app connects to the gateway’s loopback web chat on this port.
|
||||
Remote mode uses SSH -L to forward it.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var browserSection: some View {
|
||||
GroupBox("Browser (clawd)") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$browserEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Control URL")
|
||||
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Browser path")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let label = self.browserPathLabel {
|
||||
Text(label)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} else {
|
||||
Text("—")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Accent")
|
||||
HStack(spacing: 8) {
|
||||
TextField("#FF4500", text: self.$browserColorHex)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
|
||||
Circle()
|
||||
.fill(self.browserColor)
|
||||
.frame(width: 12, height: 12)
|
||||
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
Text("lobster-orange")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$browserAttachOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
||||
.help(Self.browserAttachOnlyHelp)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(Self.browserProfileNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -424,6 +474,13 @@ struct ConfigSettings: View {
|
||||
return "Context window: \(human) tokens"
|
||||
}
|
||||
|
||||
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
|
||||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||||
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
|
||||
guard choice.provider.lowercased() == "anthropic" else { return nil }
|
||||
return AnthropicAuthResolver.resolve()
|
||||
}
|
||||
|
||||
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
|
||||
@@ -113,9 +113,7 @@ enum ControlRequestHandler {
|
||||
priority: request.priority)
|
||||
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
|
||||
case .overlay:
|
||||
await MainActor.run {
|
||||
NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||
}
|
||||
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||
return Response(ok: true)
|
||||
case .auto:
|
||||
let ok = await notifier.send(
|
||||
@@ -124,9 +122,7 @@ enum ControlRequestHandler {
|
||||
sound: chosenSound,
|
||||
priority: request.priority)
|
||||
if ok { return Response(ok: true) }
|
||||
await MainActor.run {
|
||||
NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||
}
|
||||
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||
return Response(ok: true, message: "notification not authorized; used overlay")
|
||||
}
|
||||
}
|
||||
@@ -169,16 +165,15 @@ enum ControlRequestHandler {
|
||||
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
|
||||
let sessionKey = session ?? "main"
|
||||
let rpcResult = await GatewayConnection.shared.sendAgent(
|
||||
let invocation = GatewayAgentInvocation(
|
||||
message: trimmed,
|
||||
thinking: thinking,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
channel: nil)
|
||||
return rpcResult.ok
|
||||
? Response(ok: true, message: "sent")
|
||||
: Response(ok: false, message: rpcResult.error ?? "failed to send")
|
||||
channel: .last)
|
||||
let rpcResult = await GatewayConnection.shared.sendAgent(invocation)
|
||||
return rpcResult.ok ? Response(ok: true, message: "sent") : Response(ok: false, message: rpcResult.error)
|
||||
}
|
||||
|
||||
private static func canvasEnabled() -> Bool {
|
||||
@@ -195,28 +190,36 @@ enum ControlRequestHandler {
|
||||
placement: CanvasPlacement?) async -> Response
|
||||
{
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasControl")
|
||||
logger.info("canvas show start session=\(session, privacy: .public) path=\(path ?? "", privacy: .public)")
|
||||
do {
|
||||
let res = try await MainActor.run {
|
||||
try CanvasManager.shared.showDetailed(sessionKey: session, target: path, placement: placement)
|
||||
}
|
||||
logger.info("canvas show awaiting CanvasManager")
|
||||
let res = try await CanvasManager.shared.showDetailed(sessionKey: session, target: path, placement: placement)
|
||||
logger.info("canvas show done dir=\(res.directory, privacy: .public) status=\(String(describing: res.status), privacy: .public)")
|
||||
let payload = try? JSONEncoder().encode(res)
|
||||
return Response(ok: true, message: res.directory, payload: payload)
|
||||
} catch {
|
||||
logger.error("canvas show failed \(error.localizedDescription, privacy: .public)")
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleCanvasHide(session: String) async -> Response {
|
||||
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
|
||||
await CanvasManager.shared.hide(sessionKey: session)
|
||||
return Response(ok: true)
|
||||
}
|
||||
|
||||
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasControl")
|
||||
logger.info("canvas eval start session=\(session, privacy: .public) bytes=\(javaScript.utf8.count)")
|
||||
do {
|
||||
logger.info("canvas eval awaiting CanvasManager.eval")
|
||||
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
|
||||
logger.info("canvas eval done bytes=\(result.utf8.count)")
|
||||
return Response(ok: true, payload: Data(result.utf8))
|
||||
} catch {
|
||||
logger.error("canvas eval failed \(error.localizedDescription, privacy: .public)")
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -235,16 +238,12 @@ enum ControlRequestHandler {
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
do {
|
||||
// Ensure the Canvas is visible without forcing a navigation/reload.
|
||||
_ = try await MainActor.run {
|
||||
try CanvasManager.shared.show(sessionKey: session, path: nil)
|
||||
}
|
||||
_ = try await CanvasManager.shared.show(sessionKey: session, path: nil)
|
||||
|
||||
// Wait for the in-page A2UI bridge. If it doesn't appear, force-load the bundled A2UI shell once.
|
||||
var ready = await Self.waitForCanvasA2UI(session: session, requireBuiltinPath: false, timeoutMs: 2_000)
|
||||
if !ready {
|
||||
_ = try await MainActor.run {
|
||||
try CanvasManager.shared.show(sessionKey: session, path: "/__clawdis__/a2ui/")
|
||||
}
|
||||
_ = try await CanvasManager.shared.show(sessionKey: session, path: "/__clawdis__/a2ui/")
|
||||
ready = await Self.waitForCanvasA2UI(session: session, requireBuiltinPath: true, timeoutMs: 5_000)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ struct CritterStatusLabel: View {
|
||||
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
@State private var earWiggle: CGFloat = 0
|
||||
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
|
||||
|
||||
private var isWorkingNow: Bool {
|
||||
self.iconState.isWorking || self.isWorking
|
||||
@@ -32,34 +31,18 @@ struct CritterStatusLabel: View {
|
||||
.frame(width: 18, height: 18)
|
||||
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
||||
.offset(x: self.wiggleOffset)
|
||||
.onReceive(self.ticker) { now in
|
||||
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
|
||||
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
|
||||
.task(id: self.tickTaskID) {
|
||||
guard self.animationsEnabled, !self.earBoostActive else {
|
||||
self.resetMotion()
|
||||
await MainActor.run { self.resetMotion() }
|
||||
return
|
||||
}
|
||||
|
||||
if now >= self.nextBlink {
|
||||
self.blink()
|
||||
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
}
|
||||
|
||||
if now >= self.nextWiggle {
|
||||
self.wiggle()
|
||||
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
}
|
||||
|
||||
if now >= self.nextLegWiggle {
|
||||
self.wiggleLegs()
|
||||
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
}
|
||||
|
||||
if now >= self.nextEarWiggle {
|
||||
self.wiggleEars()
|
||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
if self.isWorkingNow {
|
||||
self.scurry()
|
||||
while !Task.isCancelled {
|
||||
let now = Date()
|
||||
await MainActor.run { self.tick(now) }
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||
@@ -96,6 +79,42 @@ struct CritterStatusLabel: View {
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
|
||||
private var tickTaskID: Int {
|
||||
// Ensure SwiftUI restarts (and cancels) the task when these change.
|
||||
(self.animationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
|
||||
}
|
||||
|
||||
private func tick(_ now: Date) {
|
||||
guard self.animationsEnabled, !self.earBoostActive else {
|
||||
self.resetMotion()
|
||||
return
|
||||
}
|
||||
|
||||
if now >= self.nextBlink {
|
||||
self.blink()
|
||||
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
}
|
||||
|
||||
if now >= self.nextWiggle {
|
||||
self.wiggle()
|
||||
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
}
|
||||
|
||||
if now >= self.nextLegWiggle {
|
||||
self.wiggleLegs()
|
||||
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
}
|
||||
|
||||
if now >= self.nextEarWiggle {
|
||||
self.wiggleEars()
|
||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
if self.isWorkingNow {
|
||||
self.scurry()
|
||||
}
|
||||
}
|
||||
|
||||
private var iconImage: Image {
|
||||
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
|
||||
CritterIconRenderer.Badge(
|
||||
@@ -128,7 +147,8 @@ struct CritterStatusLabel: View {
|
||||
|
||||
private func blink() {
|
||||
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 160_000_000)
|
||||
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
||||
}
|
||||
}
|
||||
@@ -140,7 +160,8 @@ struct CritterStatusLabel: View {
|
||||
self.wiggleAngle = targetAngle
|
||||
self.wiggleOffset = targetOffset
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 360_000_000)
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
@@ -153,7 +174,8 @@ struct CritterStatusLabel: View {
|
||||
withAnimation(.easeInOut(duration: 0.14)) {
|
||||
self.legWiggle = target
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 220_000_000)
|
||||
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
||||
}
|
||||
}
|
||||
@@ -164,7 +186,8 @@ struct CritterStatusLabel: View {
|
||||
self.legWiggle = target
|
||||
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 180_000_000)
|
||||
withAnimation(.easeOut(duration: 0.16)) {
|
||||
self.legWiggle = 0.25
|
||||
self.wiggleOffset = 0
|
||||
@@ -177,8 +200,11 @@ struct CritterStatusLabel: View {
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||
self.earWiggle = target
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) {
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 }
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 320_000_000)
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||
self.earWiggle = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,16 +67,12 @@ final class CronJobsStore {
|
||||
defer { self.isLoadingJobs = false }
|
||||
|
||||
do {
|
||||
if let status = try? await self.fetchCronStatus() {
|
||||
if let status = try? await GatewayConnection.shared.cronStatus() {
|
||||
self.schedulerEnabled = status.enabled
|
||||
self.schedulerStorePath = status.storePath
|
||||
self.schedulerNextWakeAtMs = status.nextWakeAtMs
|
||||
}
|
||||
let data = try await self.request(
|
||||
method: "cron.list",
|
||||
params: ["includeDisabled": true])
|
||||
let res = try JSONDecoder().decode(CronListResponse.self, from: data)
|
||||
self.jobs = res.jobs
|
||||
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)
|
||||
if self.jobs.isEmpty {
|
||||
self.statusMessage = "No cron jobs yet."
|
||||
}
|
||||
@@ -92,11 +88,7 @@ final class CronJobsStore {
|
||||
defer { self.isLoadingRuns = false }
|
||||
|
||||
do {
|
||||
let data = try await self.request(
|
||||
method: "cron.runs",
|
||||
params: ["id": jobId, "limit": limit])
|
||||
let res = try JSONDecoder().decode(CronRunsResponse.self, from: data)
|
||||
self.runEntries = res.entries
|
||||
self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit)
|
||||
} catch {
|
||||
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
|
||||
self.lastError = error.localizedDescription
|
||||
@@ -105,10 +97,7 @@ final class CronJobsStore {
|
||||
|
||||
func runJob(id: String, force: Bool = true) async {
|
||||
do {
|
||||
_ = try await self.request(
|
||||
method: "cron.run",
|
||||
params: ["id": id, "mode": force ? "force" : "due"],
|
||||
timeoutMs: 20000)
|
||||
try await GatewayConnection.shared.cronRun(jobId: id, force: force)
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
@@ -116,7 +105,7 @@ final class CronJobsStore {
|
||||
|
||||
func removeJob(id: String) async {
|
||||
do {
|
||||
_ = try await self.request(method: "cron.remove", params: ["id": id])
|
||||
try await GatewayConnection.shared.cronRemove(jobId: id)
|
||||
await self.refreshJobs()
|
||||
if self.selectedJobId == id {
|
||||
self.selectedJobId = nil
|
||||
@@ -129,9 +118,9 @@ final class CronJobsStore {
|
||||
|
||||
func setJobEnabled(id: String, enabled: Bool) async {
|
||||
do {
|
||||
_ = try await self.request(
|
||||
method: "cron.update",
|
||||
params: ["id": id, "patch": ["enabled": enabled]])
|
||||
try await GatewayConnection.shared.cronUpdate(
|
||||
jobId: id,
|
||||
patch: ["enabled": AnyCodable(enabled)])
|
||||
await self.refreshJobs()
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
@@ -140,12 +129,12 @@ final class CronJobsStore {
|
||||
|
||||
func upsertJob(
|
||||
id: String?,
|
||||
payload: [String: Any]) async throws
|
||||
payload: [String: AnyCodable]) async throws
|
||||
{
|
||||
if let id {
|
||||
_ = try await self.request(method: "cron.update", params: ["id": id, "patch": payload])
|
||||
try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload)
|
||||
} else {
|
||||
_ = try await self.request(method: "cron.add", params: payload)
|
||||
try await GatewayConnection.shared.cronAdd(payload: payload)
|
||||
}
|
||||
await self.refreshJobs()
|
||||
}
|
||||
@@ -206,26 +195,5 @@ final class CronJobsStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RPC
|
||||
|
||||
private func request(
|
||||
method: String,
|
||||
params: [String: Any]?,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
||||
return try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
private func fetchCronStatus() async throws -> CronStatusResponse {
|
||||
let data = try await self.request(method: "cron.status", params: nil)
|
||||
return try JSONDecoder().decode(CronStatusResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CronStatusResponse: Decodable {
|
||||
let enabled: Bool
|
||||
let storePath: String
|
||||
let jobs: Int
|
||||
let nextWakeAtMs: Int?
|
||||
// MARK: - (no additional RPC helpers)
|
||||
}
|
||||
@@ -454,7 +454,7 @@ struct CronSettings: View {
|
||||
return "in \(days)d"
|
||||
}
|
||||
|
||||
private func save(payload: [String: Any]) async {
|
||||
private func save(payload: [String: AnyCodable]) async {
|
||||
guard !self.isSaving else { return }
|
||||
self.isSaving = true
|
||||
self.editorError = nil
|
||||
@@ -494,7 +494,7 @@ struct CronJobEditor: View {
|
||||
@Binding var isSaving: Bool
|
||||
@Binding var error: String?
|
||||
let onCancel: () -> Void
|
||||
let onSave: ([String: Any]) -> Void
|
||||
let onSave: ([String: AnyCodable]) -> Void
|
||||
|
||||
private let labelColumnWidth: CGFloat = 160
|
||||
private static let introText =
|
||||
@@ -530,7 +530,7 @@ struct CronJobEditor: View {
|
||||
@State private var systemEventText: String = ""
|
||||
@State private var agentMessage: String = ""
|
||||
@State private var deliver: Bool = false
|
||||
@State private var channel: String = "last"
|
||||
@State private var channel: GatewayAgentChannel = .last
|
||||
@State private var to: String = ""
|
||||
@State private var thinking: String = ""
|
||||
@State private var timeoutSeconds: String = ""
|
||||
@@ -801,9 +801,9 @@ struct CronJobEditor: View {
|
||||
GridRow {
|
||||
self.gridLabel("Channel")
|
||||
Picker("", selection: self.$channel) {
|
||||
Text("last").tag("last")
|
||||
Text("whatsapp").tag("whatsapp")
|
||||
Text("telegram").tag("telegram")
|
||||
Text("last").tag(GatewayAgentChannel.last)
|
||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
@@ -861,7 +861,7 @@ struct CronJobEditor: View {
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
self.channel = channel ?? "last"
|
||||
self.channel = GatewayAgentChannel(raw: channel)
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
@@ -879,7 +879,7 @@ struct CronJobEditor: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func buildPayload() throws -> [String: Any] {
|
||||
private func buildPayload() throws -> [String: AnyCodable] {
|
||||
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let schedule: [String: Any]
|
||||
switch self.scheduleKind {
|
||||
@@ -969,7 +969,7 @@ struct CronJobEditor: View {
|
||||
]
|
||||
}
|
||||
|
||||
return root
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
private func buildAgentTurnPayload() -> [String: Any] {
|
||||
@@ -980,7 +980,7 @@ struct CronJobEditor: View {
|
||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
payload["channel"] = self.channel
|
||||
payload["channel"] = self.channel.rawValue
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
|
||||
@@ -12,6 +12,11 @@ final class DeepLinkHandler {
|
||||
|
||||
private var lastPromptAt: Date = .distantPast
|
||||
|
||||
// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
|
||||
// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
|
||||
// outside callers can't know this randomly generated key.
|
||||
private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey()
|
||||
|
||||
func handle(url: URL) async {
|
||||
guard let route = DeepLinkParser.parse(url) else {
|
||||
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
|
||||
@@ -35,7 +40,7 @@ final class DeepLinkHandler {
|
||||
return
|
||||
}
|
||||
|
||||
let allowUnattended = link.key == Self.expectedKey()
|
||||
let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey()
|
||||
if !allowUnattended {
|
||||
if Date().timeIntervalSince(self.lastPromptAt) < 1.0 {
|
||||
deepLinkLogger.debug("throttling deep link prompt")
|
||||
@@ -54,18 +59,24 @@ final class DeepLinkHandler {
|
||||
}
|
||||
|
||||
do {
|
||||
var params: [String: AnyCodable] = [
|
||||
"message": AnyCodable(messagePreview),
|
||||
"idempotencyKey": AnyCodable(UUID().uuidString),
|
||||
]
|
||||
if let sessionKey = link.sessionKey, !sessionKey.isEmpty { params["sessionKey"] = AnyCodable(sessionKey) }
|
||||
if let thinking = link.thinking, !thinking.isEmpty { params["thinking"] = AnyCodable(thinking) }
|
||||
if let to = link.to, !to.isEmpty { params["to"] = AnyCodable(to) }
|
||||
if let channel = link.channel, !channel.isEmpty { params["channel"] = AnyCodable(channel) }
|
||||
if let timeout = link.timeoutSeconds { params["timeout"] = AnyCodable(timeout) }
|
||||
params["deliver"] = AnyCodable(link.deliver)
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
let invocation = GatewayAgentInvocation(
|
||||
message: messagePreview,
|
||||
sessionKey: link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main",
|
||||
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
deliver: channel.shouldDeliver(link.deliver),
|
||||
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
channel: channel,
|
||||
timeoutSeconds: link.timeoutSeconds,
|
||||
idempotencyKey: UUID().uuidString)
|
||||
|
||||
_ = try await GatewayConnection.shared.request(method: "agent", params: params)
|
||||
let res = await GatewayConnection.shared.sendAgent(invocation)
|
||||
if !res.ok {
|
||||
throw NSError(
|
||||
domain: "DeepLink",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"])
|
||||
}
|
||||
} catch {
|
||||
self.presentAlert(title: "Agent request failed", message: error.localizedDescription)
|
||||
}
|
||||
@@ -77,6 +88,10 @@ final class DeepLinkHandler {
|
||||
self.expectedKey()
|
||||
}
|
||||
|
||||
static func currentCanvasKey() -> String {
|
||||
self.canvasUnattendedKey
|
||||
}
|
||||
|
||||
private static func expectedKey() -> String {
|
||||
let defaults = UserDefaults.standard
|
||||
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
|
||||
@@ -94,6 +109,17 @@ final class DeepLinkHandler {
|
||||
return key
|
||||
}
|
||||
|
||||
private nonisolated static func generateRandomKey() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
let data = Data(bytes)
|
||||
return data
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private func confirm(title: String, message: String) -> Bool {
|
||||
|
||||
@@ -423,8 +423,12 @@ actor GatewayChannelActor {
|
||||
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
|
||||
}
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway error"
|
||||
throw NSError(domain: "Gateway", code: 3, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
let code = res.error?["code"]?.value as? String
|
||||
let msg = res.error?["message"]?.value as? String
|
||||
let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
|
||||
acc[pair.key] = AnyCodable(pair.value.value)
|
||||
}
|
||||
throw GatewayResponseError(method: method, code: code, message: msg, details: details)
|
||||
}
|
||||
if let payload = res.payload {
|
||||
// Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions.
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
import ClawdisChatUI
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
private let gatewayConnectionLogger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.connection")
|
||||
|
||||
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case last
|
||||
case whatsapp
|
||||
case telegram
|
||||
case webchat
|
||||
|
||||
init(raw: String?) {
|
||||
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
self = GatewayAgentChannel(rawValue: normalized) ?? .last
|
||||
}
|
||||
|
||||
var isDeliverable: Bool { self == .whatsapp || self == .telegram }
|
||||
|
||||
func shouldDeliver(_ deliver: Bool) -> Bool { deliver && self.isDeliverable }
|
||||
}
|
||||
|
||||
struct GatewayAgentInvocation: Sendable {
|
||||
var message: String
|
||||
var sessionKey: String = "main"
|
||||
var thinking: String?
|
||||
var deliver: Bool = false
|
||||
var to: String?
|
||||
var channel: GatewayAgentChannel = .last
|
||||
var timeoutSeconds: Int?
|
||||
var idempotencyKey: String = UUID().uuidString
|
||||
}
|
||||
|
||||
/// Single, shared Gateway websocket connection for the whole app.
|
||||
///
|
||||
/// This owns exactly one `GatewayChannelActor` and reuses it across all callers
|
||||
@@ -11,8 +41,31 @@ actor GatewayConnection {
|
||||
|
||||
typealias Config = (url: URL, token: String?)
|
||||
|
||||
enum Method: String, Sendable {
|
||||
case agent = "agent"
|
||||
case status = "status"
|
||||
case setHeartbeats = "set-heartbeats"
|
||||
case systemEvent = "system-event"
|
||||
case health = "health"
|
||||
case chatHistory = "chat.history"
|
||||
case chatSend = "chat.send"
|
||||
case chatAbort = "chat.abort"
|
||||
case voicewakeGet = "voicewake.get"
|
||||
case voicewakeSet = "voicewake.set"
|
||||
case nodePairApprove = "node.pair.approve"
|
||||
case nodePairReject = "node.pair.reject"
|
||||
case cronList = "cron.list"
|
||||
case cronRuns = "cron.runs"
|
||||
case cronRun = "cron.run"
|
||||
case cronRemove = "cron.remove"
|
||||
case cronUpdate = "cron.update"
|
||||
case cronAdd = "cron.add"
|
||||
case cronStatus = "cron.status"
|
||||
}
|
||||
|
||||
private let configProvider: @Sendable () async throws -> Config
|
||||
private let sessionBox: WebSocketSessionBox?
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
private var client: GatewayChannelActor?
|
||||
private var configuredURL: URL?
|
||||
@@ -29,6 +82,8 @@ actor GatewayConnection {
|
||||
self.sessionBox = sessionBox
|
||||
}
|
||||
|
||||
// MARK: - Low-level request
|
||||
|
||||
func request(
|
||||
method: String,
|
||||
params: [String: AnyCodable]?,
|
||||
@@ -39,7 +94,66 @@ actor GatewayConnection {
|
||||
guard let client else {
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
||||
}
|
||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||
|
||||
do {
|
||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||
} catch {
|
||||
// Auto-recover in local mode by spawning/attaching a gateway and retrying a few times.
|
||||
// Canvas interactions should "just work" even if the local gateway isn't running yet.
|
||||
let isLocal = await MainActor.run { AppStateStore.shared.connectionMode == .local }
|
||||
guard isLocal else { throw error }
|
||||
|
||||
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
|
||||
|
||||
var lastError: Error = error
|
||||
for delayMs in [150, 400, 900] {
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
do {
|
||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
|
||||
func requestRaw(
|
||||
method: Method,
|
||||
params: [String: AnyCodable]? = nil,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func requestRaw(
|
||||
method: String,
|
||||
params: [String: AnyCodable]? = nil,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
try await self.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func requestDecoded<T: Decodable>(
|
||||
method: Method,
|
||||
params: [String: AnyCodable]? = nil,
|
||||
timeoutMs: Double? = nil) async throws -> T
|
||||
{
|
||||
let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
|
||||
do {
|
||||
return try self.decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func requestVoid(
|
||||
method: Method,
|
||||
params: [String: AnyCodable]? = nil,
|
||||
timeoutMs: Double? = nil) async throws
|
||||
{
|
||||
_ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
/// Ensure the underlying socket is configured (and replaced if config changed).
|
||||
@@ -114,33 +228,13 @@ actor GatewayConnection {
|
||||
}
|
||||
}
|
||||
|
||||
private let gatewayControlLogger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.control")
|
||||
// MARK: - Typed gateway API
|
||||
|
||||
extension GatewayConnection {
|
||||
private static func wrapParams(_ raw: [String: Any]?) -> [String: AnyCodable]? {
|
||||
guard let raw else { return nil }
|
||||
return raw.reduce(into: [String: AnyCodable]()) { acc, pair in
|
||||
acc[pair.key] = AnyCodable(pair.value)
|
||||
}
|
||||
}
|
||||
|
||||
func controlRequest(
|
||||
method: String,
|
||||
params: [String: Any]? = nil,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
try await self.request(method: method, params: Self.wrapParams(params), timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func status() async -> (ok: Bool, error: String?) {
|
||||
do {
|
||||
let data = try await self.controlRequest(method: "status")
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
(obj["ok"] as? Bool) ?? true
|
||||
{
|
||||
return (true, nil)
|
||||
}
|
||||
return (false, "status error")
|
||||
_ = try await self.requestRaw(method: .status)
|
||||
return (true, nil)
|
||||
} catch {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
@@ -148,39 +242,211 @@ extension GatewayConnection {
|
||||
|
||||
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
|
||||
do {
|
||||
_ = try await self.controlRequest(method: "set-heartbeats", params: ["enabled": enabled])
|
||||
try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)])
|
||||
return true
|
||||
} catch {
|
||||
gatewayControlLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
||||
gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
|
||||
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return (false, "message empty") }
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"message": AnyCodable(trimmed),
|
||||
"sessionKey": AnyCodable(invocation.sessionKey),
|
||||
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
||||
"deliver": AnyCodable(invocation.deliver),
|
||||
"to": AnyCodable(invocation.to ?? ""),
|
||||
"channel": AnyCodable(invocation.channel.rawValue),
|
||||
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
||||
]
|
||||
if let timeout = invocation.timeoutSeconds {
|
||||
params["timeout"] = AnyCodable(timeout)
|
||||
}
|
||||
|
||||
do {
|
||||
try await self.requestVoid(method: .agent, params: params)
|
||||
return (true, nil)
|
||||
} catch {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func sendAgent(
|
||||
message: String,
|
||||
thinking: String?,
|
||||
sessionKey: String,
|
||||
deliver: Bool,
|
||||
to: String?,
|
||||
channel: String? = nil,
|
||||
channel: GatewayAgentChannel = .last,
|
||||
timeoutSeconds: Int? = nil,
|
||||
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
||||
{
|
||||
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return (false, "message empty") }
|
||||
await self.sendAgent(GatewayAgentInvocation(
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
channel: channel,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
idempotencyKey: idempotencyKey))
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ params: [String: AnyCodable]) async {
|
||||
do {
|
||||
let params: [String: Any] = [
|
||||
"message": trimmed,
|
||||
"sessionKey": sessionKey,
|
||||
"thinking": thinking ?? "default",
|
||||
"deliver": deliver,
|
||||
"to": to ?? "",
|
||||
"channel": channel ?? "",
|
||||
"idempotencyKey": idempotencyKey,
|
||||
]
|
||||
_ = try await self.controlRequest(method: "agent", params: params)
|
||||
return (true, nil)
|
||||
try await self.requestVoid(method: .systemEvent, params: params)
|
||||
} catch {
|
||||
return (false, error.localizedDescription)
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Health
|
||||
|
||||
func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot {
|
||||
let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs)
|
||||
if let snap = decodeHealthSnapshot(from: data) { return snap }
|
||||
throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot")
|
||||
}
|
||||
|
||||
func healthOK(timeoutMs: Int = 8000) async throws -> Bool {
|
||||
let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs))
|
||||
return (try? self.decoder.decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
|
||||
}
|
||||
|
||||
// MARK: - Chat
|
||||
|
||||
func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
|
||||
try await self.requestDecoded(
|
||||
method: .chatHistory,
|
||||
params: ["sessionKey": AnyCodable(sessionKey)])
|
||||
}
|
||||
|
||||
func chatSend(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [ClawdisChatAttachmentPayload],
|
||||
timeoutMs: Int = 30000) async throws -> ClawdisChatSendResponse
|
||||
{
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionKey": AnyCodable(sessionKey),
|
||||
"message": AnyCodable(message),
|
||||
"thinking": AnyCodable(thinking),
|
||||
"idempotencyKey": AnyCodable(idempotencyKey),
|
||||
"timeoutMs": AnyCodable(timeoutMs),
|
||||
]
|
||||
|
||||
if !attachments.isEmpty {
|
||||
let encoded = attachments.map { att in
|
||||
[
|
||||
"type": att.type,
|
||||
"mimeType": att.mimeType,
|
||||
"fileName": att.fileName,
|
||||
"content": att.content,
|
||||
]
|
||||
}
|
||||
params["attachments"] = AnyCodable(encoded)
|
||||
}
|
||||
|
||||
return try await self.requestDecoded(method: .chatSend, params: params)
|
||||
}
|
||||
|
||||
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
|
||||
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
|
||||
let res: AbortResponse = try await self.requestDecoded(
|
||||
method: .chatAbort,
|
||||
params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)])
|
||||
return res.aborted ?? false
|
||||
}
|
||||
|
||||
// MARK: - VoiceWake
|
||||
|
||||
func voiceWakeGetTriggers() async throws -> [String] {
|
||||
struct VoiceWakePayload: Decodable { let triggers: [String] }
|
||||
let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet)
|
||||
return payload.triggers
|
||||
}
|
||||
|
||||
func voiceWakeSetTriggers(_ triggers: [String]) async {
|
||||
do {
|
||||
try await self.requestVoid(
|
||||
method: .voicewakeSet,
|
||||
params: ["triggers": AnyCodable(triggers)],
|
||||
timeoutMs: 10000)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Node pairing
|
||||
|
||||
func nodePairApprove(requestId: String) async throws {
|
||||
try await self.requestVoid(
|
||||
method: .nodePairApprove,
|
||||
params: ["requestId": AnyCodable(requestId)],
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
func nodePairReject(requestId: String) async throws {
|
||||
try await self.requestVoid(
|
||||
method: .nodePairReject,
|
||||
params: ["requestId": AnyCodable(requestId)],
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
// MARK: - Cron
|
||||
|
||||
struct CronSchedulerStatus: Decodable, Sendable {
|
||||
let enabled: Bool
|
||||
let storePath: String
|
||||
let jobs: Int
|
||||
let nextWakeAtMs: Int?
|
||||
}
|
||||
|
||||
func cronStatus() async throws -> CronSchedulerStatus {
|
||||
try await self.requestDecoded(method: .cronStatus)
|
||||
}
|
||||
|
||||
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
|
||||
let res: CronListResponse = try await self.requestDecoded(
|
||||
method: .cronList,
|
||||
params: ["includeDisabled": AnyCodable(includeDisabled)])
|
||||
return res.jobs
|
||||
}
|
||||
|
||||
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
|
||||
let res: CronRunsResponse = try await self.requestDecoded(
|
||||
method: .cronRuns,
|
||||
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
|
||||
return res.entries
|
||||
}
|
||||
|
||||
func cronRun(jobId: String, force: Bool = true) async throws {
|
||||
try await self.requestVoid(
|
||||
method: .cronRun,
|
||||
params: [
|
||||
"id": AnyCodable(jobId),
|
||||
"mode": AnyCodable(force ? "force" : "due"),
|
||||
],
|
||||
timeoutMs: 20000)
|
||||
}
|
||||
|
||||
func cronRemove(jobId: String) async throws {
|
||||
try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)])
|
||||
}
|
||||
|
||||
func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws {
|
||||
try await self.requestVoid(
|
||||
method: .cronUpdate,
|
||||
params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)])
|
||||
}
|
||||
|
||||
func cronAdd(payload: [String: AnyCodable]) async throws {
|
||||
try await self.requestVoid(method: .cronAdd, params: payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
|
||||
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
|
||||
struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
||||
let method: String
|
||||
let code: String
|
||||
let message: String
|
||||
let details: [String: AnyCodable]
|
||||
|
||||
init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) {
|
||||
self.method = method
|
||||
self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? code!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "GATEWAY_ERROR"
|
||||
self.message = (message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? message!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "gateway error"
|
||||
self.details = details ?? [:]
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
|
||||
return "\(self.method): [\(self.code)] \(self.message)"
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayDecodingError: LocalizedError, Sendable {
|
||||
let method: String
|
||||
let message: String
|
||||
|
||||
var errorDescription: String? { "\(self.method): \(self.message)" }
|
||||
}
|
||||
|
||||
@@ -152,23 +152,27 @@ final class GatewayProcessManager {
|
||||
private func attachExistingGatewayIfAvailable() async -> Bool {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(method: "health", params: nil)
|
||||
let data = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let instanceText: String
|
||||
if let instance {
|
||||
let path = instance.executablePath ?? "path unknown"
|
||||
instanceText = "pid \(instance.pid) \(instance.command) @ \(path)"
|
||||
} else {
|
||||
instanceText = "pid unknown"
|
||||
}
|
||||
|
||||
let details: String
|
||||
if let snap = decodeHealthSnapshot(from: data) {
|
||||
if let snap {
|
||||
let linked = snap.web.linked ? "linked" : "not linked"
|
||||
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let instanceText: String
|
||||
if let instance {
|
||||
let path = instance.executablePath ?? "path unknown"
|
||||
instanceText = "pid \(instance.pid) \(instance.command) @ \(path)"
|
||||
} else {
|
||||
instanceText = "pid unknown"
|
||||
}
|
||||
details = "port \(port), \(linked), auth \(authAge), \(instanceText)"
|
||||
} else {
|
||||
details = "port \(port), health probe succeeded"
|
||||
details = "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
|
||||
self.existingGatewayDetails = details
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
|
||||
@@ -136,10 +136,9 @@ struct InstancesSettings: View {
|
||||
}
|
||||
|
||||
private func deviceDescription(_ inst: InstanceInfo) -> String? {
|
||||
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let model = inst.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !family.isEmpty, !model.isEmpty { return "\(family) (\(model))" }
|
||||
if !model.isEmpty { return model }
|
||||
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return family.isEmpty ? nil : family
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
if let state {
|
||||
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
||||
}
|
||||
TerminationSignalWatcher.shared.start()
|
||||
NodePairingApprovalPrompter.shared.start()
|
||||
VoiceWakeGlobalSettingsSync.shared.start()
|
||||
Task { PresenceReporter.shared.start() }
|
||||
@@ -202,6 +203,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
GatewayProcessManager.shared.stop()
|
||||
PresenceReporter.shared.stop()
|
||||
NodePairingApprovalPrompter.shared.stop()
|
||||
TerminationSignalWatcher.shared.stop()
|
||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||
WebChatManager.shared.close()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
|
||||
@@ -68,7 +68,8 @@ struct MenuContent: View {
|
||||
if self.state.canvasPanelVisible {
|
||||
CanvasManager.shared.hideAll()
|
||||
} else {
|
||||
_ = try? CanvasManager.shared.show(sessionKey: "main", path: "/")
|
||||
// Don't force a navigation on re-open: preserve the current web view state.
|
||||
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class NodePairingApprovalPrompter {
|
||||
@@ -9,8 +11,28 @@ final class NodePairingApprovalPrompter {
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
|
||||
private var task: Task<Void, Never>?
|
||||
private var reconcileTask: Task<Void, Never>?
|
||||
private var isStopping = false
|
||||
private var isPresenting = false
|
||||
private var queue: [PendingRequest] = []
|
||||
private var activeAlert: NSAlert?
|
||||
private var activeRequestId: String?
|
||||
private var alertHostWindow: NSWindow?
|
||||
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
|
||||
|
||||
private struct PairingList: Codable {
|
||||
let pending: [PendingRequest]
|
||||
let paired: [PairedNode]?
|
||||
}
|
||||
|
||||
private struct PairedNode: Codable, Equatable {
|
||||
let nodeId: String
|
||||
let approvedAtMs: Double?
|
||||
let displayName: String?
|
||||
let platform: String?
|
||||
let version: String?
|
||||
let remoteIp: String?
|
||||
}
|
||||
|
||||
private struct PendingRequest: Codable, Equatable, Identifiable {
|
||||
let requestId: String
|
||||
@@ -25,11 +47,22 @@ final class NodePairingApprovalPrompter {
|
||||
var id: String { self.requestId }
|
||||
}
|
||||
|
||||
private enum PairingResolution: String {
|
||||
case approved
|
||||
case rejected
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
self.isStopping = false
|
||||
self.reconcileTask?.cancel()
|
||||
self.reconcileTask = Task { [weak self] in
|
||||
await self?.reconcileLoop()
|
||||
}
|
||||
self.task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = try? await GatewayConnection.shared.refresh()
|
||||
await self.loadPendingRequestsFromGateway()
|
||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
@@ -39,10 +72,159 @@ final class NodePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.isStopping = true
|
||||
self.endActiveAlert()
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
self.reconcileTask?.cancel()
|
||||
self.reconcileTask = nil
|
||||
self.queue.removeAll(keepingCapacity: false)
|
||||
self.isPresenting = false
|
||||
self.activeRequestId = nil
|
||||
self.alertHostWindow?.orderOut(nil)
|
||||
self.alertHostWindow?.close()
|
||||
self.alertHostWindow = nil
|
||||
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func loadPendingRequestsFromGateway() async {
|
||||
// The gateway process may start slightly after the app. Retry a bit so
|
||||
// pending pairing prompts are still shown on launch.
|
||||
var delayMs: UInt64 = 200
|
||||
for attempt in 1...8 {
|
||||
if Task.isCancelled { return }
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "node.pair.list",
|
||||
params: nil,
|
||||
timeoutMs: 6000)
|
||||
guard !data.isEmpty else { return }
|
||||
let list = try JSONDecoder().decode(PairingList.self, from: data)
|
||||
let pending = list.pending.sorted { $0.ts < $1.ts }
|
||||
guard !pending.isEmpty else { return }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.logger.info(
|
||||
"loaded \(pending.count, privacy: .public) pending node pairing request(s) on startup")
|
||||
for req in pending {
|
||||
self.enqueue(req)
|
||||
}
|
||||
}
|
||||
return
|
||||
} catch {
|
||||
if attempt == 8 {
|
||||
self.logger
|
||||
.error(
|
||||
"failed to load pending pairing requests: \(error.localizedDescription, privacy: .public)")
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: delayMs * 1_000_000)
|
||||
delayMs = min(delayMs * 2, 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reconcileLoop() async {
|
||||
// Reconcile requests periodically so multiple running apps stay in sync
|
||||
// (e.g. close dialogs + notify if another machine approves/rejects via app or CLI).
|
||||
let intervalMs: UInt64 = 800
|
||||
while !Task.isCancelled {
|
||||
if self.isStopping { return }
|
||||
do {
|
||||
let list = try await self.fetchPairingList(timeoutMs: 2500)
|
||||
await self.apply(list: list)
|
||||
} catch {
|
||||
// best effort: ignore transient connectivity failures
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: intervalMs * 1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchPairingList(timeoutMs: Double) async throws -> PairingList {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "node.pair.list",
|
||||
params: nil,
|
||||
timeoutMs: timeoutMs)
|
||||
return try JSONDecoder().decode(PairingList.self, from: data)
|
||||
}
|
||||
|
||||
private func apply(list: PairingList) async {
|
||||
if self.isStopping { return }
|
||||
|
||||
let pendingById = Dictionary(
|
||||
uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) })
|
||||
|
||||
// Enqueue any missing requests (covers missed pushes while reconnecting).
|
||||
for req in list.pending.sorted(by: { $0.ts < $1.ts }) {
|
||||
self.enqueue(req)
|
||||
}
|
||||
|
||||
// Detect resolved requests (approved/rejected elsewhere).
|
||||
let queued = self.queue
|
||||
for req in queued {
|
||||
if pendingById[req.requestId] != nil { continue }
|
||||
let resolution = self.inferResolution(for: req, list: list)
|
||||
|
||||
if self.activeRequestId == req.requestId, self.activeAlert != nil {
|
||||
self.remoteResolutionsByRequestId[req.requestId] = resolution
|
||||
self.logger.info(
|
||||
"pairing request resolved elsewhere; closing dialog requestId=\(req.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)")
|
||||
self.endActiveAlert()
|
||||
continue
|
||||
}
|
||||
|
||||
self.logger.info(
|
||||
"pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)")
|
||||
self.queue.removeAll { $0 == req }
|
||||
Task { @MainActor in
|
||||
await self.notify(resolution: resolution, request: req, via: "remote")
|
||||
}
|
||||
}
|
||||
|
||||
if self.queue.isEmpty {
|
||||
self.isPresenting = false
|
||||
}
|
||||
self.presentNextIfNeeded()
|
||||
}
|
||||
|
||||
private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution {
|
||||
let paired = list.paired ?? []
|
||||
guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else {
|
||||
return .rejected
|
||||
}
|
||||
if request.isRepair == true, let approvedAtMs = node.approvedAtMs {
|
||||
return approvedAtMs >= request.ts ? .approved : .rejected
|
||||
}
|
||||
return .approved
|
||||
}
|
||||
|
||||
private func endActiveAlert() {
|
||||
guard let alert = self.activeAlert else { return }
|
||||
if let parent = alert.window.sheetParent {
|
||||
parent.endSheet(alert.window, returnCode: .abort)
|
||||
}
|
||||
self.activeAlert = nil
|
||||
self.activeRequestId = nil
|
||||
}
|
||||
|
||||
private func requireAlertHostWindow() -> NSWindow {
|
||||
if let alertHostWindow {
|
||||
return alertHostWindow
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 440, height: 1),
|
||||
styleMask: [.titled],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Clawdis"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.level = .floating
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.center()
|
||||
|
||||
self.alertHostWindow = window
|
||||
return window
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
@@ -64,6 +246,7 @@ final class NodePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func presentNextIfNeeded() {
|
||||
guard !self.isStopping else { return }
|
||||
guard !self.isPresenting else { return }
|
||||
guard let next = self.queue.first else { return }
|
||||
self.isPresenting = true
|
||||
@@ -71,22 +254,33 @@ final class NodePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func presentAlert(for req: PendingRequest) {
|
||||
self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow node to connect?"
|
||||
alert.informativeText = Self.describe(req)
|
||||
// Fail-safe ordering: if the dialog can't be presented, default to "Later".
|
||||
alert.addButton(withTitle: "Later")
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
alert.addButton(withTitle: "Later")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
|
||||
alert.buttons[1].hasDestructiveAction = true
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||
alert.buttons[2].hasDestructiveAction = true
|
||||
}
|
||||
|
||||
let response = alert.runModal()
|
||||
Task { [weak self] in
|
||||
await self?.handleAlertResponse(response, request: req)
|
||||
self.activeAlert = alert
|
||||
self.activeRequestId = req.requestId
|
||||
let hostWindow = self.requireAlertHostWindow()
|
||||
hostWindow.makeKeyAndOrderFront(nil)
|
||||
alert.beginSheetModal(for: hostWindow) { [weak self] response in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeRequestId = nil
|
||||
self.activeAlert = nil
|
||||
await self.handleAlertResponse(response, request: req)
|
||||
hostWindow.orderOut(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,23 +295,32 @@ final class NodePairingApprovalPrompter {
|
||||
self.presentNextIfNeeded()
|
||||
}
|
||||
|
||||
// Never approve/reject while shutting down (alerts can get dismissed during app termination).
|
||||
guard !self.isStopping else { return }
|
||||
|
||||
if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) {
|
||||
await self.notify(resolution: resolved, request: request, via: "remote")
|
||||
return
|
||||
}
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
await self.approve(requestId: request.requestId)
|
||||
case .alertSecondButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
default:
|
||||
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
|
||||
return
|
||||
case .alertSecondButtonReturn:
|
||||
await self.approve(requestId: request.requestId)
|
||||
await self.notify(resolution: .approved, request: request, via: "local")
|
||||
case .alertThirdButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
await self.notify(resolution: .rejected, request: request, via: "local")
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func approve(requestId: String) async {
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.request(
|
||||
method: "node.pair.approve",
|
||||
params: ["requestId": AnyCodable(requestId)],
|
||||
timeoutMs: 10000)
|
||||
try await GatewayConnection.shared.nodePairApprove(requestId: requestId)
|
||||
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||
@@ -127,10 +330,7 @@ final class NodePairingApprovalPrompter {
|
||||
|
||||
private func reject(requestId: String) async {
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.request(
|
||||
method: "node.pair.reject",
|
||||
params: ["requestId": AnyCodable(requestId)],
|
||||
timeoutMs: 10000)
|
||||
try await GatewayConnection.shared.nodePairReject(requestId: requestId)
|
||||
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||
@@ -167,4 +367,25 @@ final class NodePairingApprovalPrompter {
|
||||
if raw.lowercased() == "macos" { return "macOS" }
|
||||
return raw
|
||||
}
|
||||
|
||||
private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
guard settings.authorizationStatus == .authorized ||
|
||||
settings.authorizationStatus == .provisional
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let title = resolution == .approved ? "Node pairing approved" : "Node pairing rejected"
|
||||
let name = request.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let device = name?.isEmpty == false ? name! : request.nodeId
|
||||
let body = "\(device)\n(via \(via))"
|
||||
|
||||
_ = await NotificationManager().send(
|
||||
title: title,
|
||||
body: body,
|
||||
sound: nil,
|
||||
priority: .active)
|
||||
}
|
||||
}
|
||||
@@ -325,7 +325,7 @@ struct OnboardingView: View {
|
||||
|
||||
private func anthropicAuthPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Sign in to Claude")
|
||||
Text("Connect Claude")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Give your model the token it needs!")
|
||||
.font(.body)
|
||||
@@ -422,7 +422,7 @@ struct OnboardingView: View {
|
||||
.font(.headline)
|
||||
Text(
|
||||
"You can also use an Anthropic API key, but this UI is instructions-only for now " +
|
||||
"(GUI apps don’t automatically inherit your shell env vars).")
|
||||
"(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -35,6 +35,7 @@ final class PresenceReporter {
|
||||
let host = InstanceIdentity.displayName
|
||||
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
|
||||
let version = Self.appVersionString()
|
||||
let platform = Self.platformString()
|
||||
let lastInput = Self.lastInputSeconds()
|
||||
let text = Self.composePresenceSummary(mode: mode, reason: reason)
|
||||
var params: [String: AnyHashable] = [
|
||||
@@ -43,9 +44,10 @@ final class PresenceReporter {
|
||||
"ip": AnyHashable(ip),
|
||||
"mode": AnyHashable(mode),
|
||||
"version": AnyHashable(version),
|
||||
"platform": AnyHashable(platform),
|
||||
"deviceFamily": AnyHashable("Mac"),
|
||||
"reason": AnyHashable(reason),
|
||||
]
|
||||
params["deviceFamily"] = AnyHashable("Mac")
|
||||
if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) }
|
||||
if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) }
|
||||
do {
|
||||
@@ -80,6 +82,11 @@ final class PresenceReporter {
|
||||
return version
|
||||
}
|
||||
|
||||
private static func platformString() -> String {
|
||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||
return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
}
|
||||
|
||||
private static func lastInputSeconds() -> Int? {
|
||||
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+149
-2
@@ -115,6 +115,8 @@ const clawdisTheme = {
|
||||
class ClawdisA2UIHost extends LitElement {
|
||||
static properties = {
|
||||
surfaces: { state: true },
|
||||
pendingAction: { state: true },
|
||||
toast: { state: true },
|
||||
};
|
||||
|
||||
#processor = v0_8.Data.createSignalA2uiMessageProcessor();
|
||||
@@ -124,11 +126,15 @@ class ClawdisA2UIHost extends LitElement {
|
||||
});
|
||||
|
||||
surfaces = [];
|
||||
pendingAction = null;
|
||||
toast = null;
|
||||
#statusListener = null;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -141,6 +147,71 @@ class ClawdisA2UIHost extends LitElement {
|
||||
overflow: auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font: 13px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font: 13px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: rgba(255, 109, 109, 0.35);
|
||||
color: rgba(255, 223, 223, 0.98);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: rgba(255, 255, 255, 0.92);
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
connectedCallback() {
|
||||
@@ -151,9 +222,50 @@ class ClawdisA2UIHost extends LitElement {
|
||||
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
|
||||
};
|
||||
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
|
||||
this.#statusListener = (evt) => this.#handleActionStatus(evt);
|
||||
globalThis.addEventListener("clawdis:a2ui-action-status", this.#statusListener);
|
||||
this.#syncSurfaces();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.#statusListener) {
|
||||
globalThis.removeEventListener("clawdis:a2ui-action-status", this.#statusListener);
|
||||
this.#statusListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
#makeActionId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
#setToast(text, kind = "ok", timeoutMs = 1400) {
|
||||
const toast = { text, kind, expiresAt: Date.now() + timeoutMs };
|
||||
this.toast = toast;
|
||||
this.requestUpdate();
|
||||
setTimeout(() => {
|
||||
if (this.toast === toast) {
|
||||
this.toast = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}, timeoutMs + 30);
|
||||
}
|
||||
|
||||
#handleActionStatus(evt) {
|
||||
const detail = evt?.detail ?? null;
|
||||
if (!detail || typeof detail.id !== "string") return;
|
||||
if (!this.pendingAction || this.pendingAction.id !== detail.id) return;
|
||||
|
||||
if (detail.ok) {
|
||||
this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() };
|
||||
} else {
|
||||
const msg = typeof detail.error === "string" && detail.error ? detail.error : "send failed";
|
||||
this.pendingAction = { ...this.pendingAction, phase: "error", error: msg };
|
||||
this.#setToast(`Failed: ${msg}`, "error", 4500);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
#handleA2UIAction(evt) {
|
||||
const payload = evt?.detail ?? evt?.payload ?? null;
|
||||
if (!payload || payload.eventType !== "a2ui.action") {
|
||||
@@ -208,7 +320,12 @@ class ClawdisA2UIHost extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
const actionId = this.#makeActionId();
|
||||
this.pendingAction = { id: actionId, name, phase: "sending", startedAt: Date.now() };
|
||||
this.requestUpdate();
|
||||
|
||||
const userAction = {
|
||||
id: actionId,
|
||||
name,
|
||||
surfaceId: surfaceId ?? "main",
|
||||
sourceComponentId,
|
||||
@@ -220,7 +337,16 @@ class ClawdisA2UIHost extends LitElement {
|
||||
|
||||
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
||||
if (handler?.postMessage) {
|
||||
handler.postMessage({ userAction });
|
||||
try {
|
||||
handler.postMessage({ userAction });
|
||||
} catch (e) {
|
||||
const msg = String(e?.message ?? e);
|
||||
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };
|
||||
this.#setToast(`Failed: ${msg}`, "error", 4500);
|
||||
}
|
||||
} else {
|
||||
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: "missing native bridge" };
|
||||
this.#setToast("Failed: missing native bridge", "error", 4500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +356,10 @@ class ClawdisA2UIHost extends LitElement {
|
||||
}
|
||||
this.#processor.processMessages(messages);
|
||||
this.#syncSurfaces();
|
||||
if (this.pendingAction?.phase === "sent") {
|
||||
this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100);
|
||||
this.pendingAction = null;
|
||||
}
|
||||
this.requestUpdate();
|
||||
return { ok: true, surfaces: this.surfaces.map(([id]) => id) };
|
||||
}
|
||||
@@ -237,6 +367,7 @@ class ClawdisA2UIHost extends LitElement {
|
||||
reset() {
|
||||
this.#processor.clearSurfaces();
|
||||
this.#syncSurfaces();
|
||||
this.pendingAction = null;
|
||||
this.requestUpdate();
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -253,7 +384,23 @@ class ClawdisA2UIHost extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`<section id="surfaces">
|
||||
const statusText =
|
||||
this.pendingAction?.phase === "sent"
|
||||
? `Working: ${this.pendingAction.name}`
|
||||
: this.pendingAction?.phase === "sending"
|
||||
? `Sending: ${this.pendingAction.name}`
|
||||
: this.pendingAction?.phase === "error"
|
||||
? `Failed: ${this.pendingAction.name}`
|
||||
: "";
|
||||
|
||||
return html`
|
||||
${this.pendingAction && this.pendingAction.phase !== "error"
|
||||
? html`<div class="status"><div class="spinner"></div><div>${statusText}</div></div>`
|
||||
: ""}
|
||||
${this.toast
|
||||
? html`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">${this.toast.text}</div>`
|
||||
: ""}
|
||||
<section id="surfaces">
|
||||
${repeat(
|
||||
this.surfaces,
|
||||
([surfaceId]) => surfaceId,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
var nonEmpty: String? {
|
||||
let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class TerminationSignalWatcher {
|
||||
static let shared = TerminationSignalWatcher()
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "lifecycle")
|
||||
private var sources: [DispatchSourceSignal] = []
|
||||
private var terminationRequested = false
|
||||
|
||||
func start() {
|
||||
guard self.sources.isEmpty else { return }
|
||||
self.install(SIGTERM)
|
||||
self.install(SIGINT)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
for s in self.sources {
|
||||
s.cancel()
|
||||
}
|
||||
self.sources.removeAll(keepingCapacity: false)
|
||||
self.terminationRequested = false
|
||||
}
|
||||
|
||||
private func install(_ sig: Int32) {
|
||||
// Make sure the default action doesn't kill the process before we can gracefully shut down.
|
||||
signal(sig, SIG_IGN)
|
||||
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
|
||||
source.setEventHandler { [weak self] in
|
||||
self?.handle(sig)
|
||||
}
|
||||
source.resume()
|
||||
self.sources.append(source)
|
||||
}
|
||||
|
||||
private func handle(_ sig: Int32) {
|
||||
guard !self.terminationRequested else { return }
|
||||
self.terminationRequested = true
|
||||
|
||||
self.logger.info("received signal \(sig, privacy: .public); terminating")
|
||||
// Ensure any pairing prompt can't accidentally approve during shutdown.
|
||||
NodePairingApprovalPrompter.shared.stop()
|
||||
NSApp.terminate(nil)
|
||||
|
||||
// Safety net: don't hang forever if something blocks termination.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,13 @@ struct ToolsSettings: View {
|
||||
description: "Control Sonos speakers (discover, status, play/pause, volume, grouping) from scripts.",
|
||||
method: .go(module: "github.com/steipete/sonoscli/cmd/sonos@latest", binary: "sonos"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "blucli",
|
||||
name: "🫐 blucli",
|
||||
url: URL(string: "https://github.com/steipete/blucli")!,
|
||||
description: "Play, group, and automate BluOS players from scripts.",
|
||||
method: .go(module: "github.com/steipete/blucli/cmd/blu@latest", binary: "blu"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "sag",
|
||||
name: "🗣️ sag",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import Dispatch
|
||||
import OSLog
|
||||
import Speech
|
||||
|
||||
/// Observes right Option and starts a push-to-talk capture while it is held.
|
||||
@MainActor
|
||||
final class VoicePushToTalkHotkey {
|
||||
final class VoicePushToTalkHotkey: @unchecked Sendable {
|
||||
static let shared = VoicePushToTalkHotkey()
|
||||
|
||||
private var globalMonitor: Any?
|
||||
@@ -13,29 +13,48 @@ final class VoicePushToTalkHotkey {
|
||||
private var optionDown = false // right option only
|
||||
private var active = false
|
||||
|
||||
private let beginAction: @Sendable () async -> Void
|
||||
private let endAction: @Sendable () async -> Void
|
||||
|
||||
init(
|
||||
beginAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.begin() },
|
||||
endAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.end() }
|
||||
) {
|
||||
self.beginAction = beginAction
|
||||
self.endAction = endAction
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
if enabled {
|
||||
self.startMonitoring()
|
||||
} else {
|
||||
self.stopMonitoring()
|
||||
self.withMainThread { [weak self] in
|
||||
guard let self else { return }
|
||||
if enabled {
|
||||
self.startMonitoring()
|
||||
} else {
|
||||
self.stopMonitoring()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startMonitoring() {
|
||||
assert(Thread.isMainThread)
|
||||
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
|
||||
// Listen-only global monitor; we rely on Input Monitoring permission to receive events.
|
||||
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
guard let self else { return }
|
||||
self.updateModifierState(from: event)
|
||||
let keyCode = event.keyCode
|
||||
let flags = event.modifierFlags
|
||||
self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags)
|
||||
}
|
||||
// Also listen locally so we still catch events when the app is active/focused.
|
||||
self.localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
self?.updateModifierState(from: event)
|
||||
let keyCode = event.keyCode
|
||||
let flags = event.modifierFlags
|
||||
self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags)
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
private func stopMonitoring() {
|
||||
assert(Thread.isMainThread)
|
||||
if let globalMonitor {
|
||||
NSEvent.removeMonitor(globalMonitor)
|
||||
self.globalMonitor = nil
|
||||
@@ -48,10 +67,25 @@ final class VoicePushToTalkHotkey {
|
||||
self.active = false
|
||||
}
|
||||
|
||||
private func updateModifierState(from event: NSEvent) {
|
||||
private func handleFlagsChanged(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
||||
self.withMainThread { [weak self] in
|
||||
self?.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags)
|
||||
}
|
||||
}
|
||||
|
||||
private func withMainThread(_ block: @escaping @Sendable () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
block()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: block)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
||||
assert(Thread.isMainThread)
|
||||
// Right Option (keyCode 61) acts as a hold-to-talk modifier.
|
||||
if event.keyCode == 61 {
|
||||
self.optionDown = event.modifierFlags.contains(.option)
|
||||
if keyCode == 61 {
|
||||
self.optionDown = modifierFlags.contains(.option)
|
||||
}
|
||||
|
||||
let chordActive = self.optionDown
|
||||
@@ -60,17 +94,21 @@ final class VoicePushToTalkHotkey {
|
||||
Task {
|
||||
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
|
||||
.info("ptt hotkey down")
|
||||
await VoicePushToTalk.shared.begin()
|
||||
await self.beginAction()
|
||||
}
|
||||
} else if !chordActive, self.active {
|
||||
self.active = false
|
||||
Task {
|
||||
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
|
||||
.info("ptt hotkey up")
|
||||
await VoicePushToTalk.shared.end()
|
||||
await self.endAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func _testUpdateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
||||
self.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags)
|
||||
}
|
||||
}
|
||||
|
||||
/// Short-lived speech recognizer that records while the hotkey is held.
|
||||
|
||||
@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
|
||||
var thinking: String = "low"
|
||||
var deliver: Bool = true
|
||||
var to: String?
|
||||
var channel: String = "last"
|
||||
var channel: GatewayAgentChannel = .last
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -46,15 +46,14 @@ enum VoiceWakeForwarder {
|
||||
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
|
||||
{
|
||||
let payload = Self.prefixedTranscript(transcript)
|
||||
let channel = options.channel.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let deliver = options.deliver && channel != "webchat"
|
||||
let result = await GatewayConnection.shared.sendAgent(
|
||||
let deliver = options.channel.shouldDeliver(options.deliver)
|
||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: payload,
|
||||
thinking: options.thinking,
|
||||
sessionKey: options.sessionKey,
|
||||
thinking: options.thinking,
|
||||
deliver: deliver,
|
||||
to: options.to,
|
||||
channel: channel)
|
||||
channel: options.channel))
|
||||
|
||||
if result.ok {
|
||||
self.logger.info("voice wake forward ok")
|
||||
|
||||
@@ -44,9 +44,8 @@ final class VoiceWakeGlobalSettingsSync {
|
||||
|
||||
private func refreshFromGateway() async {
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(method: "voicewake.get", params: nil, timeoutMs: 8000)
|
||||
let payload = try JSONDecoder().decode(VoiceWakePayload.self, from: data)
|
||||
AppStateStore.shared.applyGlobalVoiceWakeTriggers(payload.triggers)
|
||||
let triggers = try await GatewayConnection.shared.voiceWakeGetTriggers()
|
||||
AppStateStore.shared.applyGlobalVoiceWakeTriggers(triggers)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
|
||||
@@ -15,10 +15,7 @@ private enum WebChatSwiftUILayout {
|
||||
|
||||
struct MacGatewayChatTransport: ClawdisChatTransport, Sendable {
|
||||
func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "chat.history",
|
||||
params: ["sessionKey": AnyCodable(sessionKey)])
|
||||
return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: data)
|
||||
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
@@ -28,36 +25,16 @@ struct MacGatewayChatTransport: ClawdisChatTransport, Sendable {
|
||||
idempotencyKey: String,
|
||||
attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse
|
||||
{
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionKey": AnyCodable(sessionKey),
|
||||
"message": AnyCodable(message),
|
||||
"thinking": AnyCodable(thinking),
|
||||
"idempotencyKey": AnyCodable(idempotencyKey),
|
||||
"timeoutMs": AnyCodable(30000),
|
||||
]
|
||||
|
||||
if !attachments.isEmpty {
|
||||
let encoded = attachments.map { att in
|
||||
[
|
||||
"type": att.type,
|
||||
"mimeType": att.mimeType,
|
||||
"fileName": att.fileName,
|
||||
"content": att.content,
|
||||
]
|
||||
}
|
||||
params["attachments"] = AnyCodable(encoded)
|
||||
}
|
||||
|
||||
let data = try await GatewayConnection.shared.request(method: "chat.send", params: params)
|
||||
return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: data)
|
||||
try await GatewayConnection.shared.chatSend(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "health",
|
||||
params: nil,
|
||||
timeoutMs: Double(timeoutMs))
|
||||
return (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
|
||||
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<ClawdisChatTransportEvent> {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite
|
||||
struct AnthropicAuthResolverTests {
|
||||
@Test
|
||||
func prefersOAuthFileOverEnv() throws {
|
||||
let key = "PI_CODING_AGENT_DIR"
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
defer {
|
||||
if let previous {
|
||||
setenv(key, previous, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
setenv(key, dir.path, 1)
|
||||
|
||||
let oauthFile = dir.appendingPathComponent("oauth.json")
|
||||
let payload = [
|
||||
"anthropic": [
|
||||
"type": "oauth",
|
||||
"refresh": "r1",
|
||||
"access": "a1",
|
||||
"expires": 1_234_567_890,
|
||||
],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: oauthFile, options: [.atomic])
|
||||
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_API_KEY": "sk-ant-ignored",
|
||||
])
|
||||
#expect(mode == .oauthFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsOAuthEnvWhenPresent() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_OAUTH_TOKEN": "token",
|
||||
], oauthStatus: .missingFile)
|
||||
#expect(mode == .oauthEnv)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsAPIKeyEnvWhenPresent() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_API_KEY": "sk-ant-key",
|
||||
], oauthStatus: .missingFile)
|
||||
#expect(mode == .apiKeyEnv)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsMissingWhenNothingConfigured() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile)
|
||||
#expect(mode == .missing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ struct CanvasWindowSmokeTests {
|
||||
|
||||
controller.applyPreferredPlacement(CanvasPlacement(x: 120, y: 200, width: 520, height: 680))
|
||||
controller.showCanvas(path: "/")
|
||||
_ = await controller.eval(javaScript: "1 + 1")
|
||||
_ = try await controller.eval(javaScript: "1 + 1")
|
||||
controller.windowDidMove(Notification(name: NSWindow.didMoveNotification))
|
||||
controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification))
|
||||
controller.hideCanvas()
|
||||
@@ -45,4 +45,3 @@ struct CanvasWindowSmokeTests {
|
||||
controller.close()
|
||||
}
|
||||
}
|
||||
|
||||
File renamed without changes.
@@ -12,6 +12,25 @@ struct PiOAuthStoreTests {
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func usesEnvOverrideForPiAgentDir() throws {
|
||||
let key = "PI_CODING_AGENT_DIR"
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
defer {
|
||||
if let previous {
|
||||
setenv(key, previous, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-pi-agent-\(UUID().uuidString)", isDirectory: true)
|
||||
setenv(key, dir.path, 1)
|
||||
|
||||
#expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptsPiFormatTokens() throws {
|
||||
let url = try self.writeOAuthFile([
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite(.serialized) struct VoicePushToTalkHotkeyTests {
|
||||
actor Counter {
|
||||
private(set) var began = 0
|
||||
private(set) var ended = 0
|
||||
|
||||
func incBegin() { self.began += 1 }
|
||||
func incEnd() { self.ended += 1 }
|
||||
func snapshot() -> (began: Int, ended: Int) { (self.began, self.ended) }
|
||||
}
|
||||
|
||||
@Test func beginEndFiresOncePerHold() async {
|
||||
let counter = Counter()
|
||||
let hotkey = VoicePushToTalkHotkey(
|
||||
beginAction: { await counter.incBegin() },
|
||||
endAction: { await counter.incEnd() })
|
||||
|
||||
await MainActor.run {
|
||||
hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [.option])
|
||||
hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [.option])
|
||||
hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [])
|
||||
}
|
||||
|
||||
for _ in 0..<50 {
|
||||
let snap = await counter.snapshot()
|
||||
if snap.began == 1, snap.ended == 1 { break }
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
|
||||
let snap = await counter.snapshot()
|
||||
#expect(snap.began == 1)
|
||||
#expect(snap.ended == 1)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,6 @@ import Testing
|
||||
#expect(opts.thinking == "low")
|
||||
#expect(opts.deliver == true)
|
||||
#expect(opts.to == nil)
|
||||
#expect(opts.channel == "last")
|
||||
#expect(opts.channel == .last)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ git commit -m "Add Clawd workspace"
|
||||
- **spotify-player** — Terminal Spotify client to search/queue/control playback.
|
||||
- **sag** — ElevenLabs speech with mac-style say UX; streams to speakers by default.
|
||||
- **Sonos CLI** — Control Sonos speakers (discover/status/playback/volume/grouping) from scripts.
|
||||
- **blucli** — Play, group, and automate BluOS players from scripts.
|
||||
- **OpenHue CLI** — Philips Hue lighting control for scenes and automations.
|
||||
- **OpenAI Whisper** — Local speech-to-text for quick dictation and voicemail transcripts.
|
||||
- **Gemini CLI** — Google Gemini models from the terminal for fast Q&A.
|
||||
|
||||
+3
-3
@@ -108,15 +108,15 @@ open 'clawdis://agent?message=Hello%20from%20deep%20link'
|
||||
Query parameters:
|
||||
- `message` (required): the agent prompt (URL-encoded).
|
||||
- `sessionKey` (optional): explicit session key to use.
|
||||
- `thinking` (optional): `off|minimal|low|medium|high` (or omit for default).
|
||||
- `thinking` (optional): thinking hint (e.g. `low`; omit for default).
|
||||
- `deliver` (optional): `true|false` (default: false).
|
||||
- `to` / `channel` (optional): forwarded to the Gateway `agent` method (only meaningful with `deliver=true`).
|
||||
- `timeoutSeconds` (optional): timeout hint forwarded to the Gateway.
|
||||
- `key` (optional): unattended mode key (see below).
|
||||
|
||||
Safety/guardrails:
|
||||
- Disabled by default; enable in **Clawdis → Settings → Debug** (“Allow URL scheme (agent)”).
|
||||
- Without `key`, Clawdis prompts with a confirmation dialog before invoking the agent.
|
||||
- Always enabled.
|
||||
- Without a `key` query param, the app will prompt for confirmation before invoking the agent.
|
||||
- With `key=<value>`, Clawdis runs without prompting (intended for personal automations).
|
||||
- The current key is shown in Debug Settings and stored locally in UserDefaults.
|
||||
|
||||
|
||||
+2
-2
@@ -36,12 +36,12 @@ pnpm clawdis gateway --force
|
||||
- If a token is configured, clients must include it in `connect.params.auth.token` even over the tunnel.
|
||||
|
||||
## Protocol (operator view)
|
||||
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,mode,instanceId}, caps, auth?, locale?, userAgent? } }`.
|
||||
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth?, locale?, userAgent? } }`.
|
||||
- Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes).
|
||||
- After handshake:
|
||||
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
|
||||
- Events: `{type:"event", event, payload, seq?, stateVersion?}`
|
||||
- Structured presence entries: `{host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
|
||||
- Structured presence entries: `{host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
|
||||
- `agent` responses are two-stage: first `res` ack `{runId,status:"accepted"}`, then a final `res` `{runId,status:"ok"|"error",summary}` after the run finishes; streamed output arrives as `event:"agent"`.
|
||||
|
||||
## Methods (initial set)
|
||||
|
||||
+2
-1
@@ -139,8 +139,9 @@ Implementation note (important):
|
||||
- In `WKWebView`, intercept `clawdis://…` navigations in `WKNavigationDelegate` and forward them to the app, e.g. by calling `DeepLinkHandler.shared.handle(url:)` and returning `.cancel` for the navigation.
|
||||
|
||||
Safety:
|
||||
- `clawdis://agent` is disabled by default and must be enabled in **Clawdis → Settings → Debug** (“Allow URL scheme (agent)”).
|
||||
- Deep links (`clawdis://agent?...`) are always enabled.
|
||||
- Without a `key` query param, the app will prompt for confirmation before invoking the agent.
|
||||
- With a valid `key`, the run is unattended (no prompt). For Canvas-originated actions, the app injects an internal key automatically.
|
||||
|
||||
## Security / guardrails
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ Presence entries are structured objects with (some) fields:
|
||||
- `host`: a human-readable name (often the machine name)
|
||||
- `ip`: best-effort IP address (may be missing or stale)
|
||||
- `version`: client version string
|
||||
- `deviceFamily` (optional): hardware family like `iPad`, `iPhone`, `Mac`
|
||||
- `modelIdentifier` (optional): hardware model identifier like `iPad16,6` or `Mac16,6`
|
||||
- `mode`: e.g. `gateway`, `app`, `webchat`, `cli`
|
||||
- `lastInputSeconds` (optional): “seconds since last user input” for that client machine
|
||||
- `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh`
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Canvas / A2UI
|
||||
|
||||
## Goal
|
||||
- A2UI rendering works out-of-the-box (no per-user toggles).
|
||||
- A2UI button clicks always reach the agent automatically.
|
||||
- Canvas chrome (close button) stays readable on any content.
|
||||
|
||||
## Current behavior
|
||||
- Canvas can show a bundled A2UI shell at `/__clawdis__/a2ui/` when no session `index.html` exists.
|
||||
- The A2UI shell forwards `a2ui.action` button clicks to native via `WKScriptMessageHandler` (`clawdisCanvasA2UIAction`).
|
||||
- Native forwards the click to the gateway as an agent invocation.
|
||||
|
||||
## Fixes (2025-12-17)
|
||||
- Close button: render a small vibrancy/material pill behind the “x” and reduce the button size for less visual weight.
|
||||
- Click reliability:
|
||||
- Allow A2UI clicks from any local Canvas path (not just `/` or the built-in A2UI shell).
|
||||
- Inject an A2UI → native bridge at document start that listens for `a2uiaction` and forwards it:
|
||||
- Prefer `WKScriptMessageHandler` when available.
|
||||
- Otherwise fall back to an unattended `clawdis://agent?...&key=...` deep link (no prompt).
|
||||
- Avoid double-sending actions when the bundled A2UI shell is present (let the shell forward clicks so it can resolve richer context).
|
||||
- Intercept `clawdis://…` navigations inside the Canvas WKWebView and route them through `DeepLinkHandler` (no NSWorkspace bounce).
|
||||
- `GatewayConnection` auto-starts the local gateway (and retries briefly) when a request fails in `.local` mode, so Canvas actions don’t silently fail if the gateway isn’t running yet.
|
||||
- Fix a crash that made `clawdis-mac canvas show`/`eval` look “hung”:
|
||||
- `VoicePushToTalkHotkey`’s NSEvent monitor could call `@MainActor` code off-main, triggering executor checks / EXC_BAD_ACCESS on macOS 26.2.
|
||||
- Now it hops back to the main actor before mutating state.
|
||||
- Preserve in-page state when closing Canvas (hide the window instead of closing the `WKWebView`).
|
||||
- Fix another “Canvas looks hung” source: node pairing approval used `NSAlert.runModal()` on the main actor, which stalls Canvas/IPC while the alert is open.
|
||||
- Add UX feedback + better agent prompting:
|
||||
- Show a small “Sending/Working” spinner when a button is clicked.
|
||||
- Show “Updated/Failed” toasts (failures include the gateway error string).
|
||||
- Send a compact, unambiguous agent message that includes machine identity + Canvas context (instead of a big JSON markdown block).
|
||||
- Native acks the click back into the page via `clawdis:a2ui-action-status` so the UI can switch from “Sending…” to “Working…” immediately.
|
||||
|
||||
## Suggested message format (token-efficient)
|
||||
We want the model to immediately understand:
|
||||
- This is a **Canvas UI event** (not user chat).
|
||||
- It happened on **this specific Mac**.
|
||||
- Default behavior is to **update the Canvas UI** (unless the button context says otherwise).
|
||||
|
||||
Proposed message line (single-line, parseable):
|
||||
|
||||
```
|
||||
CANVAS_A2UI action=<name> session=<sessionKey> surface=<surfaceId> component=<componentId> host=<machine> instance=<instanceId> ctx=<json?> default=update_canvas
|
||||
```
|
||||
|
||||
## Follow-ups
|
||||
- Add a small “action sent / failed” debug overlay in the A2UI shell (dev-only) to make failures obvious.
|
||||
- Decide whether non-local Canvas content should ever be allowed to emit A2UI actions (current stance: no, for safety).
|
||||
@@ -0,0 +1,24 @@
|
||||
# Gateway Client Refactor (Dec 2025)
|
||||
|
||||
Goal: remove stringly-typed gateway calls from the macOS app, centralize routing/channel semantics, and improve error handling.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Fold legacy “AgentRPC” into `GatewayConnection` (single layer; no separate client object).
|
||||
- [x] Typed gateway API: `GatewayConnection.Method` + `requestDecoded/requestVoid` + typed helpers (status/agent/chat/cron/etc).
|
||||
- [x] Centralize agent routing/channel semantics via `GatewayAgentChannel` + `GatewayAgentInvocation`.
|
||||
- [x] Improve gateway error model (structured `GatewayResponseError` + decoding errors include method).
|
||||
- [x] Migrate mac call sites to typed helpers (leave only intentionally dynamic forwarding paths).
|
||||
- [x] Convert remaining UI raw channel strings to `GatewayAgentChannel` (Cron editor).
|
||||
- [x] Cleanup naming: rename remaining tests/docs that still reference “RPC/AgentRPC”.
|
||||
|
||||
### Notes
|
||||
|
||||
- Intentionally string-based:
|
||||
- `BridgeServer` dynamic request forwarding (method is data-driven).
|
||||
- `ControlChannel` request wrapper (generic escape hatch).
|
||||
|
||||
## Notes / Non-goals
|
||||
|
||||
- No functional behavior changes intended (beyond better errors and removing “magic strings”).
|
||||
- Keep changes incremental: introduce typed APIs first, then migrate call sites, then remove old helpers.
|
||||
@@ -45,7 +45,7 @@ Goal: enforce the invariant **“one gateway websocket per app process (per effe
|
||||
|
||||
Key elements:
|
||||
- `GatewayConnection.shared` owns the one websocket and is the *only* supported entry point for app code that needs gateway RPC.
|
||||
- Consumers (e.g. Control UI, Agent RPC, SwiftUI WebChat) call `GatewayConnection.shared.request(...)` and do not create their own sockets.
|
||||
- Consumers (e.g. Control UI, agent invocations, SwiftUI WebChat) call `GatewayConnection.shared.request(...)` and do not create their own sockets.
|
||||
- If the effective connection config changes (local ↔ remote tunnel port, token change), `GatewayConnection` replaces the underlying connection.
|
||||
- The transport (`GatewayChannelActor`) is an internal detail and forwards push frames back into `GatewayConnection`.
|
||||
- Server-push frames are delivered via `GatewayConnection.shared.subscribe(...) -> AsyncStream<GatewayPush>` (in-process event bus).
|
||||
@@ -84,7 +84,7 @@ Minimum invariants:
|
||||
- Config changes (token / endpoint) cancel the old socket and reconnect once.
|
||||
|
||||
Nice-to-have integration coverage:
|
||||
- Multiple “consumers” (Control UI + Agent RPC + SwiftUI WebChat) all call through the shared connection and still produce only one websocket.
|
||||
- Multiple “consumers” (Control UI + agent invocations + SwiftUI WebChat) all call through the shared connection and still produce only one websocket.
|
||||
|
||||
Additional coverage added (macOS):
|
||||
- Subscribing after connect replays the latest snapshot.
|
||||
|
||||
@@ -23,7 +23,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
||||
|
||||
## Phase 1 — Protocol specification
|
||||
- Frames (WS text JSON, all with explicit `type`):
|
||||
- `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}`
|
||||
- `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}`
|
||||
- `res {type:"res", id, ok:true, payload: hello-ok }` (or `ok:false` then close)
|
||||
- `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}`
|
||||
- `req {type:"req", id, method, params?}`
|
||||
@@ -31,7 +31,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
||||
- `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent)
|
||||
- `close` (standard WS close codes; policy uses 1008 for slow consumer/unauthorized, 1012/1001 for restart)
|
||||
- Payload types:
|
||||
- `PresenceEntry {host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}`
|
||||
- `PresenceEntry {host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}`
|
||||
- `HealthSnapshot` (match existing `clawdis health --json` fields)
|
||||
- `AgentEvent` (streamed tool/output; `{runId, seq, stream, data, ts}`)
|
||||
- `TickEvent {ts}`
|
||||
@@ -105,7 +105,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
||||
- Add lightweight WS client helper for `status/health/send/agent` when Gateway is up. ✅ `gateway` subcommands use the Gateway over WS.
|
||||
- Consider a “local only” flag to avoid accidental remote connects. (optional; not needed with tunnel-first model.)
|
||||
- **WebChat backend**:
|
||||
- Single WS to Gateway; seed UI from snapshot; forward `presence/tick/agent` to browser. ✅ implemented via `GatewayClient` in `webchat/server.ts`.
|
||||
- Single WS to Gateway; seed UI from snapshot; forward `presence/tick/agent` to browser. ✅ implemented via the WebChat gateway client in `webchat/server.ts`.
|
||||
- Fail fast if handshake fails; no fallback transports. ✅ (webchat returns gateway unavailable)
|
||||
|
||||
## Phase 6 — Send/agent path hardening
|
||||
@@ -148,7 +148,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
||||
- Mac app smoke: presence/health render from snapshot; reconnect on tick loss. (Manual: open Instances tab, verify snapshot after connect, induce seq gap by toggling wifi, ensure UI refreshes.)
|
||||
- WebChat smoke: snapshot seed + event updates; tunnel scenario. ✅ Offline snapshot harness in `src/webchat/server.test.ts` (mock gateway) now passes; live tunnel still recommended for manual.
|
||||
- Idempotency tests: retry send/agent with same key after forced disconnect; expect deduped result. ✅ send + agent dedupe + reconnect retry covered in gateway tests.
|
||||
- Seq-gap handling: ✅ clients now detect seq gaps (GatewayClient + mac GatewayChannel) and refresh health/presence (webchat) or trigger UI refresh (mac). Load-test still optional.
|
||||
- Seq-gap handling: ✅ clients now detect seq gaps (WebChat gateway client + mac `GatewayConnection/GatewayChannel`) and refresh health/presence (webchat) or trigger UI refresh (mac). Load-test still optional.
|
||||
|
||||
## Phase 10 — Rollout
|
||||
- Version bump; release notes: breaking change to control plane (WS only).
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
|
||||
export function extractAssistantText(msg: AssistantMessage): string {
|
||||
const isTextBlock = (
|
||||
block: unknown,
|
||||
): block is { type: "text"; text: string } => {
|
||||
if (!block || typeof block !== "object") return false;
|
||||
const rec = block as Record<string, unknown>;
|
||||
return rec.type === "text" && typeof rec.text === "string";
|
||||
};
|
||||
|
||||
const blocks = Array.isArray(msg.content)
|
||||
? msg.content
|
||||
.filter(isTextBlock)
|
||||
.map((c) => c.text.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return blocks.join("\n").trim();
|
||||
}
|
||||
|
||||
export function inferToolMetaFromArgs(
|
||||
toolName: string,
|
||||
args: unknown,
|
||||
): string | undefined {
|
||||
if (!args || typeof args !== "object") return undefined;
|
||||
const record = args as Record<string, unknown>;
|
||||
|
||||
const p = typeof record.path === "string" ? record.path : undefined;
|
||||
const command =
|
||||
typeof record.command === "string" ? record.command : undefined;
|
||||
|
||||
if (toolName === "read" && p) {
|
||||
const offset =
|
||||
typeof record.offset === "number" ? record.offset : undefined;
|
||||
const limit = typeof record.limit === "number" ? record.limit : undefined;
|
||||
if (offset !== undefined && limit !== undefined) {
|
||||
return `${p}:${offset}-${offset + limit}`;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
if ((toolName === "edit" || toolName === "write") && p) return p;
|
||||
if (toolName === "bash" && command) return command;
|
||||
return p ?? command;
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "@mariozechner/pi-ai";
|
||||
import {
|
||||
AgentSession,
|
||||
codingTools,
|
||||
messageTransformer,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
@@ -34,7 +33,12 @@ import { splitMediaFromOutput } from "../media/parse.js";
|
||||
import { enqueueCommand } from "../process/command-queue.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import {
|
||||
extractAssistantText,
|
||||
inferToolMetaFromArgs,
|
||||
} from "./pi-embedded-utils.js";
|
||||
import { getAnthropicOAuthToken } from "./pi-oauth.js";
|
||||
import { createClawdisCodingTools } from "./pi-tools.js";
|
||||
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
||||
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
||||
|
||||
@@ -86,49 +90,6 @@ function resolveModel(
|
||||
return model as Model<Api> | undefined;
|
||||
}
|
||||
|
||||
function extractAssistantText(msg: AssistantMessage): string {
|
||||
const isTextBlock = (
|
||||
block: unknown,
|
||||
): block is { type: "text"; text: string } => {
|
||||
if (!block || typeof block !== "object") return false;
|
||||
const rec = block as Record<string, unknown>;
|
||||
return rec.type === "text" && typeof rec.text === "string";
|
||||
};
|
||||
|
||||
const blocks = Array.isArray(msg.content)
|
||||
? msg.content
|
||||
.filter(isTextBlock)
|
||||
.map((c) => c.text.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return blocks.join("\n").trim();
|
||||
}
|
||||
|
||||
function inferToolMetaFromArgs(
|
||||
toolName: string,
|
||||
args: unknown,
|
||||
): string | undefined {
|
||||
if (!args || typeof args !== "object") return undefined;
|
||||
const record = args as Record<string, unknown>;
|
||||
|
||||
const p = typeof record.path === "string" ? record.path : undefined;
|
||||
const command =
|
||||
typeof record.command === "string" ? record.command : undefined;
|
||||
|
||||
if (toolName === "read" && p) {
|
||||
const offset =
|
||||
typeof record.offset === "number" ? record.offset : undefined;
|
||||
const limit = typeof record.limit === "number" ? record.limit : undefined;
|
||||
if (offset !== undefined && limit !== undefined) {
|
||||
return `${p}:${offset}-${offset + limit}`;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
if ((toolName === "edit" || toolName === "write") && p) return p;
|
||||
if (toolName === "bash" && command) return command;
|
||||
return p ?? command;
|
||||
}
|
||||
|
||||
async function ensureSessionHeader(params: {
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
@@ -239,7 +200,9 @@ export async function runEmbeddedPiAgent(params: {
|
||||
systemPrompt,
|
||||
model,
|
||||
thinkingLevel,
|
||||
tools: codingTools,
|
||||
// TODO(steipete): Once pi-mono publishes file-magic MIME detection in `read` image payloads,
|
||||
// remove `createClawdisCodingTools()` and use upstream `codingTools` again.
|
||||
tools: createClawdisCodingTools(),
|
||||
},
|
||||
messageTransformer,
|
||||
queueMode: settingsManager.getQueueMode(),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createClawdisCodingTools } from "./pi-tools.js";
|
||||
|
||||
const PNG_1x1 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
||||
|
||||
describe("createClawdisCodingTools", () => {
|
||||
it("sniffs mime from bytes when extension lies", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-pi-"));
|
||||
const filePath = path.join(tmpDir, "image.jpg"); // actually PNG bytes
|
||||
await fs.writeFile(filePath, Buffer.from(PNG_1x1, "base64"));
|
||||
|
||||
const read = createClawdisCodingTools().find((t) => t.name === "read");
|
||||
expect(read).toBeTruthy();
|
||||
if (!read) throw new Error("read tool missing");
|
||||
|
||||
const res = await read.execute("toolCallId", { path: filePath });
|
||||
const image = res.content.find(
|
||||
(b): b is { type: "image"; mimeType: string } =>
|
||||
!!b &&
|
||||
typeof b === "object" &&
|
||||
(b as Record<string, unknown>).type === "image" &&
|
||||
typeof (b as Record<string, unknown>).mimeType === "string",
|
||||
);
|
||||
|
||||
expect(image?.mimeType).toBe("image/png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai";
|
||||
import { codingTools, readTool } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { detectMime } from "../media/mime.js";
|
||||
|
||||
// TODO(steipete): Remove this wrapper once pi-mono ships file-magic MIME detection
|
||||
// for `read` image payloads in `@mariozechner/pi-coding-agent` (then switch back to `codingTools` directly).
|
||||
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
|
||||
type ImageContentBlock = Extract<ToolContentBlock, { type: "image" }>;
|
||||
type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
|
||||
|
||||
function sniffMimeFromBase64(base64: string): string | undefined {
|
||||
const trimmed = base64.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const take = Math.min(256, trimmed.length);
|
||||
const sliceLen = take - (take % 4);
|
||||
if (sliceLen < 8) return undefined;
|
||||
|
||||
try {
|
||||
const head = Buffer.from(trimmed.slice(0, sliceLen), "base64");
|
||||
return detectMime({ buffer: head });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteReadImageHeader(text: string, mimeType: string): string {
|
||||
// pi-coding-agent uses: "Read image file [image/png]"
|
||||
if (text.startsWith("Read image file [") && text.endsWith("]")) {
|
||||
return `Read image file [${mimeType}]`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeReadImageResult(
|
||||
result: AgentToolResult<unknown>,
|
||||
filePath: string,
|
||||
): AgentToolResult<unknown> {
|
||||
const content = Array.isArray(result.content) ? result.content : [];
|
||||
|
||||
const image = content.find(
|
||||
(b): b is ImageContentBlock =>
|
||||
!!b &&
|
||||
typeof b === "object" &&
|
||||
(b as { type?: unknown }).type === "image" &&
|
||||
typeof (b as { data?: unknown }).data === "string" &&
|
||||
typeof (b as { mimeType?: unknown }).mimeType === "string",
|
||||
);
|
||||
if (!image) return result;
|
||||
|
||||
if (!image.data.trim()) {
|
||||
throw new Error(`read: image payload is empty (${filePath})`);
|
||||
}
|
||||
|
||||
const sniffed = sniffMimeFromBase64(image.data);
|
||||
if (!sniffed) return result;
|
||||
|
||||
if (!sniffed.startsWith("image/")) {
|
||||
throw new Error(
|
||||
`read: file looks like ${sniffed} but was treated as ${image.mimeType} (${filePath})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (sniffed === image.mimeType) return result;
|
||||
|
||||
const nextContent = content.map((block) => {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as { type?: unknown }).type === "image"
|
||||
) {
|
||||
const b = block as ImageContentBlock & { mimeType: string };
|
||||
return { ...b, mimeType: sniffed } satisfies ImageContentBlock;
|
||||
}
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as { type?: unknown }).type === "text" &&
|
||||
typeof (block as { text?: unknown }).text === "string"
|
||||
) {
|
||||
const b = block as TextContentBlock & { text: string };
|
||||
return { ...b, text: rewriteReadImageHeader(b.text, sniffed) } satisfies TextContentBlock;
|
||||
}
|
||||
return block;
|
||||
});
|
||||
|
||||
return { ...result, content: nextContent };
|
||||
}
|
||||
|
||||
type AnyAgentTool = AgentTool<any, any>;
|
||||
|
||||
function createClawdisReadTool(base: AnyAgentTool): AnyAgentTool {
|
||||
return {
|
||||
...base,
|
||||
execute: async (toolCallId, params, signal) => {
|
||||
const result = (await base.execute(toolCallId, params as any, signal)) as AgentToolResult<unknown>;
|
||||
const record =
|
||||
params && typeof params === "object"
|
||||
? (params as Record<string, unknown>)
|
||||
: undefined;
|
||||
const filePath =
|
||||
typeof record?.path === "string" ? String(record.path) : "<unknown>";
|
||||
return normalizeReadImageResult(result, filePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createClawdisCodingTools(): AnyAgentTool[] {
|
||||
return (codingTools as unknown as AnyAgentTool[]).map((tool) =>
|
||||
tool.name === readTool.name ? createClawdisReadTool(tool) : (tool as AnyAgentTool),
|
||||
);
|
||||
}
|
||||
@@ -85,6 +85,9 @@ describe("gateway SIGTERM", () => {
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWDIS_SKIP_PROVIDERS: "1",
|
||||
// Avoid port collisions with other test processes that may also start a bridge server.
|
||||
CLAWDIS_BRIDGE_HOST: "127.0.0.1",
|
||||
CLAWDIS_BRIDGE_PORT: "0",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
|
||||
@@ -65,10 +65,10 @@ export const ConnectParamsSchema = Type.Object(
|
||||
name: NonEmptyString,
|
||||
version: NonEmptyString,
|
||||
platform: NonEmptyString,
|
||||
mode: NonEmptyString,
|
||||
instanceId: Type.Optional(NonEmptyString),
|
||||
deviceFamily: Type.Optional(NonEmptyString),
|
||||
modelIdentifier: Type.Optional(NonEmptyString),
|
||||
mode: NonEmptyString,
|
||||
instanceId: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -2109,10 +2109,10 @@ describe("gateway server", () => {
|
||||
name: "fingerprint",
|
||||
version: "9.9.9",
|
||||
platform: "test",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad16,6",
|
||||
mode: "ui",
|
||||
instanceId: "abc",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "Mac16,6",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2135,8 +2135,8 @@ describe("gateway server", () => {
|
||||
expect(clientEntry?.host).toBe("fingerprint");
|
||||
expect(clientEntry?.version).toBe("9.9.9");
|
||||
expect(clientEntry?.mode).toBe("ui");
|
||||
expect(clientEntry?.deviceFamily).toBe("Mac");
|
||||
expect(clientEntry?.modelIdentifier).toBe("Mac16,6");
|
||||
expect(clientEntry?.deviceFamily).toBe("iPad");
|
||||
expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
|
||||
@@ -225,6 +225,8 @@ describe("node bridge server", () => {
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
} | null = null;
|
||||
|
||||
@@ -233,6 +235,8 @@ describe("node bridge server", () => {
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
} | null = null;
|
||||
|
||||
@@ -262,6 +266,8 @@ describe("node bridge server", () => {
|
||||
displayName: "Iris",
|
||||
platform: "ios",
|
||||
version: "1.0",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad16,6",
|
||||
});
|
||||
|
||||
// Approve the pending request from the gateway side.
|
||||
@@ -296,6 +302,8 @@ describe("node bridge server", () => {
|
||||
displayName: "Different name",
|
||||
platform: "ios",
|
||||
version: "2.0",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad99,1",
|
||||
});
|
||||
const line3 = JSON.parse(await readLine2()) as { type: string };
|
||||
expect(line3.type).toBe("hello-ok");
|
||||
@@ -310,6 +318,8 @@ describe("node bridge server", () => {
|
||||
expect(lastAuthed?.displayName).toBe("Iris");
|
||||
expect(lastAuthed?.platform).toBe("ios");
|
||||
expect(lastAuthed?.version).toBe("1.0");
|
||||
expect(lastAuthed?.deviceFamily).toBe("iPad");
|
||||
expect(lastAuthed?.modelIdentifier).toBe("iPad16,6");
|
||||
expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true);
|
||||
|
||||
socket2.destroy();
|
||||
|
||||
@@ -357,6 +357,8 @@ export async function startNodeBridgeServer(
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
remoteIp: remoteAddress,
|
||||
};
|
||||
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
|
||||
export type SystemPresence = {
|
||||
@@ -50,17 +49,6 @@ function initSelfPresence() {
|
||||
const ip = resolvePrimaryIPv4() ?? undefined;
|
||||
const version =
|
||||
process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "unknown";
|
||||
const modelIdentifier = (() => {
|
||||
const p = os.platform();
|
||||
if (p === "darwin") {
|
||||
const res = spawnSync("sysctl", ["-n", "hw.model"], {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const out = typeof res.stdout === "string" ? res.stdout.trim() : "";
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
return os.arch();
|
||||
})();
|
||||
const platform = (() => {
|
||||
const p = os.platform();
|
||||
const rel = os.release();
|
||||
@@ -82,7 +70,6 @@ function initSelfPresence() {
|
||||
version,
|
||||
platform,
|
||||
deviceFamily,
|
||||
modelIdentifier,
|
||||
mode: "gateway",
|
||||
reason: "self",
|
||||
text,
|
||||
|
||||
@@ -35,6 +35,28 @@ import {
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-web-home-"));
|
||||
@@ -44,7 +66,7 @@ beforeEach(async () => {
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"fileNames":["../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../../../../noLine truncated
|
||||
-752
@@ -1,752 +0,0 @@
|
||||
export * as Events from "./events/events.js";
|
||||
export * as Types from "./types/types.js";
|
||||
export * as Primitives from "./types/primitives.js";
|
||||
export * as Styles from "./styles/index.js";
|
||||
import * as Guards from "./data/guards.js";
|
||||
import { create as createSignalA2uiMessageProcessor } from "./data/signal-model-processor.js";
|
||||
import { A2uiMessageProcessor } from "./data/model-processor.js";
|
||||
export declare const Data: {
|
||||
createSignalA2uiMessageProcessor: typeof createSignalA2uiMessageProcessor;
|
||||
A2uiMessageProcessor: typeof A2uiMessageProcessor;
|
||||
Guards: typeof Guards;
|
||||
};
|
||||
export declare const Schemas: {
|
||||
A2UIClientEventMessage: {
|
||||
title: string;
|
||||
description: string;
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
beginRendering: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
surfaceId: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
root: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
styles: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
font: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
primaryColor: {
|
||||
type: string;
|
||||
description: string;
|
||||
pattern: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
surfaceUpdate: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
surfaceId: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
components: {
|
||||
type: string;
|
||||
description: string;
|
||||
minItems: number;
|
||||
items: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
id: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
weight: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
component: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
Text: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
text: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
usageHint: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
Image: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
url: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
fit: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
usageHint: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
Icon: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
name: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
enum: string[];
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
Video: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
url: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
AudioPlayer: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
url: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
description: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
Row: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
children: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
explicitList: {
|
||||
type: string;
|
||||
items: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
template: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
componentId: {
|
||||
type: string;
|
||||
};
|
||||
dataBinding: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
distribution: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
alignment: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
Column: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
children: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
explicitList: {
|
||||
type: string;
|
||||
items: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
template: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
componentId: {
|
||||
type: string;
|
||||
};
|
||||
dataBinding: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
distribution: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
alignment: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
List: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
children: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
explicitList: {
|
||||
type: string;
|
||||
items: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
template: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
componentId: {
|
||||
type: string;
|
||||
};
|
||||
dataBinding: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
direction: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
alignment: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
Card: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
child: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
Tabs: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
tabItems: {
|
||||
type: string;
|
||||
description: string;
|
||||
items: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
title: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
child: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
Divider: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
axis: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
Modal: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
entryPointChild: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
contentChild: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
Button: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
child: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
primary: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
action: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
name: {
|
||||
type: string;
|
||||
};
|
||||
context: {
|
||||
type: string;
|
||||
items: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
key: {
|
||||
type: string;
|
||||
};
|
||||
value: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
literalNumber: {
|
||||
type: string;
|
||||
};
|
||||
literalBoolean: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
CheckBox: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
label: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
value: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalBoolean: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
TextField: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
label: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
text: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
textFieldType: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum: string[];
|
||||
};
|
||||
validationRegexp: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
DateTimeInput: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
value: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
enableDate: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
enableTime: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
outputFormat: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
MultipleChoice: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
selections: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalArray: {
|
||||
type: string;
|
||||
items: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
options: {
|
||||
type: string;
|
||||
description: string;
|
||||
items: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
label: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalString: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
value: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
maxAllowedSelections: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
Slider: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
value: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
literalNumber: {
|
||||
type: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
minValue: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
maxValue: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
dataModelUpdate: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
surfaceId: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
path: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
contents: {
|
||||
type: string;
|
||||
description: string;
|
||||
items: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
key: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
valueString: {
|
||||
type: string;
|
||||
};
|
||||
valueNumber: {
|
||||
type: string;
|
||||
};
|
||||
valueBoolean: {
|
||||
type: string;
|
||||
};
|
||||
valueMap: {
|
||||
description: string;
|
||||
type: string;
|
||||
items: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
key: {
|
||||
type: string;
|
||||
};
|
||||
valueString: {
|
||||
type: string;
|
||||
};
|
||||
valueNumber: {
|
||||
type: string;
|
||||
};
|
||||
valueBoolean: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
deleteSurface: {
|
||||
type: string;
|
||||
description: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {
|
||||
surfaceId: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
required: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
//# sourceMappingURL=core.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../../../src/0.8/core.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAC;AAC7C,OAAO,KAAK,KAAK,MAAM,kBAAkB,CAAC;AAC1C,OAAO,KAAK,UAAU,MAAM,uBAAuB,CAAC;AACpD,OAAO,KAAK,MAAM,MAAM,mBAAmB,CAAC;AAC5C,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAE3C,OAAO,EAAE,MAAM,IAAI,gCAAgC,EAAE,MAAM,kCAAkC,CAAC;AAC9F,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAGjE,eAAO,MAAM,IAAI;;;;CAIhB,CAAC;AAEF,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAEnB,CAAC"}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
export * as Events from "./events/events.js";
|
||||
export * as Types from "./types/types.js";
|
||||
export * as Primitives from "./types/primitives.js";
|
||||
export * as Styles from "./styles/index.js";
|
||||
import * as Guards from "./data/guards.js";
|
||||
import { create as createSignalA2uiMessageProcessor } from "./data/signal-model-processor.js";
|
||||
import { A2uiMessageProcessor } from "./data/model-processor.js";
|
||||
import A2UIClientEventMessage from "./schemas/server_to_client_with_standard_catalog.json" with { type: "json" };
|
||||
export const Data = {
|
||||
createSignalA2uiMessageProcessor,
|
||||
A2uiMessageProcessor,
|
||||
Guards,
|
||||
};
|
||||
export const Schemas = {
|
||||
A2UIClientEventMessage,
|
||||
};
|
||||
//# sourceMappingURL=core.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"core.js","sourceRoot":"","sources":["../../../src/0.8/core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAC;AAC7C,OAAO,KAAK,KAAK,MAAM,kBAAkB,CAAC;AAC1C,OAAO,KAAK,UAAU,MAAM,uBAAuB,CAAC;AACpD,OAAO,KAAK,MAAM,MAAM,mBAAmB,CAAC;AAC5C,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAE3C,OAAO,EAAE,MAAM,IAAI,gCAAgC,EAAE,MAAM,kCAAkC,CAAC;AAC9F,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,sBAAsB,MAAM,uDAAuD,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAC;AAEjH,MAAM,CAAC,MAAM,IAAI,GAAG;IAClB,gCAAgC;IAChC,oBAAoB;IACpB,MAAM;CACP,CAAC;AAEF,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,sBAAsB;CACvB,CAAC"}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ComponentArrayReference, ResolvedAudioPlayer, ResolvedButton, ResolvedCard, ResolvedCheckbox, ResolvedColumn, ResolvedDateTimeInput, ResolvedDivider, ResolvedIcon, ResolvedImage, ResolvedList, ResolvedModal, ResolvedMultipleChoice, ResolvedRow, ResolvedSlider, ResolvedTabs, ResolvedText, ResolvedTextField, ResolvedVideo, ValueMap } from "../types/types";
|
||||
export declare function isValueMap(value: unknown): value is ValueMap;
|
||||
export declare function isPath(key: string, value: unknown): value is string;
|
||||
export declare function isObject(value: unknown): value is Record<string, unknown>;
|
||||
export declare function isComponentArrayReference(value: unknown): value is ComponentArrayReference;
|
||||
export declare function isResolvedAudioPlayer(props: unknown): props is ResolvedAudioPlayer;
|
||||
export declare function isResolvedButton(props: unknown): props is ResolvedButton;
|
||||
export declare function isResolvedCard(props: unknown): props is ResolvedCard;
|
||||
export declare function isResolvedCheckbox(props: unknown): props is ResolvedCheckbox;
|
||||
export declare function isResolvedColumn(props: unknown): props is ResolvedColumn;
|
||||
export declare function isResolvedDateTimeInput(props: unknown): props is ResolvedDateTimeInput;
|
||||
export declare function isResolvedDivider(props: unknown): props is ResolvedDivider;
|
||||
export declare function isResolvedImage(props: unknown): props is ResolvedImage;
|
||||
export declare function isResolvedIcon(props: unknown): props is ResolvedIcon;
|
||||
export declare function isResolvedList(props: unknown): props is ResolvedList;
|
||||
export declare function isResolvedModal(props: unknown): props is ResolvedModal;
|
||||
export declare function isResolvedMultipleChoice(props: unknown): props is ResolvedMultipleChoice;
|
||||
export declare function isResolvedRow(props: unknown): props is ResolvedRow;
|
||||
export declare function isResolvedSlider(props: unknown): props is ResolvedSlider;
|
||||
export declare function isResolvedTabs(props: unknown): props is ResolvedTabs;
|
||||
export declare function isResolvedText(props: unknown): props is ResolvedText;
|
||||
export declare function isResolvedTextField(props: unknown): props is ResolvedTextField;
|
||||
export declare function isResolvedVideo(props: unknown): props is ResolvedVideo;
|
||||
//# sourceMappingURL=guards.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../../../../src/0.8/data/guards.ts"],"names":[],"mappings":"AAiBA,OAAO,EAEL,uBAAuB,EACvB,mBAAmB,EACnB,cAAc,EACd,YAAY,EACZ,gBAAgB,EAChB,cAAc,EACd,qBAAqB,EACrB,eAAe,EACf,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,aAAa,EACb,sBAAsB,EACtB,WAAW,EACX,cAAc,EAEd,YAAY,EACZ,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,QAAQ,EACT,MAAM,gBAAgB,CAAC;AAExB,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,QAAQ,CAE5D;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAEnE;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,uBAAuB,CAGlC;AAqCD,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,mBAAmB,CAE9B;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAOxE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAcpE;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAQ5E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAOxE;AAED,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,qBAAqB,CAEhC;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,eAAe,CAI1E;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,aAAa,CAEtE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAEpE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAOpE;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,aAAa,CAQtE;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,sBAAsB,CAEjC;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,WAAW,CAOlE;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAExE;AAYD,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAOpE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAEpE;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,iBAAiB,CAE5B;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,aAAa,CAEtE"}
|
||||
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
export function isValueMap(value) {
|
||||
return isObject(value) && "key" in value;
|
||||
}
|
||||
export function isPath(key, value) {
|
||||
return key === "path" && typeof value === "string";
|
||||
}
|
||||
export function isObject(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
export function isComponentArrayReference(value) {
|
||||
if (!isObject(value))
|
||||
return false;
|
||||
return "explicitList" in value || "template" in value;
|
||||
}
|
||||
function isStringValue(value) {
|
||||
return (isObject(value) &&
|
||||
("path" in value ||
|
||||
("literal" in value && typeof value.literal === "string") ||
|
||||
"literalString" in value));
|
||||
}
|
||||
function isNumberValue(value) {
|
||||
return (isObject(value) &&
|
||||
("path" in value ||
|
||||
("literal" in value && typeof value.literal === "number") ||
|
||||
"literalNumber" in value));
|
||||
}
|
||||
function isBooleanValue(value) {
|
||||
return (isObject(value) &&
|
||||
("path" in value ||
|
||||
("literal" in value && typeof value.literal === "boolean") ||
|
||||
"literalBoolean" in value));
|
||||
}
|
||||
function isAnyComponentNode(value) {
|
||||
if (!isObject(value))
|
||||
return false;
|
||||
const hasBaseKeys = "id" in value && "type" in value && "properties" in value;
|
||||
if (!hasBaseKeys)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
export function isResolvedAudioPlayer(props) {
|
||||
return isObject(props) && "url" in props && isStringValue(props.url);
|
||||
}
|
||||
export function isResolvedButton(props) {
|
||||
return (isObject(props) &&
|
||||
"child" in props &&
|
||||
isAnyComponentNode(props.child) &&
|
||||
"action" in props);
|
||||
}
|
||||
export function isResolvedCard(props) {
|
||||
if (!isObject(props))
|
||||
return false;
|
||||
if (!("child" in props)) {
|
||||
if (!("children" in props)) {
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
return (Array.isArray(props.children) &&
|
||||
props.children.every(isAnyComponentNode));
|
||||
}
|
||||
}
|
||||
return isAnyComponentNode(props.child);
|
||||
}
|
||||
export function isResolvedCheckbox(props) {
|
||||
return (isObject(props) &&
|
||||
"label" in props &&
|
||||
isStringValue(props.label) &&
|
||||
"value" in props &&
|
||||
isBooleanValue(props.value));
|
||||
}
|
||||
export function isResolvedColumn(props) {
|
||||
return (isObject(props) &&
|
||||
"children" in props &&
|
||||
Array.isArray(props.children) &&
|
||||
props.children.every(isAnyComponentNode));
|
||||
}
|
||||
export function isResolvedDateTimeInput(props) {
|
||||
return isObject(props) && "value" in props && isStringValue(props.value);
|
||||
}
|
||||
export function isResolvedDivider(props) {
|
||||
// Dividers can have all optional properties, so just checking if
|
||||
// it's an object is enough.
|
||||
return isObject(props);
|
||||
}
|
||||
export function isResolvedImage(props) {
|
||||
return isObject(props) && "url" in props && isStringValue(props.url);
|
||||
}
|
||||
export function isResolvedIcon(props) {
|
||||
return isObject(props) && "name" in props && isStringValue(props.name);
|
||||
}
|
||||
export function isResolvedList(props) {
|
||||
return (isObject(props) &&
|
||||
"children" in props &&
|
||||
Array.isArray(props.children) &&
|
||||
props.children.every(isAnyComponentNode));
|
||||
}
|
||||
export function isResolvedModal(props) {
|
||||
return (isObject(props) &&
|
||||
"entryPointChild" in props &&
|
||||
isAnyComponentNode(props.entryPointChild) &&
|
||||
"contentChild" in props &&
|
||||
isAnyComponentNode(props.contentChild));
|
||||
}
|
||||
export function isResolvedMultipleChoice(props) {
|
||||
return isObject(props) && "selections" in props;
|
||||
}
|
||||
export function isResolvedRow(props) {
|
||||
return (isObject(props) &&
|
||||
"children" in props &&
|
||||
Array.isArray(props.children) &&
|
||||
props.children.every(isAnyComponentNode));
|
||||
}
|
||||
export function isResolvedSlider(props) {
|
||||
return isObject(props) && "value" in props && isNumberValue(props.value);
|
||||
}
|
||||
function isResolvedTabItem(item) {
|
||||
return (isObject(item) &&
|
||||
"title" in item &&
|
||||
isStringValue(item.title) &&
|
||||
"child" in item &&
|
||||
isAnyComponentNode(item.child));
|
||||
}
|
||||
export function isResolvedTabs(props) {
|
||||
return (isObject(props) &&
|
||||
"tabItems" in props &&
|
||||
Array.isArray(props.tabItems) &&
|
||||
props.tabItems.every(isResolvedTabItem));
|
||||
}
|
||||
export function isResolvedText(props) {
|
||||
return isObject(props) && "text" in props && isStringValue(props.text);
|
||||
}
|
||||
export function isResolvedTextField(props) {
|
||||
return isObject(props) && "label" in props && isStringValue(props.label);
|
||||
}
|
||||
export function isResolvedVideo(props) {
|
||||
return isObject(props) && "url" in props && isStringValue(props.url);
|
||||
}
|
||||
//# sourceMappingURL=guards.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"guards.js","sourceRoot":"","sources":["../../../../src/0.8/data/guards.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA4BH,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,KAAK,CAAC;AAC3C,CAAC;AAED,MAAM,UAAU,MAAM,CAAC,GAAW,EAAE,KAAc;IAChD,OAAO,GAAG,KAAK,MAAM,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC;AACrD,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,KAAc;IACrC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC9E,CAAC;AAED,MAAM,UAAU,yBAAyB,CACvC,KAAc;IAEd,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACnC,OAAO,cAAc,IAAI,KAAK,IAAI,UAAU,IAAI,KAAK,CAAC;AACxD,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,CAAC,MAAM,IAAI,KAAK;YACd,CAAC,SAAS,IAAI,KAAK,IAAI,OAAO,KAAK,CAAC,OAAO,KAAK,QAAQ,CAAC;YACzD,eAAe,IAAI,KAAK,CAAC,CAC5B,CAAC;AACJ,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,CAAC,MAAM,IAAI,KAAK;YACd,CAAC,SAAS,IAAI,KAAK,IAAI,OAAO,KAAK,CAAC,OAAO,KAAK,QAAQ,CAAC;YACzD,eAAe,IAAI,KAAK,CAAC,CAC5B,CAAC;AACJ,CAAC;AAED,SAAS,cAAc,CAAC,KAAc;IACpC,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,CAAC,MAAM,IAAI,KAAK;YACd,CAAC,SAAS,IAAI,KAAK,IAAI,OAAO,KAAK,CAAC,OAAO,KAAK,SAAS,CAAC;YAC1D,gBAAgB,IAAI,KAAK,CAAC,CAC7B,CAAC;AACJ,CAAC;AAED,SAAS,kBAAkB,CAAC,KAAc;IACxC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACnC,MAAM,WAAW,GAAG,IAAI,IAAI,KAAK,IAAI,MAAM,IAAI,KAAK,IAAI,YAAY,IAAI,KAAK,CAAC;IAC9E,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAE/B,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,qBAAqB,CACnC,KAAc;IAEd,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,KAAK,IAAI,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;AACvE,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,OAAO,IAAI,KAAK;QAChB,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC;QAC/B,QAAQ,IAAI,KAAK,CAClB,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACnC,IAAI,CAAC,CAAC,OAAO,IAAI,KAAK,CAAC,EAAE,CAAC;QACxB,IAAI,CAAC,CAAC,UAAU,IAAI,KAAK,CAAC,EAAE,CAAC;YAC3B,OAAO,KAAK,CAAC;QACf,CAAC;aAAM,CAAC;YACN,OAAO,CACL,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC;gBAC7B,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,kBAAkB,CAAC,CACzC,CAAC;QACJ,CAAC;IACH,CAAC;IAED,OAAO,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AACzC,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,KAAc;IAC/C,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,OAAO,IAAI,KAAK;QAChB,aAAa,CAAC,KAAK,CAAC,KAAK,CAAC;QAC1B,OAAO,IAAI,KAAK;QAChB,cAAc,CAAC,KAAK,CAAC,KAAK,CAAC,CAC5B,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,UAAU,IAAI,KAAK;QACnB,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC;QAC7B,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,kBAAkB,CAAC,CACzC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,KAAc;IAEd,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,OAAO,IAAI,KAAK,IAAI,aAAa,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AAC3E,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,KAAc;IAC9C,iEAAiE;IACjE,4BAA4B;IAC5B,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC;AACzB,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,KAAK,IAAI,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;AACvE,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,MAAM,IAAI,KAAK,IAAI,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACzE,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,UAAU,IAAI,KAAK;QACnB,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC;QAC7B,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,kBAAkB,CAAC,CACzC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,iBAAiB,IAAI,KAAK;QAC1B,kBAAkB,CAAC,KAAK,CAAC,eAAe,CAAC;QACzC,cAAc,IAAI,KAAK;QACvB,kBAAkB,CAAC,KAAK,CAAC,YAAY,CAAC,CACvC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,wBAAwB,CACtC,KAAc;IAEd,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,YAAY,IAAI,KAAK,CAAC;AAClD,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,UAAU,IAAI,KAAK;QACnB,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC;QAC7B,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,kBAAkB,CAAC,CACzC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,OAAO,IAAI,KAAK,IAAI,aAAa,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AAC3E,CAAC;AAED,SAAS,iBAAiB,CAAC,IAAa;IACtC,OAAO,CACL,QAAQ,CAAC,IAAI,CAAC;QACd,OAAO,IAAI,IAAI;QACf,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC;QACzB,OAAO,IAAI,IAAI;QACf,kBAAkB,CAAC,IAAI,CAAC,KAAK,CAAC,CAC/B,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,UAAU,IAAI,KAAK;QACnB,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC;QAC7B,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,iBAAiB,CAAC,CACxC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,MAAM,IAAI,KAAK,IAAI,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACzE,CAAC;AAED,MAAM,UAAU,mBAAmB,CACjC,KAAc;IAEd,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,OAAO,IAAI,KAAK,IAAI,aAAa,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AAC3E,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,KAAK,IAAI,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;AACvE,CAAC"}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ServerToClientMessage, AnyComponentNode, DataValue, Surface, MessageProcessor } from "../types/types";
|
||||
/**
|
||||
* Processes and consolidates A2UIProtocolMessage objects into a structured,
|
||||
* hierarchical model of UI surfaces.
|
||||
*/
|
||||
export declare class A2uiMessageProcessor implements MessageProcessor {
|
||||
#private;
|
||||
readonly opts: {
|
||||
mapCtor: MapConstructor;
|
||||
arrayCtor: ArrayConstructor;
|
||||
setCtor: SetConstructor;
|
||||
objCtor: ObjectConstructor;
|
||||
};
|
||||
static readonly DEFAULT_SURFACE_ID = "@default";
|
||||
constructor(opts?: {
|
||||
mapCtor: MapConstructor;
|
||||
arrayCtor: ArrayConstructor;
|
||||
setCtor: SetConstructor;
|
||||
objCtor: ObjectConstructor;
|
||||
});
|
||||
getSurfaces(): ReadonlyMap<string, Surface>;
|
||||
clearSurfaces(): void;
|
||||
processMessages(messages: ServerToClientMessage[]): void;
|
||||
/**
|
||||
* Retrieves the data for a given component node and a relative path string.
|
||||
* This correctly handles the special `.` path, which refers to the node's
|
||||
* own data context.
|
||||
*/
|
||||
getData(node: AnyComponentNode, relativePath: string, surfaceId?: string): DataValue | null;
|
||||
setData(node: AnyComponentNode | null, relativePath: string, value: DataValue, surfaceId?: string): void;
|
||||
resolvePath(path: string, dataContextPath?: string): string;
|
||||
}
|
||||
//# sourceMappingURL=model-processor.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"model-processor.d.ts","sourceRoot":"","sources":["../../../../src/0.8/data/model-processor.ts"],"names":[],"mappings":"AAgBA,OAAO,EACL,qBAAqB,EACrB,gBAAgB,EAKhB,SAAS,EAIT,OAAO,EAGP,gBAAgB,EAGjB,MAAM,gBAAgB,CAAC;AA0BxB;;;GAGG;AACH,qBAAa,oBAAqB,YAAW,gBAAgB;;IAUzD,QAAQ,CAAC,IAAI,EAAE;QACb,OAAO,EAAE,cAAc,CAAC;QACxB,SAAS,EAAE,gBAAgB,CAAC;QAC5B,OAAO,EAAE,cAAc,CAAC;QACxB,OAAO,EAAE,iBAAiB,CAAC;KAC5B;IAdH,MAAM,CAAC,QAAQ,CAAC,kBAAkB,cAAc;gBASrC,IAAI,GAAE;QACb,OAAO,EAAE,cAAc,CAAC;QACxB,SAAS,EAAE,gBAAgB,CAAC;QAC5B,OAAO,EAAE,cAAc,CAAC;QACxB,OAAO,EAAE,iBAAiB,CAAC;KACwC;IAUvE,WAAW,IAAI,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC;IAI3C,aAAa;IAIb,eAAe,CAAC,QAAQ,EAAE,qBAAqB,EAAE,GAAG,IAAI;IA6BxD;;;;OAIG;IACH,OAAO,CACL,IAAI,EAAE,gBAAgB,EACtB,YAAY,EAAE,MAAM,EACpB,SAAS,SAA0C,GAClD,SAAS,GAAG,IAAI;IAkBnB,OAAO,CACL,IAAI,EAAE,gBAAgB,GAAG,IAAI,EAC7B,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,SAAS,EAChB,SAAS,SAA0C,GAClD,IAAI;IAuBP,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,MAAM;CA8qB5D"}
|
||||
@@ -1,632 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { isComponentArrayReference, isObject, isPath, isResolvedAudioPlayer, isResolvedButton, isResolvedCard, isResolvedCheckbox, isResolvedColumn, isResolvedDateTimeInput, isResolvedDivider, isResolvedIcon, isResolvedImage, isResolvedList, isResolvedModal, isResolvedMultipleChoice, isResolvedRow, isResolvedSlider, isResolvedTabs, isResolvedText, isResolvedTextField, isResolvedVideo, } from "./guards.js";
|
||||
/**
|
||||
* Processes and consolidates A2UIProtocolMessage objects into a structured,
|
||||
* hierarchical model of UI surfaces.
|
||||
*/
|
||||
export class A2uiMessageProcessor {
|
||||
static { this.DEFAULT_SURFACE_ID = "@default"; }
|
||||
#mapCtor = Map;
|
||||
#arrayCtor = Array;
|
||||
#setCtor = Set;
|
||||
#objCtor = Object;
|
||||
#surfaces;
|
||||
constructor(opts = { mapCtor: Map, arrayCtor: Array, setCtor: Set, objCtor: Object }) {
|
||||
this.opts = opts;
|
||||
this.#arrayCtor = opts.arrayCtor;
|
||||
this.#mapCtor = opts.mapCtor;
|
||||
this.#setCtor = opts.setCtor;
|
||||
this.#objCtor = opts.objCtor;
|
||||
this.#surfaces = new opts.mapCtor();
|
||||
}
|
||||
getSurfaces() {
|
||||
return this.#surfaces;
|
||||
}
|
||||
clearSurfaces() {
|
||||
this.#surfaces.clear();
|
||||
}
|
||||
processMessages(messages) {
|
||||
for (const message of messages) {
|
||||
if (message.beginRendering) {
|
||||
this.#handleBeginRendering(message.beginRendering, message.beginRendering.surfaceId);
|
||||
}
|
||||
if (message.surfaceUpdate) {
|
||||
this.#handleSurfaceUpdate(message.surfaceUpdate, message.surfaceUpdate.surfaceId);
|
||||
}
|
||||
if (message.dataModelUpdate) {
|
||||
this.#handleDataModelUpdate(message.dataModelUpdate, message.dataModelUpdate.surfaceId);
|
||||
}
|
||||
if (message.deleteSurface) {
|
||||
this.#handleDeleteSurface(message.deleteSurface);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Retrieves the data for a given component node and a relative path string.
|
||||
* This correctly handles the special `.` path, which refers to the node's
|
||||
* own data context.
|
||||
*/
|
||||
getData(node, relativePath, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) {
|
||||
const surface = this.#getOrCreateSurface(surfaceId);
|
||||
if (!surface)
|
||||
return null;
|
||||
let finalPath;
|
||||
// The special `.` path means the final path is the node's data context
|
||||
// path and so we return the dataContextPath as-is.
|
||||
if (relativePath === "." || relativePath === "") {
|
||||
finalPath = node.dataContextPath ?? "/";
|
||||
}
|
||||
else {
|
||||
// For all other paths, resolve them against the node's context.
|
||||
finalPath = this.resolvePath(relativePath, node.dataContextPath);
|
||||
}
|
||||
return this.#getDataByPath(surface.dataModel, finalPath);
|
||||
}
|
||||
setData(node, relativePath, value, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) {
|
||||
if (!node) {
|
||||
console.warn("No component node set");
|
||||
return;
|
||||
}
|
||||
const surface = this.#getOrCreateSurface(surfaceId);
|
||||
if (!surface)
|
||||
return;
|
||||
let finalPath;
|
||||
// The special `.` path means the final path is the node's data context
|
||||
// path and so we return the dataContextPath as-is.
|
||||
if (relativePath === "." || relativePath === "") {
|
||||
finalPath = node.dataContextPath ?? "/";
|
||||
}
|
||||
else {
|
||||
// For all other paths, resolve them against the node's context.
|
||||
finalPath = this.resolvePath(relativePath, node.dataContextPath);
|
||||
}
|
||||
this.#setDataByPath(surface.dataModel, finalPath, value);
|
||||
}
|
||||
resolvePath(path, dataContextPath) {
|
||||
// If the path is absolute, it overrides any context.
|
||||
if (path.startsWith("/")) {
|
||||
return path;
|
||||
}
|
||||
if (dataContextPath && dataContextPath !== "/") {
|
||||
// Ensure there's exactly one slash between the context and the path.
|
||||
return dataContextPath.endsWith("/")
|
||||
? `${dataContextPath}${path}`
|
||||
: `${dataContextPath}/${path}`;
|
||||
}
|
||||
// Fallback for no context or root context: make it an absolute path.
|
||||
return `/${path}`;
|
||||
}
|
||||
#parseIfJsonString(value) {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
const trimmedValue = value.trim();
|
||||
if ((trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) ||
|
||||
(trimmedValue.startsWith("[") && trimmedValue.endsWith("]"))) {
|
||||
try {
|
||||
// It looks like JSON, attempt to parse it.
|
||||
return JSON.parse(value);
|
||||
}
|
||||
catch (e) {
|
||||
// It looked like JSON but wasn't. Keep the original string.
|
||||
console.warn(`Failed to parse potential JSON string: "${value.substring(0, 50)}..."`, e);
|
||||
return value; // Return original string
|
||||
}
|
||||
}
|
||||
// It's a string, but not JSON-like.
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* Converts a specific array format [{key: "...", value_string: "..."}, ...]
|
||||
* into a standard Map. It also attempts to parse any string values that
|
||||
* appear to be stringified JSON.
|
||||
*/
|
||||
#convertKeyValueArrayToMap(arr) {
|
||||
const map = new this.#mapCtor();
|
||||
for (const item of arr) {
|
||||
if (!isObject(item) || !("key" in item))
|
||||
continue;
|
||||
const key = item.key;
|
||||
// Find the value, which is in a property prefixed with "value".
|
||||
const valueKey = this.#findValueKey(item);
|
||||
if (!valueKey)
|
||||
continue;
|
||||
let value = item[valueKey];
|
||||
// It's a valueMap. We must recursively convert it.
|
||||
if (valueKey === "valueMap" && Array.isArray(value)) {
|
||||
value = this.#convertKeyValueArrayToMap(value);
|
||||
}
|
||||
else if (typeof value === "string") {
|
||||
value = this.#parseIfJsonString(value);
|
||||
}
|
||||
this.#setDataByPath(map, key, value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
#setDataByPath(root, path, value) {
|
||||
// Check if the incoming value is the special key-value array format.
|
||||
if (Array.isArray(value) &&
|
||||
(value.length === 0 || (isObject(value[0]) && "key" in value[0]))) {
|
||||
// Check for "set primitive at path" convention:
|
||||
// path: "/messages/123", contents: [{ key: ".", valueString: "hi" }]
|
||||
if (value.length === 1 && isObject(value[0]) && value[0].key === ".") {
|
||||
const item = value[0];
|
||||
const valueKey = this.#findValueKey(item);
|
||||
if (valueKey) {
|
||||
// Extract the primitive value
|
||||
value = item[valueKey];
|
||||
// We must still process this value in case it's a valueMap or
|
||||
// a JSON string.
|
||||
if (valueKey === "valueMap" && Array.isArray(value)) {
|
||||
value = this.#convertKeyValueArrayToMap(value);
|
||||
}
|
||||
else if (typeof value === "string") {
|
||||
value = this.#parseIfJsonString(value);
|
||||
}
|
||||
// Now, `value` is the primitive (e.g., "hi"), and we continue
|
||||
// the function.
|
||||
}
|
||||
else {
|
||||
// Malformed, but fall back to existing behavior.
|
||||
value = this.#convertKeyValueArrayToMap(value);
|
||||
}
|
||||
}
|
||||
else {
|
||||
value = this.#convertKeyValueArrayToMap(value);
|
||||
}
|
||||
}
|
||||
const segments = this.#normalizePath(path)
|
||||
.split("/")
|
||||
.filter((s) => s);
|
||||
if (segments.length === 0) {
|
||||
// Root data can either be a Map or an Object. If we receive an Object,
|
||||
// however, we will normalize it to a proper Map.
|
||||
if (value instanceof Map || isObject(value)) {
|
||||
// Normalize an Object to a Map.
|
||||
if (!(value instanceof Map) && isObject(value)) {
|
||||
value = new this.#mapCtor(Object.entries(value));
|
||||
}
|
||||
root.clear();
|
||||
for (const [key, v] of value.entries()) {
|
||||
root.set(key, v);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error("Cannot set root of DataModel to a non-Map value.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
let current = root;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const segment = segments[i];
|
||||
let target;
|
||||
if (current instanceof Map) {
|
||||
target = current.get(segment);
|
||||
}
|
||||
else if (Array.isArray(current) && /^\d+$/.test(segment)) {
|
||||
target = current[parseInt(segment, 10)];
|
||||
}
|
||||
if (target === undefined ||
|
||||
typeof target !== "object" ||
|
||||
target === null) {
|
||||
target = new this.#mapCtor();
|
||||
if (current instanceof this.#mapCtor) {
|
||||
current.set(segment, target);
|
||||
}
|
||||
else if (Array.isArray(current)) {
|
||||
current[parseInt(segment, 10)] = target;
|
||||
}
|
||||
}
|
||||
current = target;
|
||||
}
|
||||
const finalSegment = segments[segments.length - 1];
|
||||
const storedValue = value;
|
||||
if (current instanceof this.#mapCtor) {
|
||||
current.set(finalSegment, storedValue);
|
||||
}
|
||||
else if (Array.isArray(current) && /^\d+$/.test(finalSegment)) {
|
||||
current[parseInt(finalSegment, 10)] = storedValue;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Normalizes a path string into a consistent, slash-delimited format.
|
||||
* Converts bracket notation and dot notation in a two-pass.
|
||||
* e.g., "bookRecommendations[0].title" -> "/bookRecommendations/0/title"
|
||||
* e.g., "book.0.title" -> "/book/0/title"
|
||||
*/
|
||||
#normalizePath(path) {
|
||||
// 1. Replace all bracket accessors `[index]` with dot accessors `.index`
|
||||
const dotPath = path.replace(/\[(\d+)\]/g, ".$1");
|
||||
// 2. Split by dots
|
||||
const segments = dotPath.split(".");
|
||||
// 3. Join with slashes and ensure it starts with a slash
|
||||
return "/" + segments.filter((s) => s.length > 0).join("/");
|
||||
}
|
||||
#getDataByPath(root, path) {
|
||||
const segments = this.#normalizePath(path)
|
||||
.split("/")
|
||||
.filter((s) => s);
|
||||
let current = root;
|
||||
for (const segment of segments) {
|
||||
if (current === undefined || current === null)
|
||||
return null;
|
||||
if (current instanceof Map) {
|
||||
current = current.get(segment);
|
||||
}
|
||||
else if (Array.isArray(current) && /^\d+$/.test(segment)) {
|
||||
current = current[parseInt(segment, 10)];
|
||||
}
|
||||
else if (isObject(current)) {
|
||||
current = current[segment];
|
||||
}
|
||||
else {
|
||||
// If we need to traverse deeper but `current` is a primitive, the path is invalid.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
#getOrCreateSurface(surfaceId) {
|
||||
let surface = this.#surfaces.get(surfaceId);
|
||||
if (!surface) {
|
||||
surface = new this.#objCtor({
|
||||
rootComponentId: null,
|
||||
componentTree: null,
|
||||
dataModel: new this.#mapCtor(),
|
||||
components: new this.#mapCtor(),
|
||||
styles: new this.#objCtor(),
|
||||
});
|
||||
this.#surfaces.set(surfaceId, surface);
|
||||
}
|
||||
return surface;
|
||||
}
|
||||
#handleBeginRendering(message, surfaceId) {
|
||||
const surface = this.#getOrCreateSurface(surfaceId);
|
||||
surface.rootComponentId = message.root;
|
||||
surface.styles = message.styles ?? {};
|
||||
this.#rebuildComponentTree(surface);
|
||||
}
|
||||
#handleSurfaceUpdate(message, surfaceId) {
|
||||
const surface = this.#getOrCreateSurface(surfaceId);
|
||||
for (const component of message.components) {
|
||||
surface.components.set(component.id, component);
|
||||
}
|
||||
this.#rebuildComponentTree(surface);
|
||||
}
|
||||
#handleDataModelUpdate(message, surfaceId) {
|
||||
const surface = this.#getOrCreateSurface(surfaceId);
|
||||
const path = message.path ?? "/";
|
||||
this.#setDataByPath(surface.dataModel, path, message.contents);
|
||||
this.#rebuildComponentTree(surface);
|
||||
}
|
||||
#handleDeleteSurface(message) {
|
||||
this.#surfaces.delete(message.surfaceId);
|
||||
}
|
||||
/**
|
||||
* Starts at the root component of the surface and builds out the tree
|
||||
* recursively. This process involves resolving all properties of the child
|
||||
* components, and expanding on any explicit children lists or templates
|
||||
* found in the structure.
|
||||
*
|
||||
* @param surface The surface to be built.
|
||||
*/
|
||||
#rebuildComponentTree(surface) {
|
||||
if (!surface.rootComponentId) {
|
||||
surface.componentTree = null;
|
||||
return;
|
||||
}
|
||||
// Track visited nodes to avoid circular references.
|
||||
const visited = new this.#setCtor();
|
||||
surface.componentTree = this.#buildNodeRecursive(surface.rootComponentId, surface, visited, "/", "" // Initial idSuffix.
|
||||
);
|
||||
}
|
||||
/** Finds a value key in a map. */
|
||||
#findValueKey(value) {
|
||||
return Object.keys(value).find((k) => k.startsWith("value"));
|
||||
}
|
||||
/**
|
||||
* Builds out the nodes recursively.
|
||||
*/
|
||||
#buildNodeRecursive(baseComponentId, surface, visited, dataContextPath, idSuffix = "") {
|
||||
const fullId = `${baseComponentId}${idSuffix}`; // Construct the full ID
|
||||
const { components } = surface;
|
||||
if (!components.has(baseComponentId)) {
|
||||
return null;
|
||||
}
|
||||
if (visited.has(fullId)) {
|
||||
throw new Error(`Circular dependency for component "${fullId}".`);
|
||||
}
|
||||
visited.add(fullId);
|
||||
const componentData = components.get(baseComponentId);
|
||||
const componentProps = componentData.component ?? {};
|
||||
const componentType = Object.keys(componentProps)[0];
|
||||
const unresolvedProperties = componentProps[componentType];
|
||||
// Manually build the resolvedProperties object by resolving each value in
|
||||
// the component's properties.
|
||||
const resolvedProperties = new this.#objCtor();
|
||||
if (isObject(unresolvedProperties)) {
|
||||
for (const [key, value] of Object.entries(unresolvedProperties)) {
|
||||
resolvedProperties[key] = this.#resolvePropertyValue(value, surface, visited, dataContextPath, idSuffix, key);
|
||||
}
|
||||
}
|
||||
visited.delete(fullId);
|
||||
// Now that we have the resolved properties in place we can go ahead and
|
||||
// ensure that they meet expectations in terms of types and so forth,
|
||||
// casting them into the specific shape for usage.
|
||||
const baseNode = {
|
||||
id: fullId,
|
||||
dataContextPath,
|
||||
weight: componentData.weight ?? "initial",
|
||||
};
|
||||
switch (componentType) {
|
||||
case "Text":
|
||||
if (!isResolvedText(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Text",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Image":
|
||||
if (!isResolvedImage(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Image",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Icon":
|
||||
if (!isResolvedIcon(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Icon",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Video":
|
||||
if (!isResolvedVideo(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Video",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "AudioPlayer":
|
||||
if (!isResolvedAudioPlayer(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "AudioPlayer",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Row":
|
||||
if (!isResolvedRow(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Row",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Column":
|
||||
if (!isResolvedColumn(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Column",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "List":
|
||||
if (!isResolvedList(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "List",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Card":
|
||||
if (!isResolvedCard(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Card",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Tabs":
|
||||
if (!isResolvedTabs(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Tabs",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Divider":
|
||||
if (!isResolvedDivider(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Divider",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Modal":
|
||||
if (!isResolvedModal(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Modal",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Button":
|
||||
if (!isResolvedButton(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Button",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "CheckBox":
|
||||
if (!isResolvedCheckbox(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "CheckBox",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "TextField":
|
||||
if (!isResolvedTextField(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "TextField",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "DateTimeInput":
|
||||
if (!isResolvedDateTimeInput(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "DateTimeInput",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "MultipleChoice":
|
||||
if (!isResolvedMultipleChoice(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "MultipleChoice",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
case "Slider":
|
||||
if (!isResolvedSlider(resolvedProperties)) {
|
||||
throw new Error(`Invalid data; expected ${componentType}`);
|
||||
}
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: "Slider",
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
default:
|
||||
// Catch-all for other custom component types.
|
||||
return new this.#objCtor({
|
||||
...baseNode,
|
||||
type: componentType,
|
||||
properties: resolvedProperties,
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Recursively resolves an individual property value. If a property indicates
|
||||
* a child node (a string that matches a component ID), an explicitList of
|
||||
* children, or a template, these will be built out here.
|
||||
*/
|
||||
#resolvePropertyValue(value, surface, visited, dataContextPath, idSuffix = "", propertyKey = null) {
|
||||
const isComponentIdReferenceKey = (key) => key === "child" || key.endsWith("Child");
|
||||
// 1. If it's a string that matches a component ID, build that node.
|
||||
if (typeof value === "string" &&
|
||||
propertyKey &&
|
||||
isComponentIdReferenceKey(propertyKey) &&
|
||||
surface.components.has(value)) {
|
||||
return this.#buildNodeRecursive(value, surface, visited, dataContextPath, idSuffix);
|
||||
}
|
||||
// 2. If it's a ComponentArrayReference (e.g., a `children` property),
|
||||
// resolve the list and return an array of nodes.
|
||||
if (isComponentArrayReference(value)) {
|
||||
if (value.explicitList) {
|
||||
return value.explicitList.map((id) => this.#buildNodeRecursive(id, surface, visited, dataContextPath, idSuffix));
|
||||
}
|
||||
if (value.template) {
|
||||
const fullDataPath = this.resolvePath(value.template.dataBinding, dataContextPath);
|
||||
const data = this.#getDataByPath(surface.dataModel, fullDataPath);
|
||||
const template = value.template;
|
||||
// Handle Array data.
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((_, index) => {
|
||||
// Create a synthetic ID based on the template ID and the
|
||||
// full index path of the data (e.g., template-id:0:1)
|
||||
const parentIndices = dataContextPath
|
||||
.split("/")
|
||||
.filter((segment) => /^\d+$/.test(segment));
|
||||
const newIndices = [...parentIndices, index];
|
||||
const newSuffix = `:${newIndices.join(":")}`;
|
||||
const childDataContextPath = `${fullDataPath}/${index}`;
|
||||
return this.#buildNodeRecursive(template.componentId, // baseId
|
||||
surface, visited, childDataContextPath, newSuffix // new suffix
|
||||
);
|
||||
});
|
||||
}
|
||||
// Handle Map data.
|
||||
const mapCtor = this.#mapCtor;
|
||||
if (data instanceof mapCtor) {
|
||||
return Array.from(data.keys(), (key) => {
|
||||
const newSuffix = `:${key}`;
|
||||
const childDataContextPath = `${fullDataPath}/${key}`;
|
||||
return this.#buildNodeRecursive(template.componentId, // baseId
|
||||
surface, visited, childDataContextPath, newSuffix // new suffix
|
||||
);
|
||||
});
|
||||
}
|
||||
// Return empty array if the data is not ready yet.
|
||||
return new this.#arrayCtor();
|
||||
}
|
||||
}
|
||||
// 3. If it's a plain array, resolve each of its items.
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.#resolvePropertyValue(item, surface, visited, dataContextPath, idSuffix, propertyKey));
|
||||
}
|
||||
// 4. If it's a plain object, resolve each of its properties.
|
||||
if (isObject(value)) {
|
||||
const newObj = new this.#objCtor();
|
||||
for (const [key, propValue] of Object.entries(value)) {
|
||||
// Special case for paths. Here we might get /item/ or ./ on the front
|
||||
// of the path which isn't what we want. In this case we check the
|
||||
// dataContextPath and if 1) it's not the default and 2) we also see the
|
||||
// path beginning with /item/ or ./we trim it.
|
||||
let propertyValue = propValue;
|
||||
if (isPath(key, propValue) && dataContextPath !== "/") {
|
||||
propertyValue = propValue
|
||||
.replace(/^\.?\/item/, "")
|
||||
.replace(/^\.?\/text/, "")
|
||||
.replace(/^\.?\/label/, "")
|
||||
.replace(/^\.?\//, "");
|
||||
newObj[key] = propertyValue;
|
||||
continue;
|
||||
}
|
||||
newObj[key] = this.#resolvePropertyValue(propertyValue, surface, visited, dataContextPath, idSuffix, key);
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
// 5. Otherwise, it's a primitive value.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=model-processor.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"model-processor.js","sourceRoot":"","sources":["../../../../src/0.8/data/model-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAoBH,OAAO,EACL,yBAAyB,EACzB,QAAQ,EACR,MAAM,EACN,qBAAqB,EACrB,gBAAgB,EAChB,cAAc,EACd,kBAAkB,EAClB,gBAAgB,EAChB,uBAAuB,EACvB,iBAAiB,EACjB,cAAc,EACd,eAAe,EACf,cAAc,EACd,eAAe,EACf,wBAAwB,EACxB,aAAa,EACb,gBAAgB,EAChB,cAAc,EACd,cAAc,EACd,mBAAmB,EACnB,eAAe,GAEhB,MAAM,aAAa,CAAC;AAErB;;;GAGG;AACH,MAAM,OAAO,oBAAoB;aACf,uBAAkB,GAAG,UAAU,CAAC;IAEhD,QAAQ,GAAmB,GAAG,CAAC;IAC/B,UAAU,GAAqB,KAAK,CAAC;IACrC,QAAQ,GAAmB,GAAG,CAAC;IAC/B,QAAQ,GAAsB,MAAM,CAAC;IACrC,SAAS,CAA0B;IAEnC,YACW,OAKL,EAAE,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE;QAL5D,SAAI,GAAJ,IAAI,CAKwD;QAErE,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC;QACjC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC;QAC7B,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC;QAC7B,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC;QAE7B,IAAI,CAAC,SAAS,GAAG,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;IACtC,CAAC;IAED,WAAW;QACT,OAAO,IAAI,CAAC,SAAS,CAAC;IACxB,CAAC;IAED,aAAa;QACX,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;IACzB,CAAC;IAED,eAAe,CAAC,QAAiC;QAC/C,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;gBAC3B,IAAI,CAAC,qBAAqB,CACxB,OAAO,CAAC,cAAc,EACtB,OAAO,CAAC,cAAc,CAAC,SAAS,CACjC,CAAC;YACJ,CAAC;YAED,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC;gBAC1B,IAAI,CAAC,oBAAoB,CACvB,OAAO,CAAC,aAAa,EACrB,OAAO,CAAC,aAAa,CAAC,SAAS,CAChC,CAAC;YACJ,CAAC;YAED,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;gBAC5B,IAAI,CAAC,sBAAsB,CACzB,OAAO,CAAC,eAAe,EACvB,OAAO,CAAC,eAAe,CAAC,SAAS,CAClC,CAAC;YACJ,CAAC;YAED,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC;gBAC1B,IAAI,CAAC,oBAAoB,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC;YACnD,CAAC;QACH,CAAC;IACH,CAAC;IAED;;;;OAIG;IACH,OAAO,CACL,IAAsB,EACtB,YAAoB,EACpB,SAAS,GAAG,oBAAoB,CAAC,kBAAkB;QAEnD,MAAM,OAAO,GAAG,IAAI,CAAC,mBAAmB,CAAC,SAAS,CAAC,CAAC;QACpD,IAAI,CAAC,OAAO;YAAE,OAAO,IAAI,CAAC;QAE1B,IAAI,SAAiB,CAAC;QAEtB,uEAAuE;QACvE,mDAAmD;QACnD,IAAI,YAAY,KAAK,GAAG,IAAI,YAAY,KAAK,EAAE,EAAE,CAAC;YAChD,SAAS,GAAG,IAAI,CAAC,eAAe,IAAI,GAAG,CAAC;QAC1C,CAAC;aAAM,CAAC;YACN,gEAAgE;YAChE,SAAS,GAAG,IAAI,CAAC,WAAW,CAAC,YAAY,EAAE,IAAI,CAAC,eAAe,CAAC,CAAC;QACnE,CAAC;QAED,OAAO,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;IAC3D,CAAC;IAED,OAAO,CACL,IAA6B,EAC7B,YAAoB,EACpB,KAAgB,EAChB,SAAS,GAAG,oBAAoB,CAAC,kBAAkB;QAEnD,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,OAAO,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC;YACtC,OAAO;QACT,CAAC;QAED,MAAM,OAAO,GAAG,IAAI,CAAC,mBAAmB,CAAC,SAAS,CAAC,CAAC;QACpD,IAAI,CAAC,OAAO;YAAE,OAAO;QAErB,IAAI,SAAiB,CAAC;QAEtB,uEAAuE;QACvE,mDAAmD;QACnD,IAAI,YAAY,KAAK,GAAG,IAAI,YAAY,KAAK,EAAE,EAAE,CAAC;YAChD,SAAS,GAAG,IAAI,CAAC,eAAe,IAAI,GAAG,CAAC;QAC1C,CAAC;aAAM,CAAC;YACN,gEAAgE;YAChE,SAAS,GAAG,IAAI,CAAC,WAAW,CAAC,YAAY,EAAE,IAAI,CAAC,eAAe,CAAC,CAAC;QACnE,CAAC;QAED,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,SAAS,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;IAC3D,CAAC;IAED,WAAW,CAAC,IAAY,EAAE,eAAwB;QAChD,qDAAqD;QACrD,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YACzB,OAAO,IAAI,CAAC;QACd,CAAC;QAED,IAAI,eAAe,IAAI,eAAe,KAAK,GAAG,EAAE,CAAC;YAC/C,qEAAqE;YACrE,OAAO,eAAe,CAAC,QAAQ,CAAC,GAAG,CAAC;gBAClC,CAAC,CAAC,GAAG,eAAe,GAAG,IAAI,EAAE;gBAC7B,CAAC,CAAC,GAAG,eAAe,IAAI,IAAI,EAAE,CAAC;QACnC,CAAC;QAED,qEAAqE;QACrE,OAAO,IAAI,IAAI,EAAE,CAAC;IACpB,CAAC;IAED,kBAAkB,CAAC,KAAgB;QACjC,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,OAAO,KAAK,CAAC;QACf,CAAC;QAED,MAAM,YAAY,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;QAClC,IACE,CAAC,YAAY,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;YAC5D,CAAC,YAAY,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAC5D,CAAC;YACD,IAAI,CAAC;gBACH,2CAA2C;gBAC3C,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAC3B,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,4DAA4D;gBAC5D,OAAO,CAAC,IAAI,CACV,2CAA2C,KAAK,CAAC,SAAS,CACxD,CAAC,EACD,EAAE,CACH,MAAM,EACP,CAAC,CACF,CAAC;gBACF,OAAO,KAAK,CAAC,CAAC,yBAAyB;YACzC,CAAC;QACH,CAAC;QAED,oCAAoC;QACpC,OAAO,KAAK,CAAC;IACf,CAAC;IAED;;;;OAIG;IACH,0BAA0B,CAAC,GAAc;QACvC,MAAM,GAAG,GAAG,IAAI,IAAI,CAAC,QAAQ,EAAqB,CAAC;QACnD,KAAK,MAAM,IAAI,IAAI,GAAG,EAAE,CAAC;YACvB,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC;gBAAE,SAAS;YAElD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAa,CAAC;YAE/B,gEAAgE;YAChE,MAAM,QAAQ,GAAG,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC;YAC1C,IAAI,CAAC,QAAQ;gBAAE,SAAS;YAExB,IAAI,KAAK,GAAc,IAAI,CAAC,QAAQ,CAAC,CAAC;YACtC,mDAAmD;YACnD,IAAI,QAAQ,KAAK,UAAU,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;gBACpD,KAAK,GAAG,IAAI,CAAC,0BAA0B,CAAC,KAAK,CAAC,CAAC;YACjD,CAAC;iBAAM,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;gBACrC,KAAK,GAAG,IAAI,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC;YACzC,CAAC;YAED,IAAI,CAAC,cAAc,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC;QACvC,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAED,cAAc,CAAC,IAAa,EAAE,IAAY,EAAE,KAAgB;QAC1D,qEAAqE;QACrE,IACE,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YACpB,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EACjE,CAAC;YACD,gDAAgD;YAChD,qEAAqE;YACrE,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,GAAG,EAAE,CAAC;gBACrE,MAAM,IAAI,GAALine truncated
|
||||
@@ -1,3 +0,0 @@
|
||||
import { A2uiMessageProcessor } from "./model-processor.js";
|
||||
export declare function create(): A2uiMessageProcessor;
|
||||
//# sourceMappingURL=signal-model-processor.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"signal-model-processor.d.ts","sourceRoot":"","sources":["../../../../src/0.8/data/signal-model-processor.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAO5D,wBAAgB,MAAM,yBAOrB"}
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { A2uiMessageProcessor } from "./model-processor.js";
|
||||
import { SignalArray } from "signal-utils/array";
|
||||
import { SignalMap } from "signal-utils/map";
|
||||
import { SignalObject } from "signal-utils/object";
|
||||
import { SignalSet } from "signal-utils/set";
|
||||
export function create() {
|
||||
return new A2uiMessageProcessor({
|
||||
arrayCtor: SignalArray,
|
||||
mapCtor: SignalMap,
|
||||
objCtor: SignalObject,
|
||||
setCtor: SignalSet,
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=signal-model-processor.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"signal-model-processor.js","sourceRoot":"","sources":["../../../../src/0.8/data/signal-model-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAE5D,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAE7C,MAAM,UAAU,MAAM;IACpB,OAAO,IAAI,oBAAoB,CAAC;QAC9B,SAAS,EAAE,WAA0C;QACrD,OAAO,EAAE,SAAsC;QAC/C,OAAO,EAAE,YAA4C;QACrD,OAAO,EAAE,SAAsC;KAChD,CAAC,CAAC;AACL,CAAC"}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Action } from "../types/components.js";
|
||||
import { AnyComponentNode } from "../types/types.js";
|
||||
import { BaseEventDetail } from "./base.js";
|
||||
type Namespace = "a2ui";
|
||||
export interface A2UIAction extends BaseEventDetail<`${Namespace}.action`> {
|
||||
readonly action: Action;
|
||||
readonly dataContextPath: string;
|
||||
readonly sourceComponentId: string;
|
||||
readonly sourceComponent: AnyComponentNode | null;
|
||||
}
|
||||
export {};
|
||||
//# sourceMappingURL=a2ui.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"a2ui.d.ts","sourceRoot":"","sources":["../../../../src/0.8/events/a2ui.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAE5C,KAAK,SAAS,GAAG,MAAM,CAAC;AAExB,MAAM,WAAW,UAAW,SAAQ,eAAe,CAAC,GAAG,SAAS,SAAS,CAAC;IACxE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,eAAe,EAAE,gBAAgB,GAAG,IAAI,CAAC;CACnD"}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
export {};
|
||||
//# sourceMappingURL=a2ui.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"a2ui.js","sourceRoot":"","sources":["../../../../src/0.8/events/a2ui.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG"}
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface BaseEventDetail<EventType extends string> {
|
||||
readonly eventType: EventType;
|
||||
}
|
||||
//# sourceMappingURL=base.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"base.d.ts","sourceRoot":"","sources":["../../../../src/0.8/events/base.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,eAAe,CAAC,SAAS,SAAS,MAAM;IACvD,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;CAC/B"}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
export {};
|
||||
//# sourceMappingURL=base.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"base.js","sourceRoot":"","sources":["../../../../src/0.8/events/base.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG"}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type * as A2UI from "./a2ui.js";
|
||||
import { BaseEventDetail } from "./base.js";
|
||||
type EnforceEventTypeMatch<T extends Record<string, BaseEventDetail<string>>> = {
|
||||
[K in keyof T]: T[K] extends BaseEventDetail<infer EventType> ? EventType extends K ? T[K] : never : never;
|
||||
};
|
||||
export type StateEventDetailMap = EnforceEventTypeMatch<{
|
||||
"a2ui.action": A2UI.A2UIAction;
|
||||
}>;
|
||||
export declare class StateEvent<T extends keyof StateEventDetailMap> extends CustomEvent<StateEventDetailMap[T]> {
|
||||
readonly payload: StateEventDetailMap[T];
|
||||
static eventName: string;
|
||||
constructor(payload: StateEventDetailMap[T]);
|
||||
}
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
a2uiaction: StateEvent<"a2ui.action">;
|
||||
}
|
||||
}
|
||||
export {};
|
||||
//# sourceMappingURL=events.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../../../src/0.8/events/events.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AACvC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAQ5C,KAAK,qBAAqB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,MAAM,CAAC,CAAC,IAC1E;KACG,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,eAAe,CAAC,MAAM,SAAS,CAAC,GACzD,SAAS,SAAS,CAAC,GACjB,CAAC,CAAC,CAAC,CAAC,GACJ,KAAK,GACP,KAAK;CACV,CAAC;AAEJ,MAAM,MAAM,mBAAmB,GAAG,qBAAqB,CAAC;IACtD,aAAa,EAAE,IAAI,CAAC,UAAU,CAAC;CAChC,CAAC,CAAC;AAEH,qBAAa,UAAU,CACrB,CAAC,SAAS,MAAM,mBAAmB,CACnC,SAAQ,WAAW,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC;IAG/B,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAC;IAFpD,MAAM,CAAC,SAAS,SAAgB;gBAEX,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAC;CAGrD;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,mBAAmB;QAC3B,UAAU,EAAE,UAAU,CAAC,aAAa,CAAC,CAAC;KACvC;CACF"}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
const eventInit = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
};
|
||||
export class StateEvent extends CustomEvent {
|
||||
static { this.eventName = "a2uiaction"; }
|
||||
constructor(payload) {
|
||||
super(StateEvent.eventName, { detail: payload, ...eventInit });
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=events.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"events.js","sourceRoot":"","sources":["../../../../src/0.8/events/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAKH,MAAM,SAAS,GAAG;IAChB,OAAO,EAAE,IAAI;IACb,UAAU,EAAE,IAAI;IAChB,QAAQ,EAAE,IAAI;CACf,CAAC;AAeF,MAAM,OAAO,UAEX,SAAQ,WAAmC;aACpC,cAAS,GAAG,YAAY,CAAC;IAEhC,YAAqB,OAA+B;QAClD,KAAK,CAAC,UAAU,CAAC,SAAS,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,SAAS,EAAE,CAAC,CAAC;QAD5C,YAAO,GAAP,OAAO,CAAwB;IAEpD,CAAC"}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./core.js";
|
||||
export * as UI from "./ui/ui.js";
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/0.8/index.ts"],"names":[],"mappings":"AAgBA,cAAc,WAAW,CAAC;AAC1B,OAAO,KAAK,EAAE,MAAM,YAAY,CAAC"}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
export * from "./core.js";
|
||||
export * as UI from "./ui/ui.js";
|
||||
//# sourceMappingURL=index.js.map
|
||||
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user