Initial commit

This commit is contained in:
2026-02-11 22:22:55 +01:00
commit 42f54954c1
58 changed files with 10639 additions and 0 deletions

View 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: "")
}
}

View 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
}
}
}

View 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: [])
}
}

View 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)
}
}

View 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)] = [
("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">"),
("&quot;", "\""), ("&#39;", "'"), ("&apos;", "'"),
("&#x27;", "'"), ("&#x2F;", "/"), ("&nbsp;", " "),
]
for (entity, char) in entities {
result = result.replacingOccurrences(of: entity, with: char)
}
return result
}
}