Files
clawdbot/apps/macos/Sources/Clawdis/Onboarding.swift
2025-12-14 03:14:58 +00:00

935 lines
37 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import ClawdisIPC
import SwiftUI
enum UIStrings {
static let welcomeTitle = "Welcome to Clawdis"
}
@MainActor
final class OnboardingController {
static let shared = OnboardingController()
private var window: NSWindow?
func show() {
if let window {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let hosting = NSHostingController(rootView: OnboardingView())
let window = NSWindow(contentViewController: hosting)
window.title = UIStrings.welcomeTitle
window.setContentSize(NSSize(width: 680, height: 840))
window.styleMask = [.titled, .closable, .fullSizeContentView]
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovableByWindowBackground = true
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
self.window = window
}
func close() {
self.window?.close()
self.window = nil
}
}
struct OnboardingView: View {
@Environment(\.openSettings) private var openSettings
@State private var currentPage = 0
@State private var isRequesting = false
@State private var installingCLI = false
@State private var cliStatus: String?
@State private var copied = false
@State private var monitoringPermissions = false
@State private var monitoringDiscovery = false
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var workspacePath: String = ""
@State private var workspaceStatus: String?
@State private var workspaceApplying = false
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String?
// swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery: MasterDiscoveryModel
@ObservedObject private var state: AppState
@ObservedObject private var permissionMonitor: PermissionMonitor
private let pageWidth: CGFloat = 680
private let contentHeight: CGFloat = 520
private let connectionPageIndex = 1
private let permissionsPageIndex = 3
private var pageCount: Int { 8 }
private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
init(
state: AppState = AppStateStore.shared,
permissionMonitor: PermissionMonitor = .shared,
masterDiscovery: MasterDiscoveryModel = MasterDiscoveryModel())
{
self._state = ObservedObject(wrappedValue: state)
self._permissionMonitor = ObservedObject(wrappedValue: permissionMonitor)
self._masterDiscovery = StateObject(wrappedValue: masterDiscovery)
}
var body: some View {
VStack(spacing: 0) {
GlowingClawdisIcon(size: 156)
.padding(.top, 10)
.padding(.bottom, 2)
.frame(height: 176)
GeometryReader { _ in
HStack(spacing: 0) {
self.welcomePage().frame(width: self.pageWidth)
self.connectionPage().frame(width: self.pageWidth)
self.gatewayPage().frame(width: self.pageWidth)
self.permissionsPage().frame(width: self.pageWidth)
self.cliPage().frame(width: self.pageWidth)
self.workspacePage().frame(width: self.pageWidth)
self.whatsappPage().frame(width: self.pageWidth)
self.readyPage().frame(width: self.pageWidth)
}
.offset(x: CGFloat(-self.currentPage) * self.pageWidth)
.animation(
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
value: self.currentPage)
.frame(height: self.contentHeight, alignment: .top)
.clipped()
}
.frame(height: self.contentHeight)
self.navigationBar
}
.frame(width: self.pageWidth, height: 720)
.background(Color(NSColor.windowBackgroundColor))
.onAppear {
self.currentPage = 0
self.updateMonitoring(for: 0)
}
.onChange(of: self.currentPage) { _, newValue in
self.updateMonitoring(for: newValue)
}
.onChange(of: self.state.connectionMode) { _, _ in
self.updateDiscoveryMonitoring(for: self.currentPage)
}
.onDisappear {
self.stopPermissionMonitoring()
self.stopDiscovery()
}
.task {
await self.refreshPerms()
self.refreshCLIStatus()
self.refreshGatewayStatus()
self.loadWorkspaceDefaults()
}
}
private func welcomePage() -> some View {
self.onboardingPage {
Text("Welcome to Clawdis")
.font(.largeTitle.weight(.semibold))
Text(
"Your macOS menu bar companion for notifications, screenshots, and agent automation — " +
"setup takes just a few minutes.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(maxWidth: 560)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 10, padding: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3.weight(.semibold))
.foregroundStyle(Color(nsColor: .systemOrange))
.frame(width: 22)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 6) {
Text("Security notice")
.font(.headline)
Text(
"""
The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac,
including running
commands, reading/writing files, and capturing screenshots — depending on the
permissions you grant.
Only enable Clawdis if you understand the risks and trust the prompts
and integrations you use.
""")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.frame(maxWidth: 520)
}
}
private func connectionPage() -> some View {
self.onboardingPage {
Text("Where Clawdis runs")
.font(.largeTitle.weight(.semibold))
Text(
"Clawdis has one primary Gateway (“master”) that runs continuously. " +
"Connect locally or over SSH/Tailscale so the agent can work on any Mac.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 12, padding: 14) {
Picker("Clawdis runs", selection: self.$state.connectionMode) {
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.segmented)
.frame(width: 360)
if self.state.connectionMode == .remote {
let labelWidth: CGFloat = 90
let fieldWidth: CGFloat = 300
let contentLeading: CGFloat = labelWidth + 12
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .center, spacing: 12) {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
MasterDiscoveryInlineList(discovery: self.masterDiscovery) { master in
self.applyDiscoveredMaster(master)
}
.frame(width: fieldWidth, alignment: .leading)
.padding(.leading, contentLeading)
DisclosureGroup("Advanced") {
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
LabeledContent("Project root") {
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
.padding(.top, 4)
}
Text("Tip: enable Tailscale so your remote Clawdis stays reachable.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
}
private func gatewayPage() -> some View {
self.onboardingPage {
Text("Install the gateway")
.font(.largeTitle.weight(.semibold))
Text(
"""
Clawdis now runs the WebSocket gateway from the global "clawdis" package.
Install/update it here and well check Node for you.
""")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 10, padding: 14) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Circle()
.fill(self.gatewayStatusColor)
.frame(width: 10, height: 10)
Text(self.gatewayStatus.message)
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
}
if let gatewayVersion = self.gatewayStatus.gatewayVersion,
let required = self.gatewayStatus.requiredGateway,
gatewayVersion != required
{
Text("Installed: \(gatewayVersion) · Required: \(required)")
.font(.caption)
.foregroundStyle(.secondary)
} else if let gatewayVersion = self.gatewayStatus.gatewayVersion {
Text("Gateway \(gatewayVersion) detected")
.font(.caption)
.foregroundStyle(.secondary)
}
if let node = self.gatewayStatus.nodeVersion {
Text("Node \(node)")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.installGateway() }
} label: {
if self.gatewayInstalling {
ProgressView()
} else {
Text("Install / Update gateway")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.gatewayInstalling)
Button("Recheck") { self.refreshGatewayStatus() }
.buttonStyle(.bordered)
.disabled(self.gatewayInstalling)
}
if let gatewayInstallMessage {
Text(gatewayInstallMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text(
"Uses \"npm install -g clawdis@<version>\" on your PATH. " +
"We keep the gateway on port 18789.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
}
// swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return }
let user = NSUserName()
var target = "\(user)@\(host)"
if master.sshPort != 22 {
target += ":\(master.sshPort)"
}
self.state.remoteTarget = target
}
private func permissionsPage() -> some View {
self.onboardingPage {
Text("Grant permissions")
.font(.largeTitle.weight(.semibold))
Text("Approve these once and the helper CLI reuses the same grants.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard {
ForEach(Capability.allCases, id: \.self) { cap in
PermissionRow(capability: cap, status: self.permissionMonitor.status[cap] ?? false) {
Task { await self.request(cap) }
}
}
HStack(spacing: 12) {
Button {
Task { await self.refreshPerms() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Refresh status")
if self.isRequesting {
ProgressView()
.controlSize(.small)
}
}
.padding(.top, 4)
}
}
}
private func cliPage() -> some View {
self.onboardingPage {
Text("Install the helper CLI")
.font(.largeTitle.weight(.semibold))
Text("Link `clawdis-mac` so scripts and the agent can talk to this app.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 10) {
HStack(spacing: 12) {
Button {
Task { await self.installCLI() }
} label: {
if self.installingCLI {
ProgressView()
} else {
Text(self.cliInstalled ? "Reinstall helper" : "Install helper")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.installingCLI)
Button(self.copied ? "Copied" : "Copy dev link") {
self.copyToPasteboard(self.devLinkCommand)
}
.disabled(self.installingCLI)
if self.cliInstalled, let loc = self.cliInstallLocation {
Label("Installed at \(loc)", systemImage: "checkmark.circle.fill")
.font(.footnote)
.foregroundStyle(.green)
}
}
if let cliStatus {
Text(cliStatus)
.font(.caption)
.foregroundStyle(.secondary)
} else if !self.cliInstalled, self.cliInstallLocation == nil {
Text(
"""
We install into /usr/local/bin and /opt/homebrew/bin.
Rerun anytime if you move the build output.
""")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
private func workspacePage() -> some View {
self.onboardingPage {
Text("Agent workspace")
.font(.largeTitle.weight(.semibold))
Text(
"""
Clawdis runs the agent from a dedicated workspace so it can load AGENTS.md
and write files without touching your other folders.
""")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 560)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 10) {
if self.state.connectionMode == .remote {
Text("Remote gateway detected")
.font(.headline)
Text(
"Create the workspace on the remote host (SSH in first). " +
"The macOS app cant write files on your gateway over SSH yet.")
.font(.subheadline)
.foregroundStyle(.secondary)
Button(self.copied ? "Copied" : "Copy setup command") {
self.copyToPasteboard(self.workspaceBootstrapCommand)
}
.buttonStyle(.bordered)
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Workspace folder")
.font(.headline)
TextField(
AgentWorkspace.displayPath(for: ClawdisConfigFile.defaultWorkspaceURL()),
text: self.$workspacePath)
.textFieldStyle(.roundedBorder)
HStack(spacing: 12) {
Button {
Task { await self.applyWorkspace() }
} label: {
if self.workspaceApplying {
ProgressView()
} else {
Text("Create workspace")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.workspaceApplying)
Button("Open folder") {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
NSWorkspace.shared.open(url)
}
.buttonStyle(.bordered)
.disabled(self.workspaceApplying)
Button("Save in config") {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url))
self.workspaceStatus = "Saved to ~/.clawdis/clawdis.json (inbound.workspace)"
}
.buttonStyle(.bordered)
.disabled(self.workspaceApplying)
}
}
if let workspaceStatus {
Text(workspaceStatus)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text("Tip: edit AGENTS.md in this folder to shape the assistants behavior.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
}
private func whatsappPage() -> some View {
self.onboardingPage {
Text("Link WhatsApp or Telegram")
.font(.largeTitle.weight(.semibold))
Text(
"""
WhatsApp uses a QR login for your personal account. Telegram uses a bot token.
Either (or both) is fine; configure them where the gateway runs.
""")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard {
self.featureRow(
title: "Open a terminal",
subtitle: "Use the same host selected above. If remote, SSH in first.",
systemImage: "terminal")
Text("WhatsApp")
.font(.headline)
self.featureRow(
title: "Run `clawdis login --verbose`",
subtitle: """
Scan the QR code with WhatsApp on your phone.
We only use your personal session; no cloud gateway involved.
""",
systemImage: "qrcode.viewfinder")
self.featureRow(
title: "Re-link after timeouts",
subtitle: """
If Baileys auth expires, re-run login on that host.
Settings → General shows remote/local mode so you know where to run it.
""",
systemImage: "clock.arrow.circlepath")
Divider()
.padding(.vertical, 6)
Text("Telegram")
.font(.headline)
self.featureRow(
title: "Set `TELEGRAM_BOT_TOKEN`",
subtitle: """
Create a bot with @BotFather and set the token as an env var
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
""",
systemImage: "key")
self.featureRow(
title: "Verify with `clawdis status --deep`",
subtitle: "This probes both WhatsApp and the Telegram API and prints whats configured.",
systemImage: "checkmark.shield")
}
}
}
private func readyPage() -> some View {
self.onboardingPage {
Text("All set")
.font(.largeTitle.weight(.semibold))
self.onboardingCard {
self.featureRow(
title: "Run the dashboard",
subtitle: """
Use the CLI helper from your scripts, and reopen onboarding from Settings
if you add a new user.
""",
systemImage: "checkmark.seal")
self.featureRow(
title: "Try Voice Wake",
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
systemImage: "waveform.circle")
self.featureRow(
title: "Use the panel + Canvas",
subtitle: "Open the menu bar panel for quick chat; the agent can show previews " +
"and richer visuals in Canvas.",
systemImage: "rectangle.inset.filled.and.person.filled")
self.featureRow(
title: "Test a notification",
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
systemImage: "bell.badge")
self.featureActionRow(
title: "Give your agent more powers",
subtitle: "Install optional tools (Peekaboo, oracle, camsnap, …) from Settings → Tools.",
systemImage: "wrench.and.screwdriver")
{
self.openSettings(tab: .tools)
}
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
.onChange(of: self.state.launchAtLogin) { _, newValue in
AppStateStore.updateLaunchAtLogin(enabled: newValue)
}
}
}
}
private func openSettings(tab: SettingsTab) {
SettingsTabRouter.request(tab)
self.openSettings()
NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab)
}
private var navigationBar: some View {
HStack(spacing: 20) {
ZStack(alignment: .leading) {
Button(action: {}, label: {
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
})
.buttonStyle(.plain)
.opacity(0)
.disabled(true)
if self.currentPage > 0 {
Button(action: self.handleBack, label: {
Label("Back", systemImage: "chevron.left")
.labelStyle(.iconOnly)
})
.buttonStyle(.plain)
.foregroundColor(.secondary)
.opacity(0.8)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
}
.frame(minWidth: 80, alignment: .leading)
Spacer()
HStack(spacing: 8) {
ForEach(0..<self.pageCount, id: \.self) { index in
Button {
withAnimation { self.currentPage = index }
} label: {
Circle()
.fill(index == self.currentPage ? Color.accentColor : Color.gray.opacity(0.3))
.frame(width: 8, height: 8)
}
.buttonStyle(.plain)
}
}
Spacer()
Button(action: self.handleNext) {
Text(self.buttonTitle)
.frame(minWidth: 88)
}
.keyboardShortcut(.return)
.buttonStyle(.borderedProminent)
}
.padding(.horizontal, 28)
.padding(.bottom, 24)
.frame(height: 60)
}
private func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
VStack(spacing: 22) {
content()
Spacer()
}
.padding(.horizontal, 28)
.frame(width: self.pageWidth, alignment: .top)
}
private func onboardingCard(
spacing: CGFloat = 12,
padding: CGFloat = 16,
@ViewBuilder _ content: () -> some View) -> some View
{
VStack(alignment: .leading, spacing: spacing) {
content()
}
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor))
.shadow(color: .black.opacity(0.06), radius: 8, y: 3))
}
private func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 26)
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
private func featureActionRow(
title: String,
subtitle: String,
systemImage: String,
action: @escaping () -> Void) -> some View
{
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 26)
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Open Settings → Tools", action: action)
.buttonStyle(.link)
.padding(.top, 2)
}
Spacer(minLength: 0)
}
}
private func handleBack() {
withAnimation {
self.currentPage = max(0, self.currentPage - 1)
}
}
private func handleNext() {
if self.currentPage < self.pageCount - 1 {
withAnimation { self.currentPage += 1 }
} else {
self.finish()
}
}
private func finish() {
UserDefaults.standard.set(true, forKey: "clawdis.onboardingSeen")
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
OnboardingController.shared.close()
}
@MainActor
private func refreshPerms() async {
await self.permissionMonitor.refreshNow()
}
@MainActor
private func request(_ cap: Capability) async {
guard !self.isRequesting else { return }
self.isRequesting = true
defer { isRequesting = false }
_ = await PermissionManager.ensure([cap], interactive: true)
await self.refreshPerms()
}
private func updatePermissionMonitoring(for pageIndex: Int) {
let shouldMonitor = pageIndex == self.permissionsPageIndex
if shouldMonitor, !self.monitoringPermissions {
self.monitoringPermissions = true
PermissionMonitor.shared.register()
} else if !shouldMonitor, self.monitoringPermissions {
self.monitoringPermissions = false
PermissionMonitor.shared.unregister()
}
}
private func updateDiscoveryMonitoring(for pageIndex: Int) {
let isConnectionPage = pageIndex == self.connectionPageIndex
let shouldMonitor = isConnectionPage && self.state.connectionMode == .remote
if shouldMonitor, !self.monitoringDiscovery {
self.monitoringDiscovery = true
self.masterDiscovery.start()
} else if !shouldMonitor, self.monitoringDiscovery {
self.monitoringDiscovery = false
self.masterDiscovery.stop()
}
}
private func updateMonitoring(for pageIndex: Int) {
self.updatePermissionMonitoring(for: pageIndex)
self.updateDiscoveryMonitoring(for: pageIndex)
}
private func stopPermissionMonitoring() {
guard self.monitoringPermissions else { return }
self.monitoringPermissions = false
PermissionMonitor.shared.unregister()
}
private func stopDiscovery() {
guard self.monitoringDiscovery else { return }
self.monitoringDiscovery = false
self.masterDiscovery.stop()
}
private func installCLI() async {
guard !self.installingCLI else { return }
self.installingCLI = true
defer { installingCLI = false }
await CLIInstaller.install { message in
await MainActor.run { self.cliStatus = message }
}
self.refreshCLIStatus()
}
private func refreshCLIStatus() {
let installLocation = CLIInstaller.installedLocation()
self.cliInstallLocation = installLocation
self.cliInstalled = installLocation != nil
}
private func refreshGatewayStatus() {
self.gatewayStatus = GatewayEnvironment.check()
}
private func installGateway() async {
guard !self.gatewayInstalling else { return }
self.gatewayInstalling = true
defer { self.gatewayInstalling = false }
self.gatewayInstallMessage = nil
let expected = GatewayEnvironment.expectedGatewayVersion()
await GatewayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.gatewayInstallMessage = message }
}
self.refreshGatewayStatus()
}
private var gatewayStatusColor: Color {
switch self.gatewayStatus.kind {
case .ok: .green
case .checking: .secondary
case .missingNode, .missingGateway, .incompatible, .error: .orange
}
}
private func copyToPasteboard(_ text: String) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
self.copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false }
}
private func loadWorkspaceDefaults() {
guard self.workspacePath.isEmpty else { return }
let configured = ClawdisConfigFile.inboundWorkspace()
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
self.workspacePath = AgentWorkspace.displayPath(for: url)
}
private var workspaceBootstrapCommand: String {
let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines)
return """
mkdir -p ~/.clawdis/workspace
cat > ~/.clawdis/workspace/AGENTS.md <<'EOF'
\(template)
EOF
"""
}
private func applyWorkspace() async {
guard !self.workspaceApplying else { return }
self.workspaceApplying = true
defer { self.workspaceApplying = false }
do {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
self.workspacePath = AgentWorkspace.displayPath(for: url)
self.workspaceStatus = "Workspace ready at \(self.workspacePath)"
} catch {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
}
}
}
private struct GlowingClawdisIcon: View {
let size: CGFloat
let glowIntensity: Double
let enableFloating: Bool
@State private var breathe = false
init(size: CGFloat = 148, glowIntensity: Double = 0.35, enableFloating: Bool = true) {
self.size = size
self.glowIntensity = glowIntensity
self.enableFloating = enableFloating
}
var body: some View {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [
Color.accentColor.opacity(self.glowIntensity),
Color.blue.opacity(self.glowIntensity * 0.6),
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blur(radius: 22)
.scaleEffect(self.breathe ? 1.12 : 0.95)
.opacity(0.9)
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: self.size, height: self.size)
.clipShape(RoundedRectangle(cornerRadius: self.size * 0.22, style: .continuous))
.shadow(color: .black.opacity(0.18), radius: 14, y: 6)
.scaleEffect(self.breathe ? 1.02 : 1.0)
}
.frame(width: self.size + 60, height: self.size + 60)
.onAppear {
guard self.enableFloating else { return }
withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) {
self.breathe.toggle()
}
}
}
}