diff --git a/clients/macos/vellum-assistant/Features/MainWindow/DynamicWorkspaceWrapper.swift b/clients/macos/vellum-assistant/Features/MainWindow/DynamicWorkspaceWrapper.swift new file mode 100644 index 00000000000..41166c6d29d --- /dev/null +++ b/clients/macos/vellum-assistant/Features/MainWindow/DynamicWorkspaceWrapper.swift @@ -0,0 +1,378 @@ +import SwiftUI +import VellumAssistantShared + +/// Observes the active ChatViewModel and renders the dynamic workspace overlays. +struct DynamicWorkspaceWrapper: View { + var viewModel: ChatViewModel + let surface: Surface + let data: DynamicPageSurfaceData + @ObservedObject var windowState: MainWindowState + let surfaceManager: SurfaceManager + let connectionManager: GatewayConnectionManager + let trafficLightPadding: CGFloat + let isSidebarOpen: Bool + var sharing: SharingState + let gatewayBaseURL: String + let onPublishPage: (String, String?, String?) -> Void + let onBundleAndShare: (String) -> Void + let isChatDockOpen: Bool + let onToggleChatDock: () -> Void + let onMicrophoneToggle: () -> Void + var featureFlagClient: FeatureFlagClientProtocol = FeatureFlagClient() + + @State private var showVersionHistory = false + @State private var publishUrlCopied = false + @State private var showShareDrawer = false + @State private var shareButtonFrame: CGRect = .zero + @State private var isDeployToVercelEnabled = false + + private static let deployToVercelFlagKey = "deploy-to-vercel" + + /// Corner radius for the WKWebView clipping container — no rounding needed since the + /// outer page container handles corner rounding. + private var webViewCornerRadius: CGFloat { 0 } + + private var webViewMaskedCorners: CACornerMask { [] } + + var body: some View { + VStack(spacing: 0) { + HStack { + // Left: Close Chat primary CTA in edit mode, Edit primary button otherwise + if case .appEditing = windowState.selection { + VButton(label: "Close chat", icon: VIcon.x.rawValue, style: .primary) { + onToggleChatDock() + } + } else { + VButton(label: "Edit", icon: VIcon.pencil.rawValue, style: .primary) { + if !isChatDockOpen { + windowState.workspaceComposerExpanded = false + } + onToggleChatDock() + } + .accessibilityLabel("Edit app") + } + + Spacer(minLength: 0) + + Text(surface.title ?? data.preview?.title ?? "App") + .font(VFont.bodyMediumDefault) + .foregroundStyle(VColor.contentSecondary) + .lineLimit(1) + + Spacer(minLength: 0) + + // Right: History + Share + Close outlined icon buttons + HStack(spacing: VSpacing.sm) { + if data.appId != nil { + VButton(label: "Version history", iconOnly: VIcon.history.rawValue, style: .outlined, iconSize: 32, tooltip: "Version history") { + showVersionHistory = true + } + } + + if let url = sharing.publishedUrl { + PublishedButton(url: url, copied: $publishUrlCopied) + } + + ZStack { + if data.appId != nil { + if sharing.isBundling || sharing.isPublishing { + ProgressView() + .controlSize(.small) + .frame(height: 32) + } else { + VButton(label: "Share", iconOnly: VIcon.share.rawValue, style: .outlined, iconSize: 32, tooltip: "Share") { + showShareDrawer.toggle() + } + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .named("appPageContainer")) + } action: { newFrame in + shareButtonFrame = newFrame + } + .overlay { + AppSharePanel( + items: sharing.shareFileURL != nil ? [sharing.shareFileURL!] : [], + isPresented: Binding( + get: { sharing.showSharePicker }, + set: { sharing.showSharePicker = $0 } + ), + appName: sharing.shareAppName, + appIcon: sharing.shareAppIcon, + appId: sharing.shareAppId, + gatewayBaseURL: gatewayBaseURL + ) + .allowsHitTesting(false) + } + } + } else if sharing.isPublishing { + ProgressView() + .controlSize(.small) + .frame(height: 32) + } else if sharing.publishedUrl == nil && isDeployToVercelEnabled { + VButton(label: "Publish", iconOnly: VIcon.arrowUpRight.rawValue, style: .outlined, iconSize: 32, tooltip: "Publish to Vercel") { + onPublishPage(data.html, data.preview?.title, data.appId) + } + } + } + + VButton(label: "Close workspace", iconOnly: VIcon.x.rawValue, style: .outlined, iconSize: 32, tooltip: "Close workspace") { + sharing.showSharePicker = false + windowState.clearDynamicWorkspaceState() + windowState.dismissOverlay() + } + } + } + .padding(.leading, VSpacing.md) + .padding(.trailing, VSpacing.md) + .padding(.vertical, VSpacing.md) + .background( + VColor.surfaceOverlay + ) + .overlay(alignment: .bottom) { + VColor.borderBase + .frame(height: 1) + } + + if let error = sharing.publishError { + HStack { + Spacer() + Text(error) + .font(VFont.labelDefault) + .foregroundStyle(VColor.systemNegativeStrong) + .padding(.horizontal, VSpacing.md) + .padding(.vertical, VSpacing.xs) + .background(VColor.systemNegativeWeak) + .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) + .padding(.trailing, VSpacing.xl) + } + } + + ZStack { + DynamicPageSurfaceView( + data: data, + onAction: { actionId, actionData in + if !isChatDockOpen { + onToggleChatDock() + } + // Route relay_prompt actions directly as chat messages so they + // reach the active session instead of being lost when the surface + // was opened outside a session context (e.g. via app_open). + if actionId == "relay_prompt" || actionId == "agent_prompt", + let dataDict = actionData as? [String: Any], + let prompt = dataDict["prompt"] as? String, + !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + // Eagerly sync dock state so sendMessage() sees the + // up-to-date value instead of the stale pre-toggle state + // (onChange(of: windowState.selection) runs asynchronously). + viewModel.isChatDockedToSide = true + viewModel.inputText = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + viewModel.sendMessage() + return + } + surfaceManager.onAction?(surface.conversationId, surface.id, actionId, actionData as? [String: Any]) + }, + appId: data.appId, + onDataRequest: data.appId != nil ? { callId, method, recordId, requestData in + guard let appId = surfaceManager.surfaceAppIds[surface.id] else { return } + surfaceManager.onDataRequest?(surface.id, callId, method, appId, recordId, requestData) + } : nil, + onCoordinatorReady: data.appId != nil ? { coordinator in + surfaceManager.surfaceCoordinators[surface.id] = coordinator + } : nil, + onPageChanged: { [weak viewModel] page in + viewModel?.currentPage = page + }, + onSnapshotCaptured: data.appId != nil ? { base64 in + guard let appId = data.appId else { return } + Task { await AppsClient().updateAppPreview(appId: appId, preview: base64) } + NotificationCenter.default.post( + name: .appPreviewImageCaptured, + object: nil, + userInfo: ["appId": appId, "previewImage": base64] + ) + } : nil, + onLinkOpen: { url, metadata in + surfaceManager.onLinkOpen?(url, metadata) + }, + topContentInset: 0, + bottomContentInset: 0, + cornerRadius: webViewCornerRadius, + maskedCorners: webViewMaskedCorners + ) + .opacity(showVersionHistory ? 0 : 1) + .allowsHitTesting(!showVersionHistory) + + if showVersionHistory, let appId = data.appId { + AppVersionHistoryPanel( + connectionManager: connectionManager, + appId: appId, + appName: data.preview?.title ?? "App", + onClose: { showVersionHistory = false } + ) + } + } + } + .coordinateSpace(name: "appPageContainer") + .overlay(alignment: .topLeading) { + if showShareDrawer { + // Dismiss backdrop + Color.clear + .contentShape(Rectangle()) + .onTapGesture { showShareDrawer = false } + } + } + .overlay(alignment: .topLeading) { + if showShareDrawer, let appId = data.appId { + ShareDrawer( + onShare: { + showShareDrawer = false + onBundleAndShare(appId) + }, + onPublish: { + showShareDrawer = false + onPublishPage(data.html, data.preview?.title, data.appId) + }, + isDeployToVercelEnabled: isDeployToVercelEnabled + ) + .offset( + x: shareButtonFrame.maxX - 180, + y: shareButtonFrame.maxY + VSpacing.xs + ) + .zIndex(10) + .transition(.opacity) + } + } + .task { + do { + let flags = try await featureFlagClient.getFeatureFlags() + if let flag = flags.first(where: { $0.key == Self.deployToVercelFlagKey }) { + isDeployToVercelEnabled = flag.enabled + } + } catch { + // Flag stays disabled on error + } + } + .onReceive(NotificationCenter.default.publisher(for: .assistantFeatureFlagDidChange)) { notification in + if let key = notification.userInfo?["key"] as? String, + let enabled = notification.userInfo?["enabled"] as? Bool, + key == Self.deployToVercelFlagKey { + isDeployToVercelEnabled = enabled + } + } + } +} + +// MARK: - Supporting Views + +/// Shows "Published ✓" with an inline copy-to-clipboard button. +/// Tapping the copy icon copies the URL and briefly shows a checkmark. +private struct PublishedButton: View { + let url: String + @Binding var copied: Bool + + @State private var isCopyHovered = false + @State private var resetTask: Task? + + var body: some View { + HStack(spacing: VSpacing.xs) { + VIconView(.check, size: 10) + .foregroundStyle(VColor.systemPositiveStrong) + Text("Published") + .font(VFont.labelDefault) + Divider() + .frame(height: 12) + VIconView(copied ? .check : .copy, size: 10) + .foregroundStyle(copied ? VColor.systemPositiveStrong : (isCopyHovered ? VColor.contentDefault : VColor.primaryBase)) + .animation(VAnimation.fast, value: copied) + .contentShape(Rectangle()) + .onTapGesture { + resetTask?.cancel() + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + copied = true + resetTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled else { return } + copied = false + } + } + .onHover { hovering in + isCopyHovered = hovering + } + .pointerCursor() + .accessibilityLabel(copied ? "URL copied" : "Copy published URL") + } + .foregroundStyle(VColor.primaryBase) + .padding(.horizontal, VSpacing.md) + .padding(.vertical, VSpacing.buttonV) + .frame(height: 24) + .background( + RoundedRectangle(cornerRadius: VRadius.lg) + .fill(Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: VRadius.lg) + .stroke(VColor.borderActive, lineWidth: 1) + ) + .controlSize(.small) + } +} + +// MARK: - Share Drawer + +/// Popover menu with "Share" and optionally "Publish to Vercel" options. +/// Styled to match ConversationSwitcherDrawer / DrawerMenuView. +private struct ShareDrawer: View { + let onShare: () -> Void + let onPublish: () -> Void + let isDeployToVercelEnabled: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ShareDrawerRow(icon: .share, label: "Share", action: onShare) + if isDeployToVercelEnabled { + VColor.borderBase.frame(height: 1) + .padding(.horizontal, VSpacing.xs) + ShareDrawerRow(icon: .arrowUpRight, label: "Publish to Vercel", action: onPublish) + } + } + .padding(.vertical, VSpacing.xs) + .frame(width: 180) + .background(VColor.surfaceOverlay) + .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) + .overlay( + RoundedRectangle(cornerRadius: VRadius.lg) + .stroke(VColor.borderBase, lineWidth: 1) + ) + .shadow(color: VColor.auxBlack.opacity(0.15), radius: 6, y: 2) + } +} + +private struct ShareDrawerRow: View { + let icon: VIcon + let label: String + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: VSpacing.sm) { + VIconView(icon, size: 12) + .foregroundStyle(isHovered ? VColor.contentDefault : VColor.contentSecondary) + .frame(width: 18) + Text(label) + .font(VFont.bodyMediumLighter) + .foregroundStyle(VColor.contentDefault) + Spacer() + } + .padding(.horizontal, VSpacing.md) + .padding(.vertical, VSpacing.sm) + .background(VColor.surfaceBase.opacity(isHovered ? 1 : 0)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + isHovered = hovering + } + .pointerCursor() + } +} diff --git a/clients/macos/vellum-assistant/Features/MainWindow/PanelCoordinator.swift b/clients/macos/vellum-assistant/Features/MainWindow/PanelCoordinator.swift index f5f457eea89..6e9f596edad 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/PanelCoordinator.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/PanelCoordinator.swift @@ -690,378 +690,6 @@ struct ActiveChatViewWrapper: View { } } -/// Observes the active ChatViewModel and renders the dynamic workspace overlays. -struct DynamicWorkspaceWrapper: View { - var viewModel: ChatViewModel - let surface: Surface - let data: DynamicPageSurfaceData - @ObservedObject var windowState: MainWindowState - let surfaceManager: SurfaceManager - let connectionManager: GatewayConnectionManager - let trafficLightPadding: CGFloat - let isSidebarOpen: Bool - var sharing: SharingState - let gatewayBaseURL: String - let onPublishPage: (String, String?, String?) -> Void - let onBundleAndShare: (String) -> Void - let isChatDockOpen: Bool - let onToggleChatDock: () -> Void - let onMicrophoneToggle: () -> Void - var featureFlagClient: FeatureFlagClientProtocol = FeatureFlagClient() - - @State private var showVersionHistory = false - @State private var publishUrlCopied = false - @State private var showShareDrawer = false - @State private var shareButtonFrame: CGRect = .zero - @State private var isDeployToVercelEnabled = false - - private static let deployToVercelFlagKey = "deploy-to-vercel" - - /// Corner radius for the WKWebView clipping container — no rounding needed since the - /// outer page container handles corner rounding. - private var webViewCornerRadius: CGFloat { 0 } - - private var webViewMaskedCorners: CACornerMask { [] } - - var body: some View { - VStack(spacing: 0) { - HStack { - // Left: Close Chat primary CTA in edit mode, Edit primary button otherwise - if case .appEditing = windowState.selection { - VButton(label: "Close chat", icon: VIcon.x.rawValue, style: .primary) { - onToggleChatDock() - } - } else { - VButton(label: "Edit", icon: VIcon.pencil.rawValue, style: .primary) { - if !isChatDockOpen { - windowState.workspaceComposerExpanded = false - } - onToggleChatDock() - } - .accessibilityLabel("Edit app") - } - - Spacer(minLength: 0) - - Text(surface.title ?? data.preview?.title ?? "App") - .font(VFont.bodyMediumDefault) - .foregroundStyle(VColor.contentSecondary) - .lineLimit(1) - - Spacer(minLength: 0) - - // Right: History + Share + Close outlined icon buttons - HStack(spacing: VSpacing.sm) { - if data.appId != nil { - VButton(label: "Version history", iconOnly: VIcon.history.rawValue, style: .outlined, iconSize: 32, tooltip: "Version history") { - showVersionHistory = true - } - } - - if let url = sharing.publishedUrl { - PublishedButton(url: url, copied: $publishUrlCopied) - } - - ZStack { - if data.appId != nil { - if sharing.isBundling || sharing.isPublishing { - ProgressView() - .controlSize(.small) - .frame(height: 32) - } else { - VButton(label: "Share", iconOnly: VIcon.share.rawValue, style: .outlined, iconSize: 32, tooltip: "Share") { - showShareDrawer.toggle() - } - .onGeometryChange(for: CGRect.self) { proxy in - proxy.frame(in: .named("appPageContainer")) - } action: { newFrame in - shareButtonFrame = newFrame - } - .overlay { - AppSharePanel( - items: sharing.shareFileURL != nil ? [sharing.shareFileURL!] : [], - isPresented: Binding( - get: { sharing.showSharePicker }, - set: { sharing.showSharePicker = $0 } - ), - appName: sharing.shareAppName, - appIcon: sharing.shareAppIcon, - appId: sharing.shareAppId, - gatewayBaseURL: gatewayBaseURL - ) - .allowsHitTesting(false) - } - } - } else if sharing.isPublishing { - ProgressView() - .controlSize(.small) - .frame(height: 32) - } else if sharing.publishedUrl == nil && isDeployToVercelEnabled { - VButton(label: "Publish", iconOnly: VIcon.arrowUpRight.rawValue, style: .outlined, iconSize: 32, tooltip: "Publish to Vercel") { - onPublishPage(data.html, data.preview?.title, data.appId) - } - } - } - - VButton(label: "Close workspace", iconOnly: VIcon.x.rawValue, style: .outlined, iconSize: 32, tooltip: "Close workspace") { - sharing.showSharePicker = false - windowState.clearDynamicWorkspaceState() - windowState.dismissOverlay() - } - } - } - .padding(.leading, VSpacing.md) - .padding(.trailing, VSpacing.md) - .padding(.vertical, VSpacing.md) - .background( - VColor.surfaceOverlay - ) - .overlay(alignment: .bottom) { - VColor.borderBase - .frame(height: 1) - } - - if let error = sharing.publishError { - HStack { - Spacer() - Text(error) - .font(VFont.labelDefault) - .foregroundStyle(VColor.systemNegativeStrong) - .padding(.horizontal, VSpacing.md) - .padding(.vertical, VSpacing.xs) - .background(VColor.systemNegativeStrong.opacity(0.8)) - .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) - .padding(.trailing, VSpacing.xl) - } - } - - ZStack { - if showVersionHistory, let appId = data.appId { - AppVersionHistoryPanel( - connectionManager: connectionManager, - appId: appId, - appName: data.preview?.title ?? "App", - onClose: { showVersionHistory = false } - ) - } else { - DynamicPageSurfaceView( - data: data, - onAction: { actionId, actionData in - if !isChatDockOpen { - onToggleChatDock() - } - // Route relay_prompt actions directly as chat messages so they - // reach the active session instead of being lost when the surface - // was opened outside a session context (e.g. via app_open). - if actionId == "relay_prompt" || actionId == "agent_prompt", - let dataDict = actionData as? [String: Any], - let prompt = dataDict["prompt"] as? String, - !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - // Eagerly sync dock state so sendMessage() sees the - // up-to-date value instead of the stale pre-toggle state - // (onChange(of: windowState.selection) runs asynchronously). - viewModel.isChatDockedToSide = true - viewModel.inputText = prompt.trimmingCharacters(in: .whitespacesAndNewlines) - viewModel.sendMessage() - return - } - surfaceManager.onAction?(surface.conversationId, surface.id, actionId, actionData as? [String: Any]) - }, - appId: data.appId, - onDataRequest: data.appId != nil ? { callId, method, recordId, requestData in - guard let appId = surfaceManager.surfaceAppIds[surface.id] else { return } - surfaceManager.onDataRequest?(surface.id, callId, method, appId, recordId, requestData) - } : nil, - onCoordinatorReady: data.appId != nil ? { coordinator in - surfaceManager.surfaceCoordinators[surface.id] = coordinator - } : nil, - onPageChanged: { [weak viewModel] page in - viewModel?.currentPage = page - }, - onSnapshotCaptured: data.appId != nil ? { base64 in - guard let appId = data.appId else { return } - Task { await AppsClient().updateAppPreview(appId: appId, preview: base64) } - NotificationCenter.default.post( - name: .appPreviewImageCaptured, - object: nil, - userInfo: ["appId": appId, "previewImage": base64] - ) - } : nil, - onLinkOpen: { url, metadata in - surfaceManager.onLinkOpen?(url, metadata) - }, - topContentInset: 0, - bottomContentInset: 0, - cornerRadius: webViewCornerRadius, - maskedCorners: webViewMaskedCorners - ) - } - } - } - .coordinateSpace(name: "appPageContainer") - .overlay(alignment: .topLeading) { - if showShareDrawer { - // Dismiss backdrop - Color.clear - .contentShape(Rectangle()) - .onTapGesture { showShareDrawer = false } - } - } - .overlay(alignment: .topLeading) { - if showShareDrawer, let appId = data.appId { - ShareDrawer( - onShare: { - showShareDrawer = false - onBundleAndShare(appId) - }, - onPublish: { - showShareDrawer = false - onPublishPage(data.html, data.preview?.title, data.appId) - }, - isDeployToVercelEnabled: isDeployToVercelEnabled - ) - .offset( - x: shareButtonFrame.maxX - 180, - y: shareButtonFrame.maxY + VSpacing.xs - ) - .zIndex(10) - .transition(.opacity) - } - } - .task { - do { - let flags = try await featureFlagClient.getFeatureFlags() - if let flag = flags.first(where: { $0.key == Self.deployToVercelFlagKey }) { - isDeployToVercelEnabled = flag.enabled - } - } catch { - // Flag stays disabled on error - } - } - .onReceive(NotificationCenter.default.publisher(for: .assistantFeatureFlagDidChange)) { notification in - if let key = notification.userInfo?["key"] as? String, - let enabled = notification.userInfo?["enabled"] as? Bool, - key == Self.deployToVercelFlagKey { - isDeployToVercelEnabled = enabled - } - } - } -} - -/// Shows "Published ✓" with an inline copy-to-clipboard button. -/// Tapping the copy icon copies the URL and briefly shows a checkmark. -private struct PublishedButton: View { - let url: String - @Binding var copied: Bool - - @State private var isCopyHovered = false - @State private var resetTask: Task? - - var body: some View { - HStack(spacing: VSpacing.xs) { - VIconView(.check, size: 10) - .foregroundStyle(VColor.systemPositiveStrong) - Text("Published") - .font(VFont.labelDefault) - Divider() - .frame(height: 12) - VIconView(copied ? .check : .copy, size: 10) - .foregroundStyle(copied ? VColor.systemPositiveStrong : (isCopyHovered ? VColor.contentDefault : VColor.primaryBase)) - .animation(VAnimation.fast, value: copied) - .contentShape(Rectangle()) - .onTapGesture { - resetTask?.cancel() - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(url, forType: .string) - copied = true - resetTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 1_500_000_000) - guard !Task.isCancelled else { return } - copied = false - } - } - .onHover { hovering in - isCopyHovered = hovering - } - .pointerCursor() - .accessibilityLabel(copied ? "URL copied" : "Copy published URL") - } - .foregroundStyle(VColor.primaryBase) - .padding(.horizontal, VSpacing.md) - .padding(.vertical, VSpacing.buttonV) - .frame(height: 24) - .background( - RoundedRectangle(cornerRadius: VRadius.lg) - .fill(Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: VRadius.lg) - .stroke(VColor.borderActive, lineWidth: 1) - ) - .controlSize(.small) - } -} - -// MARK: - Share Drawer - -/// Popover menu with "Share" and optionally "Publish to Vercel" options. -/// Styled to match ConversationSwitcherDrawer / DrawerMenuView. -private struct ShareDrawer: View { - let onShare: () -> Void - let onPublish: () -> Void - let isDeployToVercelEnabled: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - ShareDrawerRow(icon: .share, label: "Share", action: onShare) - if isDeployToVercelEnabled { - VColor.borderBase.frame(height: 1) - .padding(.horizontal, VSpacing.xs) - ShareDrawerRow(icon: .arrowUpRight, label: "Publish to Vercel", action: onPublish) - } - } - .padding(.vertical, VSpacing.xs) - .frame(width: 180) - .background(VColor.surfaceOverlay) - .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.lg) - .stroke(VColor.borderBase, lineWidth: 1) - ) - .shadow(color: VColor.auxBlack.opacity(0.15), radius: 6, y: 2) - } -} - -private struct ShareDrawerRow: View { - let icon: VIcon - let label: String - let action: () -> Void - @State private var isHovered = false - - var body: some View { - Button(action: action) { - HStack(spacing: VSpacing.sm) { - VIconView(icon, size: 12) - .foregroundStyle(isHovered ? VColor.contentDefault : VColor.contentSecondary) - .frame(width: 18) - Text(label) - .font(VFont.bodyMediumLighter) - .foregroundStyle(VColor.contentDefault) - Spacer() - } - .padding(.horizontal, VSpacing.md) - .padding(.vertical, VSpacing.sm) - .background(VColor.surfaceBase.opacity(isHovered ? 1 : 0)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in - isHovered = hovering - } - .pointerCursor() - } -} - // MARK: - App Loading View /// Shows a loading spinner while waiting for the daemon to send surface data, diff --git a/clients/macos/vellum-assistant/Features/Surfaces/DynamicPageSurfaceView+Coordinator.swift b/clients/macos/vellum-assistant/Features/Surfaces/DynamicPageSurfaceView+Coordinator.swift new file mode 100644 index 00000000000..e4968f0ea72 --- /dev/null +++ b/clients/macos/vellum-assistant/Features/Surfaces/DynamicPageSurfaceView+Coordinator.swift @@ -0,0 +1,486 @@ +import SwiftUI +@preconcurrency import WebKit +import os +import VellumAssistantShared + +private let log = Logger(subsystem: Bundle.appBundleIdentifier, category: "DynamicPage") + +extension DynamicPageSurfaceView { + + class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { + var onAction: (String, Any?) -> Void + var onDataRequest: ((String, String, String?, [String: Any]?) -> Void)? + var onPageChanged: ((String) -> Void)? + var onSnapshotCaptured: ((String) -> Void)? + var onLinkOpen: ((String, [String: Any]?) -> Void)? + var currentHTML: String + /// The page currently displayed in a multi-page app (e.g. "settings.html"). + var currentPage: String = "index.html" + let sandboxMode: Bool + weak var webView: WKWebView? + var lastTopInset: Int = 0 + var lastBottomInset: Int = 0 + var desiredTopInset: Int = 0 + var desiredBottomInset: Int = 0 + /// JSON string with {x, y} scroll position to restore after the next page load. + var pendingScrollRestore: String? + var hasCapturedSnapshot = false + var morphGeneration: Int = 0 + var lastReloadGeneration: Int = 0 + /// True when app content is loaded inline via loadHTMLString rather than a scheme URL. + var isInlineFallback: Bool = false + var lastStatus: String? + /// Status message to inject after the next page reload completes. + var pendingStatus: String? + + // MARK: - Timing diagnostics + + /// Surface and app identifiers for diagnostic log lines. + var surfaceId: String? + var appId: String? + /// Monotonic timestamp (CFAbsoluteTimeGetCurrent) recorded when a page load begins. + var loadStartTime: CFAbsoluteTime = 0 + + /// Log a timing-trail phase with elapsed milliseconds since `loadStartTime`. + private func logPhase(_ phase: String) { + let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - loadStartTime) * 1000) + log.info("[Timing] surface=\(self.surfaceId ?? "nil", privacy: .public) appId=\(self.appId ?? "nil", privacy: .public) page=\(self.currentPage, privacy: .public) phase=\(phase, privacy: .public) elapsed=\(elapsedMs)ms") + } + + init( + onAction: @escaping (String, Any?) -> Void, + onDataRequest: ((String, String, String?, [String: Any]?) -> Void)?, + onPageChanged: ((String) -> Void)?, + onSnapshotCaptured: ((String) -> Void)?, + onLinkOpen: ((String, [String: Any]?) -> Void)? = nil, + currentHTML: String, + sandboxMode: Bool = false + ) { + self.onAction = onAction + self.onDataRequest = onDataRequest + self.onPageChanged = onPageChanged + self.onSnapshotCaptured = onSnapshotCaptured + self.onLinkOpen = onLinkOpen + self.currentHTML = currentHTML + self.sandboxMode = sandboxMode + } + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard let body = message.body as? [String: Any] else { return } + + // Forward JS console messages to os.Logger. + if let type = body["type"] as? String, type == "console" { + let level = body["level"] as? String ?? "log" + let msg = body["message"] as? String ?? "" + switch level { + case "error": + log.error("[WebView] \(msg, privacy: .public)") + case "warn": + log.warning("[WebView] \(msg, privacy: .public)") + default: + log.info("[WebView] \(msg, privacy: .public)") + } + return + } + + // Handle data_request messages from the RPC bridge. + if let type = body["type"] as? String, type == "data_request" { + guard let callId = body["callId"] as? String, + let method = body["method"] as? String else { + log.error("data_request missing callId or method: \(String(describing: body), privacy: .public)") + return + } + let recordId = body["recordId"] as? String + let data = body["data"] as? [String: Any] + log.info("data_request: method=\(method, privacy: .public), callId=\(callId, privacy: .public), recordId=\(recordId ?? "nil", privacy: .public), hasData=\(data != nil)") + if onDataRequest == nil { + log.error("data_request received but onDataRequest callback is nil — appId was likely not set") + } + onDataRequest?(callId, method, recordId, data) + return + } + + // Handle openExternal requests from the JS bridge. + if let type = body["type"] as? String, type == "open_external" { + if sandboxMode { + log.warning("open_external: blocked in sandbox mode") + return + } + guard let urlString = body["url"] as? String, + let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + ["http", "https", "mailto"].contains(scheme) else { + log.warning("open_external: blocked invalid or disallowed URL: \(body["url"] as? String ?? "nil", privacy: .public)") + return + } + NSWorkspace.shared.open(url) + return + } + + // Handle openLink requests from the JS bridge. + if let type = body["type"] as? String, type == "open_link" { + guard let urlString = body["url"] as? String, + let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme) else { + log.warning("open_link: invalid URL") + return + } + // Sandbox: only allow the Vellum branding domain. + if sandboxMode { + let host = url.host?.lowercased() ?? "" + guard host == "vellum.ai" || host.hasSuffix(".vellum.ai") else { + log.warning("open_link: blocked in sandbox mode (host=\(host, privacy: .public))") + return + } + } + let metadata = body["metadata"] as? [String: Any] + onLinkOpen?(urlString, metadata) + return + } + + // Handle confirm dialog requests from the JS bridge. + if let type = body["type"] as? String, type == "confirm" { + guard let confirmId = body["confirmId"] as? String else { + log.error("confirm: missing confirmId") + return + } + let title = body["title"] as? String ?? "" + let msg = body["message"] as? String ?? "" + let alert = NSAlert() + alert.messageText = title + alert.informativeText = msg + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + let response = alert.runModal() + let confirmed = response == .alertFirstButtonReturn + let safeId = confirmId + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + let js = "window.vellum._resolveConfirm('\(safeId)', \(confirmed))" + webView?.evaluateJavaScript(js) { _, error in + if let error { + log.error("confirm: JS eval error: \(error.localizedDescription, privacy: .public)") + } + } + return + } + + // Handle page_changed messages from navigation tracking. + if let type = body["type"] as? String, type == "page_changed" { + if let page = body["page"] as? String, page != currentPage { + currentPage = page + log.info("[WebView] Page changed to: \(page, privacy: .public)") + onPageChanged?(page) + } + return + } + + guard let actionId = body["actionId"] as? String else { return } + let data = body["data"] + onAction(actionId, data) + } + + func resolveDataResponse(_ response: AppDataResponseMessage) { + log.info("resolveDataResponse: callId=\(response.callId, privacy: .public), success=\(response.success), hasResult=\(response.result != nil), error=\(response.error ?? "nil", privacy: .public)") + + let resultJson: String + if let result = response.result { + if let jsonData = try? JSONEncoder().encode(result), + let jsonStr = String(data: jsonData, encoding: .utf8) { + resultJson = jsonStr + } else { + log.error("resolveDataResponse: failed to re-encode AnyCodable result to JSON") + resultJson = "null" + } + } else { + resultJson = "null" + } + let errorStr: String + if let error = response.error { + let escaped = error + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + errorStr = "'\(escaped)'" + } else { + errorStr = "null" + } + let safeCallId = response.callId + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + + let js = "window.vellum.data._resolve('\(safeCallId)', \(response.success), \(resultJson), \(errorStr))" + + guard let webView else { + log.error("resolveDataResponse: webView is nil, cannot evaluate JS") + return + } + + webView.evaluateJavaScript(js) { _, error in + if let error { + log.error("resolveDataResponse: JS eval error: \(error.localizedDescription, privacy: .public)") + } + } + } + + /// Captures a screenshot of the current WebView content as a base64-encoded PNG. + func captureSnapshot(completion: @escaping (String?) -> Void) { + guard let webView = webView else { + completion(nil) + return + } + let config = WKSnapshotConfiguration() + config.afterScreenUpdates = true + webView.takeSnapshot(with: config) { image, error in + if let error = error { + log.error("Snapshot capture failed: \(error.localizedDescription, privacy: .public)") + completion(nil) + return + } + guard let image = image, + let tiff = image.tiffRepresentation, + let _ = NSBitmapImageRep(data: tiff) else { + completion(nil) + return + } + // Resize to a reasonable thumbnail (max 400px wide) to keep payload small + let maxWidth: CGFloat = 400 + let scale = min(1.0, maxWidth / image.size.width) + let targetSize = NSSize( + width: image.size.width * scale, + height: image.size.height * scale + ) + let resized = NSImage(size: targetSize) + resized.lockFocus() + image.draw(in: NSRect(origin: .zero, size: targetSize), + from: NSRect(origin: .zero, size: image.size), + operation: .copy, + fraction: 1.0) + resized.unlockFocus() + guard let resizedTiff = resized.tiffRepresentation, + let resizedBitmap = NSBitmapImageRep(data: resizedTiff), + let pngData = resizedBitmap.representation(using: .png, properties: [.compressionFactor: 0.8]) else { + completion(nil) + return + } + completion(pngData.base64EncodedString()) + } + } + + func captureSnapshotAfterMorph(generation: Int) { + guard let onSnapshotCaptured else { return } + logPhase("captureSnapshotAfterMorph:start") + hasCapturedSnapshot = false + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 500_000_000) + guard !Task.isCancelled else { return } + guard let self, self.morphGeneration == generation else { return } + self.logPhase("captureSnapshotAfterMorph:takeSnapshot") + self.captureSnapshot { [weak self] base64 in + if let base64 { + self?.logPhase("captureSnapshotAfterMorph:complete") + onSnapshotCaptured(base64) + } + } + } + } + + /// Send a content update to the web view via window.vellum.onContentUpdate(). + func sendContentUpdate(_ data: [String: Any]) { + guard let webView = webView else { + log.warning("sendContentUpdate: no webView available") + return + } + + guard let jsonData = try? JSONSerialization.data(withJSONObject: data), + let jsonString = String(data: jsonData, encoding: .utf8) else { + log.error("sendContentUpdate: failed to serialize data to JSON") + return + } + + let safeJSON = jsonString + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + + let script = """ + (function() { + try { + if (typeof window.vellum !== 'undefined' && + typeof window.vellum.onContentUpdate === 'function') { + var data = JSON.parse('\(safeJSON)'); + window.vellum.onContentUpdate(data); + } + } catch(e) { + console.error('onContentUpdate error:', e); + } + })(); + """ + + webView.evaluateJavaScript(script) { result, error in + if let error = error { + log.error("sendContentUpdate: JS eval error: \(error.localizedDescription, privacy: .public)") + } else { + log.debug("sendContentUpdate: successfully sent update") + } + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + logPhase("didFinish") + + // Restore scroll position if this load was a refinement update. + if let scrollJSON = pendingScrollRestore { + pendingScrollRestore = nil + let safeScrollJSON = scrollJSON + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + let js = """ + (function() { + try { + var s = JSON.parse('\(safeScrollJSON)'); + window.scrollTo(s.x || 0, s.y || 0); + } catch(e) {} + })(); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + } + + // Re-inject content insets after page load completes. The WKUserScript from + // makeNSView has creation-time values baked in, which may be stale if insets + // changed since then (e.g. composer expanded). Apply the current desired values. + let top = desiredTopInset + let bottom = desiredBottomInset + if top > 0 || bottom > 0 || lastTopInset > 0 || lastBottomInset > 0 { + lastTopInset = top + lastBottomInset = bottom + let fadeHeight = bottom + 32 + let js = """ + (function() { + var el = document.getElementById('vellum-content-insets'); + if (!el) { el = document.createElement('style'); el.id = 'vellum-content-insets'; el.setAttribute('data-vellum-injected', '1'); (document.head || document.documentElement).appendChild(el); } + el.textContent = 'body { padding-top: \(top)px; padding-bottom: \(bottom)px; }'; + var fade = document.getElementById('vellum-bottom-fade'); + if (fade) { + fade.style.height = '\(fadeHeight)px'; + var bg = getComputedStyle(document.body).backgroundColor || 'rgba(0,0,0,0)'; + fade.style.background = 'linear-gradient(to bottom, transparent 0%, ' + bg + ' 100%)'; + } + })(); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + } + + // Inject deferred status pill that was stashed during a reload. + if let status = pendingStatus { + pendingStatus = nil + let escapedStatus = status + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + let pillJS = """ + (function() { + var existing = document.getElementById('vellum-status-pill'); + if (existing) existing.remove(); + var pill = document.createElement('div'); + pill.id = 'vellum-status-pill'; + pill.setAttribute('data-vellum-injected', '1'); + pill.textContent = '\(escapedStatus)'; + pill.style.cssText = 'position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.75);color:#fff;font-size:12px;padding:6px 14px;border-radius:20px;z-index:100000;pointer-events:none;opacity:0;transition:opacity 0.3s ease;backdrop-filter:blur(8px);font-family:-apple-system,BlinkMacSystemFont,sans-serif;'; + document.body.appendChild(pill); + requestAnimationFrame(function() { pill.style.opacity = '1'; }); + setTimeout(function() { + pill.style.opacity = '0'; + setTimeout(function() { if (pill.parentNode) pill.remove(); }, 300); + }, 3000); + })(); + """ + webView.evaluateJavaScript(pillJS, completionHandler: nil) + } + + // Detect page changes from URL-based navigation (e.g. ). + if let url = webView.url { + let path = url.path + let pageName: String + if path == "/" || path.isEmpty { + pageName = "index.html" + } else { + // Extract filename from path (e.g. "/settings.html" → "settings.html") + pageName = String(path.dropFirst()) // remove leading "/" + } + if !pageName.isEmpty && pageName != currentPage { + currentPage = pageName + log.info("[WebView] Page detected from URL: \(pageName, privacy: .public)") + onPageChanged?(pageName) + } + } + + // Capture a preview screenshot after the page has rendered (once per load). + if !hasCapturedSnapshot, let onSnapshotCaptured { + hasCapturedSnapshot = true + logPhase("onSnapshotCaptured:scheduled") + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled else { return } + self?.logPhase("onSnapshotCaptured:takeSnapshot") + self?.captureSnapshot { base64 in + if let base64 { + self?.logPhase("onSnapshotCaptured:complete") + onSnapshotCaptured(base64) + } + } + } + } + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + if sandboxMode { + // In sandbox mode, only allow vellumapp:// and about:blank URLs + if let url = navigationAction.request.url { + let scheme = url.scheme?.lowercased() ?? "" + if scheme == VellumAppSchemeHandler.scheme || url.absoluteString == "about:blank" { + decisionHandler(.allow) + return + } + // Allow initial HTML load via https://*.vellum.local/ + if scheme == "https" && (url.host?.hasSuffix(".vellum.local") == true) && navigationAction.navigationType == .other { + decisionHandler(.allow) + return + } + } + log.info("Sandbox mode: blocking navigation to \(navigationAction.request.url?.absoluteString ?? "nil", privacy: .public)") + decisionHandler(.cancel) + } else { + if navigationAction.navigationType == .other { + decisionHandler(.allow) + } else if navigationAction.navigationType == .linkActivated, + let url = navigationAction.request.url, + let scheme = url.scheme?.lowercased(), + ["http", "https", "mailto"].contains(scheme) { + NSWorkspace.shared.open(url) + decisionHandler(.cancel) + } else { + decisionHandler(.cancel) + } + } + } + } +} diff --git a/clients/macos/vellum-assistant/Features/Surfaces/DynamicPageSurfaceView.swift b/clients/macos/vellum-assistant/Features/Surfaces/DynamicPageSurfaceView.swift index 7dd19503bbc..2846246613c 100644 --- a/clients/macos/vellum-assistant/Features/Surfaces/DynamicPageSurfaceView.swift +++ b/clients/macos/vellum-assistant/Features/Surfaces/DynamicPageSurfaceView.swift @@ -86,6 +86,92 @@ extension DynamicPageSurfaceView { } return js }() + + // MARK: - Pre-built WKUserScript objects + + /// Design system CSS injection script, built once from the static `designSystemCSS` string. + static let designSystemUserScript: WKUserScript = { + WKUserScript( + source: """ + (function() { + var style = document.createElement('style'); + style.id = 'vellum-design-system'; + style.setAttribute('data-vellum-injected', '1'); + style.textContent = `\(designSystemCSS)`; + var target = document.head || document.documentElement; + target.insertBefore(style, target.firstChild); + })(); + """, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + }() + + /// Widget JS utilities script, built once from the static `widgetJS` string. + static let widgetUserScript: WKUserScript = { + WKUserScript( + source: widgetJS, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + }() + + /// Edit animator script for DOM morphing with animation, or nil if the resource is missing. + static let editAnimatorUserScript: WKUserScript? = { + guard let url = ResourceBundle.bundle.url(forResource: "vellum-edit-animator", withExtension: "js"), + let js = try? String(contentsOf: url, encoding: .utf8) else { + return nil + } + return WKUserScript(source: js, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + }() + + // MARK: - Cached sandbox content rule list + + /// Pre-compiled content rule list for sandbox mode, compiled once and reused. + /// Blocks all network requests except vellumapp:// and about:blank. + private static var _cachedSandboxRuleList: WKContentRuleList? + private static var _sandboxRuleListCompiled = false + + /// Returns the cached sandbox rule list, compiling it on first access. + static func sandboxRuleList(completion: @escaping (WKContentRuleList?) -> Void) { + if _sandboxRuleListCompiled { + completion(_cachedSandboxRuleList) + return + } + let ruleJSON = """ + [ + { + "trigger": { "url-filter": ".*" }, + "action": { "type": "block" } + }, + { + "trigger": { "url-filter": "^vellumapp://.*" }, + "action": { "type": "ignore-previous-rules" } + }, + { + "trigger": { "url-filter": "^about:blank$" }, + "action": { "type": "ignore-previous-rules" } + } + ] + """ + WKContentRuleListStore.default().compileContentRuleList( + forIdentifier: "sandbox-block-external", + encodedContentRuleList: ruleJSON + ) { ruleList, error in + DispatchQueue.main.async { + if let error { + log.error("Failed to compile sandbox content rule list: \(error.localizedDescription)") + // Leave _sandboxRuleListCompiled false so the next sandboxed + // surface retries compilation instead of permanently losing + // network-blocking rules. + } else { + _cachedSandboxRuleList = ruleList + _sandboxRuleListCompiled = true + } + completion(ruleList) + } + } + } } struct DynamicPageSurfaceView: NSViewRepresentable { @@ -279,39 +365,13 @@ struct DynamicPageSurfaceView: NSViewRepresentable { forMainFrameOnly: true ) - let designSystemScript = WKUserScript( - source: """ - (function() { - var style = document.createElement('style'); - style.id = 'vellum-design-system'; - style.setAttribute('data-vellum-injected', '1'); - style.textContent = `\(Self.designSystemCSS)`; - var target = document.head || document.documentElement; - target.insertBefore(style, target.firstChild); - })(); - """, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - - // Widget JS utilities (charts, formatting, interactive behaviors). - // Runs after the bridge script so window.vellum is already defined. - let widgetScript = WKUserScript( - source: Self.widgetJS, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - let contentController = WKUserContentController() contentController.addUserScript(userScript) contentController.addUserScript(themeScript) - contentController.addUserScript(designSystemScript) - contentController.addUserScript(widgetScript) + contentController.addUserScript(Self.designSystemUserScript) + contentController.addUserScript(Self.widgetUserScript) - // Edit animator — DOM morphing with animation (runs at document end so window.vellum exists). - if let animatorURL = ResourceBundle.bundle.url(forResource: "vellum-edit-animator", withExtension: "js"), - let animatorJS = try? String(contentsOf: animatorURL, encoding: .utf8) { - let animatorScript = WKUserScript(source: animatorJS, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + if let animatorScript = Self.editAnimatorUserScript { contentController.addUserScript(animatorScript) } @@ -337,34 +397,11 @@ struct DynamicPageSurfaceView: NSViewRepresentable { log.info("Creating DynamicPageSurfaceView: appId=\(self.appId ?? "nil", privacy: .public), dataBridge=\(self.appId != nil ? "injected" : "skipped", privacy: .public), sandboxMode=\(self.sandboxMode)") - // When sandbox mode is enabled, compile a content rule list that blocks - // all network requests except those to the vellumapp:// scheme. if sandboxMode { - let ruleJSON = """ - [ - { - "trigger": { "url-filter": ".*" }, - "action": { "type": "block" } - }, - { - "trigger": { "url-filter": "^vellumapp://.*" }, - "action": { "type": "ignore-previous-rules" } - }, - { - "trigger": { "url-filter": "^about:blank$" }, - "action": { "type": "ignore-previous-rules" } - } - ] - """ - WKContentRuleListStore.default().compileContentRuleList( - forIdentifier: "sandbox-block-external", - encodedContentRuleList: ruleJSON - ) { ruleList, error in + Self.sandboxRuleList { ruleList in if let ruleList { webView.configuration.userContentController.add(ruleList) log.info("Sandbox content rule list installed") - } else if let error { - log.error("Failed to compile sandbox content rule list: \(error.localizedDescription)") } } } @@ -612,484 +649,4 @@ struct DynamicPageSurfaceView: NSViewRepresentable { webView.navigationDelegate = nil } - // MARK: - Coordinator - - class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { - var onAction: (String, Any?) -> Void - var onDataRequest: ((String, String, String?, [String: Any]?) -> Void)? - var onPageChanged: ((String) -> Void)? - var onSnapshotCaptured: ((String) -> Void)? - var onLinkOpen: ((String, [String: Any]?) -> Void)? - var currentHTML: String - /// The page currently displayed in a multi-page app (e.g. "settings.html"). - var currentPage: String = "index.html" - let sandboxMode: Bool - weak var webView: WKWebView? - var lastTopInset: Int = 0 - var lastBottomInset: Int = 0 - var desiredTopInset: Int = 0 - var desiredBottomInset: Int = 0 - /// JSON string with {x, y} scroll position to restore after the next page load. - var pendingScrollRestore: String? - var hasCapturedSnapshot = false - var morphGeneration: Int = 0 - var lastReloadGeneration: Int = 0 - /// True when app content is loaded inline via loadHTMLString rather than a scheme URL. - var isInlineFallback: Bool = false - var lastStatus: String? - /// Status message to inject after the next page reload completes. - var pendingStatus: String? - - // MARK: - Timing diagnostics - - /// Surface and app identifiers for diagnostic log lines. - var surfaceId: String? - var appId: String? - /// Monotonic timestamp (CFAbsoluteTimeGetCurrent) recorded when a page load begins. - var loadStartTime: CFAbsoluteTime = 0 - - /// Log a timing-trail phase with elapsed milliseconds since `loadStartTime`. - private func logPhase(_ phase: String) { - let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - loadStartTime) * 1000) - log.info("[Timing] surface=\(self.surfaceId ?? "nil", privacy: .public) appId=\(self.appId ?? "nil", privacy: .public) page=\(self.currentPage, privacy: .public) phase=\(phase, privacy: .public) elapsed=\(elapsedMs)ms") - } - - init( - onAction: @escaping (String, Any?) -> Void, - onDataRequest: ((String, String, String?, [String: Any]?) -> Void)?, - onPageChanged: ((String) -> Void)?, - onSnapshotCaptured: ((String) -> Void)?, - onLinkOpen: ((String, [String: Any]?) -> Void)? = nil, - currentHTML: String, - sandboxMode: Bool = false - ) { - self.onAction = onAction - self.onDataRequest = onDataRequest - self.onPageChanged = onPageChanged - self.onSnapshotCaptured = onSnapshotCaptured - self.onLinkOpen = onLinkOpen - self.currentHTML = currentHTML - self.sandboxMode = sandboxMode - } - - func userContentController( - _ userContentController: WKUserContentController, - didReceive message: WKScriptMessage - ) { - guard let body = message.body as? [String: Any] else { return } - - // Forward JS console messages to os.Logger. - if let type = body["type"] as? String, type == "console" { - let level = body["level"] as? String ?? "log" - let msg = body["message"] as? String ?? "" - switch level { - case "error": - log.error("[WebView] \(msg, privacy: .public)") - case "warn": - log.warning("[WebView] \(msg, privacy: .public)") - default: - log.info("[WebView] \(msg, privacy: .public)") - } - return - } - - // Handle data_request messages from the RPC bridge. - if let type = body["type"] as? String, type == "data_request" { - guard let callId = body["callId"] as? String, - let method = body["method"] as? String else { - log.error("data_request missing callId or method: \(String(describing: body), privacy: .public)") - return - } - let recordId = body["recordId"] as? String - let data = body["data"] as? [String: Any] - log.info("data_request: method=\(method, privacy: .public), callId=\(callId, privacy: .public), recordId=\(recordId ?? "nil", privacy: .public), hasData=\(data != nil)") - if onDataRequest == nil { - log.error("data_request received but onDataRequest callback is nil — appId was likely not set") - } - onDataRequest?(callId, method, recordId, data) - return - } - - // Handle openExternal requests from the JS bridge. - if let type = body["type"] as? String, type == "open_external" { - if sandboxMode { - log.warning("open_external: blocked in sandbox mode") - return - } - guard let urlString = body["url"] as? String, - let url = URL(string: urlString), - let scheme = url.scheme?.lowercased(), - ["http", "https", "mailto"].contains(scheme) else { - log.warning("open_external: blocked invalid or disallowed URL: \(body["url"] as? String ?? "nil", privacy: .public)") - return - } - NSWorkspace.shared.open(url) - return - } - - // Handle openLink requests from the JS bridge. - if let type = body["type"] as? String, type == "open_link" { - guard let urlString = body["url"] as? String, - let url = URL(string: urlString), - let scheme = url.scheme?.lowercased(), - ["http", "https"].contains(scheme) else { - log.warning("open_link: invalid URL") - return - } - // Sandbox: only allow the Vellum branding domain. - if sandboxMode { - let host = url.host?.lowercased() ?? "" - guard host == "vellum.ai" || host.hasSuffix(".vellum.ai") else { - log.warning("open_link: blocked in sandbox mode (host=\(host, privacy: .public))") - return - } - } - let metadata = body["metadata"] as? [String: Any] - onLinkOpen?(urlString, metadata) - return - } - - // Handle confirm dialog requests from the JS bridge. - if let type = body["type"] as? String, type == "confirm" { - guard let confirmId = body["confirmId"] as? String else { - log.error("confirm: missing confirmId") - return - } - let title = body["title"] as? String ?? "" - let msg = body["message"] as? String ?? "" - let alert = NSAlert() - alert.messageText = title - alert.informativeText = msg - alert.alertStyle = .informational - alert.addButton(withTitle: "OK") - alert.addButton(withTitle: "Cancel") - let response = alert.runModal() - let confirmed = response == .alertFirstButtonReturn - let safeId = confirmId - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - let js = "window.vellum._resolveConfirm('\(safeId)', \(confirmed))" - webView?.evaluateJavaScript(js) { _, error in - if let error { - log.error("confirm: JS eval error: \(error.localizedDescription, privacy: .public)") - } - } - return - } - - // Handle page_changed messages from navigation tracking. - if let type = body["type"] as? String, type == "page_changed" { - if let page = body["page"] as? String, page != currentPage { - currentPage = page - log.info("[WebView] Page changed to: \(page, privacy: .public)") - onPageChanged?(page) - } - return - } - - // Existing sendAction handling. - guard let actionId = body["actionId"] as? String else { return } - let data = body["data"] - onAction(actionId, data) - } - - func resolveDataResponse(_ response: AppDataResponseMessage) { - log.info("resolveDataResponse: callId=\(response.callId, privacy: .public), success=\(response.success), hasResult=\(response.result != nil), error=\(response.error ?? "nil", privacy: .public)") - - let resultJson: String - if let result = response.result { - if let jsonData = try? JSONEncoder().encode(result), - let jsonStr = String(data: jsonData, encoding: .utf8) { - resultJson = jsonStr - } else { - log.error("resolveDataResponse: failed to re-encode AnyCodable result to JSON") - resultJson = "null" - } - } else { - resultJson = "null" - } - let errorStr: String - if let error = response.error { - let escaped = error - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - errorStr = "'\(escaped)'" - } else { - errorStr = "null" - } - let safeCallId = response.callId - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - - let js = "window.vellum.data._resolve('\(safeCallId)', \(response.success), \(resultJson), \(errorStr))" - - guard let webView else { - log.error("resolveDataResponse: webView is nil, cannot evaluate JS") - return - } - - webView.evaluateJavaScript(js) { _, error in - if let error { - log.error("resolveDataResponse: JS eval error: \(error.localizedDescription, privacy: .public)") - } - } - } - - /// Captures a screenshot of the current WebView content as a base64-encoded PNG. - func captureSnapshot(completion: @escaping (String?) -> Void) { - guard let webView = webView else { - completion(nil) - return - } - let config = WKSnapshotConfiguration() - config.afterScreenUpdates = true - webView.takeSnapshot(with: config) { image, error in - if let error = error { - log.error("Snapshot capture failed: \(error.localizedDescription, privacy: .public)") - completion(nil) - return - } - guard let image = image, - let tiff = image.tiffRepresentation, - let _ = NSBitmapImageRep(data: tiff) else { - completion(nil) - return - } - // Resize to a reasonable thumbnail (max 400px wide) to keep payload small - let maxWidth: CGFloat = 400 - let scale = min(1.0, maxWidth / image.size.width) - let targetSize = NSSize( - width: image.size.width * scale, - height: image.size.height * scale - ) - let resized = NSImage(size: targetSize) - resized.lockFocus() - image.draw(in: NSRect(origin: .zero, size: targetSize), - from: NSRect(origin: .zero, size: image.size), - operation: .copy, - fraction: 1.0) - resized.unlockFocus() - guard let resizedTiff = resized.tiffRepresentation, - let resizedBitmap = NSBitmapImageRep(data: resizedTiff), - let pngData = resizedBitmap.representation(using: .png, properties: [.compressionFactor: 0.8]) else { - completion(nil) - return - } - completion(pngData.base64EncodedString()) - } - } - - func captureSnapshotAfterMorph(generation: Int) { - guard let onSnapshotCaptured else { return } - logPhase("captureSnapshotAfterMorph:start") - hasCapturedSnapshot = false - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 500_000_000) - guard !Task.isCancelled else { return } - guard let self, self.morphGeneration == generation else { return } - self.logPhase("captureSnapshotAfterMorph:takeSnapshot") - self.captureSnapshot { [weak self] base64 in - if let base64 { - self?.logPhase("captureSnapshotAfterMorph:complete") - onSnapshotCaptured(base64) - } - } - } - } - - /// Send a content update to the web view via window.vellum.onContentUpdate(). - /// Used by document editor to receive content updates from the daemon. - func sendContentUpdate(_ data: [String: Any]) { - guard let webView = webView else { - log.warning("sendContentUpdate: no webView available") - return - } - - guard let jsonData = try? JSONSerialization.data(withJSONObject: data), - let jsonString = String(data: jsonData, encoding: .utf8) else { - log.error("sendContentUpdate: failed to serialize data to JSON") - return - } - - let safeJSON = jsonString - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - - let script = """ - (function() { - try { - if (typeof window.vellum !== 'undefined' && - typeof window.vellum.onContentUpdate === 'function') { - var data = JSON.parse('\(safeJSON)'); - window.vellum.onContentUpdate(data); - } - } catch(e) { - console.error('onContentUpdate error:', e); - } - })(); - """ - - webView.evaluateJavaScript(script) { result, error in - if let error = error { - log.error("sendContentUpdate: JS eval error: \(error.localizedDescription, privacy: .public)") - } else { - log.debug("sendContentUpdate: successfully sent update") - } - } - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - logPhase("didFinish") - - // Restore scroll position if this load was a refinement update. - if let scrollJSON = pendingScrollRestore { - pendingScrollRestore = nil - let safeScrollJSON = scrollJSON - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - let js = """ - (function() { - try { - var s = JSON.parse('\(safeScrollJSON)'); - window.scrollTo(s.x || 0, s.y || 0); - } catch(e) {} - })(); - """ - webView.evaluateJavaScript(js, completionHandler: nil) - } - - // Re-inject content insets after page load completes. The WKUserScript from - // makeNSView has creation-time values baked in, which may be stale if insets - // changed since then (e.g. composer expanded). Apply the current desired values. - let top = desiredTopInset - let bottom = desiredBottomInset - if top > 0 || bottom > 0 || lastTopInset > 0 || lastBottomInset > 0 { - lastTopInset = top - lastBottomInset = bottom - let fadeHeight = bottom + 32 - let js = """ - (function() { - var el = document.getElementById('vellum-content-insets'); - if (!el) { el = document.createElement('style'); el.id = 'vellum-content-insets'; el.setAttribute('data-vellum-injected', '1'); (document.head || document.documentElement).appendChild(el); } - el.textContent = 'body { padding-top: \(top)px; padding-bottom: \(bottom)px; }'; - var fade = document.getElementById('vellum-bottom-fade'); - if (fade) { - fade.style.height = '\(fadeHeight)px'; - var bg = getComputedStyle(document.body).backgroundColor || 'rgba(0,0,0,0)'; - fade.style.background = 'linear-gradient(to bottom, transparent 0%, ' + bg + ' 100%)'; - } - })(); - """ - webView.evaluateJavaScript(js, completionHandler: nil) - } - - // Inject deferred status pill that was stashed during a reload. - if let status = pendingStatus { - pendingStatus = nil - let escapedStatus = status - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - .replacingOccurrences(of: "\n", with: " ") - .replacingOccurrences(of: "\r", with: " ") - let pillJS = """ - (function() { - var existing = document.getElementById('vellum-status-pill'); - if (existing) existing.remove(); - var pill = document.createElement('div'); - pill.id = 'vellum-status-pill'; - pill.setAttribute('data-vellum-injected', '1'); - pill.textContent = '\(escapedStatus)'; - pill.style.cssText = 'position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.75);color:#fff;font-size:12px;padding:6px 14px;border-radius:20px;z-index:100000;pointer-events:none;opacity:0;transition:opacity 0.3s ease;backdrop-filter:blur(8px);font-family:-apple-system,BlinkMacSystemFont,sans-serif;'; - document.body.appendChild(pill); - requestAnimationFrame(function() { pill.style.opacity = '1'; }); - setTimeout(function() { - pill.style.opacity = '0'; - setTimeout(function() { if (pill.parentNode) pill.remove(); }, 300); - }, 3000); - })(); - """ - webView.evaluateJavaScript(pillJS, completionHandler: nil) - } - - // Detect page changes from URL-based navigation (e.g. ). - if let url = webView.url { - let path = url.path - let pageName: String - if path == "/" || path.isEmpty { - pageName = "index.html" - } else { - // Extract filename from path (e.g. "/settings.html" → "settings.html") - pageName = String(path.dropFirst()) // remove leading "/" - } - if !pageName.isEmpty && pageName != currentPage { - currentPage = pageName - log.info("[WebView] Page detected from URL: \(pageName, privacy: .public)") - onPageChanged?(pageName) - } - } - - // Capture a preview screenshot after the page has rendered (once per load). - if !hasCapturedSnapshot, let onSnapshotCaptured { - hasCapturedSnapshot = true - logPhase("onSnapshotCaptured:scheduled") - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 1_500_000_000) - guard !Task.isCancelled else { return } - self?.logPhase("onSnapshotCaptured:takeSnapshot") - self?.captureSnapshot { base64 in - if let base64 { - self?.logPhase("onSnapshotCaptured:complete") - onSnapshotCaptured(base64) - } - } - } - } - } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void - ) { - if sandboxMode { - // In sandbox mode, only allow vellumapp:// and about:blank URLs - if let url = navigationAction.request.url { - let scheme = url.scheme?.lowercased() ?? "" - if scheme == VellumAppSchemeHandler.scheme || url.absoluteString == "about:blank" { - decisionHandler(.allow) - return - } - // Allow initial HTML load via https://*.vellum.local/ - if scheme == "https" && (url.host?.hasSuffix(".vellum.local") == true) && navigationAction.navigationType == .other { - decisionHandler(.allow) - return - } - } - log.info("Sandbox mode: blocking navigation to \(navigationAction.request.url?.absoluteString ?? "nil", privacy: .public)") - decisionHandler(.cancel) - } else { - if navigationAction.navigationType == .other { - decisionHandler(.allow) - } else if navigationAction.navigationType == .linkActivated, - let url = navigationAction.request.url, - let scheme = url.scheme?.lowercased(), - ["http", "https", "mailto"].contains(scheme) { - NSWorkspace.shared.open(url) - decisionHandler(.cancel) - } else { - decisionHandler(.cancel) - } - } - } - } }