├── 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 |
17 |
18 |
19 |
20 |
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 | }
--------------------------------------------------------------------------------