├── MobileCode ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── AppIcon~ios-marketing.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Services │ ├── SSH │ │ ├── Resources │ │ │ └── cloud_init_codeagent.yaml │ │ ├── ByteBufferExtensions.swift │ │ ├── CloudInitTemplate.swift │ │ ├── PrivateKeyAuthenticationDelegate.swift │ │ ├── SSHKeyFormatter.swift │ │ ├── SSHKeyParsingError.swift │ │ ├── OpenSSHProtocols.swift │ │ └── OpenSSHECDSAParser.swift │ ├── ConnectionPurpose.swift │ ├── ServiceManager.swift │ ├── ServerManager.swift │ ├── ProjectContext.swift │ ├── CloudProviderProtocol.swift │ ├── SSHKeyMaintenanceService.swift │ ├── SSHTypes.swift │ ├── ProjectService.swift │ ├── CloudInitMonitor.swift │ ├── MCPService.swift │ └── StreamingJSONParser.swift ├── Models │ ├── ServerProvider.swift │ ├── SSHKey.swift │ ├── FileNode.swift │ ├── Project.swift │ ├── Server.swift │ ├── MCPConfiguration.swift │ ├── Message.swift │ └── MCPServer.swift ├── Views │ ├── Components │ │ ├── TextBlockView.swift │ │ ├── ContentBlockView.swift │ │ ├── MessageTransition.swift │ │ ├── ToolResultView.swift │ │ ├── CloudInitStatusBadge.swift │ │ ├── DiffView.swift │ │ └── MarkdownTextView.swift │ ├── ConnectionStatusView.swift │ ├── TerminalView.swift │ ├── Shared │ │ └── SSHKeySelectionView.swift │ ├── ContentView.swift │ ├── SSHKeys │ │ └── AddSSHKeySheet.swift │ ├── Chat │ │ └── ClaudeNotInstalledView.swift │ ├── MCP │ │ └── MCPServerRow.swift │ ├── Servers │ │ └── ManagedServerListView.swift │ └── FolderPickerView.swift ├── ViewModels │ ├── ProjectsViewModel.swift │ ├── TerminalViewModel.swift │ ├── FolderPickerViewModel.swift │ └── FileBrowserViewModel.swift ├── CodeAgentsMobileApp.swift └── Utils │ ├── DateFormatter+Smart.swift │ ├── PathUtils.swift │ └── BlockFormattingUtils.swift ├── screenshots ├── screenshot_1.png ├── screenshot_2.png ├── screenshot_3.png ├── screenshot_4.png ├── screenshot_5.png ├── screenshot_6.png ├── screenshot_7.png └── screenshot_8.png ├── CodeAgentsMobile.xcodeproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── swiftpm │ └── Package.resolved ├── CodeAgentsMobileUITests-Info.plist ├── PRIVACY_POLICY.md ├── README.md ├── MobileCodeTests └── DirectSSHTest.swift └── .gitignore /MobileCode/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /screenshots/screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugenepyvovarov/CodeAgentsMobile/HEAD/screenshots/screenshot_1.png -------------------------------------------------------------------------------- /screenshots/screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugenepyvovarov/CodeAgentsMobile/HEAD/screenshots/screenshot_2.png -------------------------------------------------------------------------------- /screenshots/screenshot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugenepyvovarov/CodeAgentsMobile/HEAD/screenshots/screenshot_3.png -------------------------------------------------------------------------------- /screenshots/screenshot_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugenepyvovarov/CodeAgentsMobile/HEAD/screenshots/screenshot_4.png -------------------------------------------------------------------------------- /screenshots/screenshot_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugenepyvovarov/CodeAgentsMobile/HEAD/screenshots/screenshot_5.png -------------------------------------------------------------------------------- /screenshots/screenshot_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugenepyvovarov/CodeAgentsMobile/HEAD/screenshots/screenshot_6.png -------------------------------------------------------------------------------- /screenshots/screenshot_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugenepyvovarov/CodeAgentsMobile/HEAD/screenshots/screenshot_7.png -------------------------------------------------------------------------------- /screenshots/screenshot_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugenepyvovarov/CodeAgentsMobile/HEAD/screenshots/screenshot_8.png -------------------------------------------------------------------------------- /MobileCode/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugenepyvovarov/CodeAgentsMobile/HEAD/MobileCode/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png -------------------------------------------------------------------------------- /CodeAgentsMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MobileCode/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /CodeAgentsMobileUITests-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSBonjourServices 6 | 7 | _http._tcp 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /MobileCode/Services/SSH/Resources/cloud_init_codeagent.yaml: -------------------------------------------------------------------------------- 1 | #cloud-config 2 | users: 3 | - name: codeagent 4 | groups: users, admin, sudo 5 | sudo: ALL=(ALL) NOPASSWD:ALL 6 | shell: /bin/bash 7 | ssh_authorized_keys: 8 | {{SSH_KEYS_PLACEHOLDER}} 9 | 10 | package_update: true 11 | package_upgrade: true 12 | 13 | packages: 14 | - nodejs 15 | - npm 16 | - curl 17 | - build-essential 18 | - git 19 | 20 | runcmd: 21 | - npm install -g @anthropic-ai/claude-code -------------------------------------------------------------------------------- /MobileCode/Models/ServerProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerProvider.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-08-12. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | @Model 12 | final class ServerProvider { 13 | var id = UUID() 14 | var providerType: String // "digitalocean" or "hetzner" 15 | var name: String // Display name 16 | var apiTokenKey: String // Keychain reference 17 | var createdAt = Date() 18 | 19 | init(providerType: String, name: String) { 20 | self.providerType = providerType 21 | self.name = name 22 | self.apiTokenKey = "provider_token_\(UUID().uuidString)" 23 | } 24 | } -------------------------------------------------------------------------------- /MobileCode/Views/Components/TextBlockView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextBlockView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-05. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TextBlockView: View { 11 | let textBlock: TextBlock 12 | let textColor: Color 13 | let isStreaming: Bool 14 | 15 | init(textBlock: TextBlock, textColor: Color = .primary, isStreaming: Bool = false) { 16 | self.textBlock = textBlock 17 | self.textColor = textColor 18 | self.isStreaming = isStreaming 19 | } 20 | 21 | var body: some View { 22 | // No cursor animation per user preference 23 | FullMarkdownTextView(text: textBlock.text, textColor: textColor) 24 | } 25 | } -------------------------------------------------------------------------------- /MobileCode/Services/ConnectionPurpose.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConnectionPurpose.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Defines the different purposes for SSH connections 6 | // 7 | 8 | import Foundation 9 | 10 | /// Defines the purpose of an SSH connection to enable connection pooling 11 | enum ConnectionPurpose: String, CaseIterable { 12 | case claude = "claude" 13 | case terminal = "terminal" 14 | case fileOperations = "files" 15 | case cloudInit = "cloudInit" 16 | 17 | var description: String { 18 | switch self { 19 | case .claude: 20 | return "Claude Code" 21 | case .terminal: 22 | return "Terminal" 23 | case .fileOperations: 24 | return "File Operations" 25 | case .cloudInit: 26 | return "Cloud Init Status Check" 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /MobileCode/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon~ios-marketing.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "idiom" : "universal", 17 | "platform" : "ios", 18 | "size" : "1024x1024" 19 | }, 20 | { 21 | "appearances" : [ 22 | { 23 | "appearance" : "luminosity", 24 | "value" : "tinted" 25 | } 26 | ], 27 | "idiom" : "universal", 28 | "platform" : "ios", 29 | "size" : "1024x1024" 30 | } 31 | ], 32 | "info" : { 33 | "author" : "xcode", 34 | "version" : 1 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /MobileCode/Models/SSHKey.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | @Model 5 | final class SSHKey { 6 | var id = UUID() 7 | var name: String 8 | var keyType: String // e.g., "Ed25519", "P256", "P384", "P521" 9 | var privateKeyIdentifier: String // Reference to key stored in Keychain 10 | var publicKey: String = "" // Store public key for upload to providers 11 | var fingerprint: String? // SSH fingerprint from provider 12 | var createdAt = Date() 13 | 14 | init(name: String, keyType: String, privateKeyIdentifier: String) { 15 | self.name = name 16 | self.keyType = keyType 17 | self.privateKeyIdentifier = privateKeyIdentifier 18 | } 19 | 20 | // Computed property to get usage count (will query servers) 21 | var usageDescription: String { 22 | // This will be implemented once we have the query logic 23 | return "Not used yet" 24 | } 25 | } -------------------------------------------------------------------------------- /MobileCode/ViewModels/ProjectsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProjectsViewModel.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-06-10. 6 | // 7 | // Purpose: Minimal project list operations 8 | // - Handles project opening 9 | // - Provides loading state for UI 10 | // 11 | 12 | import SwiftUI 13 | import Observation 14 | 15 | /// Minimal ViewModel for project list operations 16 | @MainActor 17 | @Observable 18 | class ProjectsViewModel { 19 | // MARK: - Properties 20 | 21 | /// Loading state for UI feedback 22 | var isLoading = false 23 | 24 | // MARK: - Methods 25 | 26 | /// Open a project (can be extended to track analytics, etc.) 27 | /// - Parameter project: The project to open 28 | func openProject(_ project: RemoteProject) { 29 | // Currently just a placeholder 30 | // Can be extended to: 31 | // - Track usage analytics 32 | // - Update recent projects 33 | // - Prepare project resources 34 | } 35 | } -------------------------------------------------------------------------------- /MobileCode/CodeAgentsMobileApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodeAgentsMobileApp.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Eugene Pyvovarov on 2025-06-10. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftData 10 | 11 | @main 12 | struct CodeAgentsMobileApp: App { 13 | var sharedModelContainer: ModelContainer = { 14 | let schema = Schema([ 15 | RemoteProject.self, 16 | Server.self, 17 | Message.self, 18 | SSHKey.self, 19 | ServerProvider.self 20 | ]) 21 | let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) 22 | 23 | do { 24 | return try ModelContainer(for: schema, configurations: [modelConfiguration]) 25 | } catch { 26 | fatalError("Could not create ModelContainer: \(error)") 27 | } 28 | }() 29 | 30 | var body: some Scene { 31 | WindowGroup { 32 | ContentView() 33 | } 34 | .modelContainer(sharedModelContainer) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /MobileCode/Models/FileNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileNode.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-04. 6 | // 7 | // Purpose: Data model for file browser nodes 8 | // 9 | 10 | import Foundation 11 | 12 | struct FileNode: Identifiable { 13 | let id = UUID() 14 | let name: String 15 | let path: String 16 | let isDirectory: Bool 17 | var children: [FileNode]? 18 | let fileSize: Int64? 19 | let modificationDate: Date? 20 | var isExpanded: Bool = false 21 | 22 | var icon: String { 23 | if isDirectory { 24 | return isExpanded ? "folder.fill" : "folder" 25 | } else { 26 | switch name.split(separator: ".").last?.lowercased() { 27 | case "swift": return "swift" 28 | case "py": return "doc.text" 29 | case "js", "ts": return "doc.text" 30 | case "json": return "doc.text" 31 | case "md": return "doc.richtext" 32 | case "png", "jpg", "jpeg": return "photo" 33 | default: return "doc" 34 | } 35 | } 36 | } 37 | 38 | var formattedSize: String? { 39 | guard let fileSize = fileSize else { return nil } 40 | let formatter = ByteCountFormatter() 41 | formatter.countStyle = .file 42 | return formatter.string(fromByteCount: fileSize) 43 | } 44 | } -------------------------------------------------------------------------------- /MobileCode/Views/Components/ContentBlockView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentBlockView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-05. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentBlockView: View { 11 | let block: ContentBlock 12 | let textColor: Color 13 | let isStreaming: Bool 14 | @State private var isExpanded: Bool = false 15 | 16 | init(block: ContentBlock, textColor: Color = .primary, isStreaming: Bool = false) { 17 | self.block = block 18 | self.textColor = textColor 19 | self.isStreaming = isStreaming 20 | } 21 | 22 | var body: some View { 23 | switch block { 24 | case .text(let textBlock): 25 | TextBlockView(textBlock: textBlock, textColor: textColor, isStreaming: isStreaming) 26 | 27 | case .toolUse(let toolUseBlock): 28 | ToolUseView(toolUseBlock: toolUseBlock, isStreaming: isStreaming) 29 | 30 | case .toolResult(let toolResultBlock): 31 | ToolResultView( 32 | toolResultBlock: toolResultBlock, 33 | isExpanded: $isExpanded 34 | ) 35 | .onAppear { 36 | // Auto-expand errors 37 | if toolResultBlock.isError { 38 | isExpanded = true 39 | } 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /MobileCode/Models/Project.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project.swift 3 | // CodeAgentsMobile 4 | // 5 | // 6 | // Purpose: Data model for remote project representation 7 | // 8 | 9 | import Foundation 10 | import SwiftData 11 | 12 | /// Represents a project on a remote server 13 | @Model 14 | final class RemoteProject { 15 | var id = UUID() 16 | var name: String 17 | var path: String 18 | var lastModified: Date 19 | var createdAt: Date 20 | 21 | /// Server ID where this project exists 22 | var serverId: UUID 23 | 24 | // Session management 25 | var claudeSessionId: String? 26 | var hasActiveClaudeStream: Bool = false 27 | 28 | var terminalSessionId: String? 29 | var hasActiveTerminal: Bool = false 30 | 31 | var hasActiveFileOperation: Bool = false 32 | 33 | // Nohup tracking properties 34 | var lastOutputFilePosition: Int? 35 | var nohupProcessId: String? 36 | var outputFilePath: String? 37 | 38 | // Active streaming message tracking 39 | var activeStreamingMessageId: UUID? 40 | 41 | init(name: String, serverId: UUID, basePath: String = "/root/projects") { 42 | self.id = UUID() 43 | self.name = name 44 | self.path = "\(basePath)/\(name)" 45 | self.serverId = serverId 46 | self.lastModified = Date() 47 | self.createdAt = Date() 48 | } 49 | 50 | func updateLastModified() { 51 | lastModified = Date() 52 | } 53 | } -------------------------------------------------------------------------------- /MobileCode/ViewModels/TerminalViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TerminalViewModel.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-06-10. 6 | // 7 | // Purpose: Terminal emulator placeholder 8 | // TODO: Implement proper terminal functionality 9 | // 10 | 11 | import SwiftUI 12 | import Observation 13 | 14 | /// Placeholder for terminal functionality 15 | /// TODO: Implement proper terminal emulator with PTY support 16 | @MainActor 17 | @Observable 18 | class TerminalViewModel { 19 | // TODO: Terminal implementation requirements: 20 | // - Interactive SSH shell session with proper PTY 21 | // - ANSI escape sequence processing 22 | // - Terminal control codes (cursor movement, colors) 23 | // - Command history with up/down arrow support 24 | // - Scrollback buffer management 25 | // - Copy/paste functionality 26 | // - Terminal resizing support 27 | // - Proper character encoding (UTF-8) 28 | 29 | /// Indicates terminal is not yet implemented 30 | var isImplemented = false 31 | 32 | /// Message to show users 33 | var placeholderMessage = "Terminal functionality coming soon" 34 | 35 | /// Placeholder for future terminal output 36 | var outputLines: [String] = [ 37 | "Terminal emulator is not yet implemented.", 38 | "This feature requires:", 39 | "• SSH PTY (pseudo-terminal) support", 40 | "• ANSI escape sequence processing", 41 | "• Interactive shell handling", 42 | "", 43 | "Coming in a future update." 44 | ] 45 | } -------------------------------------------------------------------------------- /MobileCode/Views/ConnectionStatusView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConnectionStatusView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-06-10. 6 | // 7 | // Purpose: Simple project navigation button 8 | // - Shows active project name 9 | // - Button to go back to projects list 10 | // 11 | 12 | import SwiftUI 13 | 14 | /// Simple project status for navigation bar 15 | struct ConnectionStatusView: View { 16 | @StateObject private var projectContext = ProjectContext.shared 17 | 18 | var body: some View { 19 | if let project = projectContext.activeProject { 20 | Button { 21 | // Clear active project to go back to projects list 22 | projectContext.clearActiveProject() 23 | } label: { 24 | HStack(spacing: 8) { 25 | Image(systemName: "chevron.left") 26 | .font(.system(size: 12, weight: .semibold)) 27 | 28 | Text(project.name) 29 | .font(.system(size: 14, weight: .medium)) 30 | .lineLimit(1) 31 | } 32 | .foregroundColor(.blue) 33 | } 34 | .buttonStyle(PlainButtonStyle()) 35 | } 36 | } 37 | } 38 | 39 | #Preview { 40 | NavigationStack { 41 | VStack { 42 | Text("Test View") 43 | } 44 | .toolbar { 45 | ToolbarItem(placement: .principal) { 46 | ConnectionStatusView() 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /MobileCode/Services/ServiceManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceManager.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-06-10. 6 | // 7 | // Purpose: Central manager for all services 8 | // - Provides singleton access to services 9 | // - Manages service lifecycle 10 | // - Handles dependency injection 11 | // 12 | 13 | import Foundation 14 | 15 | /// Central service manager 16 | /// Provides access to all app services 17 | @MainActor 18 | class ServiceManager { 19 | // MARK: - Singleton 20 | 21 | /// Shared instance 22 | static let shared = ServiceManager() 23 | 24 | // MARK: - Services 25 | 26 | /// SSH connection service 27 | let sshService: SSHService 28 | 29 | /// Project operations service 30 | let projectService: ProjectService 31 | 32 | /// Claude Code service (will be implemented next) 33 | // let claudeService: ClaudeCodeService 34 | 35 | //TODO: Add ClaudeCodeService property 36 | //TODO: Make SSHService a computed property that returns the shared instance 37 | 38 | // MARK: - Initialization 39 | 40 | private init() { 41 | // Initialize services 42 | self.sshService = SSHService.shared 43 | self.projectService = ProjectService(sshService: sshService) 44 | 45 | print("🚀 ServiceManager: Initialized") 46 | } 47 | 48 | // MARK: - Methods 49 | 50 | /// Reset all services (useful for logout) 51 | func resetServices() { 52 | // Disconnect all SSH sessions 53 | Task { 54 | // Will implement when we have proper session tracking 55 | print("🔄 ServiceManager: Services reset") 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /MobileCode/Views/Components/MessageTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageTransition.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-06. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // MARK: - Message Appear Animation 11 | struct MessageAppearModifier: ViewModifier { 12 | @State private var isVisible = false 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .opacity(isVisible ? 1 : 0) 17 | .scaleEffect(isVisible ? 1 : 0.8) 18 | .offset(y: isVisible ? 0 : 20) 19 | .onAppear { 20 | withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { 21 | isVisible = true 22 | } 23 | } 24 | } 25 | } 26 | 27 | // MARK: - Typing Indicator 28 | struct TypingIndicator: View { 29 | @State private var dotOpacity: [Double] = [0.2, 0.2, 0.2] 30 | 31 | var body: some View { 32 | HStack(spacing: 4) { 33 | ForEach(0..<3) { index in 34 | Circle() 35 | .fill(Color.secondary) 36 | .frame(width: 6, height: 6) 37 | .opacity(dotOpacity[index]) 38 | } 39 | } 40 | .onAppear { 41 | animateDots() 42 | } 43 | } 44 | 45 | private func animateDots() { 46 | for index in 0..<3 { 47 | withAnimation(.easeInOut(duration: 0.6) 48 | .repeatForever() 49 | .delay(Double(index) * 0.2)) { 50 | dotOpacity[index] = 1.0 51 | } 52 | } 53 | } 54 | } 55 | 56 | // MARK: - Extensions 57 | extension View { 58 | func messageAppear() -> some View { 59 | modifier(MessageAppearModifier()) 60 | } 61 | } -------------------------------------------------------------------------------- /MobileCode/Models/Server.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Server.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-06-10. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | @Model 12 | final class Server { 13 | var id = UUID() 14 | var name: String 15 | var host: String 16 | var port: Int = 22 17 | var username: String 18 | var authMethodType: String = "password" // "password" or "key" 19 | var sshKeyId: UUID? // Reference to SSHKey when authMethodType is "key" 20 | // Password is stored securely in Keychain Services 21 | // SSH private keys are stored separately in SSHKey model 22 | var createdAt: Date = Date() 23 | var lastConnected: Date? 24 | 25 | /// Default directory path for projects on this server 26 | /// nil means it hasn't been detected yet (will use ~/projects when detected) 27 | var defaultProjectsPath: String? 28 | 29 | /// Last custom path selected by the user for creating projects 30 | /// nil means the user hasn't selected a custom path yet 31 | var lastUsedProjectPath: String? 32 | 33 | // Cloud Provider fields 34 | var providerId: UUID? // Reference to ServerProvider 35 | var providerServerId: String? // Droplet/Server ID from provider 36 | 37 | // Cloud-init status tracking 38 | var cloudInitComplete: Bool = false 39 | var cloudInitLastChecked: Date? 40 | var cloudInitStatus: String? // "running", "done", "error", "unknown" 41 | 42 | init(name: String, host: String, port: Int = 22, username: String, authMethodType: String = "password") { 43 | self.name = name 44 | self.host = host 45 | self.port = port 46 | self.username = username 47 | self.authMethodType = authMethodType 48 | self.defaultProjectsPath = nil // Will be auto-detected on first use 49 | self.lastUsedProjectPath = nil // Will be set when user selects custom path 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /MobileCode/Services/ServerManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerManager.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-05. 6 | // 7 | // Purpose: Manages server configurations 8 | // - Loads servers from SwiftData 9 | // - Provides access to server list 10 | // - Single responsibility: server data management 11 | // 12 | 13 | import Foundation 14 | import SwiftUI 15 | import SwiftData 16 | 17 | /// Manages server configurations across the app 18 | @MainActor 19 | class ServerManager: ObservableObject { 20 | // MARK: - Singleton 21 | 22 | static let shared = ServerManager() 23 | 24 | // MARK: - Properties 25 | 26 | /// List of all servers 27 | @Published var servers: [Server] = [] 28 | 29 | // MARK: - Initialization 30 | 31 | private init() { 32 | // Private initializer for singleton 33 | } 34 | 35 | // MARK: - Methods 36 | 37 | /// Load servers from SwiftData 38 | /// - Parameter modelContext: The model context to fetch servers from 39 | func loadServers(from modelContext: ModelContext) { 40 | do { 41 | let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\Server.name)]) 42 | let fetchedServers = try modelContext.fetch(descriptor) 43 | servers = fetchedServers 44 | SSHLogger.log("Loaded \(fetchedServers.count) servers", level: .info) 45 | } catch { 46 | SSHLogger.log("Failed to load servers: \(error)", level: .error) 47 | servers = [] 48 | } 49 | } 50 | 51 | /// Find a server by ID 52 | /// - Parameter id: Server ID 53 | /// - Returns: Server if found 54 | func server(withId id: UUID) -> Server? { 55 | servers.first { $0.id == id } 56 | } 57 | 58 | /// Update server list (for manual refresh) 59 | /// - Parameter servers: New server list 60 | func updateServers(_ servers: [Server]) { 61 | self.servers = servers 62 | } 63 | } -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy for CodeAgents 2 | 3 | **Last Updated: July 15, 2025** 4 | 5 | ## The Simple Truth 6 | 7 | **CodeAgents stores everything on your iPhone/iPad. We cannot see, access, or collect any of your data.** 8 | 9 | ## What Stays on Your Device 10 | 11 | - **Server Details**: Your SSH server information 12 | - **Passwords & Keys**: Stored securely in iOS Keychain 13 | - **Projects & Files**: Your project names and file browsing history 14 | - **Chat History**: Your conversations with Claude Code 15 | - **Settings**: Your app preferences 16 | 17 | ## We Don't Have 18 | 19 | - Servers to collect your data 20 | - Analytics or tracking 21 | - Access to your information 22 | - Ability to see what you do 23 | 24 | ## When Data Leaves Your Device 25 | 26 | Data only leaves your device when: 27 | 1. **You connect to YOUR servers** via SSH (encrypted) 28 | 2. **You chat with Claude Code** - messages go to Anthropic for processing only 29 | 30 | ## Your Control 31 | 32 | - **Delete Anything**: Remove servers, projects, or messages anytime 33 | - **Delete Everything**: Uninstall the app to remove all data 34 | - **Export Keys**: Backup your SSH keys 35 | - **iCloud Sync**: Optional - you control it in iOS Settings 36 | 37 | ## Security 38 | 39 | - Passwords and keys are encrypted in iOS Keychain 40 | - SSH connections are always encrypted 41 | - No data on our servers (we don't have any) 42 | 43 | ## Third Parties 44 | 45 | **Claude Code by Anthropic®**: When you use chat, your messages go to Anthropic. See their [privacy policy](https://www.anthropic.com/privacy). 46 | 47 | **Your SSH Servers**: You connect only to servers you add. 48 | 49 | ## Updates 50 | 51 | We'll update this policy if needed and notify you through app updates. 52 | 53 | ## Contact 54 | 55 | Questions? Open an issue on our GitHub repository. 56 | 57 | --- 58 | 59 | **Bottom Line**: Your data is yours. It stays on your device. We can't see it, track it, or collect it. Ever. 60 | 61 | *This is part of the open-source CodeAgents project.* -------------------------------------------------------------------------------- /MobileCode/Utils/DateFormatter+Smart.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateFormatter+Smart.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-11. 6 | // 7 | 8 | import Foundation 9 | 10 | extension DateFormatter { 11 | /// Smart date formatter that shows relative dates 12 | static let smartTimestamp: DateFormatter = { 13 | let formatter = DateFormatter() 14 | formatter.timeStyle = .short 15 | formatter.doesRelativeDateFormatting = true 16 | return formatter 17 | }() 18 | 19 | /// Format a date with smart timestamp logic 20 | static func smartFormat(_ date: Date) -> String { 21 | let calendar = Calendar.current 22 | let now = Date() 23 | 24 | // Check if same day 25 | if calendar.isDateInToday(date) { 26 | // Just show time for today 27 | let formatter = DateFormatter() 28 | formatter.timeStyle = .short 29 | return formatter.string(from: date) 30 | } 31 | 32 | // Check if yesterday 33 | if calendar.isDateInYesterday(date) { 34 | let formatter = DateFormatter() 35 | formatter.timeStyle = .short 36 | let time = formatter.string(from: date) 37 | return "Yesterday, \(time)" 38 | } 39 | 40 | // Check if same year 41 | let dateComponents = calendar.dateComponents([.year], from: date) 42 | let nowComponents = calendar.dateComponents([.year], from: now) 43 | 44 | if dateComponents.year == nowComponents.year { 45 | // Show month and day with time for this year 46 | let formatter = DateFormatter() 47 | formatter.dateFormat = "MMM d, h:mm a" 48 | return formatter.string(from: date) 49 | } else { 50 | // Show full date for previous years 51 | let formatter = DateFormatter() 52 | formatter.dateFormat = "MMM d, yyyy, h:mm a" 53 | return formatter.string(from: date) 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodeAgents Mobile 2 | 3 | An mobile client to Claude Code for iOS. 4 | Allows you to run Claude code on any Linux ssh server. 5 | 6 | **TestFlight Beta** 7 | 8 | https://testflight.apple.com/join/eUpweBZV 9 | 10 | **AppStore** 11 | 12 | https://apps.apple.com/app/codeagents-mobile/id6748273716 13 | 14 | ## Screenshots 15 | 16 | Screenshot 1 Screenshot 2 Screenshot 3 Screenshot 4 17 | Screenshot 5 18 | Screenshot 6 19 | Screenshot 7 20 | Screenshot 8 21 | 22 | ## Features 23 | - **NEW** Provision servers with Digital Ocean / Hetzner 24 | - **NEW** Auto install Claude Code for provisioned servers 25 | - **NEW** Auto create SSH keys 26 | - MCP servers support 27 | - Connect to remote servers via SSH 28 | - Interact with Claude Code through a native iOS interface 29 | - Supports both API key and subscription-based authentication (OAuth tokens) 30 | - Browse files on remote servers 31 | - Real-time chat with Claude Code 32 | 33 | ## Requirements 34 | 35 | - iOS 17.0+ 36 | - Xcode 15+ 37 | - Swift 5.9+ 38 | 39 | ## Getting Started 40 | 41 | ```bash 42 | # Clone the repository 43 | git clone [repository-url] 44 | 45 | # Open in Xcode 46 | open CodeAgentsMobile.xcodeproj 47 | 48 | # Build and run 49 | # Select your target device/simulator and press Cmd+R 50 | ``` 51 | 52 | 53 | ## Architecture 54 | 55 | Built with SwiftUI and SwiftData following MVVM pattern. 56 | Icon - https://tabler.io/icons/icon/brain, edited here https://icon.kitchen 57 | 58 | ## License 59 | 60 | This project is licensed under the Apache License, Version 2.0 - see the [LICENSE](LICENSE) file for details. 61 | -------------------------------------------------------------------------------- /MobileCode/Views/TerminalView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TerminalView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-06-10. 6 | // 7 | // Purpose: Terminal interface placeholder 8 | // TODO: Implement when TerminalViewModel is ready 9 | // 10 | 11 | import SwiftUI 12 | 13 | struct TerminalView: View { 14 | @State private var viewModel = TerminalViewModel() 15 | @State private var showingSettings = false 16 | 17 | var body: some View { 18 | NavigationStack { 19 | VStack(spacing: 30) { 20 | Spacer() 21 | 22 | Image(systemName: "terminal") 23 | .font(.system(size: 80)) 24 | .foregroundColor(.secondary) 25 | 26 | Text(viewModel.placeholderMessage) 27 | .font(.title2) 28 | .fontWeight(.medium) 29 | .foregroundColor(.primary) 30 | 31 | VStack(alignment: .leading, spacing: 8) { 32 | ForEach(viewModel.outputLines, id: \.self) { line in 33 | Text(line) 34 | .font(.footnote) 35 | .foregroundColor(.secondary) 36 | .monospaced() 37 | } 38 | } 39 | .padding() 40 | .frame(maxWidth: 350) 41 | .background(Color(.secondarySystemBackground)) 42 | .cornerRadius(10) 43 | 44 | Spacer() 45 | Spacer() 46 | } 47 | .padding() 48 | .navigationTitle("Terminal") 49 | .navigationBarTitleDisplayMode(.inline) 50 | .toolbar { 51 | ToolbarItem(placement: .primaryAction) { 52 | Button { 53 | showingSettings = true 54 | } label: { 55 | Image(systemName: "gearshape") 56 | } 57 | } 58 | } 59 | } 60 | .sheet(isPresented: $showingSettings) { 61 | NavigationStack { 62 | SettingsView() 63 | } 64 | } 65 | } 66 | } 67 | 68 | #Preview { 69 | TerminalView() 70 | } -------------------------------------------------------------------------------- /MobileCode/Views/Shared/SSHKeySelectionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftData 3 | 4 | struct SSHKeySelectionView: View { 5 | @Environment(\.dismiss) private var dismiss 6 | @Binding var selectedKeyID: UUID? 7 | @Query private var sshKeys: [SSHKey] 8 | 9 | let onAddKey: (() -> Void)? 10 | 11 | init(selectedKeyID: Binding, onAddKey: (() -> Void)? = nil) { 12 | self._selectedKeyID = selectedKeyID 13 | self.onAddKey = onAddKey 14 | } 15 | 16 | private var usableKeys: [SSHKey] { 17 | sshKeys.filter { 18 | (try? KeychainManager.shared.retrieveSSHKey(for: $0.id)) != nil 19 | } 20 | } 21 | 22 | var body: some View { 23 | List { 24 | if usableKeys.isEmpty { 25 | ContentUnavailableView { 26 | Label("No SSH Keys", systemImage: "key.slash") 27 | } description: { 28 | Text("Import or generate a new SSH key with a stored private key.") 29 | } 30 | } else { 31 | ForEach(usableKeys) { key in 32 | Button { 33 | selectedKeyID = key.id 34 | dismiss() 35 | } label: { 36 | HStack { 37 | VStack(alignment: .leading, spacing: 4) { 38 | Text(key.name) 39 | Text(key.keyType) 40 | .font(.caption) 41 | .foregroundColor(.secondary) 42 | } 43 | Spacer() 44 | if selectedKeyID == key.id { 45 | Image(systemName: "checkmark") 46 | .foregroundColor(.accentColor) 47 | } 48 | } 49 | .contentShape(Rectangle()) 50 | } 51 | .buttonStyle(.plain) 52 | } 53 | } 54 | } 55 | .listStyle(.insetGrouped) 56 | .navigationTitle("Select SSH Key") 57 | .navigationBarTitleDisplayMode(.inline) 58 | .toolbar { 59 | if let onAddKey = onAddKey { 60 | ToolbarItem(placement: .navigationBarTrailing) { 61 | Button { 62 | onAddKey() 63 | } label: { 64 | Image(systemName: "plus") 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /CodeAgentsMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "9260a5d8f1bbd0d06b130f68acecd78d2a4ed8fecc3893e64e5bcd5319c4db8a", 3 | "pins" : [ 4 | { 5 | "identity" : "openssl-package", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/krzyzanowskim/OpenSSL-Package.git", 8 | "state" : { 9 | "revision" : "71dbe0b4514cdaad95961470db72e8231f5943a6", 10 | "version" : "3.3.3001" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-asn1", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-asn1.git", 17 | "state" : { 18 | "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", 19 | "version" : "1.4.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-atomics", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-atomics.git", 26 | "state" : { 27 | "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", 28 | "version" : "1.3.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-collections", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-collections.git", 35 | "state" : { 36 | "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", 37 | "version" : "1.2.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-crypto", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-crypto.git", 44 | "state" : { 45 | "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed", 46 | "version" : "3.12.3" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-nio", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-nio.git", 53 | "state" : { 54 | "revision" : "ad6b5f17270a7008f60d35ec5378e6144a575162", 55 | "version" : "2.84.0" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-nio-ssh", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/apple/swift-nio-ssh", 62 | "state" : { 63 | "revision" : "80fe4bc4145780c3b717b153c62be524355c8c37", 64 | "version" : "0.11.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-system", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/apple/swift-system.git", 71 | "state" : { 72 | "revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6", 73 | "version" : "1.5.0" 74 | } 75 | } 76 | ], 77 | "version" : 3 78 | } 79 | -------------------------------------------------------------------------------- /MobileCode/Services/ProjectContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProjectContext.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-01. 6 | // 7 | // Purpose: Manages the active project context across the app 8 | // - Tracks currently active project 9 | // - Automatically manages server connections when switching projects 10 | // - Provides project path context to all views 11 | // 12 | 13 | import Foundation 14 | import SwiftUI 15 | import SwiftData 16 | 17 | /// Manages the active project context across the app 18 | @MainActor 19 | class ProjectContext: ObservableObject { 20 | // MARK: - Singleton 21 | 22 | static let shared = ProjectContext() 23 | 24 | // MARK: - Properties 25 | 26 | /// Currently active project 27 | @Published var activeProject: RemoteProject? 28 | 29 | /// Reference to server manager for server lookups 30 | private let serverManager = ServerManager.shared 31 | 32 | // MARK: - Initialization 33 | 34 | private init() { 35 | // Private initializer for singleton 36 | } 37 | 38 | // MARK: - Methods 39 | 40 | /// Set the active project 41 | /// - Parameter project: The project to activate 42 | func setActiveProject(_ project: RemoteProject) { 43 | // Clear any previous project connections if switching projects 44 | if let currentProject = activeProject, currentProject.id != project.id { 45 | clearActiveProject() 46 | } 47 | 48 | // Set the new active project 49 | activeProject = project 50 | 51 | print("✅ Activated project: \(project.name)") 52 | } 53 | 54 | /// Clear the active project 55 | func clearActiveProject() { 56 | if let project = activeProject { 57 | // Close all connections for this project 58 | ServiceManager.shared.sshService.closeConnections(projectId: project.id) 59 | } 60 | 61 | activeProject = nil 62 | } 63 | 64 | /// Get the full path for the active project 65 | var activeProjectPath: String? { 66 | activeProject?.path 67 | } 68 | 69 | /// Check if a project is currently active 70 | func isProjectActive(_ project: RemoteProject) -> Bool { 71 | activeProject?.id == project.id 72 | } 73 | 74 | /// Get the working directory for terminal/file operations 75 | var workingDirectory: String { 76 | activeProjectPath ?? "/root/projects" 77 | } 78 | 79 | /// Get the server for the active project 80 | var activeServer: Server? { 81 | guard let project = activeProject else { return nil } 82 | return serverManager.server(withId: project.serverId) 83 | } 84 | } -------------------------------------------------------------------------------- /MobileCode/Services/SSH/ByteBufferExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ByteBufferExtensions.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Extensions on NIO's ByteBuffer for SSH parsing 6 | // Adapted from Citadel's approach for clean binary parsing 7 | // 8 | 9 | import Foundation 10 | import NIO 11 | 12 | extension ByteBuffer { 13 | /// Write an SSH string (length-prefixed UTF-8 string) 14 | mutating func writeSSHString(_ string: String) { 15 | let data = string.data(using: .utf8) ?? Data() 16 | writeInteger(UInt32(data.count), endianness: .big) 17 | writeBytes(data) 18 | } 19 | 20 | /// Write an SSH buffer (length-prefixed buffer) 21 | @discardableResult 22 | mutating func writeSSHBuffer(_ buffer: inout ByteBuffer) -> Int { 23 | let lengthBytes = writeInteger(UInt32(buffer.readableBytes), endianness: .big) 24 | let dataBytes = writeBuffer(&buffer) 25 | return lengthBytes + dataBytes 26 | } 27 | 28 | /// Write data with composite SSH string format 29 | @discardableResult 30 | mutating func writeCompositeSSHString(_ closure: (inout ByteBuffer) throws -> Int) rethrows -> Int { 31 | let oldWriterIndex = writerIndex 32 | moveWriterIndex(forwardBy: 4) // Reserve space for length 33 | 34 | let bytesWritten = try closure(&self) 35 | 36 | // Write the length at the reserved position 37 | setInteger(UInt32(bytesWritten), at: oldWriterIndex, endianness: .big) 38 | 39 | return 4 + bytesWritten 40 | } 41 | 42 | /// Read an SSH string (length-prefixed UTF-8 string) 43 | mutating func readSSHString() -> String? { 44 | guard let length = readInteger(endianness: .big, as: UInt32.self), 45 | let data = readData(length: Int(length)), 46 | let string = String(data: data, encoding: .utf8) else { 47 | return nil 48 | } 49 | return string 50 | } 51 | 52 | /// Read an SSH buffer (length-prefixed buffer) 53 | mutating func readSSHBuffer() -> ByteBuffer? { 54 | guard let length = readInteger(endianness: .big, as: UInt32.self), 55 | let slice = readSlice(length: Int(length)) else { 56 | return nil 57 | } 58 | return slice 59 | } 60 | 61 | /// Read data of specified length 62 | mutating func readData(length: Int) -> Data? { 63 | guard let bytes = readBytes(length: length) else { 64 | return nil 65 | } 66 | return Data(bytes) 67 | } 68 | 69 | /// Write data 70 | @discardableResult 71 | mutating func writeData(_ data: Data) -> Int { 72 | writeBytes(data) 73 | } 74 | } 75 | 76 | // Protocol for types that can be read from and written to ByteBuffer 77 | protocol ByteBufferConvertible { 78 | static func read(consuming buffer: inout ByteBuffer) throws -> Self 79 | func write(to buffer: inout ByteBuffer) -> Int 80 | } -------------------------------------------------------------------------------- /MobileCode/Services/SSH/CloudInitTemplate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudInitTemplate.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Manages cloud-init template loading and SSH key replacement 6 | // 7 | 8 | import Foundation 9 | 10 | struct CloudInitTemplate { 11 | /// Load the cloud-init template and replace SSH keys placeholder 12 | /// - Parameter sshKeys: Array of SSH public keys to insert 13 | /// - Returns: The cloud-init configuration with SSH keys inserted 14 | static func generate(with sshKeys: [String]) -> String? { 15 | // Load template from bundle 16 | guard let templateURL = Bundle.main.url(forResource: "cloud_init_codeagent", withExtension: "yaml"), 17 | let template = try? String(contentsOf: templateURL, encoding: .utf8) else { 18 | print("Warning: Could not load cloud-init template from bundle, using fallback") 19 | return generateFallback(with: sshKeys) 20 | } 21 | 22 | // Generate SSH keys list in YAML format 23 | let sshKeysList: String 24 | if sshKeys.isEmpty { 25 | // If no keys, provide an empty list 26 | sshKeysList = " []" 27 | } else { 28 | // Format each key with proper indentation 29 | let formattedKeys = sshKeys.map { key in 30 | " - \(key.trimmingCharacters(in: .whitespacesAndNewlines))" 31 | }.joined(separator: "\n") 32 | sshKeysList = formattedKeys 33 | } 34 | 35 | // Replace placeholder with actual SSH keys 36 | let cloudInit = template.replacingOccurrences(of: "{{SSH_KEYS_PLACEHOLDER}}", with: sshKeysList) 37 | 38 | return cloudInit 39 | } 40 | 41 | /// Fallback cloud-init generation if template file is not available 42 | /// - Parameter sshKeys: Array of SSH public keys to insert 43 | /// - Returns: The cloud-init configuration 44 | private static func generateFallback(with sshKeys: [String]) -> String { 45 | var script = """ 46 | #cloud-config 47 | users: 48 | - name: codeagent 49 | groups: users, admin, sudo 50 | sudo: ALL=(ALL) NOPASSWD:ALL 51 | shell: /bin/bash 52 | """ 53 | 54 | // Add SSH keys if any are provided 55 | if !sshKeys.isEmpty { 56 | let sshKeysList = sshKeys.map { key in 57 | " - \(key.trimmingCharacters(in: .whitespacesAndNewlines))" 58 | }.joined(separator: "\n") 59 | script += "\n ssh_authorized_keys:\n\(sshKeysList)" 60 | } else { 61 | script += "\n ssh_authorized_keys: []" 62 | } 63 | 64 | // Add the rest of the configuration 65 | script += """ 66 | 67 | package_update: true 68 | package_upgrade: true 69 | 70 | packages: 71 | - nodejs 72 | - npm 73 | - curl 74 | - build-essential 75 | - git 76 | 77 | runcmd: 78 | - npm install -g @anthropic-ai/claude-code 79 | """ 80 | 81 | return script 82 | } 83 | } -------------------------------------------------------------------------------- /MobileCode/Services/CloudProviderProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudProviderProtocol.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-08-12. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - Cloud Server Models 11 | 12 | struct CloudServer: Identifiable, Codable, Equatable { 13 | let id: String 14 | let name: String 15 | let status: String 16 | let publicIP: String? 17 | let privateIP: String? 18 | let region: String 19 | let imageInfo: String 20 | let sizeInfo: String 21 | let providerType: String 22 | } 23 | 24 | // MARK: - SSH Key Models 25 | 26 | struct CloudSSHKey: Identifiable, Codable { 27 | let id: String 28 | let name: String 29 | let fingerprint: String 30 | let publicKey: String 31 | } 32 | 33 | // MARK: - Cloud Provider Protocol 34 | 35 | protocol CloudProviderProtocol { 36 | var providerType: String { get } 37 | var apiToken: String { get set } 38 | 39 | // Authentication 40 | func validateToken() async throws -> Bool 41 | 42 | // Server Management 43 | func listServers() async throws -> [CloudServer] 44 | func getServer(id: String) async throws -> CloudServer? 45 | 46 | // SSH Key Management 47 | func listSSHKeys() async throws -> [CloudSSHKey] 48 | func addSSHKey(name: String, publicKey: String) async throws -> CloudSSHKey 49 | func deleteSSHKey(id: String) async throws 50 | 51 | // Server Creation 52 | func createServer(name: String, region: String, size: String, image: String, sshKeyIds: [String], userData: String?) async throws -> CloudServer 53 | 54 | // Server Options (for server creation UI) 55 | func listRegions() async throws -> [(id: String, name: String)] 56 | func listSizes() async throws -> [(id: String, name: String, description: String)] 57 | func listImages() async throws -> [(id: String, name: String)] 58 | } 59 | 60 | // MARK: - Cloud Provider Errors 61 | 62 | enum CloudProviderError: LocalizedError { 63 | case invalidToken 64 | case insufficientPermissions 65 | case rateLimitExceeded 66 | case serverNotFound 67 | case networkError(Error) 68 | case apiError(statusCode: Int, message: String) 69 | case unknownError 70 | 71 | var errorDescription: String? { 72 | switch self { 73 | case .invalidToken: 74 | return "Invalid API token. Please check and try again." 75 | case .insufficientPermissions: 76 | return "Token needs read permission for viewing or read-write for creating." 77 | case .rateLimitExceeded: 78 | return "Too many requests. Please wait and try again." 79 | case .serverNotFound: 80 | return "Server not found." 81 | case .networkError(let error): 82 | return "Network error: \(error.localizedDescription)" 83 | case .apiError(_, let message): 84 | return message 85 | case .unknownError: 86 | return "An unknown error occurred." 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /MobileCode/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Eugene Pyvovarov on 2025-06-10. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftData 10 | 11 | struct ContentView: View { 12 | @State private var selectedTab = Tab.chat 13 | @StateObject private var projectContext = ProjectContext.shared 14 | @StateObject private var serverManager = ServerManager.shared 15 | @StateObject private var cloudInitMonitor = CloudInitMonitor.shared 16 | @Environment(\.modelContext) private var modelContext 17 | @Environment(\.scenePhase) private var scenePhase 18 | 19 | enum Tab { 20 | case chat 21 | case files 22 | case terminal 23 | } 24 | 25 | var body: some View { 26 | Group { 27 | if projectContext.activeProject == nil { 28 | // Show projects list when no project is active 29 | ProjectsView() 30 | .onAppear { 31 | configureManagers() 32 | } 33 | } else { 34 | // Show tabs when a project is active 35 | TabView(selection: $selectedTab) { 36 | ChatView() 37 | .tabItem { 38 | Label("Chat", systemImage: "message") 39 | } 40 | .tag(Tab.chat) 41 | 42 | FileBrowserView() 43 | .tabItem { 44 | Label("Files", systemImage: "doc.text") 45 | } 46 | .tag(Tab.files) 47 | 48 | TerminalView() 49 | .tabItem { 50 | Label("Terminal", systemImage: "terminal") 51 | } 52 | .tag(Tab.terminal) 53 | } 54 | .onAppear { 55 | configureManagers() 56 | } 57 | } 58 | } 59 | .onChange(of: scenePhase) { _, newPhase in 60 | switch newPhase { 61 | case .active: 62 | // App became active - start monitoring 63 | startCloudInitMonitoring() 64 | case .inactive, .background: 65 | // App went to background - stop monitoring to save resources 66 | cloudInitMonitor.stopAllMonitoring() 67 | @unknown default: 68 | break 69 | } 70 | } 71 | } 72 | 73 | private func configureManagers() { 74 | // Load servers for ServerManager 75 | serverManager.loadServers(from: modelContext) 76 | // Start cloud-init monitoring for servers that need it 77 | startCloudInitMonitoring() 78 | } 79 | 80 | private func startCloudInitMonitoring() { 81 | cloudInitMonitor.startMonitoring(modelContext: modelContext) 82 | } 83 | } 84 | 85 | #Preview { 86 | ContentView() 87 | .modelContainer(for: [RemoteProject.self, Server.self], inMemory: true) 88 | } 89 | -------------------------------------------------------------------------------- /MobileCode/Services/SSHKeyMaintenanceService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SSHKeyMaintenanceService.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Service for maintaining SSH keys, including generating missing public keys 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | import Crypto 11 | 12 | class SSHKeyMaintenanceService { 13 | static let shared = SSHKeyMaintenanceService() 14 | 15 | private init() {} 16 | 17 | /// Check all SSH keys and generate missing public keys 18 | @MainActor 19 | func generateMissingPublicKeys(in modelContext: ModelContext) async { 20 | let descriptor = FetchDescriptor() 21 | 22 | do { 23 | let sshKeys = try modelContext.fetch(descriptor) 24 | var keysUpdated = 0 25 | 26 | for sshKey in sshKeys { 27 | // Check if public key is missing or is a placeholder 28 | if sshKey.publicKey.isEmpty || sshKey.publicKey.contains("PLACEHOLDER") { 29 | print("Found SSH key '\(sshKey.name)' with missing/placeholder public key") 30 | 31 | // Try to generate the public key 32 | if let publicKey = await generatePublicKey(for: sshKey) { 33 | sshKey.publicKey = publicKey 34 | keysUpdated += 1 35 | print("✅ Generated public key for '\(sshKey.name)'") 36 | } else { 37 | print("❌ Failed to generate public key for '\(sshKey.name)'") 38 | } 39 | } 40 | } 41 | 42 | // Save changes if any keys were updated 43 | if keysUpdated > 0 { 44 | try modelContext.save() 45 | print("✅ Updated \(keysUpdated) SSH key(s) with generated public keys") 46 | } else { 47 | print("ℹ️ All SSH keys already have valid public keys") 48 | } 49 | 50 | } catch { 51 | print("Failed to fetch or update SSH keys: \(error)") 52 | } 53 | } 54 | 55 | /// Generate public key for a specific SSH key 56 | private func generatePublicKey(for sshKey: SSHKey) async -> String? { 57 | do { 58 | // Retrieve the private key from keychain 59 | let privateKeyData = try KeychainManager.shared.retrieveSSHKey(for: sshKey.id) 60 | guard !privateKeyData.isEmpty else { 61 | print("No private key found in keychain for '\(sshKey.name)'") 62 | return nil 63 | } 64 | 65 | // Check if we need a passphrase 66 | let passphrase = try? KeychainManager.shared.retrieveSSHKeyPassphrase(for: sshKey.id) 67 | 68 | // Use the SSHKeyFormatter to extract and format the public key 69 | return SSHKeyFormatter.extractPublicKey( 70 | from: privateKeyData, 71 | keyType: sshKey.keyType, 72 | passphrase: passphrase, 73 | name: sshKey.name 74 | ) 75 | 76 | } catch { 77 | print("Failed to generate public key for '\(sshKey.name)': \(error)") 78 | return nil 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /MobileCode/Models/MCPConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCPConfiguration.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Model for .mcp.json file structure 6 | // Handles parsing and serialization of MCP server configurations 7 | // 8 | 9 | import Foundation 10 | 11 | /// Root structure of .mcp.json file 12 | struct MCPConfiguration: Codable { 13 | var mcpServers: [String: MCPServer.Configuration] 14 | 15 | /// Create empty configuration 16 | init() { 17 | self.mcpServers = [:] 18 | } 19 | 20 | /// Create configuration from servers 21 | init(servers: [MCPServer]) { 22 | self.mcpServers = Dictionary( 23 | uniqueKeysWithValues: servers.map { ($0.name, $0.configuration) } 24 | ) 25 | } 26 | 27 | /// Convert to array of MCPServer objects 28 | var servers: [MCPServer] { 29 | mcpServers.map { MCPServer(name: $0.key, configuration: $0.value) } 30 | } 31 | 32 | /// Add or update a server 33 | mutating func setServer(_ server: MCPServer) { 34 | mcpServers[server.name] = server.configuration 35 | } 36 | 37 | /// Remove a server 38 | mutating func removeServer(named name: String) { 39 | mcpServers.removeValue(forKey: name) 40 | } 41 | 42 | /// Check if a server exists 43 | func hasServer(named name: String) -> Bool { 44 | mcpServers[name] != nil 45 | } 46 | 47 | /// Get a specific server 48 | func server(named name: String) -> MCPServer? { 49 | guard let config = mcpServers[name] else { return nil } 50 | return MCPServer(name: name, configuration: config) 51 | } 52 | } 53 | 54 | // MARK: - JSON Serialization 55 | extension MCPConfiguration { 56 | /// Load configuration from JSON data 57 | static func load(from data: Data) throws -> MCPConfiguration { 58 | let decoder = JSONDecoder() 59 | return try decoder.decode(MCPConfiguration.self, from: data) 60 | } 61 | 62 | /// Load configuration from JSON string 63 | static func load(from jsonString: String) throws -> MCPConfiguration { 64 | guard let data = jsonString.data(using: .utf8) else { 65 | throw MCPConfigurationError.invalidJSON 66 | } 67 | return try load(from: data) 68 | } 69 | 70 | /// Convert to JSON data 71 | func toJSON() throws -> Data { 72 | let encoder = JSONEncoder() 73 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 74 | return try encoder.encode(self) 75 | } 76 | 77 | /// Convert to JSON string 78 | func toJSONString() throws -> String { 79 | let data = try toJSON() 80 | guard let string = String(data: data, encoding: .utf8) else { 81 | throw MCPConfigurationError.encodingFailed 82 | } 83 | return string 84 | } 85 | } 86 | 87 | // MARK: - Error Types 88 | enum MCPConfigurationError: LocalizedError { 89 | case invalidJSON 90 | case encodingFailed 91 | case fileNotFound 92 | 93 | var errorDescription: String? { 94 | switch self { 95 | case .invalidJSON: 96 | return "Invalid JSON format" 97 | case .encodingFailed: 98 | return "Failed to encode configuration" 99 | case .fileNotFound: 100 | return ".mcp.json file not found" 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /MobileCode/Views/Components/ToolResultView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolResultView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-05. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ToolResultView: View { 11 | let toolResultBlock: ToolResultBlock 12 | @Binding var isExpanded: Bool 13 | 14 | private var resultIcon: String { 15 | toolResultBlock.isError ? "exclamationmark.triangle" : "checkmark.circle" 16 | } 17 | 18 | private var resultColor: Color { 19 | toolResultBlock.isError ? .red : .green 20 | } 21 | 22 | private var firstLine: String { 23 | let lines = toolResultBlock.content.split(separator: "\n", maxSplits: 1) 24 | if let first = lines.first { 25 | return String(first) 26 | } 27 | return toolResultBlock.content 28 | } 29 | 30 | private var hasMultipleLines: Bool { 31 | toolResultBlock.content.contains("\n") 32 | } 33 | 34 | var body: some View { 35 | VStack(alignment: .leading, spacing: 8) { 36 | // Header 37 | Button(action: { 38 | withAnimation(.easeInOut(duration: 0.2)) { 39 | isExpanded.toggle() 40 | } 41 | }) { 42 | HStack(spacing: 8) { 43 | Image(systemName: resultIcon) 44 | .font(.system(size: 16)) 45 | .foregroundColor(resultColor) 46 | 47 | Text("Tool Result") 48 | .font(.subheadline) 49 | .fontWeight(.medium) 50 | .foregroundColor(.primary) 51 | 52 | Spacer() 53 | 54 | if hasMultipleLines { 55 | Image(systemName: isExpanded ? "chevron.down" : "chevron.right") 56 | .font(.system(size: 12)) 57 | .foregroundColor(.secondary) 58 | } 59 | } 60 | } 61 | .buttonStyle(PlainButtonStyle()) 62 | 63 | // Content 64 | if isExpanded || !hasMultipleLines { 65 | Text(toolResultBlock.content) 66 | .font(.caption) 67 | .fontDesign(.monospaced) 68 | .foregroundColor(toolResultBlock.isError ? .red : .primary) 69 | .textSelection(.enabled) 70 | .fixedSize(horizontal: false, vertical: true) 71 | .frame(maxWidth: .infinity, alignment: .leading) 72 | } else { 73 | HStack { 74 | Text(firstLine) 75 | .font(.caption) 76 | .fontDesign(.monospaced) 77 | .foregroundColor(.secondary) 78 | .lineLimit(1) 79 | .truncationMode(.tail) 80 | 81 | Text("...") 82 | .font(.caption) 83 | .foregroundColor(.secondary) 84 | } 85 | } 86 | } 87 | .padding(12) 88 | .background(Color(.systemGray6)) 89 | .cornerRadius(12) 90 | .overlay( 91 | RoundedRectangle(cornerRadius: 12) 92 | .stroke(toolResultBlock.isError ? Color.red.opacity(0.3) : Color.clear, lineWidth: 1) 93 | ) 94 | } 95 | } -------------------------------------------------------------------------------- /MobileCode/Views/Components/CloudInitStatusBadge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudInitStatusBadge.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Visual indicator for cloud-init status on managed servers 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CloudInitStatusBadge: View { 11 | let status: String? 12 | 13 | var body: some View { 14 | if status?.lowercased() != "unknown" && status != nil { 15 | HStack(spacing: 6) { 16 | if showSpinner { 17 | ProgressView() 18 | .scaleEffect(0.6) 19 | .frame(width: 10, height: 10) 20 | } 21 | 22 | Text(statusText) 23 | .font(.caption2) 24 | .foregroundColor(statusColor) 25 | } 26 | .padding(.horizontal, 8) 27 | .padding(.vertical, 3) 28 | .background(statusColor.opacity(0.15)) 29 | .cornerRadius(4) 30 | } 31 | } 32 | 33 | private var statusText: String { 34 | switch status?.lowercased() { 35 | case "done": 36 | return "Ready" 37 | case "running": 38 | return "Installing Claude Code..." 39 | case "error": 40 | return "Error" 41 | case "checking": 42 | return "Checking..." 43 | default: 44 | return "" 45 | } 46 | } 47 | 48 | private var statusColor: Color { 49 | switch status?.lowercased() { 50 | case "done": 51 | return .green 52 | case "running", "checking": 53 | return .orange 54 | case "error": 55 | return .red 56 | default: 57 | return .gray 58 | } 59 | } 60 | 61 | private var showSpinner: Bool { 62 | status?.lowercased() == "running" || status?.lowercased() == "checking" 63 | } 64 | } 65 | 66 | // Helper functions for cloud-init status 67 | extension View { 68 | func cloudInitStatusText(_ status: String?) -> String { 69 | switch status?.lowercased() { 70 | case "done": 71 | return "Ready" 72 | case "running": 73 | return "Installing Claude Code..." 74 | case "checking": 75 | return "Checking status..." 76 | case "error": 77 | return "Configuration failed" 78 | case "timeout": 79 | return "Installation timeout" 80 | default: 81 | return "Unknown status" 82 | } 83 | } 84 | 85 | func cloudInitStatusColor(_ status: String?) -> Color { 86 | switch status?.lowercased() { 87 | case "done": 88 | return .green 89 | case "running", "checking": 90 | return .orange 91 | case "error": 92 | return .red 93 | default: 94 | return .gray 95 | } 96 | } 97 | 98 | func isServerReady(_ server: Server) -> Bool { 99 | // Server is ready if cloud-init is complete or if it's not a managed server 100 | return server.cloudInitComplete || server.providerId == nil 101 | } 102 | } 103 | 104 | #Preview { 105 | VStack(spacing: 20) { 106 | CloudInitStatusBadge(status: "running") 107 | CloudInitStatusBadge(status: "done") 108 | CloudInitStatusBadge(status: "error") 109 | CloudInitStatusBadge(status: "checking") 110 | CloudInitStatusBadge(status: "unknown") 111 | CloudInitStatusBadge(status: nil) 112 | } 113 | .padding() 114 | } -------------------------------------------------------------------------------- /MobileCodeTests/DirectSSHTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DirectSSHTest.swift 3 | // MobileCodeTests 4 | // 5 | // Direct test to verify SSH streaming works 6 | // 7 | 8 | import Testing 9 | @testable import CodeAgentsMobile 10 | import Foundation 11 | 12 | struct DirectSSHTest { 13 | 14 | @Test func testDirectSSHStreaming() async throws { 15 | print("🚀 Starting Direct SSH Streaming Test") 16 | 17 | // Server configuration 18 | let server = Server( 19 | name: "Test Server", 20 | host: "5.75.250.220", 21 | port: 22, 22 | username: "root" 23 | ) 24 | 25 | // Store password 26 | try KeychainManager.shared.storePassword("", for: server.id) 27 | 28 | // Connect 29 | print("📡 Connecting to server...") 30 | let sshSession = try await SwiftSHSession.connect(to: server) 31 | defer { 32 | print("🔌 Disconnecting...") 33 | sshSession.disconnect() 34 | } 35 | 36 | print("✅ Connected successfully!") 37 | 38 | // Test 1: Simple execute 39 | print("\n📌 Test 1: Simple execute command") 40 | let echoResult = try await sshSession.execute("echo 'Hello from SSH'") 41 | print("Result: \(echoResult)") 42 | #expect(echoResult.contains("Hello from SSH")) 43 | 44 | // Test 2: Streaming command 45 | print("\n📌 Test 2: Streaming command") 46 | let streamCommand = "for i in 1 2 3; do echo \"Stream $i\"; sleep 0.1; done" 47 | let processHandle = try await sshSession.startProcess(streamCommand) 48 | 49 | var chunks: [String] = [] 50 | print("⏳ Waiting for stream output...") 51 | 52 | for try await chunk in processHandle.outputStream() { 53 | print("📥 Received: '\(chunk)'") 54 | chunks.append(chunk) 55 | if chunks.count >= 3 { break } 56 | } 57 | 58 | processHandle.terminate() 59 | 60 | let fullOutput = chunks.joined() 61 | print("📄 Full output: '\(fullOutput)'") 62 | #expect(!chunks.isEmpty, "Should receive streaming chunks") 63 | #expect(fullOutput.contains("Stream 1")) 64 | 65 | // Test 3: Claude command 66 | print("\n📌 Test 3: Claude command") 67 | let claudeCommand = """ 68 | cd /root/projects/First && \ 69 | export ANTHROPIC_API_KEY="" && \ 70 | timeout 20 claude --print "Say hello" \ 71 | --output-format stream-json --verbose \ 72 | --allowedTools Bash,Write,Edit,MultiEdit,NotebookEdit,Read,LS,Grep,Glob,WebFetch 2>&1 73 | """ 74 | 75 | print("🤖 Starting Claude process...") 76 | let claudeHandle = try await sshSession.startProcess(claudeCommand) 77 | 78 | var claudeChunks: [String] = [] 79 | let timeout = Task { 80 | try await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds 81 | print("⏱️ Timeout reached") 82 | } 83 | 84 | print("⏳ Waiting for Claude output...") 85 | for try await chunk in claudeHandle.outputStream() { 86 | print("🤖 Claude says: '\(chunk)'") 87 | claudeChunks.append(chunk) 88 | if chunk.contains("hello") || chunk.contains("Hello") { 89 | print("✅ Found hello in output!") 90 | break 91 | } 92 | } 93 | 94 | timeout.cancel() 95 | claudeHandle.terminate() 96 | 97 | let claudeOutput = claudeChunks.joined() 98 | print("📄 Claude full output: '\(claudeOutput)'") 99 | print("📊 Received \(claudeChunks.count) chunks, total \(claudeOutput.count) characters") 100 | 101 | #expect(!claudeChunks.isEmpty, "Should receive Claude output") 102 | 103 | print("\n✅ All tests completed!") 104 | } 105 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 8 | *.xcscmblueprint 9 | *.xccheckout 10 | 11 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 12 | build/ 13 | DerivedData/ 14 | *.moved-aside 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | 24 | ## Obj-C/Swift specific 25 | *.hmap 26 | 27 | ## App packaging 28 | *.ipa 29 | *.dSYM.zip 30 | *.dSYM 31 | 32 | ## Playgrounds 33 | timeline.xctimeline 34 | playground.xcworkspace 35 | 36 | # Swift Package Manager 37 | # 38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 39 | # Packages/ 40 | # Package.pins 41 | # Package.resolved 42 | # *.xcodeproj 43 | # 44 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 45 | # hence it is not needed unless you have added a package configuration file to your project 46 | # .swiftpm 47 | 48 | .build/ 49 | 50 | # CocoaPods 51 | # 52 | # We recommend against adding the Pods directory to your .gitignore. However 53 | # you should judge for yourself, the pros and cons are mentioned at: 54 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 55 | # 56 | # Pods/ 57 | # 58 | # Add this line if you want to avoid checking in source code from the Xcode workspace 59 | # *.xcworkspace 60 | 61 | # Carthage 62 | # 63 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 64 | # Carthage/Checkouts 65 | 66 | Carthage/Build/ 67 | 68 | # Accio dependency management 69 | Dependencies/ 70 | .accio/ 71 | 72 | # fastlane 73 | # 74 | # It is recommended to not store the screenshots in the git repo. 75 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 76 | # For more information about the recommended setup visit: 77 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 78 | 79 | fastlane/report.xml 80 | fastlane/Preview.html 81 | fastlane/screenshots/**/*.png 82 | fastlane/test_output 83 | 84 | # Code Injection 85 | # 86 | # After new code Injection tools there's a generated folder /iOSInjectionProject 87 | # https://github.com/johnno1962/injectionforxcode 88 | 89 | iOSInjectionProject/ 90 | 91 | # macOS 92 | .DS_Store 93 | .AppleDouble 94 | .LSOverride 95 | 96 | # Icon must end with two \r 97 | Icon 98 | 99 | # Thumbnails 100 | ._* 101 | 102 | # Files that might appear in the root of a volume 103 | .DocumentRevisions-V100 104 | .fseventsd 105 | .Spotlight-V100 106 | .TemporaryItems 107 | .Trashes 108 | .VolumeIcon.icns 109 | .com.apple.timemachine.donotpresent 110 | 111 | # Directories potentially created on remote AFP share 112 | .AppleDB 113 | .AppleDesktop 114 | Network Trash Folder 115 | Temporary Items 116 | .apdisk 117 | 118 | # SwiftLint 119 | .swiftlint-cache/ 120 | 121 | # SPM 122 | .swiftpm/config/registries.json 123 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 124 | .netrc 125 | 126 | # Xcode Workspace 127 | *.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist 128 | *.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings 129 | 130 | # Build artifacts 131 | *.xcarchive 132 | 133 | # Temporary files 134 | *.swp 135 | *~.nib 136 | *.tmp 137 | 138 | # IDE - VSCode 139 | .vscode/ 140 | 141 | # IDE - JetBrains 142 | .idea/ 143 | *.iml 144 | *.iws 145 | *.ipr 146 | 147 | # Environment variables 148 | .env 149 | .env.local 150 | .env.*.local 151 | 152 | # Debug logs 153 | *.log 154 | 155 | # Generated files 156 | *.generated.swift 157 | 158 | # Test coverage 159 | *.xcresult 160 | coverage/ 161 | 162 | # Project specific files to ignore 163 | CLAUDE.md 164 | GEMINI.md 165 | specs/ 166 | requirements/ 167 | citadel/ -------------------------------------------------------------------------------- /MobileCode/Services/SSH/PrivateKeyAuthenticationDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrivateKeyAuthenticationDelegate.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: SSH private key authentication delegate for SwiftNIO SSH 6 | // - Supports Ed25519, P256, P384, P521 key types 7 | // - Handles passphrase-protected keys 8 | // - Integrates with KeychainManager for secure key storage 9 | // 10 | 11 | import Foundation 12 | import NIO 13 | @preconcurrency import NIOSSH 14 | 15 | /// Private key authentication delegate for SSH connections 16 | final class PrivateKeyAuthenticationDelegate: NIOSSHClientUserAuthenticationDelegate { 17 | private let username: String 18 | private let keyId: UUID 19 | private let passphraseProvider: () async throws -> String? 20 | 21 | /// Initialize with SSH key information 22 | /// - Parameters: 23 | /// - username: SSH username 24 | /// - keyId: UUID of the SSH key stored in Keychain 25 | /// - passphraseProvider: Closure to provide passphrase if needed 26 | init(username: String, keyId: UUID, passphraseProvider: @escaping () async throws -> String? = { nil }) { 27 | self.username = username 28 | self.keyId = keyId 29 | self.passphraseProvider = passphraseProvider 30 | } 31 | 32 | func nextAuthenticationType( 33 | availableMethods: NIOSSHAvailableUserAuthenticationMethods, 34 | nextChallengePromise: EventLoopPromise 35 | ) { 36 | // Check if public key authentication is available 37 | guard availableMethods.contains(.publicKey) else { 38 | SSHLogger.log("Public key authentication not available", level: .warning) 39 | nextChallengePromise.succeed(nil) 40 | return 41 | } 42 | 43 | // Load the private key from Keychain 44 | Task { 45 | do { 46 | // Retrieve the private key data from keychain 47 | let privateKeyData = try KeychainManager.shared.retrieveSSHKey(for: keyId) 48 | 49 | // Check if we have a stored passphrase 50 | var passphrase = KeychainManager.shared.retrieveSSHKeyPassphrase(for: keyId) 51 | 52 | // If no stored passphrase, ask the provider 53 | if passphrase == nil { 54 | passphrase = try await passphraseProvider() 55 | } 56 | 57 | // Parse the private key using SSHKeyParser 58 | let parseResult = try SSHKeyParserV2.parse(keyData: privateKeyData, passphrase: passphrase) 59 | let privateKey = parseResult.privateKey 60 | 61 | // Create the authentication offer 62 | let offer = NIOSSHUserAuthenticationOffer( 63 | username: username, 64 | serviceName: "ssh-connection", 65 | offer: .privateKey(.init(privateKey: privateKey)) 66 | ) 67 | 68 | nextChallengePromise.succeed(offer) 69 | 70 | } catch { 71 | SSHLogger.log("Failed to load private key: \(error)", level: .error) 72 | nextChallengePromise.succeed(nil) 73 | } 74 | } 75 | } 76 | 77 | } 78 | 79 | /// Extension to detect SSH key type from key data 80 | extension PrivateKeyAuthenticationDelegate { 81 | /// Detect the type of SSH key from its content 82 | /// - Parameter data: Private key data 83 | /// - Returns: Key type string (e.g., "Ed25519", "P256", etc.) 84 | static func detectKeyType(from data: Data) -> String? { 85 | // Use SSHKeyParser's detection 86 | return SSHKeyParserV2.detectKeyType(from: data) 87 | } 88 | 89 | /// Validate if a key type is supported 90 | /// - Parameter keyType: String 91 | /// - Returns: True if supported 92 | static func isKeyTypeSupported(_ keyType: String) -> Bool { 93 | let supportedTypes = ["Ed25519", "P256", "P384", "P521", "ECDSA"] 94 | return supportedTypes.contains(keyType) 95 | } 96 | } -------------------------------------------------------------------------------- /MobileCode/Utils/PathUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PathUtils.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Utility functions for path manipulation 6 | // - Tilde expansion 7 | // - Path normalization 8 | // 9 | 10 | import Foundation 11 | 12 | /// Utility functions for path manipulation 13 | struct PathUtils { 14 | 15 | /// Expand tilde (~) in a path to the full home directory path 16 | /// - Parameters: 17 | /// - path: Path that may contain ~ 18 | /// - homeDirectory: The user's home directory path 19 | /// - Returns: Expanded path with ~ replaced by home directory 20 | static func expandTilde(_ path: String, homeDirectory: String) -> String { 21 | guard path.hasPrefix("~") else { 22 | return path 23 | } 24 | 25 | // Handle just "~" 26 | if path == "~" { 27 | return homeDirectory 28 | } 29 | 30 | // Handle "~/..." 31 | if path.hasPrefix("~/") { 32 | let relativePath = String(path.dropFirst(2)) 33 | return homeDirectory + "/" + relativePath 34 | } 35 | 36 | // For other cases like "~username", just return as-is 37 | // (not commonly used on mobile app servers) 38 | return path 39 | } 40 | 41 | /// Normalize a path by removing redundant slashes and resolving . and .. 42 | /// - Parameter path: Path to normalize 43 | /// - Returns: Normalized path 44 | static func normalize(_ path: String) -> String { 45 | // Split by "/" and filter out empty components 46 | let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) 47 | 48 | // Handle absolute paths 49 | let isAbsolute = path.hasPrefix("/") 50 | 51 | // Process . and .. 52 | var normalizedComponents: [String] = [] 53 | for component in components { 54 | if component == "." { 55 | // Skip current directory references 56 | continue 57 | } else if component == ".." { 58 | // Go up one directory if possible 59 | if !normalizedComponents.isEmpty && normalizedComponents.last != ".." { 60 | normalizedComponents.removeLast() 61 | } else if !isAbsolute { 62 | // For relative paths, keep the .. 63 | normalizedComponents.append(component) 64 | } 65 | } else { 66 | normalizedComponents.append(component) 67 | } 68 | } 69 | 70 | // Reconstruct the path 71 | var result = normalizedComponents.joined(separator: "/") 72 | if isAbsolute { 73 | result = "/" + result 74 | } 75 | 76 | // Ensure we don't return an empty string 77 | if result.isEmpty { 78 | result = isAbsolute ? "/" : "." 79 | } 80 | 81 | return result 82 | } 83 | 84 | /// Join path components safely 85 | /// - Parameter components: Path components to join 86 | /// - Returns: Joined path 87 | static func join(_ components: String...) -> String { 88 | return components.reduce("") { result, component in 89 | if result.isEmpty { 90 | return component 91 | } 92 | 93 | let resultEndsWithSlash = result.hasSuffix("/") 94 | let componentStartsWithSlash = component.hasPrefix("/") 95 | 96 | if resultEndsWithSlash && componentStartsWithSlash { 97 | // Remove duplicate slash 98 | return result + String(component.dropFirst()) 99 | } else if !resultEndsWithSlash && !componentStartsWithSlash { 100 | // Add missing slash 101 | return result + "/" + component 102 | } else { 103 | // One slash present, just concatenate 104 | return result + component 105 | } 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /MobileCode/Views/SSHKeys/AddSSHKeySheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddSSHKeySheet.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-08-12. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftData 10 | 11 | struct AddSSHKeySheet: View { 12 | @Environment(\.dismiss) private var dismiss 13 | @State private var selectedOption: KeyOption = .import 14 | @State private var showingImportSheet = false 15 | @State private var showingGenerateSheet = false 16 | 17 | enum KeyOption: String, CaseIterable { 18 | case `import` = "Import Existing Key" 19 | case generate = "Generate New Key" 20 | 21 | var icon: String { 22 | switch self { 23 | case .import: return "square.and.arrow.down" 24 | case .generate: return "key.fill" 25 | } 26 | } 27 | 28 | var description: String { 29 | switch self { 30 | case .import: return "Import an existing SSH private key from a file or paste it directly" 31 | case .generate: return "Generate a new Ed25519 SSH key pair" 32 | } 33 | } 34 | } 35 | 36 | var body: some View { 37 | NavigationStack { 38 | VStack(spacing: 20) { 39 | Text("How would you like to add an SSH key?") 40 | .font(.headline) 41 | .padding(.top, 20) 42 | 43 | VStack(spacing: 16) { 44 | ForEach(KeyOption.allCases, id: \.self) { option in 45 | Button(action: { 46 | selectedOption = option 47 | if option == .import { 48 | showingImportSheet = true 49 | } else { 50 | showingGenerateSheet = true 51 | } 52 | }) { 53 | HStack { 54 | Image(systemName: option.icon) 55 | .font(.title2) 56 | .frame(width: 40) 57 | 58 | VStack(alignment: .leading, spacing: 4) { 59 | Text(option.rawValue) 60 | .font(.headline) 61 | .foregroundColor(.primary) 62 | 63 | Text(option.description) 64 | .font(.caption) 65 | .foregroundColor(.secondary) 66 | .multilineTextAlignment(.leading) 67 | } 68 | 69 | Spacer() 70 | 71 | Image(systemName: "chevron.right") 72 | .font(.caption) 73 | .foregroundColor(.secondary) 74 | } 75 | .padding() 76 | .background(Color(.secondarySystemBackground)) 77 | .cornerRadius(12) 78 | } 79 | .buttonStyle(PlainButtonStyle()) 80 | } 81 | } 82 | .padding(.horizontal) 83 | 84 | Spacer() 85 | } 86 | .navigationTitle("Add SSH Key") 87 | .navigationBarTitleDisplayMode(.inline) 88 | .toolbar { 89 | ToolbarItem(placement: .cancellationAction) { 90 | Button("Cancel") { 91 | dismiss() 92 | } 93 | } 94 | } 95 | .sheet(isPresented: $showingImportSheet, onDismiss: { 96 | // When import sheet dismisses, also dismiss this sheet 97 | // This ensures we return directly to settings 98 | dismiss() 99 | }) { 100 | ImportSSHKeySheet() 101 | } 102 | .sheet(isPresented: $showingGenerateSheet, onDismiss: { 103 | // When generate sheet dismisses, also dismiss this sheet 104 | // This ensures we return directly to settings 105 | dismiss() 106 | }) { 107 | GenerateSSHKeySheet() 108 | } 109 | } 110 | } 111 | } 112 | 113 | #Preview { 114 | AddSSHKeySheet() 115 | .modelContainer(for: [SSHKey.self], inMemory: true) 116 | } -------------------------------------------------------------------------------- /MobileCode/Models/Message.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Message.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-06-10. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | @Model 12 | final class Message { 13 | var id: UUID = UUID() 14 | var content: String = "" 15 | var role: MessageRole = MessageRole.user 16 | var timestamp: Date = Date() 17 | var projectId: UUID? 18 | 19 | // Store original JSON response from Claude for future UI rendering 20 | var originalJSON: Data? 21 | 22 | // Track if message streaming is complete 23 | var isComplete: Bool = true 24 | 25 | // Track if message is currently streaming 26 | var isStreaming: Bool = false 27 | 28 | init(content: String = "", role: MessageRole = .user, projectId: UUID? = nil, originalJSON: Data? = nil, isComplete: Bool = true, isStreaming: Bool = false) { 29 | self.id = UUID() 30 | self.content = content 31 | self.role = role 32 | self.projectId = projectId 33 | self.timestamp = Date() 34 | self.originalJSON = originalJSON 35 | self.isComplete = isComplete 36 | self.isStreaming = isStreaming 37 | } 38 | 39 | // Computed property to decode original JSON when needed 40 | var originalResponse: [String: Any]? { 41 | guard let data = originalJSON else { return nil } 42 | return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] 43 | } 44 | 45 | // Computed property to parse message structure (legacy single message) 46 | var structuredContent: StructuredMessageContent? { 47 | guard let data = originalJSON else { return nil } 48 | do { 49 | let decoder = JSONDecoder() 50 | return try decoder.decode(StructuredMessageContent.self, from: data) 51 | } catch { 52 | // Try parsing as array for new format 53 | if let messages = structuredMessages, let first = messages.first { 54 | return first 55 | } 56 | print("Failed to decode structured content: \(error)") 57 | return nil 58 | } 59 | } 60 | 61 | // New property to handle array of structured messages 62 | var structuredMessages: [StructuredMessageContent]? { 63 | guard let data = originalJSON else { return nil } 64 | 65 | // Try to parse as array of JSON lines 66 | let jsonString = String(data: data, encoding: .utf8) ?? "" 67 | let lines = jsonString.split(separator: "\n").map { String($0) } 68 | 69 | var messages: [StructuredMessageContent] = [] 70 | 71 | for line in lines { 72 | guard !line.isEmpty, 73 | let lineData = line.data(using: .utf8) else { continue } 74 | 75 | do { 76 | let decoder = JSONDecoder() 77 | let message = try decoder.decode(StructuredMessageContent.self, from: lineData) 78 | messages.append(message) 79 | } catch { 80 | print("Failed to decode line: \(error)") 81 | } 82 | } 83 | 84 | return messages.isEmpty ? nil : messages 85 | } 86 | 87 | // Helper to determine message type 88 | var messageType: MessageType { 89 | guard let structured = structuredContent else { 90 | return .plainText 91 | } 92 | 93 | switch structured.type { 94 | case "user": 95 | return .user 96 | case "assistant": 97 | return .assistant 98 | case "system": 99 | return .system 100 | case "result": 101 | return .result 102 | default: 103 | return .plainText 104 | } 105 | } 106 | 107 | // Display function that returns text content for UI rendering 108 | func displayText() -> String { 109 | // If we have original JSON, return its pretty-printed representation 110 | if let data = originalJSON, 111 | let json = try? JSONSerialization.jsonObject(with: data, options: []), 112 | let prettyData = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]), 113 | let prettyString = String(data: prettyData, encoding: .utf8) { 114 | return prettyString 115 | } 116 | 117 | // Otherwise return the plain text content 118 | return content 119 | } 120 | } 121 | 122 | enum MessageRole: Codable { 123 | case user 124 | case assistant 125 | } 126 | 127 | enum MessageType { 128 | case plainText 129 | case user 130 | case assistant 131 | case system 132 | case result 133 | } 134 | 135 | // Extension for Identifiable conformance 136 | extension Message: Identifiable {} 137 | -------------------------------------------------------------------------------- /MobileCode/Views/Components/DiffView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiffView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-06. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DiffView: View { 11 | let oldString: String 12 | let newString: String 13 | 14 | var body: some View { 15 | ScrollView(.horizontal, showsIndicators: true) { 16 | VStack(alignment: .leading, spacing: 4) { 17 | ForEach(Array(diffLines.enumerated()), id: \.offset) { _, line in 18 | DiffLineView(line: line) 19 | } 20 | } 21 | .font(.system(.caption, design: .monospaced)) 22 | } 23 | } 24 | 25 | private var diffLines: [DiffLine] { 26 | let oldLines = oldString.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) 27 | let newLines = newString.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) 28 | 29 | var result: [DiffLine] = [] 30 | var i = 0 31 | var j = 0 32 | 33 | // Simple diff algorithm - just show changes line by line 34 | while i < oldLines.count || j < newLines.count { 35 | if i >= oldLines.count { 36 | // Remaining new lines 37 | result.append(.added(newLines[j])) 38 | j += 1 39 | } else if j >= newLines.count { 40 | // Remaining old lines 41 | result.append(.removed(oldLines[i])) 42 | i += 1 43 | } else if oldLines[i] == newLines[j] { 44 | // Same line 45 | if result.count < 3 || i >= oldLines.count - 3 || j >= newLines.count - 3 { 46 | result.append(.unchanged(oldLines[i])) 47 | } else if result.last?.isUnchanged != true { 48 | result.append(.ellipsis) 49 | } 50 | i += 1 51 | j += 1 52 | } else { 53 | // Different lines 54 | result.append(.removed(oldLines[i])) 55 | result.append(.added(newLines[j])) 56 | i += 1 57 | j += 1 58 | } 59 | } 60 | 61 | return result 62 | } 63 | } 64 | 65 | enum DiffLine { 66 | case added(String) 67 | case removed(String) 68 | case unchanged(String) 69 | case ellipsis 70 | 71 | var isUnchanged: Bool { 72 | if case .unchanged = self { return true } 73 | return false 74 | } 75 | } 76 | 77 | struct DiffLineView: View { 78 | let line: DiffLine 79 | 80 | var body: some View { 81 | HStack(spacing: 4) { 82 | Text(prefix) 83 | .foregroundColor(prefixColor) 84 | .frame(width: 12, alignment: .center) 85 | 86 | Text(content) 87 | .foregroundColor(contentColor) 88 | .lineLimit(1) 89 | } 90 | .padding(.vertical, 1) 91 | .fixedSize(horizontal: true, vertical: false) 92 | } 93 | 94 | private var prefix: String { 95 | switch line { 96 | case .added: return "+" 97 | case .removed: return "-" 98 | case .unchanged: return " " 99 | case .ellipsis: return "..." 100 | } 101 | } 102 | 103 | private var prefixColor: Color { 104 | switch line { 105 | case .added: return .green 106 | case .removed: return .red 107 | case .unchanged, .ellipsis: return .secondary 108 | } 109 | } 110 | 111 | private var content: String { 112 | switch line { 113 | case .added(let text), .removed(let text), .unchanged(let text): 114 | return text 115 | case .ellipsis: 116 | return "" 117 | } 118 | } 119 | 120 | private var contentColor: Color { 121 | switch line { 122 | case .added: return .green 123 | case .removed: return .red 124 | case .unchanged: return .primary 125 | case .ellipsis: return .secondary 126 | } 127 | } 128 | } 129 | 130 | #Preview { 131 | VStack(spacing: 20) { 132 | DiffView( 133 | oldString: "def hello():\n print('Hello')\n return True", 134 | newString: "def hello():\n print('Hello World')\n return True" 135 | ) 136 | .padding() 137 | .background(Color(.systemGray6)) 138 | .cornerRadius(8) 139 | 140 | DiffView( 141 | oldString: "@app.get('/users')\ndef get_users():\n return users", 142 | newString: "@app.get('/users')\ndef get_users(current_user: User = Depends(auth)):\n return users" 143 | ) 144 | .padding() 145 | .background(Color(.systemGray6)) 146 | .cornerRadius(8) 147 | } 148 | .padding() 149 | } -------------------------------------------------------------------------------- /MobileCode/Views/Chat/ClaudeNotInstalledView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClaudeNotInstalledView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-11. 6 | // 7 | // Purpose: Display installation instructions when Claude CLI is not installed 8 | // 9 | 10 | import SwiftUI 11 | 12 | struct ClaudeNotInstalledView: View { 13 | let server: Server 14 | @State private var isChecking = false 15 | @State private var showCopiedFeedback = false 16 | 17 | var body: some View { 18 | VStack(spacing: 20) { 19 | // Icon 20 | Image(systemName: "exclamationmark.triangle") 21 | .font(.system(size: 60)) 22 | .foregroundColor(.orange) 23 | 24 | // Title 25 | Text("Claude CLI Not Installed") 26 | .font(.title2) 27 | .fontWeight(.medium) 28 | 29 | // Instructions 30 | VStack(alignment: .leading, spacing: 8) { 31 | Text("To use Claude chat, install Claude CLI on your server:") 32 | .font(.footnote) 33 | .foregroundColor(.secondary) 34 | .multilineTextAlignment(.center) 35 | 36 | // Command box with copy button 37 | HStack { 38 | Text("npm install -g @anthropic-ai/claude-code") 39 | .font(.system(.footnote, design: .monospaced)) 40 | .foregroundColor(.primary) 41 | .padding(.vertical, 4) 42 | 43 | Spacer() 44 | 45 | Button(action: copyCommand) { 46 | Text(showCopiedFeedback ? "Copied!" : "Copy") 47 | .font(.footnote) 48 | .fontWeight(.medium) 49 | .foregroundColor(.white) 50 | .padding(.horizontal, 12) 51 | .padding(.vertical, 4) 52 | .background(showCopiedFeedback ? Color.green : Color.blue) 53 | .cornerRadius(6) 54 | .animation(.easeInOut(duration: 0.2), value: showCopiedFeedback) 55 | } 56 | .buttonStyle(PlainButtonStyle()) 57 | } 58 | .padding() 59 | .background(Color(.secondarySystemBackground)) 60 | .cornerRadius(8) 61 | } 62 | .frame(maxWidth: 400) 63 | 64 | // Check Again button 65 | Button(action: checkInstallation) { 66 | HStack { 67 | if isChecking { 68 | ProgressView() 69 | .progressViewStyle(CircularProgressViewStyle()) 70 | .scaleEffect(0.8) 71 | } 72 | Text(isChecking ? "Checking..." : "Check Again") 73 | .fontWeight(.medium) 74 | } 75 | .foregroundColor(.white) 76 | .padding(.horizontal, 24) 77 | .padding(.vertical, 12) 78 | .background(Color.blue) 79 | .cornerRadius(8) 80 | } 81 | .disabled(isChecking) 82 | 83 | // Additional help text 84 | Text("After installation, tap 'Check Again' to verify") 85 | .font(.caption) 86 | .foregroundColor(.secondary) 87 | } 88 | .padding() 89 | .frame(maxWidth: .infinity, maxHeight: .infinity) 90 | .background(Color(.systemBackground)) 91 | } 92 | 93 | private func copyCommand() { 94 | UIPasteboard.general.string = "npm install -g @anthropic-ai/claude-code" 95 | 96 | // Show feedback 97 | withAnimation { 98 | showCopiedFeedback = true 99 | } 100 | 101 | // Reset after delay 102 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 103 | withAnimation { 104 | showCopiedFeedback = false 105 | } 106 | } 107 | } 108 | 109 | private func checkInstallation() { 110 | isChecking = true 111 | 112 | Task { 113 | // Perform the installation check 114 | _ = await ClaudeCodeService.shared.checkClaudeInstallation(for: server) 115 | 116 | // Reset checking state 117 | await MainActor.run { 118 | isChecking = false 119 | } 120 | } 121 | } 122 | } 123 | 124 | #Preview { 125 | ClaudeNotInstalledView(server: Server( 126 | name: "Demo Server", 127 | host: "demo.example.com", 128 | port: 22, 129 | username: "user", 130 | authMethodType: "password" 131 | )) 132 | } -------------------------------------------------------------------------------- /MobileCode/Services/SSH/SSHKeyFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SSHKeyFormatter.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Utility class for formatting SSH public keys in OpenSSH format 6 | // 7 | 8 | import Foundation 9 | import Crypto 10 | 11 | /// Utility class for formatting SSH keys in various formats 12 | class SSHKeyFormatter { 13 | 14 | /// Format an Ed25519 public key in OpenSSH format 15 | /// - Parameters: 16 | /// - keyData: The raw public key data (32 bytes) 17 | /// - name: The key name/comment to append 18 | /// - Returns: OpenSSH formatted public key string 19 | static func formatEd25519PublicKey(_ keyData: Data, name: String) -> String { 20 | // Build SSH format for Ed25519 21 | var data = Data() 22 | 23 | // Add key type 24 | let keyType = "ssh-ed25519" 25 | let typeData = keyType.data(using: .utf8)! 26 | var typeLength = UInt32(typeData.count).bigEndian 27 | data.append(Data(bytes: &typeLength, count: 4)) 28 | data.append(typeData) 29 | 30 | // Add public key 31 | var keyLength = UInt32(keyData.count).bigEndian 32 | data.append(Data(bytes: &keyLength, count: 4)) 33 | data.append(keyData) 34 | 35 | return "\(keyType) \(data.base64EncodedString()) \(name)" 36 | } 37 | 38 | /// Format an ECDSA public key in OpenSSH format 39 | /// - Parameters: 40 | /// - keyData: The raw public key data 41 | /// - curve: The curve identifier (nistp256, nistp384, nistp521) 42 | /// - name: The key name/comment to append 43 | /// - Returns: OpenSSH formatted public key string 44 | static func formatECDSAPublicKey(_ keyData: Data, curve: String, name: String) -> String { 45 | // Build SSH format for ECDSA 46 | var data = Data() 47 | 48 | // Add key type 49 | let keyType = "ecdsa-sha2-\(curve)" 50 | let typeData = keyType.data(using: .utf8)! 51 | var typeLength = UInt32(typeData.count).bigEndian 52 | data.append(Data(bytes: &typeLength, count: 4)) 53 | data.append(typeData) 54 | 55 | // Add curve identifier 56 | let curveData = curve.data(using: .utf8)! 57 | var curveLength = UInt32(curveData.count).bigEndian 58 | data.append(Data(bytes: &curveLength, count: 4)) 59 | data.append(curveData) 60 | 61 | // Add public key (for ECDSA, it needs to be in uncompressed format: 0x04 || x || y) 62 | // The rawRepresentation from swift-crypto gives us just x || y (64 bytes for P256) 63 | // We need to add the 0x04 prefix for SSH format 64 | var publicKeyWithPrefix = Data([0x04]) 65 | publicKeyWithPrefix.append(keyData) 66 | 67 | var keyLength = UInt32(publicKeyWithPrefix.count).bigEndian 68 | data.append(Data(bytes: &keyLength, count: 4)) 69 | data.append(publicKeyWithPrefix) 70 | 71 | return "\(keyType) \(data.base64EncodedString()) \(name)" 72 | } 73 | 74 | /// Extract and format public key from private key data 75 | /// - Parameters: 76 | /// - privateKeyData: The private key data 77 | /// - keyType: The key type (Ed25519, P256, P384, P521) 78 | /// - passphrase: Optional passphrase for encrypted keys 79 | /// - name: The key name/comment to append 80 | /// - Returns: OpenSSH formatted public key string, or nil if extraction fails 81 | static func extractPublicKey(from privateKeyData: Data, keyType: String, passphrase: String? = nil, name: String) -> String? { 82 | do { 83 | // Parse the private key to get access to the key object and public key data 84 | let parseResult = try SSHKeyParserV2.parse(keyData: privateKeyData, passphrase: passphrase) 85 | 86 | // Use the public key data from the parse result 87 | guard let publicKeyData = parseResult.publicKeyData else { 88 | print("No public key data available for key type: \(keyType)") 89 | return nil 90 | } 91 | 92 | // Format the public key based on type 93 | switch keyType { 94 | case "Ed25519": 95 | return formatEd25519PublicKey(publicKeyData, name: name) 96 | 97 | case "P256": 98 | return formatECDSAPublicKey(publicKeyData, curve: "nistp256", name: name) 99 | 100 | case "P384": 101 | return formatECDSAPublicKey(publicKeyData, curve: "nistp384", name: name) 102 | 103 | case "P521": 104 | return formatECDSAPublicKey(publicKeyData, curve: "nistp521", name: name) 105 | 106 | default: 107 | print("Unsupported key type for public key extraction: \(keyType)") 108 | return nil 109 | } 110 | } catch { 111 | print("Failed to extract public key: \(error)") 112 | return nil 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /MobileCode/Views/MCP/MCPServerRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCPServerRow.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Row component for displaying an MCP server with status 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MCPServerRow: View { 11 | let server: MCPServer 12 | 13 | private var statusColor: Color { 14 | switch server.status { 15 | case .connected: 16 | return .green 17 | case .disconnected: 18 | return .red 19 | case .unknown: 20 | return .gray 21 | case .checking: 22 | return .blue 23 | } 24 | } 25 | 26 | private var statusIcon: String { 27 | switch server.status { 28 | case .connected: 29 | return "checkmark.circle.fill" 30 | case .disconnected: 31 | return "xmark.circle.fill" 32 | case .unknown: 33 | return "questionmark.circle.fill" 34 | case .checking: 35 | return "arrow.triangle.2.circlepath.circle.fill" 36 | } 37 | } 38 | 39 | var body: some View { 40 | HStack(spacing: 12) { 41 | // Status indicator 42 | Image(systemName: statusIcon) 43 | .foregroundColor(statusColor) 44 | .font(.title3) 45 | .frame(width: 24) 46 | 47 | // Server details 48 | VStack(alignment: .leading, spacing: 4) { 49 | Text(cleanServerName(server.name)) 50 | .font(.headline) 51 | .lineLimit(1) 52 | 53 | Text(server.fullCommand) 54 | .font(.caption) 55 | .foregroundColor(.secondary) 56 | .lineLimit(1) 57 | .truncationMode(.middle) 58 | 59 | if !server.env.isNilOrEmpty { 60 | HStack(spacing: 4) { 61 | Image(systemName: "key.fill") 62 | .font(.caption2) 63 | Text("\(server.env!.count) env var\(server.env!.count == 1 ? "" : "s")") 64 | .font(.caption2) 65 | } 66 | .foregroundColor(.secondary) 67 | } 68 | } 69 | 70 | Spacer() 71 | 72 | // Status text 73 | VStack(alignment: .trailing, spacing: 2) { 74 | Text(server.status.displayText) 75 | .font(.caption) 76 | .foregroundColor(server.status == .checking ? .primary : statusColor) 77 | 78 | if server.status == .checking { 79 | ProgressView() 80 | .scaleEffect(0.7) 81 | } 82 | 83 | if server.isRemote { 84 | Label("Remote", systemImage: "network") 85 | .font(.caption2) 86 | .foregroundColor(.blue) 87 | } 88 | } 89 | 90 | // Edit indicator 91 | Image(systemName: "chevron.right") 92 | .font(.caption) 93 | .foregroundColor(.secondary) 94 | } 95 | .padding(.vertical, 4) 96 | } 97 | 98 | private func cleanServerName(_ name: String) -> String { 99 | // Remove (HTTP) or (SSE) suffixes from server names 100 | let cleaned = name 101 | .replacingOccurrences(of: " (HTTP)", with: "") 102 | .replacingOccurrences(of: " (SSE)", with: "") 103 | .replacingOccurrences(of: "(HTTP)", with: "") 104 | .replacingOccurrences(of: "(SSE)", with: "") 105 | return cleaned.trimmingCharacters(in: .whitespaces) 106 | } 107 | } 108 | 109 | // MARK: - Helper Extensions 110 | private extension Optional where Wrapped: Collection { 111 | var isNilOrEmpty: Bool { 112 | return self?.isEmpty ?? true 113 | } 114 | } 115 | 116 | #Preview { 117 | List { 118 | MCPServerRow(server: MCPServer( 119 | name: "firecrawl", 120 | command: "npx", 121 | args: ["-y", "firecrawl-mcp"], 122 | env: nil, 123 | status: .connected 124 | )) 125 | 126 | MCPServerRow(server: MCPServer( 127 | name: "sqlite", 128 | command: "uv", 129 | args: ["--directory", "/Users/path/to/project", "run", "mcp-server-sqlite"], 130 | env: ["DB_PATH": "/path/to/db.sqlite"], 131 | status: .disconnected 132 | )) 133 | 134 | MCPServerRow(server: MCPServer( 135 | name: "playwright", 136 | command: "npx", 137 | args: ["@playwright/mcp@latest"], 138 | env: nil, 139 | status: .checking 140 | )) 141 | 142 | MCPServerRow(server: MCPServer( 143 | name: "remote-api", 144 | command: "https://api.example.com/mcp", 145 | args: nil, 146 | env: nil, 147 | status: .connected 148 | )) 149 | } 150 | .listStyle(InsetGroupedListStyle()) 151 | } -------------------------------------------------------------------------------- /MobileCode/Services/SSHTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SSHTypes.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Common SSH-related types and protocols 6 | // - Shared types used across SSH services 7 | // - Error definitions 8 | // - Protocol definitions 9 | // 10 | 11 | import Foundation 12 | 13 | /// Connection key for SSH connection pooling 14 | struct ConnectionKey: Hashable, CustomStringConvertible { 15 | let projectId: UUID 16 | let purpose: ConnectionPurpose 17 | 18 | var description: String { 19 | "\(projectId)_\(purpose.rawValue)" 20 | } 21 | } 22 | 23 | /// SSH-related errors 24 | enum SSHError: LocalizedError { 25 | case notConnected 26 | case connectionFailed(String) 27 | case authenticationFailed 28 | case commandFailed(String) 29 | case fileTransferFailed(String) 30 | 31 | var errorDescription: String? { 32 | switch self { 33 | case .notConnected: 34 | return "Not connected to server" 35 | case .connectionFailed(let reason): 36 | return "Connection failed: \(reason)" 37 | case .authenticationFailed: 38 | return "Authentication failed" 39 | case .commandFailed(let reason): 40 | return "Command failed: \(reason)" 41 | case .fileTransferFailed(let reason): 42 | return "File transfer failed: \(reason)" 43 | } 44 | } 45 | } 46 | 47 | /// Remote file information 48 | struct RemoteFile { 49 | let name: String 50 | let path: String 51 | let isDirectory: Bool 52 | let size: Int64? 53 | let modificationDate: Date? 54 | let permissions: String? 55 | } 56 | 57 | /// SSH Session protocol - represents an active SSH connection 58 | protocol SSHSession { 59 | /// Execute a command with user's shell environment (default - loads .zshrc/.bashrc) 60 | func execute(_ command: String) async throws -> String 61 | 62 | /// Execute a command without shell (raw execution - no PATH or environment setup) 63 | func executeRaw(_ command: String) async throws -> String 64 | 65 | /// Start a long-running process with user's shell environment (default) 66 | func startProcess(_ command: String) async throws -> ProcessHandle 67 | 68 | /// Start a long-running process without shell (raw execution) 69 | func startProcessRaw(_ command: String) async throws -> ProcessHandle 70 | 71 | /// Upload a file to the server 72 | func uploadFile(localPath: URL, remotePath: String) async throws 73 | 74 | /// Download a file from the server 75 | func downloadFile(remotePath: String, localPath: URL) async throws 76 | 77 | /// Read file content from remote 78 | /// - Parameter remotePath: Path on remote server 79 | /// - Returns: File content as string 80 | func readFile(_ remotePath: String) async throws -> String 81 | 82 | /// List files in a directory 83 | func listDirectory(_ path: String) async throws -> [RemoteFile] 84 | 85 | /// Close the session 86 | func disconnect() 87 | } 88 | 89 | /// Handle for a running process 90 | protocol ProcessHandle { 91 | /// Send input to the process 92 | func sendInput(_ text: String) async throws 93 | 94 | /// Read output from the process 95 | func readOutput() async throws -> String 96 | 97 | /// Read output as a stream 98 | func outputStream() -> AsyncThrowingStream 99 | 100 | /// Check if process is still running 101 | var isRunning: Bool { get } 102 | 103 | /// Kill the process 104 | func terminate() 105 | } 106 | 107 | /// Logging levels for SSH operations 108 | enum SSHLogLevel: Int { 109 | case none = 0 110 | case error = 1 111 | case warning = 2 112 | case info = 3 113 | case debug = 4 114 | case verbose = 5 115 | } 116 | 117 | /// SSH Logger configuration 118 | struct SSHLogger { 119 | static var logLevel: SSHLogLevel = .info 120 | 121 | static func log(_ message: String, level: SSHLogLevel = .info, file: String = #file, function: String = #function, line: Int = #line) { 122 | guard level.rawValue <= logLevel.rawValue else { return } 123 | 124 | let fileName = URL(fileURLWithPath: file).lastPathComponent 125 | let prefix: String 126 | 127 | switch level { 128 | case .none: 129 | return 130 | case .error: 131 | prefix = "❌" 132 | case .warning: 133 | prefix = "⚠️" 134 | case .info: 135 | prefix = "ℹ️" 136 | case .debug: 137 | prefix = "🔍" 138 | case .verbose: 139 | prefix = "📝" 140 | } 141 | 142 | print("\(prefix) SSH [\(fileName):\(line)] \(message)") 143 | } 144 | } 145 | 146 | /// Helper to clean SSH output 147 | func cleanSSHOutput(_ output: String) -> String { 148 | let lines = output.components(separatedBy: .newlines) 149 | let cleanedLines = lines.filter { line in 150 | let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) 151 | // Filter out common SSH warnings and authentication messages 152 | return !trimmed.isEmpty && 153 | !trimmed.lowercased().contains("password change required") && 154 | !trimmed.lowercased().contains("warning") && 155 | !trimmed.contains("TTY") && 156 | !trimmed.contains("expired") && 157 | !trimmed.contains("Last login") && 158 | !trimmed.contains("authenticity") && 159 | !trimmed.contains("fingerprint") 160 | } 161 | return cleanedLines.joined(separator: "\n") 162 | } -------------------------------------------------------------------------------- /MobileCode/Services/SSH/SSHKeyParsingError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SSHKeyParsingError.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Comprehensive error types for SSH key parsing 6 | // Inspired by Citadel's specific error handling approach 7 | // 8 | 9 | import Foundation 10 | 11 | /// Comprehensive errors for SSH key parsing operations 12 | public enum SSHKeyParsingError: LocalizedError { 13 | // Format errors 14 | case invalidOpenSSHBoundary 15 | case invalidBase64Payload 16 | case invalidPEMBoundary 17 | case unknownKeyFormat 18 | 19 | // Structure errors 20 | case invalidOpenSSHPrefix 21 | case missingPublicKeyBuffer 22 | case missingPrivateKeyBuffer 23 | case missingComment 24 | case invalidCheck 25 | case invalidPadding 26 | 27 | // Key data errors 28 | case invalidKeyData 29 | case invalidPublicKeyInPrivateKey 30 | case invalidKeySize(expected: Int, actual: Int) 31 | case missingKeyComponent(String) 32 | 33 | // Type errors 34 | case unsupportedKeyType(String) 35 | case unsupportedCipher(String) 36 | case unsupportedKDF(String) 37 | case keyTypeMismatch(expected: String, actual: String) 38 | 39 | // Encryption errors 40 | case encryptedKeyRequiresPassphrase 41 | case decryptionFailed 42 | case invalidPassphrase 43 | 44 | // Parsing errors 45 | case parsingFailed(String) 46 | case truncatedData(needed: Int, available: Int) 47 | case malformedStructure(String) 48 | 49 | // Feature errors 50 | case unsupportedFeature(String) 51 | case multipleKeysNotSupported 52 | 53 | public var errorDescription: String? { 54 | switch self { 55 | // Format errors 56 | case .invalidOpenSSHBoundary: 57 | return "Invalid OpenSSH key boundaries. Expected '-----BEGIN OPENSSH PRIVATE KEY-----' and '-----END OPENSSH PRIVATE KEY-----'" 58 | case .invalidBase64Payload: 59 | return "Invalid base64 encoded key data" 60 | case .invalidPEMBoundary: 61 | return "Invalid PEM key boundaries" 62 | case .unknownKeyFormat: 63 | return "Unknown key format. Supported formats: OpenSSH, PEM" 64 | 65 | // Structure errors 66 | case .invalidOpenSSHPrefix: 67 | return "Invalid OpenSSH key prefix. Expected 'openssh-key-v1\\0'" 68 | case .missingPublicKeyBuffer: 69 | return "Missing public key data in OpenSSH key" 70 | case .missingPrivateKeyBuffer: 71 | return "Missing private key data in OpenSSH key" 72 | case .missingComment: 73 | return "Missing comment field in OpenSSH key" 74 | case .invalidCheck: 75 | return "Invalid check bytes in OpenSSH key. Key may be corrupted or passphrase incorrect" 76 | case .invalidPadding: 77 | return "Invalid padding in OpenSSH key" 78 | 79 | // Key data errors 80 | case .invalidKeyData: 81 | return "Invalid key data" 82 | case .invalidPublicKeyInPrivateKey: 83 | return "Public key in private key section doesn't match" 84 | case .invalidKeySize(let expected, let actual): 85 | return "Invalid key size: expected \(expected) bytes, got \(actual) bytes" 86 | case .missingKeyComponent(let component): 87 | return "Missing key component: \(component)" 88 | 89 | // Type errors 90 | case .unsupportedKeyType(let type): 91 | return "Unsupported key type: \(type)" 92 | case .unsupportedCipher(let cipher): 93 | return "Unsupported cipher: \(cipher)" 94 | case .unsupportedKDF(let kdf): 95 | return "Unsupported key derivation function: \(kdf)" 96 | case .keyTypeMismatch(let expected, let actual): 97 | return "Key type mismatch: expected \(expected), got \(actual)" 98 | 99 | // Encryption errors 100 | case .encryptedKeyRequiresPassphrase: 101 | return "This key is encrypted and requires a passphrase" 102 | case .decryptionFailed: 103 | return "Failed to decrypt key. Check your passphrase" 104 | case .invalidPassphrase: 105 | return "Invalid passphrase" 106 | 107 | // Parsing errors 108 | case .parsingFailed(let reason): 109 | return "Failed to parse key: \(reason)" 110 | case .truncatedData(let needed, let available): 111 | return "Truncated data: needed \(needed) bytes, only \(available) available" 112 | case .malformedStructure(let description): 113 | return "Malformed key structure: \(description)" 114 | 115 | // Feature errors 116 | case .unsupportedFeature(let feature): 117 | return "Unsupported feature: \(feature)" 118 | case .multipleKeysNotSupported: 119 | return "Multiple keys in one file are not supported" 120 | } 121 | } 122 | 123 | /// Helper to create user-friendly error messages for common issues 124 | public var userGuidance: String? { 125 | switch self { 126 | case .unsupportedKeyType(let type) where type.contains("ecdsa"): 127 | return "OpenSSH ECDSA keys require conversion to PEM format.\n\nPlease run:\nssh-keygen -p -m PEM -f your_key_file" 128 | case .encryptedKeyRequiresPassphrase: 129 | return "This key is password-protected. Please provide the passphrase when importing." 130 | case .invalidPassphrase, .invalidCheck: 131 | return "The passphrase appears to be incorrect. Please try again." 132 | case .unsupportedKeyType(let type) where type.contains("rsa"): 133 | return "RSA keys are not supported. Please use Ed25519 or ECDSA keys for better security." 134 | default: 135 | return nil 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /MobileCode/ViewModels/FolderPickerViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FolderPickerViewModel.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-22. 6 | // 7 | // Purpose: Manages folder selection for custom project paths 8 | // - Lists directories only (no files) 9 | // - Validates write permissions 10 | // - Navigates directory structure 11 | // 12 | 13 | import SwiftUI 14 | import Observation 15 | 16 | /// Represents a directory entry for folder picking 17 | struct FolderEntry: Identifiable { 18 | let id = UUID() 19 | let name: String 20 | let path: String 21 | let isWritable: Bool 22 | let isHidden: Bool 23 | } 24 | 25 | /// ViewModel for folder picker functionality 26 | @MainActor 27 | @Observable 28 | class FolderPickerViewModel { 29 | // MARK: - Properties 30 | 31 | /// Current directory entries (folders only) 32 | var folders: [FolderEntry] = [] 33 | 34 | /// Current directory path 35 | var currentPath: String 36 | 37 | /// Initial path when picker was opened 38 | let initialPath: String 39 | 40 | /// Loading state 41 | var isLoading = false 42 | 43 | /// Error message 44 | var errorMessage: String? 45 | 46 | /// Whether current directory is writable 47 | var isCurrentPathWritable = false 48 | 49 | /// SSH Service reference 50 | private let sshService = ServiceManager.shared.sshService 51 | 52 | /// Server to browse 53 | private let server: Server 54 | 55 | // MARK: - Initialization 56 | 57 | init(server: Server, initialPath: String) { 58 | self.server = server 59 | self.initialPath = initialPath 60 | self.currentPath = initialPath 61 | } 62 | 63 | // MARK: - Public Methods 64 | 65 | /// Load folders for current path 66 | func loadFolders() async { 67 | isLoading = true 68 | errorMessage = nil 69 | 70 | do { 71 | // Get SSH connection 72 | let session = try await sshService.connect(to: server) 73 | 74 | // Check if current path is writable 75 | let writeCheck = try await session.execute("test -w '\(currentPath)' && echo 'writable' || echo 'not writable'") 76 | isCurrentPathWritable = writeCheck.trimmingCharacters(in: .whitespaces) == "writable" 77 | 78 | // List all entries in current directory 79 | let remoteFiles = try await session.listDirectory(currentPath) 80 | 81 | // Filter to only directories and check permissions 82 | var folderEntries: [FolderEntry] = [] 83 | 84 | for file in remoteFiles { 85 | // Skip non-directories and special entries 86 | if !file.isDirectory || file.name == "." || file.name == ".." { 87 | continue 88 | } 89 | 90 | // Check if folder is writable 91 | let folderPath = PathUtils.join(currentPath, file.name) 92 | let folderWriteCheck = try await session.execute("test -w '\(folderPath)' && echo 'writable' || echo 'not writable'") 93 | let isWritable = folderWriteCheck.trimmingCharacters(in: .whitespaces) == "writable" 94 | 95 | let entry = FolderEntry( 96 | name: file.name, 97 | path: folderPath, 98 | isWritable: isWritable, 99 | isHidden: file.name.hasPrefix(".") 100 | ) 101 | 102 | // Only include non-hidden directories 103 | if !entry.isHidden { 104 | folderEntries.append(entry) 105 | } 106 | } 107 | 108 | // Sort folders alphabetically 109 | folders = folderEntries.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } 110 | 111 | } catch { 112 | errorMessage = "Failed to load folders: \(error.localizedDescription)" 113 | SSHLogger.log("Failed to load folders: \(error)", level: .error) 114 | } 115 | 116 | isLoading = false 117 | } 118 | 119 | /// Navigate to a specific folder 120 | /// - Parameter path: The folder path to navigate to 121 | func navigateTo(path: String) { 122 | currentPath = path 123 | Task { 124 | await loadFolders() 125 | } 126 | } 127 | 128 | /// Navigate to parent directory 129 | func navigateUp() { 130 | // Don't go above root 131 | guard currentPath != "/" else { return } 132 | 133 | // Get parent directory by removing last component 134 | let components = currentPath.split(separator: "/").map(String.init) 135 | let parentPath: String 136 | if components.count > 1 { 137 | parentPath = "/" + components.dropLast().joined(separator: "/") 138 | } else { 139 | parentPath = "/" 140 | } 141 | 142 | navigateTo(path: parentPath) 143 | } 144 | 145 | /// Get breadcrumb components for current path 146 | var pathComponents: [(name: String, path: String)] { 147 | let components = currentPath.split(separator: "/").map(String.init) 148 | var result: [(name: String, path: String)] = [] 149 | var currentBuildPath = "" 150 | 151 | // Add root 152 | result.append((name: "/", path: "/")) 153 | 154 | // Add each component 155 | for component in components { 156 | currentBuildPath += "/\(component)" 157 | result.append((name: component, path: currentBuildPath)) 158 | } 159 | 160 | return result 161 | } 162 | 163 | /// Check if a path can be selected 164 | /// - Parameter path: The path to check 165 | /// - Returns: true if the path is writable and can be selected 166 | func canSelectPath(_ path: String) -> Bool { 167 | return isCurrentPathWritable 168 | } 169 | } -------------------------------------------------------------------------------- /MobileCode/Services/SSH/OpenSSHProtocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenSSHProtocols.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Protocol definitions for OpenSSH key parsing 6 | // Adapted from Citadel's approach for extensible key support 7 | // 8 | 9 | import Foundation 10 | import NIO 11 | import Crypto 12 | @preconcurrency import NIOSSH 13 | 14 | /// Protocol for OpenSSH private keys that can be parsed from ByteBuffer 15 | protocol OpenSSHPrivateKey: ByteBufferConvertible { 16 | /// The SSH key type prefix for private keys (e.g., "ssh-ed25519") 17 | static var privateKeyPrefix: String { get } 18 | 19 | /// The SSH key type prefix for public keys (should match private) 20 | static var publicKeyPrefix: String { get } 21 | 22 | /// Associated public key type 23 | associatedtype PublicKey: ByteBufferConvertible 24 | } 25 | 26 | /// Extension to make Curve25519.Signing.PrivateKey conform to OpenSSHPrivateKey 27 | extension Curve25519.Signing.PrivateKey: OpenSSHPrivateKey { 28 | static var privateKeyPrefix: String { "ssh-ed25519" } 29 | static var publicKeyPrefix: String { "ssh-ed25519" } 30 | 31 | typealias PublicKey = Curve25519.Signing.PublicKey 32 | 33 | static func read(consuming buffer: inout ByteBuffer) throws -> Self { 34 | // Read public key buffer 35 | guard let publicKeyBuffer = buffer.readSSHBuffer() else { 36 | throw SSHKeyParsingError.missingPublicKeyBuffer 37 | } 38 | 39 | // Read private key buffer 40 | guard var privateKeyBuffer = buffer.readSSHBuffer() else { 41 | throw SSHKeyParsingError.missingPrivateKeyBuffer 42 | } 43 | 44 | // Ed25519 private key is 32 bytes, followed by 32 bytes of public key 45 | guard let privateKeyBytes = privateKeyBuffer.readBytes(length: 32), 46 | let publicKeyBytes = privateKeyBuffer.readBytes(length: 32) else { 47 | throw SSHKeyParsingError.invalidKeyData 48 | } 49 | 50 | // Verify public key matches 51 | let publicKeyData = Data(publicKeyBytes) 52 | if publicKeyBuffer.readableBytes > 0 || publicKeyData != publicKeyBuffer.getData(at: 0, length: publicKeyBuffer.readableBytes) { 53 | // Public key mismatch - but for Ed25519 we can still use the private key 54 | } 55 | 56 | return try Self(rawRepresentation: privateKeyBytes) 57 | } 58 | 59 | func write(to buffer: inout ByteBuffer) -> Int { 60 | let publicKeyData = publicKey.rawRepresentation 61 | var publicKeyBuffer = ByteBuffer(data: publicKeyData) 62 | let n = buffer.writeSSHBuffer(&publicKeyBuffer) 63 | return n + buffer.writeCompositeSSHString { buffer in 64 | let n = buffer.writeData(self.rawRepresentation) 65 | return n + buffer.writeData(publicKeyData) 66 | } 67 | } 68 | } 69 | 70 | /// Extension to make Curve25519.Signing.PublicKey conform to ByteBufferConvertible 71 | extension Curve25519.Signing.PublicKey: ByteBufferConvertible { 72 | static func read(consuming buffer: inout ByteBuffer) throws -> Self { 73 | guard var publicKeyBuffer = buffer.readSSHBuffer() else { 74 | throw SSHKeyParsingError.missingPublicKeyBuffer 75 | } 76 | 77 | guard let keyBytes = publicKeyBuffer.readBytes(length: publicKeyBuffer.readableBytes) else { 78 | throw SSHKeyParsingError.invalidKeyData 79 | } 80 | 81 | return try Self(rawRepresentation: keyBytes) 82 | } 83 | 84 | @discardableResult 85 | func write(to buffer: inout ByteBuffer) -> Int { 86 | buffer.writeData(self.rawRepresentation) 87 | } 88 | } 89 | 90 | /// Placeholder extensions for ECDSA keys - to be implemented with SwiftASN1 91 | extension P256.Signing.PrivateKey: OpenSSHPrivateKey { 92 | static var privateKeyPrefix: String { "ecdsa-sha2-nistp256" } 93 | static var publicKeyPrefix: String { "ecdsa-sha2-nistp256" } 94 | 95 | typealias PublicKey = P256.Signing.PublicKey 96 | 97 | static func read(consuming buffer: inout ByteBuffer) throws -> Self { 98 | try readOpenSSH(consuming: &buffer) 99 | } 100 | 101 | func write(to buffer: inout ByteBuffer) -> Int { 102 | // TODO: Implement if needed 103 | 0 104 | } 105 | } 106 | 107 | extension P384.Signing.PrivateKey: OpenSSHPrivateKey { 108 | static var privateKeyPrefix: String { "ecdsa-sha2-nistp384" } 109 | static var publicKeyPrefix: String { "ecdsa-sha2-nistp384" } 110 | 111 | typealias PublicKey = P384.Signing.PublicKey 112 | 113 | static func read(consuming buffer: inout ByteBuffer) throws -> Self { 114 | try readOpenSSH(consuming: &buffer) 115 | } 116 | 117 | func write(to buffer: inout ByteBuffer) -> Int { 118 | // TODO: Implement if needed 119 | 0 120 | } 121 | } 122 | 123 | extension P521.Signing.PrivateKey: OpenSSHPrivateKey { 124 | static var privateKeyPrefix: String { "ecdsa-sha2-nistp521" } 125 | static var publicKeyPrefix: String { "ecdsa-sha2-nistp521" } 126 | 127 | typealias PublicKey = P521.Signing.PublicKey 128 | 129 | static func read(consuming buffer: inout ByteBuffer) throws -> Self { 130 | try readOpenSSH(consuming: &buffer) 131 | } 132 | 133 | func write(to buffer: inout ByteBuffer) -> Int { 134 | // TODO: Implement if needed 135 | 0 136 | } 137 | } 138 | 139 | // Public key extensions for ECDSA 140 | extension P256.Signing.PublicKey: ByteBufferConvertible { 141 | static func read(consuming buffer: inout ByteBuffer) throws -> Self { 142 | throw SSHKeyParsingError.unsupportedKeyType("ECDSA public key parsing not implemented") 143 | } 144 | 145 | func write(to buffer: inout ByteBuffer) -> Int { 146 | 0 147 | } 148 | } 149 | 150 | extension P384.Signing.PublicKey: ByteBufferConvertible { 151 | static func read(consuming buffer: inout ByteBuffer) throws -> Self { 152 | throw SSHKeyParsingError.unsupportedKeyType("ECDSA public key parsing not implemented") 153 | } 154 | 155 | func write(to buffer: inout ByteBuffer) -> Int { 156 | 0 157 | } 158 | } 159 | 160 | extension P521.Signing.PublicKey: ByteBufferConvertible { 161 | static func read(consuming buffer: inout ByteBuffer) throws -> Self { 162 | throw SSHKeyParsingError.unsupportedKeyType("ECDSA public key parsing not implemented") 163 | } 164 | 165 | func write(to buffer: inout ByteBuffer) -> Int { 166 | 0 167 | } 168 | } -------------------------------------------------------------------------------- /MobileCode/Views/Components/MarkdownTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarkdownTextView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-05. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MarkdownTextView: View { 11 | let text: String 12 | let textColor: Color 13 | 14 | init(text: String, textColor: Color = .primary) { 15 | self.text = text 16 | self.textColor = textColor 17 | } 18 | 19 | var body: some View { 20 | Text(attributedString) 21 | .textSelection(.enabled) 22 | .fixedSize(horizontal: false, vertical: true) 23 | } 24 | 25 | private var attributedString: AttributedString { 26 | do { 27 | var attributed = try AttributedString( 28 | markdown: text, 29 | options: AttributedString.MarkdownParsingOptions( 30 | interpretedSyntax: .inlineOnlyPreservingWhitespace 31 | ) 32 | ) 33 | 34 | // Apply base text color 35 | attributed.foregroundColor = textColor 36 | 37 | // Style code blocks - check for code styling 38 | if #available(iOS 15.0, *) { 39 | for run in attributed.runs { 40 | // Check if this run has code formatting 41 | if run.inlinePresentationIntent?.contains(.code) == true { 42 | attributed[run.range].font = .system(.body, design: .monospaced) 43 | attributed[run.range].backgroundColor = textColor == .white 44 | ? Color.white.opacity(0.1) 45 | : Color.gray.opacity(0.1) 46 | attributed[run.range].foregroundColor = textColor 47 | } 48 | } 49 | } 50 | 51 | return attributed 52 | } catch { 53 | // Fallback to plain text if markdown parsing fails 54 | return AttributedString(text) 55 | } 56 | } 57 | } 58 | 59 | // Alternative implementation for multi-line markdown with better formatting 60 | struct FullMarkdownTextView: View { 61 | let text: String 62 | let textColor: Color 63 | 64 | init(text: String, textColor: Color = .primary) { 65 | self.text = text 66 | self.textColor = textColor 67 | } 68 | 69 | var body: some View { 70 | VStack(alignment: .leading, spacing: 8) { 71 | ForEach(parseMarkdownBlocks(), id: \.id) { block in 72 | block.view(textColor: textColor) 73 | } 74 | } 75 | } 76 | 77 | private func parseMarkdownBlocks() -> [MarkdownBlock] { 78 | var blocks: [MarkdownBlock] = [] 79 | let lines = text.split(separator: "\n", omittingEmptySubsequences: false) 80 | var currentCodeBlock: [String] = [] 81 | var inCodeBlock = false 82 | var currentParagraph: [String] = [] 83 | 84 | for line in lines { 85 | let lineStr = String(line) 86 | 87 | // Check for code block markers 88 | if lineStr.starts(with: "```") { 89 | if inCodeBlock { 90 | // End code block 91 | if !currentCodeBlock.isEmpty { 92 | blocks.append(.codeBlock(currentCodeBlock.joined(separator: "\n"))) 93 | currentCodeBlock = [] 94 | } 95 | inCodeBlock = false 96 | } else { 97 | // Start code block 98 | // First, flush any pending paragraph 99 | if !currentParagraph.isEmpty { 100 | blocks.append(.paragraph(currentParagraph.joined(separator: "\n"))) 101 | currentParagraph = [] 102 | } 103 | inCodeBlock = true 104 | } 105 | continue 106 | } 107 | 108 | if inCodeBlock { 109 | currentCodeBlock.append(lineStr) 110 | } else if lineStr.starts(with: "- ") { 111 | // Bullet point 112 | if !currentParagraph.isEmpty { 113 | blocks.append(.paragraph(currentParagraph.joined(separator: "\n"))) 114 | currentParagraph = [] 115 | } 116 | blocks.append(.bulletPoint(String(lineStr.dropFirst(2)))) 117 | } else if lineStr.isEmpty { 118 | // Empty line - end current paragraph 119 | if !currentParagraph.isEmpty { 120 | blocks.append(.paragraph(currentParagraph.joined(separator: "\n"))) 121 | currentParagraph = [] 122 | } 123 | } else { 124 | currentParagraph.append(lineStr) 125 | } 126 | } 127 | 128 | // Flush any remaining content 129 | if !currentParagraph.isEmpty { 130 | blocks.append(.paragraph(currentParagraph.joined(separator: "\n"))) 131 | } 132 | if !currentCodeBlock.isEmpty { 133 | blocks.append(.codeBlock(currentCodeBlock.joined(separator: "\n"))) 134 | } 135 | 136 | return blocks 137 | } 138 | } 139 | 140 | private enum MarkdownBlock: Identifiable { 141 | case paragraph(String) 142 | case bulletPoint(String) 143 | case codeBlock(String) 144 | 145 | var id: String { 146 | switch self { 147 | case .paragraph(let text): return "p_\(text.hashValue)" 148 | case .bulletPoint(let text): return "b_\(text.hashValue)" 149 | case .codeBlock(let text): return "c_\(text.hashValue)" 150 | } 151 | } 152 | 153 | @ViewBuilder 154 | func view(textColor: Color) -> some View { 155 | switch self { 156 | case .paragraph(let text): 157 | MarkdownTextView(text: text, textColor: textColor) 158 | 159 | case .bulletPoint(let text): 160 | HStack(alignment: .top, spacing: 8) { 161 | Text("•") 162 | .foregroundColor(textColor) 163 | MarkdownTextView(text: text, textColor: textColor) 164 | .frame(maxWidth: .infinity, alignment: .leading) 165 | } 166 | 167 | case .codeBlock(let code): 168 | Text(code) 169 | .font(.system(.caption, design: .monospaced)) 170 | .foregroundColor(textColor) 171 | .padding(8) 172 | .background(textColor == .white 173 | ? Color.white.opacity(0.1) 174 | : Color.gray.opacity(0.1)) 175 | .cornerRadius(8) 176 | .textSelection(.enabled) 177 | } 178 | } 179 | } -------------------------------------------------------------------------------- /MobileCode/Services/ProjectService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProjectService.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-05. 6 | // 7 | // Purpose: Handles project-related operations 8 | // - Create/delete projects on remote servers 9 | // - Discover projects (if needed in future) 10 | // - Single responsibility: project file operations 11 | // 12 | 13 | import Foundation 14 | import SwiftUI 15 | 16 | /// Service for project-related operations on remote servers 17 | @MainActor 18 | class ProjectService { 19 | // MARK: - Properties 20 | 21 | private let sshService: SSHService 22 | 23 | // MARK: - Initialization 24 | 25 | init(sshService: SSHService) { 26 | self.sshService = sshService 27 | } 28 | 29 | // MARK: - Methods 30 | 31 | /// Create a new project directory on the server 32 | /// - Parameters: 33 | /// - name: Name of the project 34 | /// - server: Server to create the project on 35 | /// - customPath: Optional custom path for the project (if nil, uses server default) 36 | func createProject(name: String, on server: Server, customPath: String? = nil) async throws { 37 | SSHLogger.log("Creating project '\(name)' on server \(server.name)", level: .info) 38 | 39 | // Get a direct connection to the server 40 | let session = try await sshService.connect(to: server) 41 | 42 | // Use custom path if provided, otherwise ensure the server has a default projects path 43 | let basePath: String 44 | if let customPath = customPath { 45 | basePath = customPath 46 | } else { 47 | if let swiftSHSession = session as? SwiftSHSession { 48 | try await swiftSHSession.ensureDefaultProjectsPath() 49 | } 50 | basePath = server.defaultProjectsPath ?? "/root/projects" 51 | } 52 | 53 | let projectPath = "\(basePath)/\(name)" 54 | 55 | SSHLogger.log("Creating project at path: \(projectPath)", level: .info) 56 | 57 | // First, ensure the base projects directory exists and check permissions 58 | _ = try await session.execute("mkdir -p '\(basePath)'") 59 | 60 | // Validate write permissions on the base directory 61 | let canWrite = try await validateWritePermission(at: basePath, using: session) 62 | 63 | if !canWrite { 64 | SSHLogger.log("No write permission at \(basePath), trying fallback", level: .warning) 65 | // If we can't write to the default location, try the fallback 66 | if basePath != "/root/projects" { 67 | let fallbackPath = "/root/projects/\(name)" 68 | _ = try await session.execute("mkdir -p '/root/projects'") 69 | 70 | if try await validateWritePermission(at: "/root/projects", using: session) { 71 | SSHLogger.log("Using fallback path: \(fallbackPath)", level: .info) 72 | _ = try await session.execute("mkdir -p '\(fallbackPath)'") 73 | 74 | // Verify creation 75 | let checkFallback = try await session.execute("test -d '\(fallbackPath)' && echo 'EXISTS'") 76 | guard checkFallback.trimmingCharacters(in: .whitespacesAndNewlines) == "EXISTS" else { 77 | throw ProjectServiceError.failedToCreateDirectory 78 | } 79 | 80 | SSHLogger.log("Successfully created project at fallback: \(fallbackPath)", level: .info) 81 | return 82 | } 83 | } 84 | throw ProjectServiceError.noWritePermission 85 | } 86 | 87 | // Create the project directory 88 | _ = try await session.execute("mkdir -p '\(projectPath)'") 89 | 90 | // Check if directory was created successfully 91 | let checkResult = try await session.execute("test -d '\(projectPath)' && echo 'EXISTS'") 92 | guard checkResult.trimmingCharacters(in: .whitespacesAndNewlines) == "EXISTS" else { 93 | throw ProjectServiceError.failedToCreateDirectory 94 | } 95 | 96 | SSHLogger.log("Successfully created project at \(projectPath)", level: .info) 97 | } 98 | 99 | /// Delete a project directory from the server 100 | /// - Parameter project: Project to delete 101 | func deleteProject(_ project: RemoteProject) async throws { 102 | guard let server = ServerManager.shared.server(withId: project.serverId) else { 103 | throw ProjectServiceError.serverNotFound 104 | } 105 | 106 | SSHLogger.log("Deleting project '\(project.name)' from server \(server.name)", level: .info) 107 | 108 | // Get a connection for this project 109 | let session = try await sshService.getConnection(for: project, purpose: .fileOperations) 110 | 111 | // Confirm the directory exists before deletion 112 | let checkResult = try await session.execute("test -d '\(project.path)' && echo 'EXISTS'") 113 | guard checkResult.trimmingCharacters(in: .whitespacesAndNewlines) == "EXISTS" else { 114 | throw ProjectServiceError.projectNotFound 115 | } 116 | 117 | // Delete the project directory 118 | _ = try await session.execute("rm -rf '\(project.path)'") 119 | 120 | // Close all connections for this project 121 | sshService.closeConnections(projectId: project.id) 122 | 123 | SSHLogger.log("Successfully deleted project at \(project.path)", level: .info) 124 | } 125 | 126 | // MARK: - Private Methods 127 | 128 | /// Validate write permissions at a given path 129 | /// - Parameters: 130 | /// - path: Path to check 131 | /// - session: SSH session to use for checking 132 | /// - Returns: true if writable, false otherwise 133 | private func validateWritePermission(at path: String, using session: SSHSession) async throws -> Bool { 134 | let result = try await session.execute("test -w '\(path)' && echo 'writable' || echo 'not writable'") 135 | return result.trimmingCharacters(in: .whitespacesAndNewlines) == "writable" 136 | } 137 | } 138 | 139 | // MARK: - Errors 140 | 141 | enum ProjectServiceError: LocalizedError { 142 | case serverNotFound 143 | case projectNotFound 144 | case failedToCreateDirectory 145 | case noWritePermission 146 | 147 | var errorDescription: String? { 148 | switch self { 149 | case .serverNotFound: 150 | return "Server not found" 151 | case .projectNotFound: 152 | return "Project directory not found on server" 153 | case .failedToCreateDirectory: 154 | return "Failed to create project directory" 155 | case .noWritePermission: 156 | return "No write permission in the selected directory" 157 | } 158 | } 159 | } -------------------------------------------------------------------------------- /MobileCode/Services/CloudInitMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudInitMonitor.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Monitor and update cloud-init status for servers being provisioned 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | import SwiftUI 11 | 12 | @MainActor 13 | class CloudInitMonitor: ObservableObject { 14 | static let shared = CloudInitMonitor() 15 | 16 | private var monitoringTasks: [UUID: Task] = [:] 17 | private var modelContext: ModelContext? 18 | 19 | private init() {} 20 | 21 | // MARK: - Public Methods 22 | 23 | /// Start monitoring all servers that need cloud-init status checks 24 | func startMonitoring(modelContext: ModelContext) { 25 | self.modelContext = modelContext 26 | 27 | // Fetch all servers that need monitoring 28 | do { 29 | let descriptor = FetchDescriptor( 30 | predicate: #Predicate { server in 31 | server.providerId != nil && !server.cloudInitComplete 32 | } 33 | ) 34 | let servers = try modelContext.fetch(descriptor) 35 | 36 | SSHLogger.log("Found \(servers.count) servers needing cloud-init monitoring", level: .info) 37 | 38 | // Start monitoring each server 39 | for server in servers { 40 | startMonitoring(server: server) 41 | } 42 | } catch { 43 | SSHLogger.log("Failed to fetch servers for monitoring: \(error)", level: .error) 44 | } 45 | } 46 | 47 | /// Start monitoring a specific server 48 | func startMonitoring(server: Server) { 49 | // Cancel any existing monitoring for this server 50 | stopMonitoring(server: server) 51 | 52 | SSHLogger.log("Starting cloud-init monitoring for server: \(server.name)", level: .info) 53 | 54 | // Create a new monitoring task 55 | let task = Task { 56 | await monitorCloudInit(for: server) 57 | } 58 | 59 | monitoringTasks[server.id] = task 60 | } 61 | 62 | /// Stop monitoring a specific server 63 | func stopMonitoring(server: Server) { 64 | if let existingTask = monitoringTasks[server.id] { 65 | existingTask.cancel() 66 | monitoringTasks.removeValue(forKey: server.id) 67 | SSHLogger.log("Stopped cloud-init monitoring for server: \(server.name)", level: .info) 68 | } 69 | } 70 | 71 | /// Stop all monitoring 72 | func stopAllMonitoring() { 73 | for (_, task) in monitoringTasks { 74 | task.cancel() 75 | } 76 | monitoringTasks.removeAll() 77 | SSHLogger.log("Stopped all cloud-init monitoring", level: .info) 78 | } 79 | 80 | // MARK: - Private Methods 81 | 82 | private func monitorCloudInit(for server: Server) async { 83 | var attempts = 0 84 | let maxAttempts = 120 // 20 minutes (120 * 10 seconds) 85 | let checkInterval: UInt64 = 10_000_000_000 // 10 seconds in nanoseconds 86 | 87 | // Wait initial time for server to boot 88 | try? await Task.sleep(nanoseconds: 20_000_000_000) // 20 seconds 89 | 90 | while attempts < maxAttempts && !Task.isCancelled { 91 | attempts += 1 92 | 93 | // Update status to checking 94 | await updateServerStatus(server: server, status: "checking") 95 | 96 | // Check cloud-init status 97 | if let status = await checkCloudInitStatus(for: server) { 98 | await updateServerStatus(server: server, status: status) 99 | 100 | // If done or error, stop monitoring 101 | if status == "done" || status == "error" { 102 | await markCloudInitComplete(server: server, success: status == "done") 103 | stopMonitoring(server: server) 104 | return 105 | } 106 | } 107 | 108 | // Wait before next check 109 | try? await Task.sleep(nanoseconds: checkInterval) 110 | } 111 | 112 | // Timeout reached 113 | if !Task.isCancelled { 114 | SSHLogger.log("Cloud-init monitoring timeout for server: \(server.name)", level: .warning) 115 | await updateServerStatus(server: server, status: "timeout") 116 | await markCloudInitComplete(server: server, success: false) 117 | stopMonitoring(server: server) 118 | } 119 | } 120 | 121 | private func checkCloudInitStatus(for server: Server) async -> String? { 122 | do { 123 | let sshService = ServiceManager.shared.sshService 124 | let session = try await sshService.connect(to: server, purpose: .cloudInit) 125 | 126 | // Check cloud-init status 127 | let output = try await session.execute("sudo cloud-init status") 128 | 129 | // Disconnect after checking 130 | session.disconnect() 131 | 132 | // Parse the output 133 | if output.contains("status: done") { 134 | SSHLogger.log("Cloud-init complete for server: \(server.name)", level: .info) 135 | return "done" 136 | } else if output.contains("status: running") { 137 | return "running" 138 | } else if output.contains("status: error") { 139 | SSHLogger.log("Cloud-init error for server: \(server.name)", level: .error) 140 | return "error" 141 | } else { 142 | return "running" // Default to running if status unclear 143 | } 144 | } catch { 145 | SSHLogger.log("Failed to check cloud-init status for \(server.name): \(error)", level: .warning) 146 | // Don't return nil immediately - SSH might not be ready yet 147 | return nil 148 | } 149 | } 150 | 151 | private func updateServerStatus(server: Server, status: String) async { 152 | guard let modelContext = modelContext else { return } 153 | 154 | server.cloudInitStatus = status 155 | 156 | do { 157 | try modelContext.save() 158 | } catch { 159 | SSHLogger.log("Failed to save server status: \(error)", level: .error) 160 | } 161 | } 162 | 163 | private func markCloudInitComplete(server: Server, success: Bool) async { 164 | guard let modelContext = modelContext else { return } 165 | 166 | server.cloudInitComplete = success 167 | server.cloudInitStatus = success ? "done" : server.cloudInitStatus 168 | 169 | do { 170 | try modelContext.save() 171 | SSHLogger.log("Marked cloud-init \(success ? "complete" : "failed") for server: \(server.name)", level: .info) 172 | } catch { 173 | SSHLogger.log("Failed to save cloud-init completion: \(error)", level: .error) 174 | } 175 | } 176 | } -------------------------------------------------------------------------------- /MobileCode/Models/MCPServer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCPServer.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Model for MCP (Model Context Protocol) server configuration 6 | // Represents both configuration data from .mcp.json and runtime status 7 | // 8 | 9 | import Foundation 10 | 11 | /// Represents an MCP server with its configuration and runtime status 12 | struct MCPServer: Identifiable, Equatable { 13 | let id = UUID() 14 | let name: String 15 | 16 | // Local server properties 17 | var command: String? 18 | var args: [String]? 19 | var env: [String: String]? 20 | 21 | // Remote server properties 22 | var url: String? 23 | var headers: [String: String]? 24 | 25 | // Runtime status (not persisted in .mcp.json) 26 | var status: MCPStatus = .unknown 27 | 28 | /// Server connection status 29 | enum MCPStatus: Equatable { 30 | case connected 31 | case disconnected 32 | case unknown 33 | case checking 34 | 35 | var displayColor: String { 36 | switch self { 37 | case .connected: 38 | return "green" 39 | case .disconnected: 40 | return "red" 41 | case .unknown: 42 | return "gray" 43 | case .checking: 44 | return "blue" 45 | } 46 | } 47 | 48 | var displayText: String { 49 | switch self { 50 | case .connected: 51 | return "Connected" 52 | case .disconnected: 53 | return "Disconnected" 54 | case .unknown: 55 | return "Unknown" 56 | case .checking: 57 | return "Checking..." 58 | } 59 | } 60 | } 61 | 62 | /// Server scope for claude mcp add command 63 | enum MCPScope: String, CaseIterable { 64 | case project = "project" // Shared via .mcp.json (default) 65 | case local = "local" // Private to this machine 66 | case global = "global" // Available across all projects 67 | 68 | var displayName: String { 69 | switch self { 70 | case .project: 71 | return "Project (Shared)" 72 | case .local: 73 | return "Local (Private)" 74 | case .global: 75 | return "Global (All Projects)" 76 | } 77 | } 78 | 79 | var description: String { 80 | switch self { 81 | case .project: 82 | return "Saved in .mcp.json, shared with team" 83 | case .local: 84 | return "Only on this device" 85 | case .global: 86 | return "Available in all your projects" 87 | } 88 | } 89 | } 90 | 91 | /// Full command with arguments for display 92 | var fullCommand: String { 93 | if let url = url { 94 | return url 95 | } else if let command = command { 96 | // Don't show "http" or "sse" as command for remote servers 97 | if command.lowercased() == "http" || command.lowercased() == "sse" { 98 | return "Remote MCP Server" 99 | } 100 | var parts = [command] 101 | if let args = args { 102 | parts.append(contentsOf: args) 103 | } 104 | return parts.joined(separator: " ") 105 | } 106 | return "" 107 | } 108 | 109 | /// Check if this is a remote server (has URL instead of command) 110 | var isRemote: Bool { 111 | if url != nil { 112 | return true 113 | } 114 | // Also check if command is "http" or "sse" which indicates remote server from claude mcp list 115 | if let command = command { 116 | return command.lowercased() == "http" || command.lowercased() == "sse" 117 | } 118 | return false 119 | } 120 | 121 | /// Generate the claude mcp add-json command for this server 122 | func generateAddJsonCommand(scope: MCPScope = .project) -> String? { 123 | // Create JSON configuration 124 | var jsonConfig: [String: Any] = [:] 125 | 126 | if let url = url { 127 | // Remote server - determine type based on URL 128 | // If URL contains "sse" (case-insensitive), use "sse", otherwise use "http" 129 | let serverType = url.lowercased().contains("sse") ? "sse" : "http" 130 | jsonConfig["type"] = serverType 131 | jsonConfig["url"] = url 132 | if let headers = headers { 133 | jsonConfig["headers"] = headers 134 | } 135 | } else if let command = command { 136 | // Local server 137 | jsonConfig["type"] = "stdio" 138 | jsonConfig["command"] = command 139 | if let args = args { 140 | jsonConfig["args"] = args 141 | } 142 | if let env = env { 143 | jsonConfig["env"] = env 144 | } 145 | } else { 146 | return nil 147 | } 148 | 149 | // Convert to JSON string without escaping slashes 150 | guard let jsonData = try? JSONSerialization.data(withJSONObject: jsonConfig, options: [.withoutEscapingSlashes]), 151 | let jsonString = String(data: jsonData, encoding: .utf8) else { 152 | return nil 153 | } 154 | 155 | // Build command 156 | var cmdParts = ["claude", "mcp", "add-json"] 157 | 158 | // Add scope if not project (default) 159 | if scope != .project { 160 | cmdParts.append("-s") 161 | cmdParts.append(scope.rawValue) 162 | } 163 | 164 | // Add name and JSON 165 | cmdParts.append("\"\(name)\"") 166 | cmdParts.append("'\(jsonString)'") 167 | 168 | return cmdParts.joined(separator: " ") 169 | } 170 | } 171 | 172 | // MARK: - Codable Support 173 | extension MCPServer { 174 | /// Configuration structure matching .mcp.json format 175 | struct Configuration: Codable { 176 | // Local server properties 177 | var command: String? 178 | var args: [String]? 179 | var env: [String: String]? 180 | 181 | // Remote server properties 182 | var url: String? 183 | var headers: [String: String]? 184 | } 185 | 186 | /// Create server from configuration 187 | init(name: String, configuration: Configuration) { 188 | self.name = name 189 | self.command = configuration.command 190 | self.args = configuration.args 191 | self.env = configuration.env 192 | self.url = configuration.url 193 | self.headers = configuration.headers 194 | } 195 | 196 | /// Convert to configuration for saving 197 | var configuration: Configuration { 198 | Configuration( 199 | command: command, 200 | args: args, 201 | env: env, 202 | url: url, 203 | headers: headers 204 | ) 205 | } 206 | } -------------------------------------------------------------------------------- /MobileCode/Services/MCPService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCPService.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Service for managing MCP (Model Context Protocol) servers 6 | // Uses hybrid approach: reads .mcp.json for config, claude mcp list for status 7 | // 8 | 9 | import Foundation 10 | 11 | /// Service for managing MCP servers 12 | @MainActor 13 | class MCPService: ObservableObject { 14 | static let shared = MCPService() 15 | 16 | private let sshService = ServiceManager.shared.sshService 17 | 18 | // MARK: - Public Methods 19 | 20 | /// Fetch all MCP servers with configuration and status for a project 21 | func fetchServers(for project: RemoteProject) async throws -> [MCPServer] { 22 | // Get server list and status from claude mcp list 23 | let session = try await sshService.getConnection(for: project, purpose: .fileOperations) 24 | 25 | let command = "cd \"\(project.path)\" && claude mcp list" 26 | 27 | SSHLogger.log("Fetching MCP servers: \(command)", level: .debug) 28 | 29 | do { 30 | let result = try await session.execute(command) 31 | 32 | // Check for command not found 33 | if result.contains("claude: command not found") { 34 | throw MCPServiceError.claudeNotInstalled 35 | } 36 | 37 | // Parse the output to get server info 38 | let serverInfos = MCPStatusParser.parseServerList(result) 39 | 40 | // Convert to MCPServer objects 41 | var servers: [MCPServer] = [] 42 | for info in serverInfos { 43 | var server = MCPServer( 44 | name: info.name, 45 | command: info.command, // This is the full command line from claude mcp list 46 | args: nil, 47 | env: nil, 48 | url: nil, 49 | headers: nil 50 | ) 51 | server.status = info.status 52 | servers.append(server) 53 | } 54 | 55 | return servers.sorted { $0.name < $1.name } 56 | 57 | } catch { 58 | SSHLogger.log("Failed to fetch MCP servers: \(error)", level: .warning) 59 | throw error 60 | } 61 | } 62 | 63 | /// Add a new MCP server using claude mcp add-json 64 | func addServer(_ server: MCPServer, scope: MCPServer.MCPScope = .project, for project: RemoteProject) async throws { 65 | let session = try await sshService.getConnection(for: project, purpose: .fileOperations) 66 | 67 | // Generate the claude mcp add-json command 68 | guard let command = server.generateAddJsonCommand(scope: scope) else { 69 | throw MCPServiceError.invalidConfiguration("Cannot generate command for server") 70 | } 71 | let fullCommand = "cd \"\(project.path)\" && \(command)" 72 | 73 | SSHLogger.log("Adding MCP server with add-json: \(fullCommand)", level: .info) 74 | 75 | let result = try await session.execute(fullCommand) 76 | 77 | // Check for errors in output 78 | if MCPStatusParser.isErrorOutput(result) { 79 | if let error = MCPStatusParser.extractError(result) { 80 | throw MCPServiceError.commandFailed(error) 81 | } 82 | } 83 | } 84 | 85 | /// Remove an MCP server 86 | func removeServer(named name: String, for project: RemoteProject) async throws { 87 | let session = try await sshService.getConnection(for: project, purpose: .fileOperations) 88 | 89 | let command = "cd \"\(project.path)\" && claude mcp remove \"\(name)\"" 90 | 91 | SSHLogger.log("Removing MCP server: \(command)", level: .info) 92 | 93 | let result = try await session.execute(command) 94 | 95 | // Check for errors 96 | if MCPStatusParser.isErrorOutput(result) { 97 | if let error = MCPStatusParser.extractError(result) { 98 | throw MCPServiceError.commandFailed(error) 99 | } 100 | } 101 | } 102 | 103 | /// Edit an MCP server by removing and re-adding it 104 | func editServer(oldName: String, newServer: MCPServer, scope: MCPServer.MCPScope = .project, for project: RemoteProject) async throws { 105 | // First remove the old server 106 | try await removeServer(named: oldName, for: project) 107 | 108 | // Then add the new configuration 109 | try await addServer(newServer, scope: scope, for: project) 110 | } 111 | 112 | /// Get details of a specific server 113 | func getServer(named name: String, for project: RemoteProject) async throws -> MCPServer? { 114 | let servers = try await fetchServers(for: project) 115 | return servers.first { $0.name == name } 116 | } 117 | 118 | /// Get detailed information about a specific MCP server including env vars 119 | func getServerDetails(named name: String, for project: RemoteProject) async throws -> (server: MCPServer, scope: MCPServer.MCPScope)? { 120 | let session = try await sshService.getConnection(for: project, purpose: .fileOperations) 121 | 122 | let command = "cd \"\(project.path)\" && claude mcp get \"\(name)\"" 123 | 124 | SSHLogger.log("Getting MCP server details: \(command)", level: .debug) 125 | 126 | do { 127 | let result = try await session.execute(command) 128 | 129 | // Check for errors 130 | if result.contains("Server not found") || result.contains("No server named") { 131 | SSHLogger.log("MCP server not found: \(name)", level: .warning) 132 | return nil 133 | } 134 | 135 | if result.contains("claude: command not found") { 136 | throw MCPServiceError.claudeNotInstalled 137 | } 138 | 139 | // Parse the detailed output 140 | return MCPStatusParser.parseServerDetails(result) 141 | 142 | } catch { 143 | SSHLogger.log("Failed to get MCP server details: \(error)", level: .error) 144 | throw error 145 | } 146 | } 147 | 148 | } 149 | 150 | // MARK: - Error Types 151 | enum MCPServiceError: LocalizedError { 152 | case claudeNotInstalled 153 | case commandFailed(String) 154 | case invalidConfiguration(String) 155 | case permissionDenied 156 | case serverNotFound 157 | 158 | var errorDescription: String? { 159 | switch self { 160 | case .claudeNotInstalled: 161 | return "Claude Code is not installed on this server" 162 | case .commandFailed(let message): 163 | return "Command failed: \(message)" 164 | case .invalidConfiguration(let message): 165 | return "Invalid configuration: \(message)" 166 | case .permissionDenied: 167 | return "Permission denied to modify .mcp.json" 168 | case .serverNotFound: 169 | return "MCP server not found" 170 | } 171 | } 172 | } -------------------------------------------------------------------------------- /MobileCode/ViewModels/FileBrowserViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileBrowserViewModel.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-06-10. 6 | // 7 | // Purpose: Manages file browser state and navigation 8 | // - Handles file tree structure 9 | // - Manages current path navigation 10 | // - Provides basic file operations 11 | // 12 | 13 | import SwiftUI 14 | import Observation 15 | 16 | /// ViewModel for file browser functionality 17 | @MainActor 18 | @Observable 19 | class FileBrowserViewModel { 20 | // MARK: - Properties 21 | 22 | /// Root nodes of the file tree 23 | var rootNodes: [FileNode] = [] 24 | 25 | /// Current directory path 26 | var currentPath: String = "" 27 | 28 | /// Root path of the current project 29 | var projectRootPath: String = "" 30 | 31 | /// Currently selected file for viewing/editing 32 | var selectedFile: FileNode? 33 | 34 | /// Loading state 35 | var isLoading = false 36 | 37 | /// SSH Service reference 38 | private let sshService = ServiceManager.shared.sshService 39 | 40 | /// Current project reference 41 | private var currentProject: RemoteProject? 42 | 43 | // MARK: - Public Methods 44 | 45 | /// Initialize file browser for a project 46 | /// - Parameter project: The project to browse 47 | func initializeForProject(_ project: RemoteProject) { 48 | Task { @MainActor in 49 | currentProject = project 50 | projectRootPath = project.path 51 | currentPath = project.path 52 | await loadRemoteFiles() 53 | } 54 | } 55 | 56 | /// Navigate to a specific path 57 | /// - Parameter path: The path to navigate to 58 | func navigateTo(path: String) { 59 | // Ensure we don't navigate above project root 60 | if path.hasPrefix(projectRootPath) || path == projectRootPath { 61 | currentPath = path 62 | Task { 63 | await loadRemoteFiles() 64 | } 65 | } 66 | } 67 | 68 | /// Load files from remote server 69 | func loadRemoteFiles() async { 70 | guard let project = currentProject else { return } 71 | 72 | isLoading = true 73 | 74 | do { 75 | // Get SSH connection for file operations 76 | let session = try await sshService.getConnection(for: project, purpose: .fileOperations) 77 | let remoteFiles = try await session.listDirectory(currentPath) 78 | 79 | // Convert to FileNode structure 80 | let nodes = remoteFiles.compactMap { remoteFile -> FileNode? in 81 | // Skip . and .. entries 82 | if remoteFile.name == "." || remoteFile.name == ".." { 83 | return nil 84 | } 85 | 86 | return FileNode( 87 | name: remoteFile.name, 88 | path: remoteFile.path, 89 | isDirectory: remoteFile.isDirectory, 90 | children: remoteFile.isDirectory ? [] : nil, 91 | fileSize: remoteFile.size, 92 | modificationDate: remoteFile.modificationDate 93 | ) 94 | } 95 | .sorted { $0.name < $1.name } 96 | 97 | rootNodes = nodes 98 | 99 | } catch { 100 | print("Failed to load remote files: \(error)") 101 | } 102 | 103 | isLoading = false 104 | } 105 | 106 | /// Open a file for viewing 107 | /// - Parameter file: The file to open 108 | func openFile(_ file: FileNode) { 109 | if !file.isDirectory { 110 | selectedFile = file 111 | } 112 | } 113 | 114 | /// Create a new folder in current directory 115 | /// - Parameter name: Name of the folder 116 | func createFolder(name: String) async { 117 | let folderPath = currentPath.hasSuffix("/") ? "\(currentPath)\(name)" : "\(currentPath)/\(name)" 118 | 119 | do { 120 | try await executeCommand("mkdir -p '\(folderPath)'") 121 | await loadRemoteFiles() 122 | } catch { 123 | print("Failed to create folder: \(error)") 124 | } 125 | } 126 | 127 | /// Delete a file or folder 128 | /// - Parameter node: The file node to delete 129 | func deleteNode(_ node: FileNode) async { 130 | let command = node.isDirectory ? "rm -rf '\(node.path)'" : "rm '\(node.path)'" 131 | 132 | do { 133 | try await executeCommand(command) 134 | await loadRemoteFiles() 135 | } catch { 136 | print("Failed to delete node: \(error)") 137 | } 138 | } 139 | 140 | /// Rename a file or folder 141 | /// - Parameters: 142 | /// - node: The file node to rename 143 | /// - newName: The new name 144 | func renameNode(_ node: FileNode, to newName: String) async { 145 | let parentPath = (node.path as NSString).deletingLastPathComponent 146 | let newPath = (parentPath as NSString).appendingPathComponent(newName) 147 | 148 | do { 149 | try await executeCommand("mv '\(node.path)' '\(newPath)'") 150 | await loadRemoteFiles() 151 | } catch { 152 | print("Failed to rename node: \(error)") 153 | } 154 | } 155 | 156 | /// Setup project path from ProjectContext 157 | func setupProjectPath() { 158 | if let project = ProjectContext.shared.activeProject { 159 | currentProject = project 160 | projectRootPath = project.path 161 | currentPath = project.path 162 | } 163 | } 164 | 165 | /// Get relative path from project root 166 | func getRelativePath(from path: String) -> String { 167 | if path.hasPrefix(projectRootPath) { 168 | return String(path.dropFirst(projectRootPath.count)) 169 | } 170 | return "" 171 | } 172 | 173 | /// Refresh current directory 174 | func refresh() async { 175 | await loadRemoteFiles() 176 | } 177 | 178 | /// Load file content 179 | func loadFileContent(path: String) async throws -> String { 180 | guard let project = currentProject else { 181 | throw FileBrowserError.noProject 182 | } 183 | 184 | let session = try await sshService.getConnection(for: project, purpose: .fileOperations) 185 | return try await session.readFile(path) 186 | } 187 | 188 | // MARK: - Private Methods 189 | 190 | /// Execute SSH command 191 | private func executeCommand(_ command: String) async throws { 192 | guard let project = currentProject else { 193 | throw FileBrowserError.noProject 194 | } 195 | 196 | let session = try await sshService.getConnection(for: project, purpose: .fileOperations) 197 | _ = try await session.execute(command) 198 | } 199 | } 200 | 201 | // MARK: - Errors 202 | 203 | enum FileBrowserError: LocalizedError { 204 | case noProject 205 | 206 | var errorDescription: String? { 207 | switch self { 208 | case .noProject: 209 | return "No active project" 210 | } 211 | } 212 | } -------------------------------------------------------------------------------- /MobileCode/Services/SSH/OpenSSHECDSAParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenSSHECDSAParser.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Parse OpenSSH ECDSA keys and convert to SEC1 format 6 | // 7 | // This parser extracts ECDSA private keys from OpenSSH format and 8 | // converts them to SEC1 ASN.1 format that swift-crypto can parse. 9 | // Supports P256, P384, and P521 curves. 10 | // 11 | 12 | import Foundation 13 | import NIO 14 | import Crypto 15 | import SwiftASN1 16 | @preconcurrency import NIOSSH 17 | 18 | /// SEC1 ECPrivateKey structure for ECDSA keys 19 | struct SEC1ECPrivateKey: DERImplicitlyTaggable { 20 | static var defaultIdentifier: ASN1Identifier { .sequence } 21 | 22 | let version: Int 23 | let privateKey: Data 24 | let parameters: ASN1ObjectIdentifier? 25 | let publicKey: Data? 26 | 27 | /// OIDs for different curves 28 | static let p256OID = try! ASN1ObjectIdentifier(elements: [1, 2, 840, 10045, 3, 1, 7]) // prime256v1 29 | static let p384OID = try! ASN1ObjectIdentifier(elements: [1, 3, 132, 0, 34]) // secp384r1 30 | static let p521OID = try! ASN1ObjectIdentifier(elements: [1, 3, 132, 0, 35]) // secp521r1 31 | 32 | init(version: Int = 1, privateKey: Data, parameters: ASN1ObjectIdentifier? = nil, publicKey: Data? = nil) { 33 | self.version = version 34 | self.privateKey = privateKey 35 | self.parameters = parameters 36 | self.publicKey = publicKey 37 | } 38 | 39 | init(derEncoded: ASN1Node, withIdentifier identifier: ASN1Identifier) throws { 40 | // This initializer is required by DERImplicitlyTaggable protocol 41 | // but not used in our implementation as we only encode to DER 42 | throw SSHKeyParsingError.invalidKeyData 43 | } 44 | 45 | /// Serialize to DER format 46 | func serialize(into coder: inout DER.Serializer, withIdentifier identifier: ASN1Identifier) throws { 47 | try coder.appendConstructedNode(identifier: identifier) { coder in 48 | // Version (integer 1) 49 | try coder.serialize(self.version) 50 | 51 | // Private key (octet string) 52 | let octetString = ASN1OctetString(contentBytes: ArraySlice(self.privateKey)) 53 | try coder.serialize(octetString) 54 | 55 | // Parameters (optional, context-specific tag 0) 56 | if let oid = self.parameters { 57 | try coder.serialize(oid, explicitlyTaggedWithIdentifier: .init(tagWithNumber: 0, tagClass: .contextSpecific)) 58 | } 59 | 60 | // Public key (optional, context-specific tag 1) 61 | if let pubKey = self.publicKey { 62 | let bitString = ASN1BitString(bytes: ArraySlice(pubKey), paddingBits: 0) 63 | try coder.serialize(bitString, 64 | explicitlyTaggedWithIdentifier: .init(tagWithNumber: 1, tagClass: .contextSpecific)) 65 | } 66 | } 67 | } 68 | 69 | /// Encode to DER format 70 | func encodeToDER() throws -> Data { 71 | var serializer = DER.Serializer() 72 | try serializer.serialize(self) 73 | return Data(serializer.serializedBytes) 74 | } 75 | } 76 | 77 | /// Parser for OpenSSH ECDSA keys 78 | enum OpenSSHECDSAParser { 79 | 80 | /// Parse OpenSSH ECDSA key from ByteBuffer 81 | static func parseP256(from buffer: inout ByteBuffer) throws -> P256.Signing.PrivateKey { 82 | // Extract components from OpenSSH format 83 | let (privateKeyData, publicKeyData) = try extractECDSAComponents(from: &buffer, expectedKeySize: 32) 84 | 85 | // Create SEC1 structure with P256 OID 86 | let sec1Key = SEC1ECPrivateKey( 87 | version: 1, 88 | privateKey: privateKeyData, 89 | parameters: SEC1ECPrivateKey.p256OID, 90 | publicKey: publicKeyData 91 | ) 92 | 93 | // Encode to DER format 94 | let derEncoded = try sec1Key.encodeToDER() 95 | 96 | // Create P256 private key from DER representation 97 | return try P256.Signing.PrivateKey(derRepresentation: derEncoded) 98 | } 99 | 100 | /// Parse OpenSSH ECDSA P384 key 101 | static func parseP384(from buffer: inout ByteBuffer) throws -> P384.Signing.PrivateKey { 102 | // Extract components from OpenSSH format 103 | let (privateKeyData, publicKeyData) = try extractECDSAComponents(from: &buffer, expectedKeySize: 48) 104 | 105 | // Create SEC1 structure with P384 OID 106 | let sec1Key = SEC1ECPrivateKey( 107 | version: 1, 108 | privateKey: privateKeyData, 109 | parameters: SEC1ECPrivateKey.p384OID, 110 | publicKey: publicKeyData 111 | ) 112 | 113 | // Encode to DER format 114 | let derEncoded = try sec1Key.encodeToDER() 115 | 116 | // Create P384 private key from DER representation 117 | return try P384.Signing.PrivateKey(derRepresentation: derEncoded) 118 | } 119 | 120 | /// Parse OpenSSH ECDSA P521 key 121 | static func parseP521(from buffer: inout ByteBuffer) throws -> P521.Signing.PrivateKey { 122 | // Extract components from OpenSSH format 123 | let (privateKeyData, publicKeyData) = try extractECDSAComponents(from: &buffer, expectedKeySize: 66) 124 | 125 | // Create SEC1 structure with P521 OID 126 | let sec1Key = SEC1ECPrivateKey( 127 | version: 1, 128 | privateKey: privateKeyData, 129 | parameters: SEC1ECPrivateKey.p521OID, 130 | publicKey: publicKeyData 131 | ) 132 | 133 | // Encode to DER format 134 | let derEncoded = try sec1Key.encodeToDER() 135 | 136 | // Create P521 private key from DER representation 137 | return try P521.Signing.PrivateKey(derRepresentation: derEncoded) 138 | } 139 | 140 | /// Extract ECDSA components from OpenSSH format 141 | private static func extractECDSAComponents(from buffer: inout ByteBuffer, expectedKeySize: Int) throws -> (privateKey: Data, publicKey: Data?) { 142 | // Read and validate curve name 143 | guard let curveName = buffer.readSSHString() else { 144 | throw SSHKeyParsingError.missingKeyComponent("curve name") 145 | } 146 | // Curve name is validated by the calling function 147 | 148 | // Read public key 149 | guard let publicKeyBuffer = buffer.readSSHBuffer() else { 150 | throw SSHKeyParsingError.missingPublicKeyBuffer 151 | } 152 | 153 | // Read private key scalar 154 | guard let privateKeyBuffer = buffer.readSSHBuffer() else { 155 | throw SSHKeyParsingError.missingPrivateKeyBuffer 156 | } 157 | 158 | // Extract private key bytes 159 | var privateKeyData = privateKeyBuffer.getData(at: 0, length: privateKeyBuffer.readableBytes) ?? Data() 160 | 161 | // Handle leading zero byte (OpenSSH sometimes adds it) 162 | if privateKeyData.count == expectedKeySize + 1 && privateKeyData[0] == 0 { 163 | privateKeyData = privateKeyData.dropFirst() 164 | } 165 | 166 | // Validate key size 167 | guard privateKeyData.count == expectedKeySize else { 168 | throw SSHKeyParsingError.invalidKeySize(expected: expectedKeySize, actual: privateKeyData.count) 169 | } 170 | 171 | // Extract public key if available 172 | let publicKeyData = publicKeyBuffer.getData(at: 0, length: publicKeyBuffer.readableBytes) 173 | 174 | return (privateKeyData, publicKeyData) 175 | } 176 | } 177 | 178 | // Update the OpenSSHPrivateKey extensions to use the new parser 179 | extension P256.Signing.PrivateKey { 180 | static func readOpenSSH(consuming buffer: inout ByteBuffer) throws -> Self { 181 | try OpenSSHECDSAParser.parseP256(from: &buffer) 182 | } 183 | } 184 | 185 | extension P384.Signing.PrivateKey { 186 | static func readOpenSSH(consuming buffer: inout ByteBuffer) throws -> Self { 187 | try OpenSSHECDSAParser.parseP384(from: &buffer) 188 | } 189 | } 190 | 191 | extension P521.Signing.PrivateKey { 192 | static func readOpenSSH(consuming buffer: inout ByteBuffer) throws -> Self { 193 | try OpenSSHECDSAParser.parseP521(from: &buffer) 194 | } 195 | } -------------------------------------------------------------------------------- /MobileCode/Services/StreamingJSONParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StreamingJSONParser.swift 3 | // CodeAgentsMobile 4 | // 5 | // Purpose: Parse streaming JSON output from Claude Code 6 | // - Handles line-by-line parsing 7 | // - Converts JSON to MessageChunk format 8 | // - Formats tool usage for display 9 | // 10 | 11 | import Foundation 12 | 13 | /// Parser for Claude Code streaming JSON output 14 | class StreamingJSONParser { 15 | 16 | /// Parse a single streaming JSON line into a MessageChunk 17 | static func parseStreamingLine(_ line: String) -> MessageChunk? { 18 | guard let data = line.data(using: .utf8) else { return nil } 19 | 20 | do { 21 | let response = try JSONDecoder().decode(ClaudeStreamingResponse.self, from: data) 22 | 23 | // Convert streaming response to MessageChunk based on type 24 | var chunk: MessageChunk? 25 | switch response.type { 26 | case "system": 27 | chunk = parseSystemMessage(response) 28 | 29 | case "assistant": 30 | chunk = parseAssistantMessage(response) 31 | 32 | case "user": 33 | chunk = parseUserMessage(response) 34 | 35 | case "result": 36 | chunk = parseResultMessage(response) 37 | 38 | default: 39 | // Unknown message type, ignore 40 | return nil 41 | } 42 | 43 | // Add original JSON data to metadata 44 | if var chunk = chunk { 45 | var metadata = chunk.metadata ?? [:] 46 | metadata["originalJSON"] = line 47 | return MessageChunk( 48 | content: chunk.content, 49 | isComplete: chunk.isComplete, 50 | isError: chunk.isError, 51 | metadata: metadata 52 | ) 53 | } 54 | 55 | return chunk 56 | 57 | } catch { 58 | // Log parsing error but don't show to user 59 | SSHLogger.log("Failed to parse streaming line: \(error)", level: .debug) 60 | SSHLogger.log("Line content: \(line)", level: .verbose) 61 | return nil 62 | } 63 | } 64 | 65 | // MARK: - Private Parsing Methods 66 | 67 | private static func parseSystemMessage(_ response: ClaudeStreamingResponse) -> MessageChunk? { 68 | guard response.subtype == "init" else { return nil } 69 | 70 | // Return raw data for UI to format 71 | var metadata: [String: Any] = [ 72 | "type": "system", 73 | "subtype": response.subtype ?? "unknown" 74 | ] 75 | 76 | if let tools = response.tools { 77 | metadata["tools"] = tools 78 | } 79 | 80 | if let sessionId = response.sessionId { 81 | metadata["sessionId"] = sessionId 82 | } 83 | 84 | return MessageChunk( 85 | content: "", // No pre-formatted content 86 | isComplete: false, 87 | isError: false, 88 | metadata: metadata 89 | ) 90 | } 91 | 92 | private static func parseAssistantMessage(_ response: ClaudeStreamingResponse) -> MessageChunk? { 93 | guard let message = response.message, 94 | let contents = message.content else { return nil } 95 | 96 | // Convert content to metadata for structured display 97 | var contentBlocks: [[String: Any]] = [] 98 | 99 | for content in contents { 100 | switch content { 101 | case .text(let text): 102 | contentBlocks.append([ 103 | "type": "text", 104 | "text": text 105 | ]) 106 | 107 | case .toolUse(let toolUse): 108 | var block: [String: Any] = [ 109 | "type": "tool_use", 110 | "id": toolUse.id, 111 | "name": toolUse.name 112 | ] 113 | if let input = toolUse.input { 114 | block["input"] = input 115 | } 116 | contentBlocks.append(block) 117 | 118 | case .toolResult(_): 119 | // Tool results come in user messages, not assistant 120 | break 121 | 122 | case .unknown: 123 | break 124 | } 125 | } 126 | 127 | guard !contentBlocks.isEmpty else { return nil } 128 | 129 | return MessageChunk( 130 | content: "", // No pre-formatted content 131 | isComplete: false, 132 | isError: false, 133 | metadata: [ 134 | "type": "assistant", 135 | "role": "assistant", 136 | "content": contentBlocks 137 | ] 138 | ) 139 | } 140 | 141 | private static func parseUserMessage(_ response: ClaudeStreamingResponse) -> MessageChunk? { 142 | guard let message = response.message, 143 | let contents = message.content else { return nil } 144 | 145 | // Convert content to metadata for structured display 146 | var contentBlocks: [[String: Any]] = [] 147 | 148 | for content in contents { 149 | switch content { 150 | case .text(let text): 151 | contentBlocks.append([ 152 | "type": "text", 153 | "text": text 154 | ]) 155 | 156 | case .toolResult(let toolResult): 157 | var block: [String: Any] = [ 158 | "type": "tool_result", 159 | "tool_use_id": toolResult.toolUseId, 160 | "is_error": false // ToolResult doesn't have isError field 161 | ] 162 | if let content = toolResult.content { 163 | block["content"] = content 164 | } 165 | contentBlocks.append(block) 166 | 167 | case .toolUse(_): 168 | // Tool uses come in assistant messages, not user 169 | break 170 | 171 | case .unknown: 172 | break 173 | } 174 | } 175 | 176 | guard !contentBlocks.isEmpty else { return nil } 177 | 178 | return MessageChunk( 179 | content: "", // No pre-formatted content 180 | isComplete: false, 181 | isError: false, 182 | metadata: [ 183 | "type": "user", 184 | "role": "user", 185 | "content": contentBlocks 186 | ] 187 | ) 188 | } 189 | 190 | private static func parseResultMessage(_ response: ClaudeStreamingResponse) -> MessageChunk? { 191 | var metadata: [String: Any] = ["type": "result"] 192 | 193 | if let subtype = response.subtype { 194 | metadata["subtype"] = subtype 195 | } 196 | if let sessionId = response.sessionId { 197 | metadata["sessionId"] = sessionId 198 | } 199 | if let durationMs = response.durationMs { 200 | metadata["duration_ms"] = durationMs 201 | } 202 | // These fields don't exist in ClaudeStreamingResponse 203 | // if let durationApiMs = response.durationApiMs { 204 | // metadata["duration_api_ms"] = durationApiMs 205 | // } 206 | // if let numTurns = response.numTurns { 207 | // metadata["num_turns"] = numTurns 208 | // } 209 | if let totalCostUsd = response.totalCostUsd { 210 | metadata["total_cost_usd"] = totalCostUsd 211 | } 212 | if let usage = response.usage { 213 | // Usage is a generic dictionary, pass it through as-is 214 | metadata["usage"] = usage 215 | } 216 | if let result = response.result { 217 | metadata["result"] = result 218 | } 219 | 220 | return MessageChunk( 221 | content: "", // No pre-formatted content 222 | isComplete: true, 223 | isError: response.isError ?? false, 224 | metadata: metadata 225 | ) 226 | } 227 | } -------------------------------------------------------------------------------- /MobileCode/Views/Servers/ManagedServerListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ManagedServerListView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-08-12. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftData 10 | 11 | struct ManagedServerListView: View { 12 | let provider: ServerProvider 13 | let dismissAll: (() -> Void)? 14 | 15 | @Environment(\.modelContext) private var modelContext 16 | @Environment(\.dismiss) private var dismiss 17 | 18 | init(provider: ServerProvider, dismissAll: (() -> Void)? = nil) { 19 | self.provider = provider 20 | self.dismissAll = dismissAll 21 | } 22 | 23 | @Query private var existingServers: [Server] 24 | @Query private var sshKeys: [SSHKey] 25 | 26 | @State private var cloudServers: [CloudServer] = [] 27 | @State private var isLoading = false 28 | @State private var errorMessage: String? 29 | @State private var showError = false 30 | @State private var selectedServer: CloudServer? 31 | @State private var showAttachSheet = false 32 | @State private var showCreateServer = false 33 | @State private var isServerProvisioning = false 34 | @State private var shouldRefreshList = true 35 | 36 | var body: some View { 37 | NavigationStack { 38 | VStack { 39 | if isLoading { 40 | Spacer() 41 | ProgressView("Loading servers...") 42 | Spacer() 43 | } else if cloudServers.isEmpty { 44 | ContentUnavailableView { 45 | Label("No Servers Found", systemImage: "server.rack") 46 | } description: { 47 | Text("No servers found in this account.") 48 | } actions: { 49 | Button(action: { showCreateServer = true }) { 50 | Label("Create New Server", systemImage: "plus.circle") 51 | } 52 | .buttonStyle(.borderedProminent) 53 | } 54 | } else { 55 | List { 56 | ForEach(cloudServers) { server in 57 | CloudServerRow( 58 | server: server, 59 | isAttached: isServerAttached(server), 60 | onAttach: { attachServer(server) } 61 | ) 62 | } 63 | 64 | // Create Server button at the bottom 65 | Section { 66 | Button(action: { showCreateServer = true }) { 67 | HStack { 68 | Image(systemName: "plus.circle.fill") 69 | .font(.title3) 70 | .foregroundColor(.blue) 71 | Text("Create New Server") 72 | .font(.body) 73 | .fontWeight(.medium) 74 | .foregroundColor(.primary) 75 | Spacer() 76 | Image(systemName: "chevron.right") 77 | .font(.caption) 78 | .foregroundColor(.secondary) 79 | } 80 | .padding(.vertical, 4) 81 | } 82 | } 83 | } 84 | } 85 | } 86 | .navigationTitle(provider.name) 87 | .navigationBarTitleDisplayMode(.inline) 88 | .toolbar { 89 | ToolbarItem(placement: .navigationBarTrailing) { 90 | Menu { 91 | Button(action: loadServers) { 92 | Label("Refresh", systemImage: "arrow.clockwise") 93 | } 94 | 95 | Button(action: { showCreateServer = true }) { 96 | Label("Create Server", systemImage: "plus") 97 | } 98 | } label: { 99 | Image(systemName: "ellipsis.circle") 100 | } 101 | } 102 | } 103 | .onAppear { 104 | loadServers() 105 | } 106 | .sheet(isPresented: $showAttachSheet) { 107 | if let server = selectedServer { 108 | AttachServerSheet( 109 | cloudServer: server, 110 | provider: provider, 111 | onComplete: { attachedServer in 112 | modelContext.insert(attachedServer) 113 | try? modelContext.save() 114 | showAttachSheet = false 115 | selectedServer = nil 116 | loadServers() 117 | } 118 | ) 119 | } 120 | } 121 | .sheet(isPresented: $showCreateServer) { 122 | CreateCloudServerView( 123 | provider: provider, 124 | dismissAll: dismissAll, 125 | isProvisioning: $isServerProvisioning 126 | ) { 127 | // Reload servers after creation 128 | // Only reload if not provisioning 129 | if !isServerProvisioning { 130 | loadServers() 131 | } 132 | } 133 | .interactiveDismissDisabled(isServerProvisioning) 134 | .onDisappear { 135 | // Reset provisioning flag when sheet dismisses 136 | isServerProvisioning = false 137 | shouldRefreshList = true 138 | } 139 | } 140 | .onChange(of: isServerProvisioning) { _, newValue in 141 | // When provisioning starts, prevent list refresh 142 | if newValue { 143 | print("🔒 Provisioning started - locking sheet") 144 | shouldRefreshList = false 145 | } else { 146 | print("🔓 Provisioning ended - unlocking sheet") 147 | shouldRefreshList = true 148 | } 149 | } 150 | .alert("Error", isPresented: $showError) { 151 | Button("OK") {} 152 | } message: { 153 | Text(errorMessage ?? "An error occurred") 154 | } 155 | } 156 | } 157 | 158 | private func isServerAttached(_ cloudServer: CloudServer) -> Bool { 159 | existingServers.contains { server in 160 | server.providerServerId == cloudServer.id && 161 | server.providerId == provider.id 162 | } 163 | } 164 | 165 | private func loadServers() { 166 | isLoading = true 167 | 168 | Task { 169 | do { 170 | let token = try KeychainManager.shared.retrieveProviderToken(for: provider) 171 | 172 | let service: CloudProviderProtocol = provider.providerType == "digitalocean" ? 173 | DigitalOceanService(apiToken: token) : 174 | HetznerCloudService(apiToken: token) 175 | 176 | let servers = try await service.listServers() 177 | 178 | await MainActor.run { 179 | self.cloudServers = servers 180 | self.isLoading = false 181 | } 182 | } catch { 183 | await MainActor.run { 184 | self.errorMessage = error.localizedDescription 185 | self.showError = true 186 | self.isLoading = false 187 | self.cloudServers = [] 188 | } 189 | } 190 | } 191 | } 192 | 193 | private func attachServer(_ cloudServer: CloudServer) { 194 | selectedServer = cloudServer 195 | showAttachSheet = true 196 | } 197 | } 198 | 199 | 200 | #Preview { 201 | ManagedServerListView(provider: ServerProvider(providerType: "digitalocean", name: "DigitalOcean")) 202 | .modelContainer(for: [Server.self, SSHKey.self, ServerProvider.self], inMemory: true) 203 | } -------------------------------------------------------------------------------- /MobileCode/Utils/BlockFormattingUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlockFormattingUtils.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-06. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BlockFormattingUtils { 11 | 12 | // MARK: - MCP Tool Parsing 13 | static func parseMCPToolName(_ tool: String) -> (isMCP: Bool, serverName: String?, toolName: String) { 14 | if tool.starts(with: "mcp__") { 15 | // Remove "mcp__" prefix 16 | let withoutPrefix = String(tool.dropFirst(5)) 17 | // Split by "__" to separate server name and tool name 18 | if let separatorRange = withoutPrefix.range(of: "__") { 19 | let serverName = String(withoutPrefix[.. String { 29 | let (isMCP, serverName, _) = parseMCPToolName(tool) 30 | if isMCP, let serverName = serverName { 31 | // For MCP tools, return the server name with first letter capitalized 32 | return serverName.prefix(1).uppercased() + serverName.dropFirst() 33 | } 34 | return tool 35 | } 36 | 37 | // MARK: - MCP Function Name 38 | static func getMCPFunctionName(for tool: String) -> String? { 39 | let (isMCP, _, toolName) = parseMCPToolName(tool) 40 | if isMCP { 41 | // Replace underscores with spaces for better readability 42 | return toolName.replacingOccurrences(of: "_", with: " ") 43 | } 44 | return nil 45 | } 46 | 47 | // MARK: - Tool Icons 48 | static func getToolIcon(for tool: String) -> String { 49 | // Check if it's an MCP tool 50 | let (isMCP, _, _) = parseMCPToolName(tool) 51 | if isMCP { 52 | return "server.rack" 53 | } 54 | 55 | switch tool.lowercased() { 56 | case "todowrite", "todoread": 57 | return "checklist" 58 | case "read": 59 | return "doc.text" 60 | case "write": 61 | return "square.and.pencil" 62 | case "edit", "multiedit": 63 | return "pencil" 64 | case "bash": 65 | return "terminal" 66 | case "grep": 67 | return "magnifyingglass" 68 | case "glob": 69 | return "folder.badge.gearshape" 70 | case "ls": 71 | return "folder" 72 | case "webfetch", "websearch": 73 | return "globe" 74 | case "task": 75 | return "person.2" 76 | case "exit_plan_mode": 77 | return "arrow.right.square" 78 | case "notebookread", "notebookedit": 79 | return "book" 80 | default: 81 | return "wrench.and.screwdriver" 82 | } 83 | } 84 | 85 | // MARK: - Tool Colors 86 | static func getToolColor(for tool: String) -> Color { 87 | // Check if it's an MCP tool 88 | let (isMCP, _, _) = parseMCPToolName(tool) 89 | if isMCP { 90 | return .teal 91 | } 92 | 93 | switch tool.lowercased() { 94 | case "todowrite", "todoread": 95 | return .blue 96 | case "read", "write", "edit", "multiedit": 97 | return .orange 98 | case "bash": 99 | return .green 100 | case "grep", "glob", "ls": 101 | return .purple 102 | case "webfetch", "websearch": 103 | return .indigo 104 | default: 105 | return .gray 106 | } 107 | } 108 | 109 | // MARK: - Content Truncation 110 | static func truncateContent(_ content: String, maxLength: Int = 100) -> String { 111 | if content.count <= maxLength { 112 | return content 113 | } 114 | let endIndex = content.index(content.startIndex, offsetBy: maxLength) 115 | return String(content[.. String { 120 | // Special handling for TodoWrite 121 | if let todos = parameters["todos"] as? [[String: Any]] { 122 | let activeCount = todos.filter { ($0["status"] as? String) != "completed" }.count 123 | let completedCount = todos.filter { ($0["status"] as? String) == "completed" }.count 124 | 125 | if activeCount > 0 && completedCount > 0 { 126 | return "\(activeCount) active, \(completedCount) completed" 127 | } else if activeCount > 0 { 128 | return "\(activeCount) todo\(activeCount == 1 ? "" : "s")" 129 | } else if completedCount > 0 { 130 | return "\(completedCount) completed" 131 | } else { 132 | return "\(todos.count) todo\(todos.count == 1 ? "" : "s")" 133 | } 134 | } 135 | 136 | // Special handling for MultiEdit 137 | if let edits = parameters["edits"] as? [[String: Any]] { 138 | let editCount = edits.count 139 | if let filePath = parameters["file_path"] as? String { 140 | let fileName = filePath.split(separator: "/").last.map(String.init) ?? filePath 141 | return "\(editCount) edit\(editCount == 1 ? "" : "s") to \(fileName)" 142 | } 143 | return "\(editCount) edit\(editCount == 1 ? "" : "s")" 144 | } 145 | 146 | // Prioritize command for bash and similar tools 147 | if let command = parameters["command"] as? String { 148 | // Show command in monospace if it's short enough 149 | if command.count <= 60 { 150 | return command 151 | } 152 | return truncateContent(command, maxLength: 60) 153 | } 154 | 155 | if let filePath = parameters["file_path"] as? String { 156 | return formatFilePath(filePath) 157 | } 158 | 159 | if let path = parameters["path"] as? String { 160 | return formatFilePath(path) 161 | } 162 | 163 | if let pattern = parameters["pattern"] as? String { 164 | return "\"\(truncateContent(pattern, maxLength: 30))\"" 165 | } 166 | 167 | // For tools with multiple parameters, show the most important one 168 | if parameters.count == 1, let firstValue = parameters.values.first { 169 | if let stringValue = firstValue as? String { 170 | return truncateContent(stringValue, maxLength: 60) 171 | } 172 | } 173 | 174 | // Generic parameter count 175 | if parameters.isEmpty { 176 | return "No parameters" 177 | } 178 | 179 | return "\(parameters.count) parameters" 180 | } 181 | 182 | // MARK: - Result Status 183 | static func getResultIcon(isError: Bool) -> String { 184 | return isError ? "xmark.circle.fill" : "checkmark.circle.fill" 185 | } 186 | 187 | static func getResultColor(isError: Bool) -> Color { 188 | return isError ? .red : .green 189 | } 190 | 191 | // MARK: - File Path Formatting 192 | static func formatFilePath(_ path: String) -> String { 193 | let components = path.split(separator: "/") 194 | if components.count > 3 { 195 | let last = components.suffix(2).joined(separator: "/") 196 | return ".../\(last)" 197 | } 198 | return path 199 | } 200 | 201 | // MARK: - Time Formatting 202 | static func formatDuration(_ milliseconds: Int) -> String { 203 | let seconds = Double(milliseconds) / 1000.0 204 | if seconds < 1 { 205 | return String(format: "%.0fms", Double(milliseconds)) 206 | } else if seconds < 60 { 207 | return String(format: "%.1fs", seconds) 208 | } else { 209 | let minutes = Int(seconds / 60) 210 | let remainingSeconds = Int(seconds.truncatingRemainder(dividingBy: 60)) 211 | return "\(minutes)m \(remainingSeconds)s" 212 | } 213 | } 214 | 215 | // MARK: - Cost Formatting 216 | static func formatCost(_ cost: Double) -> String { 217 | if cost < 0.01 { 218 | return String(format: "$%.4f", cost) 219 | } else { 220 | return String(format: "$%.2f", cost) 221 | } 222 | } 223 | } -------------------------------------------------------------------------------- /MobileCode/Views/FolderPickerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FolderPickerView.swift 3 | // CodeAgentsMobile 4 | // 5 | // Created by Claude on 2025-07-22. 6 | // 7 | // Purpose: UI for selecting custom project folders 8 | // - Shows directory structure 9 | // - Validates permissions 10 | // - Provides navigation 11 | // 12 | 13 | import SwiftUI 14 | 15 | struct FolderPickerView: View { 16 | let server: Server 17 | let initialPath: String 18 | let onSelect: (String) -> Void 19 | 20 | @State private var viewModel: FolderPickerViewModel 21 | @Environment(\.dismiss) private var dismiss 22 | 23 | init(server: Server, initialPath: String, onSelect: @escaping (String) -> Void) { 24 | self.server = server 25 | self.initialPath = initialPath 26 | self.onSelect = onSelect 27 | 28 | // Create view model with the provided server and path 29 | self._viewModel = State(initialValue: FolderPickerViewModel(server: server, initialPath: initialPath)) 30 | } 31 | 32 | var body: some View { 33 | NavigationStack { 34 | VStack(spacing: 0) { 35 | // Breadcrumb navigation 36 | breadcrumbView 37 | 38 | Divider() 39 | 40 | // Current path display 41 | currentPathView 42 | 43 | Divider() 44 | 45 | // Folder list 46 | if viewModel.isLoading { 47 | Spacer() 48 | ProgressView("Loading folders...") 49 | Spacer() 50 | } else if let error = viewModel.errorMessage { 51 | Spacer() 52 | VStack(spacing: 16) { 53 | Image(systemName: "exclamationmark.triangle.fill") 54 | .font(.largeTitle) 55 | .foregroundColor(.orange) 56 | Text(error) 57 | .multilineTextAlignment(.center) 58 | .foregroundColor(.secondary) 59 | Button("Retry") { 60 | Task { 61 | await viewModel.loadFolders() 62 | } 63 | } 64 | } 65 | .padding() 66 | Spacer() 67 | } else { 68 | folderListView 69 | } 70 | } 71 | .navigationTitle("Select Folder") 72 | .navigationBarTitleDisplayMode(.inline) 73 | .toolbar { 74 | ToolbarItem(placement: .cancellationAction) { 75 | Button("Cancel") { 76 | dismiss() 77 | } 78 | } 79 | 80 | ToolbarItem(placement: .confirmationAction) { 81 | Button("Select") { 82 | onSelect(viewModel.currentPath) 83 | dismiss() 84 | } 85 | .disabled(!viewModel.isCurrentPathWritable) 86 | } 87 | } 88 | .task { 89 | await viewModel.loadFolders() 90 | } 91 | } 92 | } 93 | 94 | @ViewBuilder 95 | private var breadcrumbView: some View { 96 | ScrollView(.horizontal, showsIndicators: false) { 97 | HStack(spacing: 4) { 98 | ForEach(viewModel.pathComponents, id: \.path) { component in 99 | Button(action: { 100 | viewModel.navigateTo(path: component.path) 101 | }) { 102 | HStack(spacing: 4) { 103 | Text(component.name) 104 | .font(.caption) 105 | .fontDesign(.monospaced) 106 | 107 | if component.path != viewModel.currentPath { 108 | Image(systemName: "chevron.right") 109 | .font(.caption2) 110 | .foregroundColor(.secondary) 111 | } 112 | } 113 | } 114 | .buttonStyle(.plain) 115 | .disabled(component.path == viewModel.currentPath) 116 | } 117 | } 118 | .padding(.horizontal) 119 | .padding(.vertical, 8) 120 | } 121 | } 122 | 123 | @ViewBuilder 124 | private var currentPathView: some View { 125 | HStack { 126 | VStack(alignment: .leading, spacing: 4) { 127 | Text("Current Location") 128 | .font(.caption) 129 | .foregroundColor(.secondary) 130 | 131 | HStack(spacing: 8) { 132 | Image(systemName: viewModel.isCurrentPathWritable ? "checkmark.circle.fill" : "xmark.circle.fill") 133 | .foregroundColor(viewModel.isCurrentPathWritable ? .green : .red) 134 | .font(.caption) 135 | 136 | Text(viewModel.currentPath) 137 | .font(.footnote) 138 | .fontDesign(.monospaced) 139 | .lineLimit(1) 140 | .truncationMode(.middle) 141 | } 142 | } 143 | 144 | Spacer() 145 | 146 | if viewModel.currentPath != "/" { 147 | Button(action: { 148 | viewModel.navigateUp() 149 | }) { 150 | Label("Up", systemImage: "arrow.up.circle") 151 | .labelStyle(.iconOnly) 152 | } 153 | .buttonStyle(.bordered) 154 | .controlSize(.small) 155 | } 156 | } 157 | .padding() 158 | .background(Color(.systemGray6)) 159 | } 160 | 161 | @ViewBuilder 162 | private var folderListView: some View { 163 | if viewModel.folders.isEmpty { 164 | VStack { 165 | Spacer() 166 | VStack(spacing: 16) { 167 | Image(systemName: "folder.badge.questionmark") 168 | .font(.largeTitle) 169 | .foregroundColor(.secondary) 170 | Text("No folders found") 171 | .foregroundColor(.secondary) 172 | 173 | if !viewModel.isCurrentPathWritable { 174 | Label("This folder is read-only", systemImage: "lock.fill") 175 | .font(.caption) 176 | .foregroundColor(.orange) 177 | } 178 | } 179 | Spacer() 180 | } 181 | } else { 182 | List(viewModel.folders) { folder in 183 | FolderRow(folder: folder) { 184 | viewModel.navigateTo(path: folder.path) 185 | } 186 | } 187 | .listStyle(.plain) 188 | } 189 | } 190 | } 191 | 192 | struct FolderRow: View { 193 | let folder: FolderEntry 194 | let onTap: () -> Void 195 | 196 | var body: some View { 197 | Button(action: onTap) { 198 | HStack { 199 | Image(systemName: "folder.fill") 200 | .foregroundColor(folder.isWritable ? .blue : .gray) 201 | 202 | Text(folder.name) 203 | .fontDesign(.monospaced) 204 | .foregroundColor(folder.isWritable ? .primary : .secondary) 205 | 206 | Spacer() 207 | 208 | if !folder.isWritable { 209 | Image(systemName: "lock.fill") 210 | .font(.caption) 211 | .foregroundColor(.secondary) 212 | } 213 | 214 | Image(systemName: "chevron.right") 215 | .font(.caption) 216 | .foregroundColor(.secondary) 217 | } 218 | .contentShape(Rectangle()) 219 | } 220 | .buttonStyle(.plain) 221 | } 222 | } 223 | 224 | #Preview("Folder Picker") { 225 | FolderPickerView( 226 | server: Server(name: "Test Server", host: "example.com", username: "user"), 227 | initialPath: "/home/user/projects" 228 | ) { path in 229 | print("Selected path: \(path)") 230 | } 231 | } --------------------------------------------------------------------------------