using System.IO; using System.Text.Json; using AxCopilot.Models; using AxCopilot.Services.Agent; namespace AxCopilot.Services; public class SettingsService { private static readonly string AppDataDir = InitAppDataDir(); /// AppData 디렉터리 초기화. 기존 AxCommander 폴더가 있으면 AxCopilot으로 마이그레이션. private static string InitAppDataDir() { var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); var newDir = Path.Combine(appData, "AxCopilot"); var oldDir = Path.Combine(appData, "AxCommander"); if (!Directory.Exists(newDir) && Directory.Exists(oldDir)) try { Directory.Move(oldDir, newDir); } catch { } return newDir; } public static readonly string SettingsPath = Path.Combine(AppDataDir, "settings.dat"); private static readonly string LegacyJsonPath = Path.Combine(AppDataDir, "settings.json"); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true }; private AppSettings _settings = new(); public AppSettings Settings => _settings; public event EventHandler? SettingsChanged; /// 마이그레이션이 수행된 경우 변경 내역 문자열. null이면 마이그레이션 없음. public string? MigrationSummary { get; private set; } /// /// 설정 파일의 스키마 버전 (앱 버전과 별개). /// ★ 버전 관리 규칙: /// - 앱 릴리스 버전은 AxCopilot.csproj → <Version> 에서만 관리합니다. /// - 이 값은 settings.json 구조(필드 추가·이름 변경 등)가 바뀔 때만 올립니다. /// - 올릴 때마다 MigrateIfNeeded() 에 해당 버전으로의 마이그레이션 블록을 추가합니다. /// - 현재 매핑: 앱 v1.0.0~1.0.2 → 설정 v1.0 / 앱 v1.0.3~ → 설정 v1.1 /// private const string CurrentSettingsVersion = "1.2"; public void Load() { EnsureDirectories(); // 1. 암호화된 설정 파일 우선 if (File.Exists(SettingsPath)) { try { var encrypted = File.ReadAllText(SettingsPath); var json = CryptoService.PortableDecrypt(encrypted); if (string.IsNullOrEmpty(json)) throw new InvalidOperationException("복호화 결과가 비어 있습니다."); _settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new(); ApplyLegacySigmoidAliases(json, _settings); MigrateIfNeeded(); NormalizeRuntimeSettings(); return; } catch (Exception ex) { var backupPath = SettingsPath + ".bak"; try { File.Copy(SettingsPath, backupPath, overwrite: true); } catch (Exception backupEx) { LogService.Warn($"settings.dat 백업 실패: {backupEx.Message}"); } LogService.Error($"settings.dat 복호화/파싱 실패, 기본값으로 복구: {ex.Message}"); } } // 2. 레거시 평문 JSON 마이그레이션 if (File.Exists(LegacyJsonPath)) { try { var json = File.ReadAllText(LegacyJsonPath); _settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new(); ApplyLegacySigmoidAliases(json, _settings); MigrateIfNeeded(); NormalizeRuntimeSettings(); Save(); // 암호화된 settings.dat로 저장 try { File.Delete(LegacyJsonPath); } catch (Exception delEx) { LogService.Warn($"레거시 settings.json 삭제 실패: {delEx.Message}"); } LogService.Info("설정 마이그레이션: settings.json → settings.dat (암호화 적용)"); return; } catch (Exception ex) { LogService.Error($"레거시 settings.json 파싱 실패: {ex.Message}"); } } // 3. 신규 설치 _settings = CreateDefaults(); _settings.Version = CurrentSettingsVersion; NormalizeRuntimeSettings(); Save(); } /// /// 설정 파일 버전을 확인하고, 이전 버전이면 순차적으로 마이그레이션합니다. /// 새 프로퍼티는 C# 기본값으로 자동 적용되며, /// 구조 변경(키 이름 변경, 하위 객체 이동 등)만 여기서 처리합니다. /// private void MigrateIfNeeded() { var ver = _settings.Version ?? "1.0"; var migrated = false; // ── 1.0 → 1.1 마이그레이션 ────────────────────────────────────────── if (IsVersionLessThan(ver, "1.1")) { // 폴더 별칭 프리픽스 변경: ~xxx → cd xxx (v1.1) foreach (var alias in _settings.Aliases.Where(a => a.Type == "folder")) { if (alias.Key.StartsWith("~")) alias.Key = "cd " + alias.Key.TrimStart('~'); } // 워크스페이스 기호 변경 알림은 도움말에서 안내 LogService.Info("설정 마이그레이션: 1.0 → 1.1 (폴더 별칭 prefix ~ → cd)"); ver = "1.1"; migrated = true; } // ── 1.1 → 1.2 마이그레이션 ────────────────────────────────────────── if (IsVersionLessThan(ver, "1.2")) { // 파일 대화상자 연동 기능 기본값 비활성화 (v1.6.2) // 브라우저 파일 업로드·메일 첨부 시 런처가 의도치 않게 열리는 오작동 방지 _settings.Launcher.EnableFileDialogIntegration = false; LogService.Info("설정 마이그레이션: 1.1 → 1.2 (파일 대화상자 연동 비활성화)"); ver = "1.2"; migrated = true; } // ── 향후 마이그레이션은 여기에 추가 ───────────────────────────────── // if (IsVersionLessThan(ver, "1.3")) { ... ver = "1.3"; migrated = true; } if (migrated) { _settings.Version = CurrentSettingsVersion; Save(); MigrationSummary = $"설정이 v{CurrentSettingsVersion}으로 업데이트되었습니다.\n\n" + "• 파일 대화상자 연동 기능이 비활성화되었습니다.\n" + " 웹 브라우저 파일 업로드 시 런처가 열리는 문제를 방지합니다.\n" + " 필요 시 설정 → 기능 → AI 기능에서 다시 활성화할 수 있습니다."; LogService.Info($"설정 파일 마이그레이션 완료 → v{CurrentSettingsVersion}"); } } private static bool IsVersionLessThan(string current, string target) { if (Version.TryParse(current, out var cv) && Version.TryParse(target, out var tv)) return cv < tv; return string.Compare(current, target, StringComparison.Ordinal) < 0; } public void Save() { EnsureDirectories(); NormalizeRuntimeSettings(); var json = JsonSerializer.Serialize(_settings, JsonOptions); var encrypted = CryptoService.PortableEncrypt(json); File.WriteAllText(SettingsPath, encrypted); SettingsChanged?.Invoke(this, EventArgs.Empty); } private void NormalizeRuntimeSettings() { var expressionLevel = (_settings.Llm.AgentUiExpressionLevel ?? "").Trim().ToLowerInvariant(); _settings.Llm.AgentUiExpressionLevel = expressionLevel switch { "rich" => "rich", "balanced" => "balanced", "simple" => "simple", _ => "balanced" }; _settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Llm.FilePermission); _settings.Llm.DefaultAgentPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Llm.DefaultAgentPermission); if (_settings.Llm.ToolPermissions != null && _settings.Llm.ToolPermissions.Count > 0) { var keys = _settings.Llm.ToolPermissions.Keys.ToList(); foreach (var key in keys) _settings.Llm.ToolPermissions[key] = PermissionModeCatalog.NormalizeToolOverride(_settings.Llm.ToolPermissions[key]); } _settings.Llm.MaxFavoriteSlashCommands = Math.Clamp(_settings.Llm.MaxFavoriteSlashCommands <= 0 ? 10 : _settings.Llm.MaxFavoriteSlashCommands, 1, 30); _settings.Llm.MaxRecentSlashCommands = Math.Clamp(_settings.Llm.MaxRecentSlashCommands <= 0 ? 20 : _settings.Llm.MaxRecentSlashCommands, 5, 50); if (_settings.Llm.FavoriteSlashCommands.Count > _settings.Llm.MaxFavoriteSlashCommands) _settings.Llm.FavoriteSlashCommands = _settings.Llm.FavoriteSlashCommands.Take(_settings.Llm.MaxFavoriteSlashCommands).ToList(); if (_settings.Llm.RecentSlashCommands.Count > _settings.Llm.MaxRecentSlashCommands) _settings.Llm.RecentSlashCommands = _settings.Llm.RecentSlashCommands.Take(_settings.Llm.MaxRecentSlashCommands).ToList(); NormalizeLlmThresholds(_settings.Llm); } private static void ApplyLegacySigmoidAliases(string rawJson, AppSettings settings) { if (settings?.Llm == null || string.IsNullOrWhiteSpace(rawJson)) return; if (!string.IsNullOrWhiteSpace(settings.Llm.SigmoidApiKey) && !string.IsNullOrWhiteSpace(settings.Llm.SigmoidModel)) return; try { using var doc = JsonDocument.Parse(rawJson); if (!doc.RootElement.TryGetProperty("llm", out var llm)) return; var legacyApiKeyName = string.Concat("cl", "audeApiKey"); var legacyModelName = string.Concat("cl", "audeModel"); if (string.IsNullOrWhiteSpace(settings.Llm.SigmoidApiKey) && llm.TryGetProperty(legacyApiKeyName, out var legacyApiKey) && legacyApiKey.ValueKind == JsonValueKind.String) { settings.Llm.SigmoidApiKey = legacyApiKey.GetString() ?? ""; } if (string.IsNullOrWhiteSpace(settings.Llm.SigmoidModel) && llm.TryGetProperty(legacyModelName, out var legacyModel) && legacyModel.ValueKind == JsonValueKind.String) { settings.Llm.SigmoidModel = legacyModel.GetString() ?? ""; } } catch { } } private static void NormalizeLlmThresholds(LlmSettings llm) { llm.ReadOnlySignatureLoopThreshold = NormalizeOptionalThreshold(llm.ReadOnlySignatureLoopThreshold, 2, 12); llm.ReadOnlyStagnationThreshold = NormalizeOptionalThreshold(llm.ReadOnlyStagnationThreshold, 3, 20); llm.NoProgressRecoveryThreshold = NormalizeOptionalThreshold(llm.NoProgressRecoveryThreshold, 4, 30); llm.NoProgressAbortThreshold = NormalizeOptionalThreshold(llm.NoProgressAbortThreshold, 6, 50); llm.NoProgressRecoveryMaxRetries = NormalizeOptionalThreshold(llm.NoProgressRecoveryMaxRetries, 1, 5); llm.ToolExecutionTimeoutMs = NormalizeOptionalThreshold(llm.ToolExecutionTimeoutMs, 5000, 600000); } private static int NormalizeOptionalThreshold(int value, int min, int max) { if (value <= 0) return 0; return Math.Clamp(value, min, max); } private static void EnsureDirectories() { Directory.CreateDirectory(AppDataDir); Directory.CreateDirectory(Path.Combine(AppDataDir, "logs")); Directory.CreateDirectory(Path.Combine(AppDataDir, "crashes")); Directory.CreateDirectory(Path.Combine(AppDataDir, "skills")); } private static AppSettings CreateDefaults() { var indexPaths = new List { "%USERPROFILE%\\Desktop", "%APPDATA%\\Microsoft\\Windows\\Start Menu" }; // D:\Non_Documents 폴더가 존재하면 기본 인덱스 경로에 추가 const string nonDocPath = "D:\\Non_Documents"; if (Directory.Exists(nonDocPath)) indexPaths.Add(nonDocPath); return new AppSettings { IndexPaths = indexPaths, Aliases = new List { new() { Key = "@blog", Type = "url", Target = "https://example.com", Description = "내 블로그" }, new() { Key = "~home", Type = "folder", Target = "%USERPROFILE%", Description = "홈 폴더" }, }, // 커스텀 테마 초기값 (theme: "custom"으로 변경 시 활성화) Launcher = new() { CustomTheme = new AxCopilot.Models.CustomThemeColors() } }; } }