Initial commit
This commit is contained in:
298
oAI/Services/AnthropicOAuthService.swift
Normal file
298
oAI/Services/AnthropicOAuthService.swift
Normal file
@@ -0,0 +1,298 @@
|
||||
//
|
||||
// AnthropicOAuthService.swift
|
||||
// oAI
|
||||
//
|
||||
// OAuth 2.0 PKCE flow for Anthropic Pro/Max subscription login
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Security
|
||||
|
||||
@Observable
|
||||
class AnthropicOAuthService {
|
||||
static let shared = AnthropicOAuthService()
|
||||
|
||||
// OAuth configuration (matches Claude Code CLI)
|
||||
private let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
private let redirectURI = "https://console.anthropic.com/oauth/code/callback"
|
||||
private let scope = "org:create_api_key user:profile user:inference"
|
||||
private let tokenEndpoint = "https://console.anthropic.com/v1/oauth/token"
|
||||
|
||||
// Keychain keys
|
||||
private enum Keys {
|
||||
static let accessToken = "com.oai.anthropic.oauth.accessToken"
|
||||
static let refreshToken = "com.oai.anthropic.oauth.refreshToken"
|
||||
static let expiresAt = "com.oai.anthropic.oauth.expiresAt"
|
||||
}
|
||||
|
||||
// PKCE state for current flow
|
||||
private var currentVerifier: String?
|
||||
|
||||
// Observable state
|
||||
var isAuthenticated: Bool { accessToken != nil }
|
||||
var isLoggingIn = false
|
||||
|
||||
// MARK: - Token Access
|
||||
|
||||
var accessToken: String? {
|
||||
getKeychainValue(for: Keys.accessToken)
|
||||
}
|
||||
|
||||
private var refreshToken: String? {
|
||||
getKeychainValue(for: Keys.refreshToken)
|
||||
}
|
||||
|
||||
private var expiresAt: Date? {
|
||||
guard let str = getKeychainValue(for: Keys.expiresAt),
|
||||
let interval = Double(str) else { return nil }
|
||||
return Date(timeIntervalSince1970: interval)
|
||||
}
|
||||
|
||||
var isTokenExpired: Bool {
|
||||
guard let expires = expiresAt else { return true }
|
||||
return Date() >= expires
|
||||
}
|
||||
|
||||
// MARK: - Step 1: Generate Authorization URL
|
||||
|
||||
func generateAuthorizationURL() -> URL {
|
||||
let verifier = generateCodeVerifier()
|
||||
currentVerifier = verifier
|
||||
let challenge = generateCodeChallenge(from: verifier)
|
||||
|
||||
var components = URLComponents(string: "https://claude.ai/oauth/authorize")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "code", value: "true"),
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
||||
URLQueryItem(name: "scope", value: scope),
|
||||
URLQueryItem(name: "code_challenge", value: challenge),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||
URLQueryItem(name: "state", value: verifier),
|
||||
]
|
||||
|
||||
return components.url!
|
||||
}
|
||||
|
||||
// MARK: - Step 2: Exchange Code for Tokens
|
||||
|
||||
func exchangeCode(_ pastedCode: String) async throws {
|
||||
guard let verifier = currentVerifier else {
|
||||
throw OAuthError.noVerifier
|
||||
}
|
||||
|
||||
// Code format: "auth_code#state"
|
||||
let parts = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "#")
|
||||
let authCode: String
|
||||
let state: String
|
||||
|
||||
if parts.count >= 2 {
|
||||
authCode = parts[0]
|
||||
state = parts.dropFirst().joined(separator: "#")
|
||||
} else {
|
||||
// If no # separator, treat entire string as the code
|
||||
authCode = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
state = verifier
|
||||
}
|
||||
|
||||
Log.api.info("Exchanging OAuth code for tokens")
|
||||
|
||||
let body: [String: String] = [
|
||||
"code": authCode,
|
||||
"state": state,
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": clientId,
|
||||
"redirect_uri": redirectURI,
|
||||
"code_verifier": verifier,
|
||||
]
|
||||
|
||||
let tokenResponse = try await postTokenRequest(body)
|
||||
saveTokens(tokenResponse)
|
||||
currentVerifier = nil
|
||||
|
||||
Log.api.info("OAuth login successful, token expires in \(tokenResponse.expiresIn)s")
|
||||
}
|
||||
|
||||
// MARK: - Token Refresh
|
||||
|
||||
func refreshAccessToken() async throws {
|
||||
guard let refresh = refreshToken else {
|
||||
throw OAuthError.noRefreshToken
|
||||
}
|
||||
|
||||
Log.api.info("Refreshing OAuth access token")
|
||||
|
||||
let body: [String: String] = [
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh,
|
||||
"client_id": clientId,
|
||||
]
|
||||
|
||||
let tokenResponse = try await postTokenRequest(body)
|
||||
saveTokens(tokenResponse)
|
||||
|
||||
Log.api.info("OAuth token refreshed successfully")
|
||||
}
|
||||
|
||||
/// Returns a valid access token, refreshing if needed
|
||||
func getValidAccessToken() async throws -> String {
|
||||
guard let token = accessToken else {
|
||||
throw OAuthError.notAuthenticated
|
||||
}
|
||||
|
||||
if isTokenExpired {
|
||||
try await refreshAccessToken()
|
||||
guard let newToken = accessToken else {
|
||||
throw OAuthError.notAuthenticated
|
||||
}
|
||||
return newToken
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// MARK: - Logout
|
||||
|
||||
func logout() {
|
||||
deleteKeychainValue(for: Keys.accessToken)
|
||||
deleteKeychainValue(for: Keys.refreshToken)
|
||||
deleteKeychainValue(for: Keys.expiresAt)
|
||||
currentVerifier = nil
|
||||
Log.api.info("OAuth logout complete")
|
||||
}
|
||||
|
||||
// MARK: - PKCE Helpers
|
||||
|
||||
private func generateCodeVerifier() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return Data(bytes).base64URLEncoded()
|
||||
}
|
||||
|
||||
private func generateCodeChallenge(from verifier: String) -> String {
|
||||
let data = Data(verifier.utf8)
|
||||
let hash = SHA256.hash(data: data)
|
||||
return Data(hash).base64URLEncoded()
|
||||
}
|
||||
|
||||
// MARK: - Token Request
|
||||
|
||||
private func postTokenRequest(_ body: [String: String]) async throws -> TokenResponse {
|
||||
var request = URLRequest(url: URL(string: tokenEndpoint)!)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw OAuthError.invalidResponse
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
Log.api.error("OAuth token exchange failed HTTP \(httpResponse.statusCode): \(errorBody)")
|
||||
throw OAuthError.tokenExchangeFailed(httpResponse.statusCode, errorBody)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Token Storage
|
||||
|
||||
private func saveTokens(_ response: TokenResponse) {
|
||||
setKeychainValue(response.accessToken, for: Keys.accessToken)
|
||||
if let refresh = response.refreshToken {
|
||||
setKeychainValue(refresh, for: Keys.refreshToken)
|
||||
}
|
||||
let expiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn))
|
||||
setKeychainValue(String(expiresAt.timeIntervalSince1970), for: Keys.expiresAt)
|
||||
}
|
||||
|
||||
// MARK: - Keychain Helpers
|
||||
|
||||
private func getKeychainValue(for key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var ref: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &ref) == errSecSuccess,
|
||||
let data = ref as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func setKeychainValue(_ value: String, for key: String) {
|
||||
guard let data = value.data(using: .utf8) else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
let attrs: [String: Any] = [kSecValueData as String: data]
|
||||
let status = SecItemUpdate(query as CFDictionary, attrs as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var newItem = query
|
||||
newItem[kSecValueData as String] = data
|
||||
SecItemAdd(newItem as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteKeychainValue(for key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
struct TokenResponse: Decodable {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresIn: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
case expiresIn = "expires_in"
|
||||
}
|
||||
}
|
||||
|
||||
enum OAuthError: LocalizedError {
|
||||
case noVerifier
|
||||
case noRefreshToken
|
||||
case notAuthenticated
|
||||
case invalidResponse
|
||||
case tokenExchangeFailed(Int, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noVerifier: return "No PKCE verifier — start the login flow first."
|
||||
case .noRefreshToken: return "No refresh token available. Please log in again."
|
||||
case .notAuthenticated: return "Not authenticated. Please log in."
|
||||
case .invalidResponse: return "Invalid response from Anthropic OAuth server."
|
||||
case .tokenExchangeFailed(let code, let body):
|
||||
return "Token exchange failed (HTTP \(code)): \(body)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Base64URL Encoding
|
||||
|
||||
private extension Data {
|
||||
func base64URLEncoded() -> String {
|
||||
base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
318
oAI/Services/DatabaseService.swift
Normal file
318
oAI/Services/DatabaseService.swift
Normal file
@@ -0,0 +1,318 @@
|
||||
//
|
||||
// DatabaseService.swift
|
||||
// oAI
|
||||
//
|
||||
// SQLite persistence layer for conversations using GRDB
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import os
|
||||
|
||||
// MARK: - Database Record Types
|
||||
|
||||
struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "conversations"
|
||||
|
||||
var id: String
|
||||
var name: String
|
||||
var createdAt: String
|
||||
var updatedAt: String
|
||||
}
|
||||
|
||||
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "messages"
|
||||
|
||||
var id: String
|
||||
var conversationId: String
|
||||
var role: String
|
||||
var content: String
|
||||
var tokens: Int?
|
||||
var cost: Double?
|
||||
var timestamp: String
|
||||
var sortOrder: Int
|
||||
}
|
||||
|
||||
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "settings"
|
||||
|
||||
var key: String
|
||||
var value: String
|
||||
}
|
||||
|
||||
// MARK: - DatabaseService
|
||||
|
||||
final class DatabaseService: Sendable {
|
||||
nonisolated static let shared = DatabaseService()
|
||||
|
||||
private let dbQueue: DatabaseQueue
|
||||
private let isoFormatter: ISO8601DateFormatter
|
||||
|
||||
nonisolated private init() {
|
||||
isoFormatter = ISO8601DateFormatter()
|
||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
|
||||
|
||||
try! fileManager.createDirectory(at: dbDirectory, withIntermediateDirectories: true)
|
||||
|
||||
let dbPath = dbDirectory.appendingPathComponent("oai_conversations.db").path
|
||||
Log.db.info("Opening database at \(dbPath)")
|
||||
dbQueue = try! DatabaseQueue(path: dbPath)
|
||||
|
||||
try! migrator.migrate(dbQueue)
|
||||
}
|
||||
|
||||
private var migrator: DatabaseMigrator {
|
||||
var migrator = DatabaseMigrator()
|
||||
|
||||
migrator.registerMigration("v1") { db in
|
||||
try db.create(table: "conversations") { t in
|
||||
t.primaryKey("id", .text)
|
||||
t.column("name", .text).notNull()
|
||||
t.column("createdAt", .text).notNull()
|
||||
t.column("updatedAt", .text).notNull()
|
||||
}
|
||||
|
||||
try db.create(table: "messages") { t in
|
||||
t.primaryKey("id", .text)
|
||||
t.column("conversationId", .text).notNull()
|
||||
.references("conversations", onDelete: .cascade)
|
||||
t.column("role", .text).notNull()
|
||||
t.column("content", .text).notNull()
|
||||
t.column("tokens", .integer)
|
||||
t.column("cost", .double)
|
||||
t.column("timestamp", .text).notNull()
|
||||
t.column("sortOrder", .integer).notNull()
|
||||
}
|
||||
|
||||
try db.create(
|
||||
index: "messages_on_conversationId",
|
||||
on: "messages",
|
||||
columns: ["conversationId"]
|
||||
)
|
||||
}
|
||||
|
||||
migrator.registerMigration("v2") { db in
|
||||
try db.create(table: "settings") { t in
|
||||
t.primaryKey("key", .text)
|
||||
t.column("value", .text).notNull()
|
||||
}
|
||||
}
|
||||
|
||||
return migrator
|
||||
}
|
||||
|
||||
// MARK: - Settings Operations
|
||||
|
||||
nonisolated func loadAllSettings() throws -> [String: String] {
|
||||
try dbQueue.read { db in
|
||||
let records = try SettingRecord.fetchAll(db)
|
||||
return Dictionary(uniqueKeysWithValues: records.map { ($0.key, $0.value) })
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func setSetting(key: String, value: String) {
|
||||
try? dbQueue.write { db in
|
||||
let record = SettingRecord(key: key, value: value)
|
||||
try record.save(db)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func deleteSetting(key: String) {
|
||||
try? dbQueue.write { db in
|
||||
_ = try SettingRecord.deleteOne(db, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Conversation Operations
|
||||
|
||||
nonisolated func saveConversation(name: String, messages: [Message]) throws -> Conversation {
|
||||
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages")
|
||||
let convId = UUID()
|
||||
let now = Date()
|
||||
let nowString = isoFormatter.string(from: now)
|
||||
|
||||
let convRecord = ConversationRecord(
|
||||
id: convId.uuidString,
|
||||
name: name,
|
||||
createdAt: nowString,
|
||||
updatedAt: nowString
|
||||
)
|
||||
|
||||
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
||||
guard msg.role != .system else { return nil }
|
||||
return MessageRecord(
|
||||
id: UUID().uuidString,
|
||||
conversationId: convId.uuidString,
|
||||
role: msg.role.rawValue,
|
||||
content: msg.content,
|
||||
tokens: msg.tokens,
|
||||
cost: msg.cost,
|
||||
timestamp: isoFormatter.string(from: msg.timestamp),
|
||||
sortOrder: index
|
||||
)
|
||||
}
|
||||
|
||||
try dbQueue.write { db in
|
||||
try convRecord.insert(db)
|
||||
for record in messageRecords {
|
||||
try record.insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
let savedMessages = messages.filter { $0.role != .system }
|
||||
return Conversation(
|
||||
id: convId,
|
||||
name: name,
|
||||
messages: savedMessages,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func loadConversation(id: UUID) throws -> (Conversation, [Message])? {
|
||||
try dbQueue.read { db in
|
||||
guard let convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let messageRecords = try MessageRecord
|
||||
.filter(Column("conversationId") == id.uuidString)
|
||||
.order(Column("sortOrder"))
|
||||
.fetchAll(db)
|
||||
|
||||
let messages = messageRecords.compactMap { record -> Message? in
|
||||
guard let msgId = UUID(uuidString: record.id),
|
||||
let role = MessageRole(rawValue: record.role),
|
||||
let timestamp = self.isoFormatter.date(from: record.timestamp)
|
||||
else { return nil }
|
||||
|
||||
return Message(
|
||||
id: msgId,
|
||||
role: role,
|
||||
content: record.content,
|
||||
tokens: record.tokens,
|
||||
cost: record.cost,
|
||||
timestamp: timestamp
|
||||
)
|
||||
}
|
||||
|
||||
guard let convId = UUID(uuidString: convRecord.id),
|
||||
let createdAt = self.isoFormatter.date(from: convRecord.createdAt),
|
||||
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt)
|
||||
else { return nil }
|
||||
|
||||
let conversation = Conversation(
|
||||
id: convId,
|
||||
name: convRecord.name,
|
||||
messages: messages,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
|
||||
return (conversation, messages)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func listConversations() throws -> [Conversation] {
|
||||
try dbQueue.read { db in
|
||||
let records = try ConversationRecord
|
||||
.order(Column("updatedAt").desc)
|
||||
.fetchAll(db)
|
||||
|
||||
return records.compactMap { record -> Conversation? in
|
||||
guard let id = UUID(uuidString: record.id),
|
||||
let createdAt = self.isoFormatter.date(from: record.createdAt),
|
||||
let updatedAt = self.isoFormatter.date(from: record.updatedAt)
|
||||
else { return nil }
|
||||
|
||||
// Fetch message count without loading all messages
|
||||
let messageCount = (try? MessageRecord
|
||||
.filter(Column("conversationId") == record.id)
|
||||
.fetchCount(db)) ?? 0
|
||||
|
||||
// Get last message date
|
||||
let lastMsg = try? MessageRecord
|
||||
.filter(Column("conversationId") == record.id)
|
||||
.order(Column("sortOrder").desc)
|
||||
.fetchOne(db)
|
||||
|
||||
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt
|
||||
|
||||
// Create conversation with empty messages array but correct metadata
|
||||
var conv = Conversation(
|
||||
id: id,
|
||||
name: record.name,
|
||||
messages: Array(repeating: Message(role: .user, content: ""), count: messageCount),
|
||||
createdAt: createdAt,
|
||||
updatedAt: lastDate
|
||||
)
|
||||
// We store placeholder messages just for the count; lastMessageDate uses updatedAt
|
||||
conv.updatedAt = lastDate
|
||||
return conv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func deleteConversation(id: UUID) throws -> Bool {
|
||||
Log.db.info("Deleting conversation \(id.uuidString)")
|
||||
return try dbQueue.write { db in
|
||||
try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
|
||||
return try ConversationRecord.deleteOne(db, key: id.uuidString)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func deleteConversation(name: String) throws -> Bool {
|
||||
try dbQueue.write { db in
|
||||
guard let record = try ConversationRecord
|
||||
.filter(Column("name") == name)
|
||||
.fetchOne(db)
|
||||
else { return false }
|
||||
|
||||
try MessageRecord.filter(Column("conversationId") == record.id).deleteAll(db)
|
||||
return try ConversationRecord.deleteOne(db, key: record.id)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func updateConversation(id: UUID, name: String?, messages: [Message]?) throws -> Bool {
|
||||
try dbQueue.write { db in
|
||||
guard var convRecord = try ConversationRecord.fetchOne(db, key: id.uuidString) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let name = name {
|
||||
convRecord.name = name
|
||||
}
|
||||
|
||||
convRecord.updatedAt = self.isoFormatter.string(from: Date())
|
||||
try convRecord.update(db)
|
||||
|
||||
if let messages = messages {
|
||||
try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
|
||||
|
||||
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
||||
guard msg.role != .system else { return nil }
|
||||
return MessageRecord(
|
||||
id: UUID().uuidString,
|
||||
conversationId: id.uuidString,
|
||||
role: msg.role.rawValue,
|
||||
content: msg.content,
|
||||
tokens: msg.tokens,
|
||||
cost: msg.cost,
|
||||
timestamp: self.isoFormatter.string(from: msg.timestamp),
|
||||
sortOrder: index
|
||||
)
|
||||
}
|
||||
|
||||
for record in messageRecords {
|
||||
try record.insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
840
oAI/Services/MCPService.swift
Normal file
840
oAI/Services/MCPService.swift
Normal file
@@ -0,0 +1,840 @@
|
||||
//
|
||||
// MCPService.swift
|
||||
// oAI
|
||||
//
|
||||
// MCP (Model Context Protocol) service for filesystem tool execution
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
class MCPService {
|
||||
static let shared = MCPService()
|
||||
|
||||
private(set) var allowedFolders: [String] = []
|
||||
private let settings = SettingsService.shared
|
||||
private let fm = FileManager.default
|
||||
|
||||
private let maxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
private let maxTextDisplay = 50 * 1024 // 50 KB before truncation
|
||||
private let maxDirItems = 1000
|
||||
private let maxSearchResults = 100
|
||||
|
||||
private let skipPatterns: Set<String> = [
|
||||
".git", "node_modules", ".DS_Store", "__pycache__",
|
||||
".build", ".swiftpm", "Pods", ".Trash", ".Spotlight-V100"
|
||||
]
|
||||
|
||||
/// Cached gitignore rules per allowed folder
|
||||
private var gitignoreRules: [String: GitignoreParser] = [:]
|
||||
|
||||
private init() {
|
||||
allowedFolders = settings.mcpAllowedFolders
|
||||
if settings.mcpRespectGitignore {
|
||||
loadGitignores()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Folder Management
|
||||
|
||||
func addFolder(_ rawPath: String) -> String? {
|
||||
let expanded = (rawPath as NSString).expandingTildeInPath
|
||||
let resolved = (expanded as NSString).standardizingPath
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
|
||||
return "Path is not a directory: \(rawPath)"
|
||||
}
|
||||
|
||||
if allowedFolders.contains(resolved) {
|
||||
return "Folder already added: \(resolved)"
|
||||
}
|
||||
|
||||
allowedFolders.append(resolved)
|
||||
settings.mcpAllowedFolders = allowedFolders
|
||||
if settings.mcpRespectGitignore {
|
||||
loadGitignoreForFolder(resolved)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeFolder(at index: Int) -> Bool {
|
||||
guard index >= 0 && index < allowedFolders.count else { return false }
|
||||
let removed = allowedFolders.remove(at: index)
|
||||
settings.mcpAllowedFolders = allowedFolders
|
||||
gitignoreRules.removeValue(forKey: removed)
|
||||
return true
|
||||
}
|
||||
|
||||
func removeFolder(path: String) -> Bool {
|
||||
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
if let index = allowedFolders.firstIndex(of: resolved) {
|
||||
allowedFolders.remove(at: index)
|
||||
settings.mcpAllowedFolders = allowedFolders
|
||||
gitignoreRules.removeValue(forKey: resolved)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPathAllowed(_ path: String) -> Bool {
|
||||
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
return allowedFolders.contains { resolved.hasPrefix($0) }
|
||||
}
|
||||
|
||||
// MARK: - Permission Helpers
|
||||
|
||||
var canWriteFiles: Bool { settings.mcpCanWriteFiles }
|
||||
var canDeleteFiles: Bool { settings.mcpCanDeleteFiles }
|
||||
var canCreateDirectories: Bool { settings.mcpCanCreateDirectories }
|
||||
var canMoveFiles: Bool { settings.mcpCanMoveFiles }
|
||||
var respectGitignore: Bool { settings.mcpRespectGitignore }
|
||||
|
||||
// MARK: - Tool Schema Generation
|
||||
|
||||
func getToolSchemas() -> [Tool] {
|
||||
var tools: [Tool] = [
|
||||
makeTool(
|
||||
name: "read_file",
|
||||
description: "Read the contents of a file. Returns the text content of the file. Maximum file size is 10MB.",
|
||||
properties: [
|
||||
"file_path": prop("string", "The absolute path to the file to read")
|
||||
],
|
||||
required: ["file_path"]
|
||||
),
|
||||
makeTool(
|
||||
name: "list_directory",
|
||||
description: "List the contents of a directory. Returns file and directory names. Skips hidden/build directories like .git, node_modules, etc.",
|
||||
properties: [
|
||||
"dir_path": prop("string", "The absolute path to the directory to list"),
|
||||
"recursive": prop("boolean", "Whether to list recursively (default: false)")
|
||||
],
|
||||
required: ["dir_path"]
|
||||
),
|
||||
makeTool(
|
||||
name: "search_files",
|
||||
description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents.",
|
||||
properties: [
|
||||
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
|
||||
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
|
||||
"content_search": prop("string", "Optional text to search for inside files")
|
||||
],
|
||||
required: ["pattern"]
|
||||
)
|
||||
]
|
||||
|
||||
if canWriteFiles {
|
||||
tools.append(makeTool(
|
||||
name: "write_file",
|
||||
description: "Create or overwrite a file with the given content. Parent directories are created automatically.",
|
||||
properties: [
|
||||
"file_path": prop("string", "The absolute path to the file to write"),
|
||||
"content": prop("string", "The text content to write to the file")
|
||||
],
|
||||
required: ["file_path", "content"]
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "edit_file",
|
||||
description: "Find and replace text in a file. The old_text must appear exactly once in the file.",
|
||||
properties: [
|
||||
"file_path": prop("string", "The absolute path to the file to edit"),
|
||||
"old_text": prop("string", "The exact text to find (must be a unique match)"),
|
||||
"new_text": prop("string", "The replacement text")
|
||||
],
|
||||
required: ["file_path", "old_text", "new_text"]
|
||||
))
|
||||
}
|
||||
|
||||
if canDeleteFiles {
|
||||
tools.append(makeTool(
|
||||
name: "delete_file",
|
||||
description: "Delete a file at the given path.",
|
||||
properties: [
|
||||
"file_path": prop("string", "The absolute path to the file to delete")
|
||||
],
|
||||
required: ["file_path"]
|
||||
))
|
||||
}
|
||||
|
||||
if canCreateDirectories {
|
||||
tools.append(makeTool(
|
||||
name: "create_directory",
|
||||
description: "Create a directory (and any intermediate directories) at the given path.",
|
||||
properties: [
|
||||
"dir_path": prop("string", "The absolute path to the directory to create")
|
||||
],
|
||||
required: ["dir_path"]
|
||||
))
|
||||
}
|
||||
|
||||
if canMoveFiles {
|
||||
tools.append(makeTool(
|
||||
name: "move_file",
|
||||
description: "Move or rename a file or directory.",
|
||||
properties: [
|
||||
"source": prop("string", "The absolute path of the file/directory to move"),
|
||||
"destination": prop("string", "The absolute destination path")
|
||||
],
|
||||
required: ["source", "destination"]
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "copy_file",
|
||||
description: "Copy a file or directory to a new location.",
|
||||
properties: [
|
||||
"source": prop("string", "The absolute path of the file/directory to copy"),
|
||||
"destination": prop("string", "The absolute destination path for the copy")
|
||||
],
|
||||
required: ["source", "destination"]
|
||||
))
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||
Tool(
|
||||
type: "function",
|
||||
function: Tool.Function(
|
||||
name: name,
|
||||
description: description,
|
||||
parameters: Tool.Function.Parameters(
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
|
||||
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
|
||||
}
|
||||
|
||||
// MARK: - Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) -> [String: Any] {
|
||||
Log.mcp.info("Executing tool: \(name)")
|
||||
guard let argData = arguments.data(using: .utf8),
|
||||
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
|
||||
Log.mcp.error("Invalid arguments JSON for tool \(name)")
|
||||
return ["error": "Invalid arguments JSON"]
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "read_file":
|
||||
guard let filePath = args["file_path"] as? String else {
|
||||
return ["error": "Missing required parameter: file_path"]
|
||||
}
|
||||
return readFile(filePath: filePath)
|
||||
|
||||
case "list_directory":
|
||||
guard let dirPath = args["dir_path"] as? String else {
|
||||
return ["error": "Missing required parameter: dir_path"]
|
||||
}
|
||||
let recursive = args["recursive"] as? Bool ?? false
|
||||
return listDirectory(dirPath: dirPath, recursive: recursive)
|
||||
|
||||
case "search_files":
|
||||
guard let pattern = args["pattern"] as? String else {
|
||||
return ["error": "Missing required parameter: pattern"]
|
||||
}
|
||||
let searchPath = args["search_path"] as? String
|
||||
let contentSearch = args["content_search"] as? String
|
||||
return searchFiles(pattern: pattern, searchPath: searchPath, contentSearch: contentSearch)
|
||||
|
||||
case "write_file":
|
||||
guard canWriteFiles else {
|
||||
return ["error": "Permission denied: write_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
|
||||
}
|
||||
guard let filePath = args["file_path"] as? String,
|
||||
let content = args["content"] as? String else {
|
||||
return ["error": "Missing required parameters: file_path, content"]
|
||||
}
|
||||
return writeFile(filePath: filePath, content: content)
|
||||
|
||||
case "edit_file":
|
||||
guard canWriteFiles else {
|
||||
return ["error": "Permission denied: edit_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
|
||||
}
|
||||
guard let filePath = args["file_path"] as? String,
|
||||
let oldText = args["old_text"] as? String,
|
||||
let newText = args["new_text"] as? String else {
|
||||
return ["error": "Missing required parameters: file_path, old_text, new_text"]
|
||||
}
|
||||
return editFile(filePath: filePath, oldText: oldText, newText: newText)
|
||||
|
||||
case "delete_file":
|
||||
guard canDeleteFiles else {
|
||||
return ["error": "Permission denied: delete_file is not enabled. Enable 'Delete Files' in Settings > MCP."]
|
||||
}
|
||||
guard let filePath = args["file_path"] as? String else {
|
||||
return ["error": "Missing required parameter: file_path"]
|
||||
}
|
||||
return deleteFile(filePath: filePath)
|
||||
|
||||
case "create_directory":
|
||||
guard canCreateDirectories else {
|
||||
return ["error": "Permission denied: create_directory is not enabled. Enable 'Create Directories' in Settings > MCP."]
|
||||
}
|
||||
guard let dirPath = args["dir_path"] as? String else {
|
||||
return ["error": "Missing required parameter: dir_path"]
|
||||
}
|
||||
return createDirectory(dirPath: dirPath)
|
||||
|
||||
case "move_file":
|
||||
guard canMoveFiles else {
|
||||
return ["error": "Permission denied: move_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
|
||||
}
|
||||
guard let source = args["source"] as? String,
|
||||
let destination = args["destination"] as? String else {
|
||||
return ["error": "Missing required parameters: source, destination"]
|
||||
}
|
||||
return moveFile(source: source, destination: destination)
|
||||
|
||||
case "copy_file":
|
||||
guard canMoveFiles else {
|
||||
return ["error": "Permission denied: copy_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
|
||||
}
|
||||
guard let source = args["source"] as? String,
|
||||
let destination = args["destination"] as? String else {
|
||||
return ["error": "Missing required parameters: source, destination"]
|
||||
}
|
||||
return copyFile(source: source, destination: destination)
|
||||
|
||||
default:
|
||||
return ["error": "Unknown tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Read Tool Implementations
|
||||
|
||||
private func readFile(filePath: String) -> [String: Any] {
|
||||
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
guard isPathAllowed(resolved) else {
|
||||
Log.mcp.warning("Read denied: path outside allowed folders: \(resolved)")
|
||||
return ["error": "Access denied: path is outside allowed folders"]
|
||||
}
|
||||
|
||||
guard fm.fileExists(atPath: resolved) else {
|
||||
return ["error": "File not found: \(filePath)"]
|
||||
}
|
||||
|
||||
guard let attrs = try? fm.attributesOfItem(atPath: resolved),
|
||||
let fileSize = attrs[.size] as? Int else {
|
||||
return ["error": "Cannot read file attributes: \(filePath)"]
|
||||
}
|
||||
|
||||
if fileSize > maxFileSize {
|
||||
let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
|
||||
return ["error": "File too large (\(sizeMB) MB, max 10 MB)"]
|
||||
}
|
||||
|
||||
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
|
||||
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
|
||||
}
|
||||
|
||||
var finalContent = content
|
||||
if content.utf8.count > maxTextDisplay {
|
||||
let lines = content.components(separatedBy: "\n")
|
||||
if lines.count > 600 {
|
||||
let head = lines.prefix(500).joined(separator: "\n")
|
||||
let tail = lines.suffix(100).joined(separator: "\n")
|
||||
let omitted = lines.count - 600
|
||||
finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail
|
||||
}
|
||||
}
|
||||
|
||||
return ["content": finalContent, "path": resolved, "size": fileSize]
|
||||
}
|
||||
|
||||
private func listDirectory(dirPath: String, recursive: Bool) -> [String: Any] {
|
||||
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
guard isPathAllowed(resolved) else {
|
||||
return ["error": "Access denied: path is outside allowed folders"]
|
||||
}
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
|
||||
return ["error": "Directory not found: \(dirPath)"]
|
||||
}
|
||||
|
||||
var items: [String] = []
|
||||
|
||||
if recursive {
|
||||
guard let enumerator = fm.enumerator(atPath: resolved) else {
|
||||
return ["error": "Cannot enumerate directory"]
|
||||
}
|
||||
while let item = enumerator.nextObject() as? String {
|
||||
let components = item.components(separatedBy: "/")
|
||||
if components.contains(where: { skipPatterns.contains($0) }) {
|
||||
enumerator.skipDescendants()
|
||||
continue
|
||||
}
|
||||
let fullPath = (resolved as NSString).appendingPathComponent(item)
|
||||
if respectGitignore && isGitignored(fullPath) {
|
||||
// Skip gitignored directories entirely
|
||||
var itemIsDir: ObjCBool = false
|
||||
if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
|
||||
enumerator.skipDescendants()
|
||||
}
|
||||
continue
|
||||
}
|
||||
items.append(item)
|
||||
if items.count >= maxDirItems { break }
|
||||
}
|
||||
} else {
|
||||
guard let contents = try? fm.contentsOfDirectory(atPath: resolved) else {
|
||||
return ["error": "Cannot list directory"]
|
||||
}
|
||||
items = contents.filter { !skipPatterns.contains($0) }
|
||||
.filter { entry in
|
||||
if respectGitignore {
|
||||
let fullPath = (resolved as NSString).appendingPathComponent(entry)
|
||||
return !isGitignored(fullPath)
|
||||
}
|
||||
return true
|
||||
}
|
||||
.prefix(maxDirItems).map { entry in
|
||||
var entryIsDir: ObjCBool = false
|
||||
let fullPath = (resolved as NSString).appendingPathComponent(entry)
|
||||
fm.fileExists(atPath: fullPath, isDirectory: &entryIsDir)
|
||||
return entryIsDir.boolValue ? "\(entry)/" : entry
|
||||
}
|
||||
}
|
||||
|
||||
let truncated = items.count >= maxDirItems
|
||||
return ["items": items, "count": items.count, "truncated": truncated, "path": resolved]
|
||||
}
|
||||
|
||||
private func searchFiles(pattern: String, searchPath: String?, contentSearch: String?) -> [String: Any] {
|
||||
let basePath: String
|
||||
if let sp = searchPath {
|
||||
basePath = ((sp as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
} else if let first = allowedFolders.first {
|
||||
basePath = first
|
||||
} else {
|
||||
return ["error": "No search path specified and no allowed folders configured"]
|
||||
}
|
||||
|
||||
guard isPathAllowed(basePath) else {
|
||||
return ["error": "Access denied: search path is outside allowed folders"]
|
||||
}
|
||||
|
||||
guard let enumerator = fm.enumerator(atPath: basePath) else {
|
||||
return ["error": "Cannot enumerate directory: \(basePath)"]
|
||||
}
|
||||
|
||||
var results: [String] = []
|
||||
|
||||
while let item = enumerator.nextObject() as? String {
|
||||
let components = item.components(separatedBy: "/")
|
||||
if components.contains(where: { skipPatterns.contains($0) }) {
|
||||
enumerator.skipDescendants()
|
||||
continue
|
||||
}
|
||||
|
||||
let fullPath = (basePath as NSString).appendingPathComponent(item)
|
||||
|
||||
if respectGitignore && isGitignored(fullPath) {
|
||||
var itemIsDir: ObjCBool = false
|
||||
if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
|
||||
enumerator.skipDescendants()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let filename = (item as NSString).lastPathComponent
|
||||
|
||||
// Filename pattern match
|
||||
if fnmatch(pattern, filename, 0) != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Content search if requested
|
||||
if let searchText = contentSearch, !searchText.isEmpty {
|
||||
guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else {
|
||||
continue
|
||||
}
|
||||
if !content.localizedCaseInsensitiveContains(searchText) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
results.append(item)
|
||||
if results.count >= maxSearchResults { break }
|
||||
}
|
||||
|
||||
let truncated = results.count >= maxSearchResults
|
||||
return ["matches": results, "count": results.count, "truncated": truncated, "base_path": basePath]
|
||||
}
|
||||
|
||||
// MARK: - Write Tool Implementations
|
||||
|
||||
private func writeFile(filePath: String, content: String) -> [String: Any] {
|
||||
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
guard isPathAllowed(resolved) else {
|
||||
Log.mcp.warning("Write denied: path outside allowed folders: \(resolved)")
|
||||
return ["error": "Access denied: path is outside allowed folders"]
|
||||
}
|
||||
|
||||
// Create parent directories if needed
|
||||
let parentDir = (resolved as NSString).deletingLastPathComponent
|
||||
if !fm.fileExists(atPath: parentDir) {
|
||||
do {
|
||||
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
return ["error": "Cannot create parent directories: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try content.write(toFile: resolved, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
Log.mcp.error("Failed to write file \(resolved): \(error.localizedDescription)")
|
||||
return ["error": "Cannot write file: \(error.localizedDescription)"]
|
||||
}
|
||||
|
||||
Log.mcp.info("Wrote \(content.utf8.count) bytes to \(resolved)")
|
||||
return ["success": true, "path": resolved, "bytes_written": content.utf8.count]
|
||||
}
|
||||
|
||||
private func editFile(filePath: String, oldText: String, newText: String) -> [String: Any] {
|
||||
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
guard isPathAllowed(resolved) else {
|
||||
Log.mcp.warning("Edit denied: path outside allowed folders: \(resolved)")
|
||||
return ["error": "Access denied: path is outside allowed folders"]
|
||||
}
|
||||
|
||||
guard fm.fileExists(atPath: resolved) else {
|
||||
return ["error": "File not found: \(filePath)"]
|
||||
}
|
||||
|
||||
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
|
||||
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
|
||||
}
|
||||
|
||||
// Count occurrences
|
||||
let occurrences = content.components(separatedBy: oldText).count - 1
|
||||
if occurrences == 0 {
|
||||
return ["error": "old_text not found in file"]
|
||||
}
|
||||
if occurrences > 1 {
|
||||
return ["error": "old_text appears \(occurrences) times in the file — it must be unique (exactly 1 match). Provide more surrounding context to make it unique."]
|
||||
}
|
||||
|
||||
let newContent = content.replacingOccurrences(of: oldText, with: newText)
|
||||
do {
|
||||
try newContent.write(toFile: resolved, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
return ["error": "Cannot write file: \(error.localizedDescription)"]
|
||||
}
|
||||
|
||||
return ["success": true, "path": resolved]
|
||||
}
|
||||
|
||||
private func deleteFile(filePath: String) -> [String: Any] {
|
||||
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
guard isPathAllowed(resolved) else {
|
||||
Log.mcp.warning("Delete denied: path outside allowed folders: \(resolved)")
|
||||
return ["error": "Access denied: path is outside allowed folders"]
|
||||
}
|
||||
|
||||
guard fm.fileExists(atPath: resolved) else {
|
||||
return ["error": "File not found: \(filePath)"]
|
||||
}
|
||||
|
||||
do {
|
||||
try fm.removeItem(atPath: resolved)
|
||||
} catch {
|
||||
Log.mcp.error("Failed to delete \(resolved): \(error.localizedDescription)")
|
||||
return ["error": "Cannot delete file: \(error.localizedDescription)"]
|
||||
}
|
||||
|
||||
Log.mcp.info("Deleted \(resolved)")
|
||||
return ["success": true, "path": resolved]
|
||||
}
|
||||
|
||||
private func createDirectory(dirPath: String) -> [String: Any] {
|
||||
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
guard isPathAllowed(resolved) else {
|
||||
return ["error": "Access denied: path is outside allowed folders"]
|
||||
}
|
||||
|
||||
do {
|
||||
try fm.createDirectory(atPath: resolved, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
return ["error": "Cannot create directory: \(error.localizedDescription)"]
|
||||
}
|
||||
|
||||
return ["success": true, "path": resolved]
|
||||
}
|
||||
|
||||
private func moveFile(source: String, destination: String) -> [String: Any] {
|
||||
let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
guard isPathAllowed(resolvedSrc) else {
|
||||
return ["error": "Access denied: source path is outside allowed folders"]
|
||||
}
|
||||
guard isPathAllowed(resolvedDst) else {
|
||||
return ["error": "Access denied: destination path is outside allowed folders"]
|
||||
}
|
||||
guard fm.fileExists(atPath: resolvedSrc) else {
|
||||
return ["error": "Source not found: \(source)"]
|
||||
}
|
||||
|
||||
// Create parent directory of destination if needed
|
||||
let parentDir = (resolvedDst as NSString).deletingLastPathComponent
|
||||
if !fm.fileExists(atPath: parentDir) {
|
||||
do {
|
||||
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try fm.moveItem(atPath: resolvedSrc, toPath: resolvedDst)
|
||||
} catch {
|
||||
return ["error": "Cannot move file: \(error.localizedDescription)"]
|
||||
}
|
||||
|
||||
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
|
||||
}
|
||||
|
||||
private func copyFile(source: String, destination: String) -> [String: Any] {
|
||||
let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
guard isPathAllowed(resolvedSrc) else {
|
||||
return ["error": "Access denied: source path is outside allowed folders"]
|
||||
}
|
||||
guard isPathAllowed(resolvedDst) else {
|
||||
return ["error": "Access denied: destination path is outside allowed folders"]
|
||||
}
|
||||
guard fm.fileExists(atPath: resolvedSrc) else {
|
||||
return ["error": "Source not found: \(source)"]
|
||||
}
|
||||
|
||||
// Create parent directory of destination if needed
|
||||
let parentDir = (resolvedDst as NSString).deletingLastPathComponent
|
||||
if !fm.fileExists(atPath: parentDir) {
|
||||
do {
|
||||
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try fm.copyItem(atPath: resolvedSrc, toPath: resolvedDst)
|
||||
} catch {
|
||||
return ["error": "Cannot copy file: \(error.localizedDescription)"]
|
||||
}
|
||||
|
||||
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
|
||||
}
|
||||
|
||||
// MARK: - Gitignore Support
|
||||
|
||||
/// Reload gitignore rules for all allowed folders
|
||||
func reloadGitignores() {
|
||||
gitignoreRules.removeAll()
|
||||
if settings.mcpRespectGitignore {
|
||||
loadGitignores()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadGitignores() {
|
||||
for folder in allowedFolders {
|
||||
loadGitignoreForFolder(folder)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadGitignoreForFolder(_ folder: String) {
|
||||
var parser = GitignoreParser(rootPath: folder)
|
||||
parser.loadRules(fileManager: fm)
|
||||
gitignoreRules[folder] = parser
|
||||
}
|
||||
|
||||
/// Check if an absolute path is gitignored by any loaded gitignore rules
|
||||
func isGitignored(_ absolutePath: String) -> Bool {
|
||||
guard settings.mcpRespectGitignore else { return false }
|
||||
|
||||
for (folder, parser) in gitignoreRules {
|
||||
if absolutePath.hasPrefix(folder) {
|
||||
let relativePath = String(absolutePath.dropFirst(folder.count).drop(while: { $0 == "/" }))
|
||||
if !relativePath.isEmpty && parser.isIgnored(relativePath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GitignoreParser
|
||||
|
||||
/// Parses .gitignore files and checks paths against the patterns.
|
||||
/// Supports: wildcards (*), double wildcards (**), directory patterns (/), negation (!), comments (#).
|
||||
struct GitignoreParser {
|
||||
let rootPath: String
|
||||
private var rules: [GitignoreRule] = []
|
||||
|
||||
struct GitignoreRule {
|
||||
let pattern: String
|
||||
let isNegation: Bool
|
||||
let isDirectoryOnly: Bool
|
||||
/// Regex compiled from the gitignore glob pattern
|
||||
let regex: NSRegularExpression?
|
||||
}
|
||||
|
||||
init(rootPath: String) {
|
||||
self.rootPath = rootPath
|
||||
}
|
||||
|
||||
/// Load .gitignore from the root path (non-recursive — only the root .gitignore)
|
||||
mutating func loadRules(fileManager fm: FileManager) {
|
||||
let gitignorePath = (rootPath as NSString).appendingPathComponent(".gitignore")
|
||||
guard let content = try? String(contentsOfFile: gitignorePath, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
parseContent(content)
|
||||
}
|
||||
|
||||
mutating func parseContent(_ content: String) {
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
// Skip empty lines and comments
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
|
||||
var pattern = trimmed
|
||||
let isNegation = pattern.hasPrefix("!")
|
||||
if isNegation {
|
||||
pattern = String(pattern.dropFirst())
|
||||
}
|
||||
|
||||
// Remove trailing spaces (unless escaped)
|
||||
while pattern.hasSuffix(" ") && !pattern.hasSuffix("\\ ") {
|
||||
pattern = String(pattern.dropLast())
|
||||
}
|
||||
|
||||
let isDirectoryOnly = pattern.hasSuffix("/")
|
||||
if isDirectoryOnly {
|
||||
pattern = String(pattern.dropLast())
|
||||
}
|
||||
|
||||
// Remove leading slash (anchors to root, but we match relative paths)
|
||||
if pattern.hasPrefix("/") {
|
||||
pattern = String(pattern.dropFirst())
|
||||
}
|
||||
|
||||
guard !pattern.isEmpty else { continue }
|
||||
|
||||
let regex = gitignorePatternToRegex(pattern)
|
||||
rules.append(GitignoreRule(
|
||||
pattern: pattern,
|
||||
isNegation: isNegation,
|
||||
isDirectoryOnly: isDirectoryOnly,
|
||||
regex: regex
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a relative path (from rootPath) is ignored
|
||||
func isIgnored(_ relativePath: String) -> Bool {
|
||||
var ignored = false
|
||||
for rule in rules {
|
||||
let matches: Bool
|
||||
if let regex = rule.regex {
|
||||
let range = NSRange(relativePath.startIndex..., in: relativePath)
|
||||
matches = regex.firstMatch(in: relativePath, range: range) != nil
|
||||
} else {
|
||||
// Fallback: simple contains check for the pattern basename
|
||||
let basename = (relativePath as NSString).lastPathComponent
|
||||
matches = basename == rule.pattern
|
||||
}
|
||||
|
||||
if matches {
|
||||
if rule.isNegation {
|
||||
ignored = false
|
||||
} else {
|
||||
ignored = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return ignored
|
||||
}
|
||||
|
||||
/// Convert a gitignore glob pattern to a regex
|
||||
private func gitignorePatternToRegex(_ pattern: String) -> NSRegularExpression? {
|
||||
var regex = ""
|
||||
let chars = Array(pattern)
|
||||
var i = 0
|
||||
|
||||
// If the pattern contains no slash, it matches against the filename only
|
||||
let matchesPath = pattern.contains("/")
|
||||
|
||||
if !matchesPath {
|
||||
// Match against any path component — the pattern can appear as the last component
|
||||
regex += "(?:^|/)"
|
||||
} else {
|
||||
regex += "^"
|
||||
}
|
||||
|
||||
while i < chars.count {
|
||||
let c = chars[i]
|
||||
switch c {
|
||||
case "*":
|
||||
if i + 1 < chars.count && chars[i + 1] == "*" {
|
||||
// **
|
||||
if i + 2 < chars.count && chars[i + 2] == "/" {
|
||||
// **/ — matches zero or more directories
|
||||
regex += "(?:.+/)?"
|
||||
i += 3
|
||||
continue
|
||||
} else {
|
||||
// ** at end — matches everything
|
||||
regex += ".*"
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Single * — matches anything except /
|
||||
regex += "[^/]*"
|
||||
}
|
||||
case "?":
|
||||
regex += "[^/]"
|
||||
case ".":
|
||||
regex += "\\."
|
||||
case "[":
|
||||
// Character class — pass through
|
||||
regex += "["
|
||||
case "]":
|
||||
regex += "]"
|
||||
case "\\":
|
||||
// Escape next character
|
||||
if i + 1 < chars.count {
|
||||
i += 1
|
||||
regex += NSRegularExpression.escapedPattern(for: String(chars[i]))
|
||||
}
|
||||
default:
|
||||
regex += NSRegularExpression.escapedPattern(for: String(c))
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
// Allow matching as a prefix (directory) or exact match
|
||||
regex += "(?:/.*)?$"
|
||||
|
||||
return try? NSRegularExpression(pattern: regex, options: [])
|
||||
}
|
||||
}
|
||||
408
oAI/Services/SettingsService.swift
Normal file
408
oAI/Services/SettingsService.swift
Normal file
@@ -0,0 +1,408 @@
|
||||
//
|
||||
// SettingsService.swift
|
||||
// oAI
|
||||
//
|
||||
// Settings persistence: SQLite for preferences, Keychain for API keys
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
import Security
|
||||
|
||||
@Observable
|
||||
class SettingsService {
|
||||
static let shared = SettingsService()
|
||||
|
||||
// In-memory cache of DB settings for fast reads
|
||||
private var cache: [String: String] = [:]
|
||||
|
||||
// Keychain keys (secrets only)
|
||||
private enum KeychainKeys {
|
||||
static let openrouterAPIKey = "com.oai.apikey.openrouter"
|
||||
static let anthropicAPIKey = "com.oai.apikey.anthropic"
|
||||
static let openaiAPIKey = "com.oai.apikey.openai"
|
||||
static let googleAPIKey = "com.oai.apikey.google"
|
||||
static let googleSearchEngineID = "com.oai.google.searchEngineID"
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Load all settings from DB into cache
|
||||
cache = (try? DatabaseService.shared.loadAllSettings()) ?? [:]
|
||||
Log.settings.info("Settings initialized with \(self.cache.count) cached entries")
|
||||
|
||||
// Migrate from UserDefaults on first launch
|
||||
migrateFromUserDefaultsIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Provider Settings
|
||||
|
||||
var defaultProvider: Settings.Provider {
|
||||
get {
|
||||
if let raw = cache["defaultProvider"],
|
||||
let provider = Settings.Provider(rawValue: raw) {
|
||||
return provider
|
||||
}
|
||||
return .openrouter
|
||||
}
|
||||
set {
|
||||
cache["defaultProvider"] = newValue.rawValue
|
||||
DatabaseService.shared.setSetting(key: "defaultProvider", value: newValue.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var defaultModel: String? {
|
||||
get { cache["defaultModel"] }
|
||||
set {
|
||||
if let value = newValue {
|
||||
cache["defaultModel"] = value
|
||||
DatabaseService.shared.setSetting(key: "defaultModel", value: value)
|
||||
} else {
|
||||
cache.removeValue(forKey: "defaultModel")
|
||||
DatabaseService.shared.deleteSetting(key: "defaultModel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model Settings
|
||||
|
||||
var streamEnabled: Bool {
|
||||
get { cache["streamEnabled"].map { $0 == "true" } ?? true }
|
||||
set {
|
||||
cache["streamEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "streamEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var maxTokens: Int {
|
||||
get { cache["maxTokens"].flatMap(Int.init) ?? 0 }
|
||||
set {
|
||||
cache["maxTokens"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "maxTokens", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var temperature: Double {
|
||||
get { cache["temperature"].flatMap(Double.init) ?? 0.0 }
|
||||
set {
|
||||
cache["temperature"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "temperature", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Settings
|
||||
|
||||
var onlineMode: Bool {
|
||||
get { cache["onlineMode"] == "true" }
|
||||
set {
|
||||
cache["onlineMode"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "onlineMode", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var memoryEnabled: Bool {
|
||||
get { cache["memoryEnabled"].map { $0 == "true" } ?? true }
|
||||
set {
|
||||
cache["memoryEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "memoryEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var mcpEnabled: Bool {
|
||||
get { cache["mcpEnabled"] == "true" }
|
||||
set {
|
||||
cache["mcpEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "mcpEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Text Size Settings
|
||||
|
||||
/// GUI text size (headers, labels, buttons) — default 13
|
||||
var guiTextSize: Double {
|
||||
get { cache["guiTextSize"].flatMap(Double.init) ?? 13.0 }
|
||||
set {
|
||||
cache["guiTextSize"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "guiTextSize", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog/chat message text size — default 14
|
||||
var dialogTextSize: Double {
|
||||
get { cache["dialogTextSize"].flatMap(Double.init) ?? 14.0 }
|
||||
set {
|
||||
cache["dialogTextSize"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "dialogTextSize", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// Input box text size — default 14
|
||||
var inputTextSize: Double {
|
||||
get { cache["inputTextSize"].flatMap(Double.init) ?? 14.0 }
|
||||
set {
|
||||
cache["inputTextSize"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "inputTextSize", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MCP Permissions
|
||||
|
||||
var mcpCanWriteFiles: Bool {
|
||||
get { cache["mcpCanWriteFiles"] == "true" }
|
||||
set {
|
||||
cache["mcpCanWriteFiles"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "mcpCanWriteFiles", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var mcpCanDeleteFiles: Bool {
|
||||
get { cache["mcpCanDeleteFiles"] == "true" }
|
||||
set {
|
||||
cache["mcpCanDeleteFiles"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "mcpCanDeleteFiles", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var mcpCanCreateDirectories: Bool {
|
||||
get { cache["mcpCanCreateDirectories"] == "true" }
|
||||
set {
|
||||
cache["mcpCanCreateDirectories"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "mcpCanCreateDirectories", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var mcpCanMoveFiles: Bool {
|
||||
get { cache["mcpCanMoveFiles"] == "true" }
|
||||
set {
|
||||
cache["mcpCanMoveFiles"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "mcpCanMoveFiles", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var mcpRespectGitignore: Bool {
|
||||
get { cache["mcpRespectGitignore"].map { $0 == "true" } ?? true }
|
||||
set {
|
||||
cache["mcpRespectGitignore"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "mcpRespectGitignore", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MCP Allowed Folders
|
||||
|
||||
var mcpAllowedFolders: [String] {
|
||||
get {
|
||||
guard let json = cache["mcpAllowedFolders"],
|
||||
let data = json.data(using: .utf8),
|
||||
let folders = try? JSONDecoder().decode([String].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return folders
|
||||
}
|
||||
set {
|
||||
if let data = try? JSONEncoder().encode(newValue),
|
||||
let json = String(data: data, encoding: .utf8) {
|
||||
cache["mcpAllowedFolders"] = json
|
||||
DatabaseService.shared.setSetting(key: "mcpAllowedFolders", value: json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Settings
|
||||
|
||||
var searchProvider: Settings.SearchProvider {
|
||||
get {
|
||||
if let raw = cache["searchProvider"],
|
||||
let provider = Settings.SearchProvider(rawValue: raw) {
|
||||
return provider
|
||||
}
|
||||
return .duckduckgo
|
||||
}
|
||||
set {
|
||||
cache["searchProvider"] = newValue.rawValue
|
||||
DatabaseService.shared.setSetting(key: "searchProvider", value: newValue.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ollama Settings
|
||||
|
||||
var ollamaBaseURL: String {
|
||||
get { cache["ollamaBaseURL"] ?? "" }
|
||||
set {
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty {
|
||||
cache.removeValue(forKey: "ollamaBaseURL")
|
||||
DatabaseService.shared.deleteSetting(key: "ollamaBaseURL")
|
||||
} else {
|
||||
cache["ollamaBaseURL"] = trimmed
|
||||
DatabaseService.shared.setSetting(key: "ollamaBaseURL", value: trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved Ollama URL — returns the user value or the default
|
||||
var ollamaEffectiveURL: String {
|
||||
let url = ollamaBaseURL
|
||||
return url.isEmpty ? "http://localhost:11434" : url
|
||||
}
|
||||
|
||||
/// Whether the user has explicitly configured an Ollama URL
|
||||
var ollamaConfigured: Bool {
|
||||
cache["ollamaBaseURL"] != nil && !(cache["ollamaBaseURL"]!.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - API Keys (Keychain)
|
||||
|
||||
var openrouterAPIKey: String? {
|
||||
get { getKeychainValue(for: KeychainKeys.openrouterAPIKey) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
setKeychainValue(value, for: KeychainKeys.openrouterAPIKey)
|
||||
} else {
|
||||
deleteKeychainValue(for: KeychainKeys.openrouterAPIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var anthropicAPIKey: String? {
|
||||
get { getKeychainValue(for: KeychainKeys.anthropicAPIKey) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
setKeychainValue(value, for: KeychainKeys.anthropicAPIKey)
|
||||
} else {
|
||||
deleteKeychainValue(for: KeychainKeys.anthropicAPIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var openaiAPIKey: String? {
|
||||
get { getKeychainValue(for: KeychainKeys.openaiAPIKey) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
setKeychainValue(value, for: KeychainKeys.openaiAPIKey)
|
||||
} else {
|
||||
deleteKeychainValue(for: KeychainKeys.openaiAPIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var googleAPIKey: String? {
|
||||
get { getKeychainValue(for: KeychainKeys.googleAPIKey) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
setKeychainValue(value, for: KeychainKeys.googleAPIKey)
|
||||
} else {
|
||||
deleteKeychainValue(for: KeychainKeys.googleAPIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var googleSearchEngineID: String? {
|
||||
get { getKeychainValue(for: KeychainKeys.googleSearchEngineID) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
setKeychainValue(value, for: KeychainKeys.googleSearchEngineID)
|
||||
} else {
|
||||
deleteKeychainValue(for: KeychainKeys.googleSearchEngineID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UserDefaults Migration
|
||||
|
||||
private func migrateFromUserDefaultsIfNeeded() {
|
||||
// Skip if already migrated
|
||||
guard cache["_migrated"] == nil else { return }
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let migrations: [(udKey: String, dbKey: String)] = [
|
||||
("defaultProvider", "defaultProvider"),
|
||||
("defaultModel", "defaultModel"),
|
||||
("streamEnabled", "streamEnabled"),
|
||||
("maxTokens", "maxTokens"),
|
||||
("temperature", "temperature"),
|
||||
("onlineMode", "onlineMode"),
|
||||
("memoryEnabled", "memoryEnabled"),
|
||||
("mcpEnabled", "mcpEnabled"),
|
||||
("searchProvider", "searchProvider"),
|
||||
("ollamaBaseURL", "ollamaBaseURL"),
|
||||
]
|
||||
|
||||
for (udKey, dbKey) in migrations {
|
||||
guard cache[dbKey] == nil else { continue }
|
||||
|
||||
if let stringVal = defaults.string(forKey: udKey) {
|
||||
cache[dbKey] = stringVal
|
||||
DatabaseService.shared.setSetting(key: dbKey, value: stringVal)
|
||||
} else if defaults.object(forKey: udKey) != nil {
|
||||
// Handle bool/int/double stored as non-string
|
||||
let value: String
|
||||
if let boolVal = defaults.object(forKey: udKey) as? Bool {
|
||||
value = String(boolVal)
|
||||
} else if defaults.integer(forKey: udKey) != 0 {
|
||||
value = String(defaults.integer(forKey: udKey))
|
||||
} else if defaults.double(forKey: udKey) != 0.0 {
|
||||
value = String(defaults.double(forKey: udKey))
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
cache[dbKey] = value
|
||||
DatabaseService.shared.setSetting(key: dbKey, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark migration complete
|
||||
cache["_migrated"] = "true"
|
||||
DatabaseService.shared.setSetting(key: "_migrated", value: "true")
|
||||
}
|
||||
|
||||
// MARK: - Keychain Helpers
|
||||
|
||||
private func getKeychainValue(for key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var dataTypeRef: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = dataTypeRef as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
private func setKeychainValue(_ value: String, for key: String) {
|
||||
guard let data = value.data(using: .utf8) else { return }
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data
|
||||
]
|
||||
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
|
||||
if updateStatus == errSecItemNotFound {
|
||||
var newItem = query
|
||||
newItem[kSecValueData as String] = data
|
||||
SecItemAdd(newItem as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteKeychainValue(for key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
143
oAI/Services/WebSearchService.swift
Normal file
143
oAI/Services/WebSearchService.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// WebSearchService.swift
|
||||
// oAI
|
||||
//
|
||||
// DuckDuckGo web search for non-OpenRouter providers
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct SearchResult: Sendable {
|
||||
let title: String
|
||||
let url: String
|
||||
let snippet: String
|
||||
}
|
||||
|
||||
final class WebSearchService: Sendable {
|
||||
nonisolated static let shared = WebSearchService()
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
nonisolated private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 10
|
||||
session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
/// Search DuckDuckGo HTML interface (no API key needed)
|
||||
nonisolated func search(query: String, maxResults: Int = 5) async -> [SearchResult] {
|
||||
Log.search.info("Web search: \(query)")
|
||||
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "https://html.duckduckgo.com/html/?q=\(encoded)")
|
||||
else { return [] }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
forHTTPHeaderField: "User-Agent"
|
||||
)
|
||||
|
||||
do {
|
||||
let (data, _) = try await session.data(for: request)
|
||||
guard let html = String(data: data, encoding: .utf8) else { return [] }
|
||||
return parseResults(from: html, maxResults: maxResults)
|
||||
} catch {
|
||||
Log.search.error("Web search failed: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Format search results as markdown for prompt injection
|
||||
nonisolated func formatResults(_ results: [SearchResult], maxLength: Int = 2000) -> String {
|
||||
if results.isEmpty { return "No search results found." }
|
||||
|
||||
var formatted = "**Web Search Results:**\n\n"
|
||||
|
||||
for (i, result) in results.enumerated() {
|
||||
var entry = "\(i + 1). **\(result.title)**\n"
|
||||
entry += " URL: \(result.url)\n"
|
||||
if !result.snippet.isEmpty {
|
||||
entry += " \(result.snippet)\n"
|
||||
}
|
||||
entry += "\n"
|
||||
|
||||
if formatted.count + entry.count > maxLength {
|
||||
formatted += "... (\(results.count - i) more results truncated)\n"
|
||||
break
|
||||
}
|
||||
|
||||
formatted += entry
|
||||
}
|
||||
|
||||
return formatted.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// MARK: - HTML Parsing
|
||||
|
||||
private nonisolated func parseResults(from html: String, maxResults: Int) -> [SearchResult] {
|
||||
var results: [SearchResult] = []
|
||||
|
||||
// Match result blocks: <div class="result results_links ...">
|
||||
let blockPattern = #"<div class="result results_links.*?(?=<div class="result results_links|<div id="links")"#
|
||||
guard let blockRegex = try? NSRegularExpression(pattern: blockPattern, options: .dotMatchesLineSeparators) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let range = NSRange(html.startIndex..., in: html)
|
||||
let blocks = blockRegex.matches(in: html, range: range)
|
||||
|
||||
for match in blocks.prefix(maxResults) {
|
||||
guard let blockRange = Range(match.range, in: html) else { continue }
|
||||
let block = String(html[blockRange])
|
||||
|
||||
// Extract title and URL from <a class="result__a" href="...">Title</a>
|
||||
let titlePattern = #"<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)</a>"#
|
||||
guard let titleRegex = try? NSRegularExpression(pattern: titlePattern),
|
||||
let titleMatch = titleRegex.firstMatch(in: block, range: NSRange(block.startIndex..., in: block)),
|
||||
let urlRange = Range(titleMatch.range(at: 1), in: block),
|
||||
let titleRange = Range(titleMatch.range(at: 2), in: block)
|
||||
else { continue }
|
||||
|
||||
var resultURL = String(block[urlRange])
|
||||
let title = decodeHTMLEntities(String(block[titleRange]).trimmingCharacters(in: .whitespaces))
|
||||
|
||||
// Extract snippet from <a class="result__snippet" ...>text</a>
|
||||
let snippetPattern = #"<a[^>]*class="result__snippet"[^>]*>([^<]+)</a>"#
|
||||
var snippet = ""
|
||||
if let snippetRegex = try? NSRegularExpression(pattern: snippetPattern),
|
||||
let snippetMatch = snippetRegex.firstMatch(in: block, range: NSRange(block.startIndex..., in: block)),
|
||||
let snippetRange = Range(snippetMatch.range(at: 1), in: block) {
|
||||
snippet = decodeHTMLEntities(String(block[snippetRange]).trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
|
||||
// Decode DDG redirect URL
|
||||
if resultURL.contains("uddg=") {
|
||||
let uddgPattern = #"uddg=([^&]+)"#
|
||||
if let uddgRegex = try? NSRegularExpression(pattern: uddgPattern),
|
||||
let uddgMatch = uddgRegex.firstMatch(in: resultURL, range: NSRange(resultURL.startIndex..., in: resultURL)),
|
||||
let uddgRange = Range(uddgMatch.range(at: 1), in: resultURL) {
|
||||
resultURL = String(resultURL[uddgRange]).removingPercentEncoding ?? resultURL
|
||||
}
|
||||
}
|
||||
|
||||
results.append(SearchResult(title: title, url: resultURL, snippet: snippet))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private nonisolated func decodeHTMLEntities(_ string: String) -> String {
|
||||
var result = string
|
||||
let entities: [(String, String)] = [
|
||||
("&", "&"), ("<", "<"), (">", ">"),
|
||||
(""", "\""), ("'", "'"), ("'", "'"),
|
||||
("'", "'"), ("/", "/"), (" ", " "),
|
||||
]
|
||||
for (entity, char) in entities {
|
||||
result = result.replacingOccurrences(of: entity, with: char)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user