diff --git a/App.axaml b/App.axaml index 9e13ae9..5b9d2e8 100644 --- a/App.axaml +++ b/App.axaml @@ -1,8 +1,13 @@  + xmlns:local="using:TraeTools"> + diff --git a/App.axaml.cs b/App.axaml.cs index eb816f8..aaa4c1f 100644 --- a/App.axaml.cs +++ b/App.axaml.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using TraeTools.Services; using TraeTools.ViewModels; using TraeTools.Views; @@ -21,9 +22,15 @@ public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + var vm = new MainViewModel(); + + // 主题必须在窗口创建前应用:否则会先按默认变体绘制一帧再切过去(可见闪一下)。 + // MainViewModel 的构造已完成 AppConfig.Load(),此处读到的就是用户设置。 + ThemeService.Apply(MainViewModel.AppConfig?.ThemeMode); + desktop.MainWindow = new MainWindow { - DataContext = new MainViewModel(), + DataContext = vm, }; } diff --git a/Services/AppLog.cs b/Services/AppLog.cs new file mode 100644 index 0000000..c9eff12 --- /dev/null +++ b/Services/AppLog.cs @@ -0,0 +1,454 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace TraeTools.Services; + +/// 日志级别(Success 仅用于签到成功等正面结果,便于界面着色)。 +public enum LogLevel +{ + Info, + Success, + Warn, + Error, +} + +/// 一条日志记录(内存视图用;落盘为纯文本行,读取时再解析)。 +public sealed class LogEntry +{ + public DateTime Time { get; init; } = DateTime.Now; + public LogLevel Level { get; init; } = LogLevel.Info; + public string Category { get; init; } = "app"; + public string Account { get; init; } = ""; + public string Message { get; init; } = ""; + + /// 单行展示文案:`HH:mm:ss 消息`(账号信息拼在消息前,保持行宽一致)。 + public string Display + { + get + { + var who = string.IsNullOrEmpty(Account) || Account == "?" ? "" : $"[{Account}] "; + return Time.ToString("HH:mm:ss") + " " + who + Message; + } + } + + /// 级别标签(UI 左侧色块用)。 + public string LevelTag => Level switch + { + LogLevel.Error => "错误", + LogLevel.Warn => "警告", + LogLevel.Success => "成功", + _ => "信息", + }; + + // ===== 供视图用 Classes 绑定驱动着色的布尔位(避免把 Avalonia 画刷类型引到日志层) ===== + public bool IsError => Level == LogLevel.Error; + public bool IsWarn => Level == LogLevel.Warn; + public bool IsSuccess => Level == LogLevel.Success; + + /// 级别标签的左括号形式,用于单行纯文本导出。 + public string LevelCode => Level switch + { + LogLevel.Error => "ERROR", + LogLevel.Warn => "WARN", + LogLevel.Success => "OK", + _ => "INFO", + }; +} + +/// 一个日志文件(「全部日志」列表项)。 +public sealed class LogFileItem +{ + public required string Path { get; init; } + /// 分组名:模块日志取 category(checkin/account/cloud/…),崩溃日志为 crash。 + public required string Group { get; init; } + public required string FileName { get; init; } + public long Size { get; init; } + public DateTime Modified { get; init; } + + /// 展示用时间:`MM-dd HH:mm`。 + public string TimeText => Modified.ToString("MM-dd HH:mm"); + + /// 展示用体积:小于 1KB 显示 B,其余显示 KB/MB(一位小数)。 + public string SizeText => Size < 1024 + ? $"{Size} B" + : Size < 1024 * 1024 + ? $"{Size / 1024.0:F1} KB" + : $"{Size / 1048576.0:F1} MB"; + + /// 列表标题:`checkin_log_202610.txt`。 + public string Title => FileName; +} + +/// +/// 统一运行日志(分级 + 内存环形缓冲 + 按月分文件落盘)。 +/// +/// 设计目标:让「日志」页能同时提供两种视角—— +/// · 最近:本进程实时产生的日志(内存缓冲,随写随到,可自动滚动); +/// · 全部:磁盘上按模块/月份归档的历史日志文件(含崩溃日志目录),可逐文件查看。 +/// +/// 落盘格式与历史版本保持一致(`[时间] [账号] 消息`),仅在中间插入级别标记 +/// (`[时间] [级别] [账号] 消息`),因此旧日志文件仍能被「全部」页正常读取展示。 +/// 所有写入都在 try/catch 内,日志自身故障绝不影响业务流程。 +/// +public static class AppLog +{ + /// 内存缓冲上限(超出后丢弃最旧一条)。 + private const int MaxMemoryEntries = 800; + + /// 单文件读取上限:超出只取尾部,避免超大日志把界面卡死。 + private const int MaxReadBytes = 256 * 1024; + + private static readonly object Sync = new(); + private static readonly LinkedList Buffer = new(); + + private static bool _seeded; + + /// 新日志写入时触发(可能来自后台线程,订阅方需自行切回 UI 线程)。 + public static event Action? EntryAdded; + + // ==================== 写入 ==================== + + public static void Info(string category, string account, string message) + => Write(LogLevel.Info, category, account, message); + + public static void Success(string category, string account, string message) + => Write(LogLevel.Success, category, account, message); + + public static void Warn(string category, string account, string message) + => Write(LogLevel.Warn, category, account, message); + + public static void Error(string category, string account, string message) + => Write(LogLevel.Error, category, account, message); + + /// 记录一条日志:先进内存缓冲,再按月追加到 `{category}_log_yyyyMM.txt`。 + public static void Write(LogLevel level, string category, string account, string message) + { + try + { + if (string.IsNullOrWhiteSpace(category)) category = "app"; + if (message == null) message = ""; + + var entry = new LogEntry + { + Time = DateTime.Now, + Level = level, + Category = category, + Account = account ?? "", + Message = message, + }; + + lock (Sync) + { + Buffer.AddLast(entry); + while (Buffer.Count > MaxMemoryEntries) Buffer.RemoveFirst(); + } + + // 落盘:与历史格式兼容,仅在中间插入级别标记 + try + { + var line = $"[{entry.Time:yyyy-MM-dd HH:mm:ss}] [{LevelLabel(level)}] [{Safe(entry.Account)}] {message}"; + var file = Path.Combine(DataPaths.LogsDir, $"{category}_log_{entry.Time:yyyyMM}.txt"); + AppendLine(file, line); + } + catch { /* 落盘失败不影响内存视图 */ } + + try { EntryAdded?.Invoke(entry); } catch { /* 订阅方异常不影响写入 */ } + } + catch { /* 日志失败绝不影响业务 */ } + } + + /// + /// 仅写入内存缓冲、不落盘。供 CrashLog 使用: + /// 崩溃/启动轨迹已有自己的落盘文件(logs/crash/log_日期.txt), + /// 再落一份会造成同一事件在两个文件里重复出现;但「最近日志」应当能看到它们, + /// 因此这里只做镜像。 + /// + public static void Mirror(LogLevel level, string category, string message) + { + try + { + var entry = new LogEntry + { + Time = DateTime.Now, + Level = level, + Category = category, + Account = "", + Message = message ?? "", + }; + lock (Sync) + { + Buffer.AddLast(entry); + while (Buffer.Count > MaxMemoryEntries) Buffer.RemoveFirst(); + } + try { EntryAdded?.Invoke(entry); } catch { /* 订阅方异常不影响写入 */ } + } + catch { /* 日志失败绝不影响业务 */ } + } + + /// 按行追加(带锁,跨线程安全)。 + private static void AppendLine(string path, string line) + { + lock (Sync) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.AppendAllText(path, line + Environment.NewLine, Encoding.UTF8); + } + } + + private static string LevelLabel(LogLevel level) => level switch + { + LogLevel.Error => "ERROR", + LogLevel.Warn => "WARN", + LogLevel.Success => "OK", + _ => "INFO", + }; + + private static string Safe(string? s) => string.IsNullOrWhiteSpace(s) ? "?" : s!; + + // ==================== 读取(最近) ==================== + + /// 取最近 count 条(按时间正序);count <= 0 时返回全部缓冲。 + public static IReadOnlyList Recent(int count = 0) + { + lock (Sync) + { + if (count <= 0 || count >= Buffer.Count) return Buffer.ToList(); + return Buffer.Skip(Buffer.Count - count).ToList(); + } + } + + /// + /// 首次读取「最近」前,把今天已落盘的日志回填进内存缓冲(进程重启后仍能看到今天早些时候的记录)。 + /// 幂等:只执行一次。 + /// + public static void SeedFromTodayFiles() + { + lock (Sync) + { + if (_seeded) return; + _seeded = true; + } + + try + { + var today = DateTime.Today; + var lines = new List(); + + foreach (var file in EnumerateFiles()) + { + if (file.Modified.Date != today) continue; + try + { + foreach (var raw in TailLines(file.Path, MaxReadBytes)) + { + if (TryParse(raw, file.Group, out var e)) lines.Add(e); + } + } + catch { /* 单个文件读取失败跳过 */ } + } + + // 合并后按时间排序,只保留最近的若干条 + lines.Sort((a, b) => a.Time.CompareTo(b.Time)); + if (lines.Count == 0) return; + + lock (Sync) + { + foreach (var e in lines.TakeLast(MaxMemoryEntries)) + Buffer.AddLast(e); + while (Buffer.Count > MaxMemoryEntries) Buffer.RemoveFirst(); + } + } + catch { /* 回填失败不影响功能 */ } + } + + // ==================== 读取(全部文件) ==================== + + /// 枚举所有日志文件(模块日志目录 + 账号切换目录),按修改时间倒序。 + public static IReadOnlyList EnumerateFiles() + { + var list = new List(); + + Collect(DataPaths.LogsDir, list); + try { Collect(DataPaths.SwitchDir, list); } catch { /* 切换配置目录中的日志(如有) */ } + + return list + .GroupBy(f => f.Path, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .OrderByDescending(f => f.Modified) + .ToList(); + } + + private static void Collect(string? dir, List sink) + { + try + { + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return; + foreach (var path in Directory.GetFiles(dir, "*.txt", SearchOption.AllDirectories)) + { + try + { + var info = new FileInfo(path); + sink.Add(new LogFileItem + { + Path = path, + Group = GroupOf(info.Name), + FileName = info.Name, + Size = info.Length, + Modified = info.LastWriteTime, + }); + } + catch { /* 单个文件信息失败跳过 */ } + } + } + catch { /* 目录枚举失败跳过 */ } + } + + /// 从文件名推断分组:`checkin_log_202610.txt` → checkin;崩溃日志 `log_20261014.txt` → crash。 + private static string GroupOf(string fileName) + { + var m = Regex.Match(fileName, @"^(?[a-z]+)_log_\d{6}\.txt$", RegexOptions.IgnoreCase); + if (m.Success) return m.Groups["g"].Value.ToLowerInvariant(); + if (Regex.IsMatch(fileName, @"^log_\d{8}\.txt$", RegexOptions.IgnoreCase)) return "crash"; + return "other"; + } + + /// 读取日志文件尾部内容(超过上限只取尾部,并加省略提示)。 + public static string ReadTail(string path, int maxBytes = MaxReadBytes) + { + try + { + if (!File.Exists(path)) return "(文件不存在)"; + var info = new FileInfo(path); + if (info.Length <= maxBytes) + return File.ReadAllText(path, Encoding.UTF8); + + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + fs.Seek(-maxBytes, SeekOrigin.End); + using var reader = new StreamReader(fs, Encoding.UTF8); + var text = reader.ReadToEnd(); + return $"(文件较大,仅显示最后 {maxBytes / 1024} KB){Environment.NewLine}{text}"; + } + catch (Exception ex) + { + return "(读取失败) " + ex.Message; + } + } + + /// 删除一个日志文件。 + public static bool DeleteFile(string path) + { + try + { + if (!File.Exists(path)) return false; + File.Delete(path); + return true; + } + catch + { + return false; + } + } + + /// 清空全部模块日志(保留文件,内容置空)。返回清空的文件数。 + public static int ClearAllFiles() + { + int n = 0; + foreach (var f in EnumerateFiles()) + { + try + { + File.WriteAllText(f.Path, string.Empty, Encoding.UTF8); + n++; + } + catch { /* 单个失败跳过 */ } + } + try + { + lock (Sync) Buffer.Clear(); + } + catch { /* 忽略 */ } + return n; + } + + // ==================== 解析 ==================== + + /// 把磁盘上的一行日志解析为 LogEntry;无法识别返回 false。 + private static bool TryParse(string line, string fallbackCategory, out LogEntry entry) + { + entry = null!; + if (string.IsNullOrWhiteSpace(line)) return false; + + try + { + // 新格式:[yyyy-MM-dd HH:mm:ss] [LEVEL] [account] message + var mNew = Regex.Match(line, + @"^\[(?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s*\[(?[A-Za-z]+)\]\s*\[(?[^\]]*)\]\s*(?.*)$"); + if (mNew.Success && DateTime.TryParse(mNew.Groups["t"].Value, out var t1)) + { + entry = new LogEntry + { + Time = t1, + Level = ParseLevel(mNew.Groups["lv"].Value), + Category = fallbackCategory, + Account = mNew.Groups["ac"].Value, + Message = mNew.Groups["msg"].Value, + }; + return true; + } + + // 旧格式:[yyyy-MM-dd HH:mm:ss] [account] message + var mOld = Regex.Match(line, + @"^\[(?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s*\[(?[^\]]*)\]\s*(?.*)$"); + if (mOld.Success && DateTime.TryParse(mOld.Groups["t"].Value, out var t2)) + { + entry = new LogEntry + { + Time = t2, + Level = LogLevel.Info, + Category = fallbackCategory, + Account = mOld.Groups["ac"].Value, + Message = mOld.Groups["msg"].Value, + }; + return true; + } + + // 崩溃日志:`[BOOT HH:mm:ss.fff] message`(无日期,按今天推算) + var mBoot = Regex.Match(line, @"^\[BOOT (?\d{2}:\d{2}:\d{2})\.?\d*\]\s*(?.*)$"); + if (mBoot.Success && TimeSpan.TryParse(mBoot.Groups["t"].Value, out var ts)) + { + entry = new LogEntry + { + Time = DateTime.Today.Add(ts), + Level = LogLevel.Info, + Category = "crash", + Account = "", + Message = mBoot.Groups["msg"].Value, + }; + return true; + } + } + catch { /* 解析失败视为不可识别 */ } + + return false; + } + + private static LogLevel ParseLevel(string raw) => raw.ToUpperInvariant() switch + { + "ERROR" or "FATAL" => LogLevel.Error, + "WARN" or "WARNING" => LogLevel.Warn, + "OK" or "SUCCESS" => LogLevel.Success, + _ => LogLevel.Info, + }; + + /// 读取文件尾部的行(用于回填内存缓冲)。 + private static IEnumerable TailLines(string path, int maxBytes) + { + var text = ReadTail(path, maxBytes); + return text.Split('\n').Select(l => l.TrimEnd('\r')).Where(l => l.Length > 0); + } +} diff --git a/Services/Checkin/AppConfig.cs b/Services/Checkin/AppConfig.cs index c236019..0b3002a 100644 --- a/Services/Checkin/AppConfig.cs +++ b/Services/Checkin/AppConfig.cs @@ -48,6 +48,64 @@ public class AppConfig /// 关闭主窗口时是否最小化到系统托盘(托盘驻留)。 public bool MinimizeToTray { get; set; } = true; + // ==================== 以下为本次 PR 追加的配置项 ==================== + // 注意:本类的落盘走 CloneForStorage 白名单(见该方法注释), + // 因此**每新增一个属性都必须同步加进 CloneForStorage 与 UnsealSecrets**, + // 否则该配置永远不会被写入 config.json(表现为「改完重启就失效」)。 + + /// 旧版深色开关(仅 true/false 二态)。保留用于旧配置迁移,新代码请改用 ThemeMode。 + public bool DarkMode { get; set; } + + /// 主题模式:"system"(跟随系统)/ "light" / "dark"。空字符串表示旧配置待迁移。 + public string ThemeMode { get; set; } = ""; + + /// 日志页是否自动滚动到最新一行。 + public bool LogAutoScroll { get; set; } = true; + + /// 签到完成后是否触发推送(总开关,关闭则所有渠道都不发)。 + public bool NotifyOnCheckin { get; set; } = true; + + /// 系统本地通知渠道。 + public bool NotifySystemEnabled { get; set; } + + /// 自定义 SMTP 邮箱渠道。 + public bool EmailEnabled { get; set; } + public string SmtpHost { get; set; } = ""; + /// SMTP 端口,默认 465(隐式 SSL)。常见取值:465=SSL、587=STARTTLS、25=明文。 + public int SmtpPort { get; set; } = 465; + /// 连接安全方式:"ssl"(隐式 TLS)/ "starttls" / "none"。 + public string SmtpSecurity { get; set; } = "ssl"; + public string SmtpUser { get; set; } = ""; + /// SMTP 密码 / 授权码(加密落盘)。 + public string? SmtpPassword { get; set; } + public string SmtpFrom { get; set; } = ""; + public string SmtpTo { get; set; } = ""; + + /// 通用 Webhook 渠道。 + public bool WebhookEnabled { get; set; } + /// 目标 URL(加密落盘:地址里常带 token 类查询参数)。 + public string? WebhookUrl { get; set; } + /// 请求体模板,支持 {title} / {content} 占位符。 + public string WebhookBodyTemplate { get; set; } = "{\"title\":\"{title}\",\"content\":\"{content}\"}"; + public string WebhookContentType { get; set; } = "application/json"; + + /// 钉钉群机器人。 + public bool DingTalkEnabled { get; set; } + public string? DingTalkWebhook { get; set; } + /// 加签密钥(可选)。机器人安全设置选择「加签」时必填。 + public string? DingTalkSecret { get; set; } + + /// 企业微信群机器人。 + public bool WeComEnabled { get; set; } + public string? WeComWebhook { get; set; } + + /// Telegram Bot 推送。 + public bool TelegramEnabled { get; set; } + /// Bot Token(加密落盘)。 + public string? TelegramBotToken { get; set; } + /// 目标会话 ID(私聊为数字 ID,群为负数)。 + public string TelegramChatId { get; set; } = ""; + private static string ConfigDir => TraeTools.Services.DataPaths.Root; private static string ConfigPath => Path.Combine(ConfigDir, "config.json"); @@ -87,7 +145,9 @@ public static AppConfig Load() cfg.Save(); } // 旧明文首次加载:立即加密落盘,避免明文长期留存 - if (needSeal) cfg.Save(); + // 主题模式迁移:旧配置只有 DarkMode 布尔值,此处补齐三态 ThemeMode + bool themeMigrated = cfg.NormalizeTheme(); + if (needSeal || themeMigrated) cfg.Save(); return cfg; } } @@ -95,6 +155,7 @@ public static AppConfig Load() catch { /* 配置损坏时回退默认 */ } var fresh = new AppConfig(); + fresh.NormalizeTheme(); var resolved = TryResolveAhaDeviceId(); if (resolved != null) fresh.DeviceId = resolved; return fresh; @@ -174,7 +235,29 @@ public void Save() /// 敏感字段加密版本号。 private const string SecretVaultVersionV1 = "dpapi-v1"; - /// 生成一份敏感字段(Token/Session/GitHubToken/DeviceId)已 DPAPI 加密的副本。 + /// + /// 主题模式规整:把旧版仅有的 布尔值迁移为三态字符串; + /// 取值非法时回退 "system"。返回是否发生了修正(调用方据此决定是否落盘)。 + /// + public bool NormalizeTheme() + { + var normalized = ThemeMode switch + { + "system" or "light" or "dark" => ThemeMode, + _ => DarkMode ? "dark" : "system", + }; + if (normalized == ThemeMode) return false; + ThemeMode = normalized; + return true; + } + + /// + /// 生成一份敏感字段已 DPAPI 加密的副本。 + /// 敏感字段:Token / Session / GitHubToken / DeviceId, + /// 以及本次 PR 追加的推送渠道凭证 SmtpPassword / WebhookUrl / + /// DingTalkWebhook / DingTalkSecret / WeComWebhook / TelegramBotToken + /// (这些 URL / 令牌本身就是凭据,明文落盘等于泄露发送权限)。 + /// private AppConfig CloneForStorage() { return new AppConfig @@ -186,7 +269,9 @@ private AppConfig CloneForStorage() GitHubToken = EncryptSecret(GitHubToken), GitHubLogin = GitHubLogin, FeishuWebhook = FeishuWebhook, - DeviceId = EncryptSecret(DeviceId), + // DeviceId 非可空:加密失败时退回明文,而不是写入 null。 + // 明文 DeviceId 在下次加载解密失败后会被原样保留,属可自愈的降级,比落盘 null 安全。 + DeviceId = EncryptSecret(DeviceId) ?? DeviceId, AutoCheckinEnabled = AutoCheckinEnabled, AutoCheckinTime = AutoCheckinTime, CheckinIntervalSeconds = CheckinIntervalSeconds, @@ -200,13 +285,40 @@ private AppConfig CloneForStorage() EulaAccepted = EulaAccepted, StarAskedAfterDeploy = StarAskedAfterDeploy, MinimizeToTray = MinimizeToTray, + // ===== 本次 PR 追加项 ===== + DarkMode = DarkMode, + ThemeMode = ThemeMode, + LogAutoScroll = LogAutoScroll, + NotifyOnCheckin = NotifyOnCheckin, + NotifySystemEnabled = NotifySystemEnabled, + EmailEnabled = EmailEnabled, + SmtpHost = SmtpHost, + SmtpPort = SmtpPort, + SmtpSecurity = SmtpSecurity, + SmtpUser = SmtpUser, + SmtpPassword = EncryptSecret(SmtpPassword), + SmtpFrom = SmtpFrom, + SmtpTo = SmtpTo, + WebhookEnabled = WebhookEnabled, + WebhookUrl = EncryptSecret(WebhookUrl), + WebhookBodyTemplate = WebhookBodyTemplate, + WebhookContentType = WebhookContentType, + DingTalkEnabled = DingTalkEnabled, + DingTalkWebhook = EncryptSecret(DingTalkWebhook), + DingTalkSecret = EncryptSecret(DingTalkSecret), + WeComEnabled = WeComEnabled, + WeComWebhook = EncryptSecret(WeComWebhook), + TelegramEnabled = TelegramEnabled, + TelegramBotToken = EncryptSecret(TelegramBotToken), + TelegramChatId = TelegramChatId, Accounts = Accounts.Select(a => new TraeAccount { Id = a.Id, Name = a.Name, Token = EncryptSecret(a.Token), Session = EncryptSecret(a.Session), - DeviceId = EncryptSecret(a.DeviceId), + // 同 DeviceId:非可空,加密失败时保留明文而不是赋 null + DeviceId = EncryptSecret(a.DeviceId) ?? a.DeviceId, AccountUid = a.AccountUid, TokenUpdatedAt = a.TokenUpdatedAt, LastCheckinDate = a.LastCheckinDate, @@ -228,6 +340,14 @@ private static void UnsealSecrets(AppConfig cfg) cfg.Token = DecryptSecret(cfg.Token); cfg.Session = DecryptSecret(cfg.Session); cfg.GitHubToken = DecryptSecret(cfg.GitHubToken); + // 本次 PR 追加的敏感字段:它们是新增字段,历史上从未以明文落盘, + // 因此不需要像 FeishuWebhook 那样做「解密失败保留原值」的兼容处理。 + cfg.SmtpPassword = DecryptSecret(cfg.SmtpPassword); + cfg.WebhookUrl = DecryptSecret(cfg.WebhookUrl); + cfg.DingTalkWebhook = DecryptSecret(cfg.DingTalkWebhook); + cfg.DingTalkSecret = DecryptSecret(cfg.DingTalkSecret); + cfg.WeComWebhook = DecryptSecret(cfg.WeComWebhook); + cfg.TelegramBotToken = DecryptSecret(cfg.TelegramBotToken); var dev = DecryptSecret(cfg.DeviceId); if (!string.IsNullOrEmpty(dev)) cfg.DeviceId = dev; // 解密失败(换机/换用户)保留原值,等重新登录刷新 foreach (var a in cfg.Accounts) diff --git a/Services/Checkin/TokenUtils.cs b/Services/Checkin/TokenUtils.cs index a9d6d63..4413776 100644 --- a/Services/Checkin/TokenUtils.cs +++ b/Services/Checkin/TokenUtils.cs @@ -53,4 +53,60 @@ public static bool ShouldAcceptNewToken(string? initialToken, string? currentTok } return null; } + + /// + /// 从 JWT 解析过期时间。Trae 的 exp 可能挂在根上,也可能在 data.exp 里,两处都试。 + /// exp 是 Unix 秒;返回本地时间。非 JWT / 无 exp / 格式异常均返回 null。 + /// + public static DateTime? TryGetExpiry(string? jwt) + { + var root = DecodePayload(jwt); + if (root is not { } el) return null; + if (TryReadExp(el, out var exp)) return exp; + if (el.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object && + TryReadExp(data, out exp)) return exp; + return null; + } + + private static bool TryReadExp(JsonElement obj, out DateTime expiry) + { + expiry = default; + if (!obj.TryGetProperty("exp", out var e)) return false; + long seconds = e.ValueKind switch + { + JsonValueKind.Number => e.TryGetInt64(out var v) ? v : 0, + JsonValueKind.String => long.TryParse(e.GetString(), out var v2) ? v2 : 0, + _ => 0, + }; + if (seconds <= 0) return false; + // 越界(异常数据)时直接放弃,避免 FromUnixTimeSeconds 抛异常 + if (seconds < 1_000_000_000 || seconds > 4_102_444_800) return false; + expiry = DateTimeOffset.FromUnixTimeSeconds(seconds).ToLocalTime().DateTime; + return true; + } + + /// 解析 JWT 的 payload 段为 JsonElement(Clone 后返回,不受 JsonDocument 释放影响)。 + private static JsonElement? DecodePayload(string? jwt) + { + if (string.IsNullOrEmpty(jwt)) return null; + var parts = jwt.Split('.'); + if (parts.Length < 2) return null; + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + switch (payload.Length % 4) + { + case 2: payload += "=="; break; + case 3: payload += "="; break; + } + try + { + var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + // Clone:JsonDocument 释放后元素仍可用 + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + catch + { + return null; + } + } } diff --git a/Services/Json/AppJson.cs b/Services/Json/AppJson.cs index 3d1625a..57a394e 100644 --- a/Services/Json/AppJson.cs +++ b/Services/Json/AppJson.cs @@ -4,6 +4,8 @@ using TraeCheckin; using TraeSwitch.Services; using TraeTools.Models; +using TraeTools.Services.Login; +using TraeTools.Services.Notify; namespace TraeTools.Services.Json; @@ -58,6 +60,13 @@ private static JsonTypeInfo Info() [JsonSerializable(typeof(GitHubSecretPayload))] [JsonSerializable(typeof(GitHubActionsPermissionPayload))] [JsonSerializable(typeof(GitHubWorkflowDispatchPayload))] +// ===== 本次 PR 追加:推送渠道的 HTTP 请求体 ===== +// 这三种渠道消息体原先用匿名类型 + 反射 JsonSerializer 构造,在 full 裁剪 / NativeAOT 下 +// 既会产生 IL2026 警告,也会在运行时静默丢字段。改为具名类型 + 此处登记后完全走源生成。 +[JsonSerializable(typeof(TextRobotPayload))] +[JsonSerializable(typeof(TelegramSendPayload))] +// 账号备份的明文负载(外层已 AES-GCM 加密,故用紧凑输出;其中含 List,由生成器递归展开) +[JsonSerializable(typeof(AccountBackup.Payload))] internal sealed partial class CompactJsonContext : JsonSerializerContext { } @@ -69,6 +78,10 @@ internal sealed partial class CompactJsonContext : JsonSerializerContext [JsonSerializable(typeof(VaultMeta))] // DataPaths.Migrate 需要把合并后的 JsonObject 缩进写回(旧 config 合并路径) [JsonSerializable(typeof(System.Text.Json.Nodes.JsonObject))] +// ===== 本次 PR 追加:账号备份文件的容器 ===== +// 备份是给人看/可手工转存的 JSON 文件,故走缩进上下文; +// 明文负载(AccountBackup.Payload)因其外层已由 AES-GCM 加密,走紧凑上下文即可。 +[JsonSerializable(typeof(AccountBackup.Envelope))] internal sealed partial class IndentedJsonContext : JsonSerializerContext { } diff --git a/Services/Login/AccountBackup.cs b/Services/Login/AccountBackup.cs new file mode 100644 index 0000000..3c50243 --- /dev/null +++ b/Services/Login/AccountBackup.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using TraeTools.Services.Json; + +namespace TraeTools.Services.Login; + +/// 账号备份导出/导入结果。 +public sealed class BackupResult +{ + public bool Ok { get; init; } + public string Message { get; init; } = ""; + public int Imported { get; init; } + public int Updated { get; init; } + public int Skipped { get; init; } + /// 导出时生成的文本内容(由调用方负责写入用户选择的文件)。 + public string? Text { get; init; } +} + +/// +/// 账号导入 / 导出(可跨设备迁移)。 +/// +/// 为什么不复用平台的 DPAPI / Keystore 加密:那两者都是「本机绑定」的—— +/// DPAPI 换 Windows 用户或换机器即解不开,Keystore 换机解不开,加密后的备份 +/// 恰恰无法完成「换机迁移」这一唯一目的。 +/// 因此这里改用**用户口令派生的对称密钥**:PBKDF2-SHA256(20 万次)派生 256 位密钥, +/// AES-256-GCM 认证加密(同时保证机密性与完整性,口令错误或被篡改都会解密失败), +/// 备份文件因此可以安全地走网盘 / 聊天工具传输。 +/// +public static class AccountBackup +{ + private const string Magic = "TraeTools"; + private const string Kind = "account-backup"; + private const int FormatVersion = 1; + private const int KdfIterations = 200_000; + private const int SaltBytes = 16; + private const int NonceBytes = 12; + private const int TagBytes = 16; + + /// + /// 备份文件容器。 + /// 可见性为 internal(而非 private)是**必须的**:JSON 走 NativeAOT 安全的源生成, + /// 而源生成上下文集中在 Services/Json/AppJson.cs,那里需要能引用到本类型来登记 + /// [JsonSerializable](详见该文件注释)。 + /// + internal sealed class Envelope + { + public string App { get; set; } = Magic; + public string Type { get; set; } = Kind; + public int Version { get; set; } = FormatVersion; + public string CreatedAt { get; set; } = ""; + public string Kdf { get; set; } = "PBKDF2-SHA256"; + public int Iterations { get; set; } = KdfIterations; + public string Salt { get; set; } = ""; + public string Cipher { get; set; } = "AES-256-GCM"; + public string Nonce { get; set; } = ""; + public string Tag { get; set; } = ""; + public string Payload { get; set; } = ""; + } + + /// 明文负载(可见性说明同 )。 + internal sealed class Payload + { + public string ExportedAt { get; set; } = ""; + public string AppVersion { get; set; } = ""; + public List Accounts { get; set; } = new(); + } + + /// + /// 导出全部账号为加密备份文本。 + /// 口令至少 6 位(过短的口令对这份包含登录凭证的文件而言没有实际保护意义)。 + /// + public static BackupResult Export(IEnumerable accounts, string? passphrase) + { + try + { + if (string.IsNullOrEmpty(passphrase) || passphrase!.Length < 6) + return new BackupResult { Ok = false, Message = "口令至少 6 位" }; + + var list = accounts.Where(a => !string.IsNullOrEmpty(a.Token) || !string.IsNullOrEmpty(a.Session)).ToList(); + if (list.Count == 0) + return new BackupResult { Ok = false, Message = "没有可导出的账号(尚无账号已登录)" }; + + var payload = new Payload + { + ExportedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), + AppVersion = TraeTools.Services.AppVersion.Display, + Accounts = list, + }; + // 明文负载随后立即被 AES-GCM 加密,因此用紧凑输出即可(源生成,AOT/裁剪安全) + var plain = Encoding.UTF8.GetBytes(AppJson.Serialize(payload)); + + var salt = RandomNumberGenerator.GetBytes(SaltBytes); + var nonce = RandomNumberGenerator.GetBytes(NonceBytes); + var key = DeriveKey(passphrase!, salt, KdfIterations); + var cipher = new byte[plain.Length]; + var tag = new byte[TagBytes]; + + using (var aes = new AesGcm(key, TagBytes)) + aes.Encrypt(nonce, plain, cipher, tag); + + var envelope = new Envelope + { + CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), + Iterations = KdfIterations, + Salt = Convert.ToBase64String(salt), + Nonce = Convert.ToBase64String(nonce), + Tag = Convert.ToBase64String(tag), + Payload = Convert.ToBase64String(cipher), + }; + + return new BackupResult + { + Ok = true, + Text = AppJsonIndented.Serialize(envelope), + Imported = list.Count, + Message = $"已生成备份,共 {list.Count} 个账号", + }; + } + catch (Exception ex) + { + return new BackupResult { Ok = false, Message = "导出失败:" + ex.Message }; + } + } + + /// 解密并解析备份文件文本,返回其中的账号列表。不做合并。 + public static (bool Ok, string Message, List Accounts) Read(string? text, string? passphrase) + { + try + { + if (string.IsNullOrWhiteSpace(text)) + return (false, "备份内容为空", new List()); + if (string.IsNullOrEmpty(passphrase)) + return (false, "请输入备份口令", new List()); + + var env = AppJsonIndented.Deserialize(text!); + if (env == null || env.App != Magic || env.Type != Kind) + return (false, "这不是 TraeTools 的账号备份文件", new List()); + if (env.Version > FormatVersion) + return (false, $"备份文件版本({env.Version})高于当前程序支持的版本({FormatVersion}),请先升级软件", new List()); + + var salt = Convert.FromBase64String(env.Salt); + var nonce = Convert.FromBase64String(env.Nonce); + var tag = Convert.FromBase64String(env.Tag); + var cipher = Convert.FromBase64String(env.Payload); + var key = DeriveKey(passphrase!, salt, env.Iterations > 0 ? env.Iterations : KdfIterations); + + var plain = new byte[cipher.Length]; + using (var aes = new AesGcm(key, TagBytes)) + { + try + { + aes.Decrypt(nonce, cipher, tag, plain); + } + catch (CryptographicException) + { + // GCM 认证失败:口令错误或文件被篡改,二者无法区分也无法定位,故合并提示 + return (false, "口令错误,或备份文件已损坏", new List()); + } + } + + var payload = AppJson.Deserialize(Encoding.UTF8.GetString(plain)); + if (payload?.Accounts == null || payload.Accounts.Count == 0) + return (false, "备份中没有账号", new List()); + + return (true, $"共 {payload.Accounts.Count} 个账号", payload.Accounts); + } + catch (Exception ex) + { + return (false, "读取备份失败:" + ex.Message, new List()); + } + } + + /// + /// 把备份中的账号合并进当前配置: + /// · 命中同一账号(AccountUid 相同,或 Id 相同)→ 刷新凭证(Token/Session/DeviceId),保留本地 Id; + /// · 否则作为新账号追加(重新分配 Id,避免与本地冲突)。 + /// 调用方负责随后 Save。 + /// + public static BackupResult Merge(TraeCheckin.AppConfig cfg, List incoming) + { + try + { + int updated = 0, added = 0, skipped = 0; + + foreach (var src in incoming) + { + var match = cfg.Accounts.FirstOrDefault(a => + (!string.IsNullOrEmpty(src.AccountUid) && a.AccountUid == src.AccountUid) + || a.Id == src.Id); + + if (match != null) + { + match.Token = src.Token ?? match.Token; + match.Session = src.Session ?? match.Session; + if (!string.IsNullOrWhiteSpace(src.DeviceId)) match.DeviceId = src.DeviceId; + match.TokenUpdatedAt = src.TokenUpdatedAt ?? match.TokenUpdatedAt; + match.AccountUid ??= src.AccountUid; + match.Name ??= src.Name; + match.ScreenName ??= src.ScreenName; + match.MobileMasked ??= src.MobileMasked; + match.AvatarUrl ??= src.AvatarUrl; + updated++; + continue; + } + + // 设备号必须在本机唯一,否则多账号共用会触发 9074 风控 + var deviceId = src.DeviceId; + if (string.IsNullOrWhiteSpace(deviceId) || cfg.Accounts.Any(a => a.DeviceId == deviceId)) + deviceId = Random.Shared.NextInt64(1_000_000_000_000_000L, 10_000_000_000_000_000L).ToString(); + + cfg.Accounts.Add(new TraeCheckin.TraeAccount + { + Id = Guid.NewGuid().ToString("N"), + Name = src.Name, + Token = src.Token, + Session = src.Session, + DeviceId = deviceId, + AccountUid = src.AccountUid, + TokenUpdatedAt = src.TokenUpdatedAt, + RemainingCredits = src.RemainingCredits, + Enabled = src.Enabled, + IsMember = src.IsMember, + ScreenName = src.ScreenName, + MobileMasked = src.MobileMasked, + AvatarUrl = src.AvatarUrl, + IsStudent = src.IsStudent, + }); + added++; + } + + if (string.IsNullOrEmpty(cfg.ActiveAccountId) && cfg.Accounts.Count > 0) + cfg.ActiveAccountId = cfg.Accounts[0].Id; + + return new BackupResult + { + Ok = added + updated > 0, + Imported = added, + Updated = updated, + Skipped = skipped, + Message = $"导入完成:新增 {added} 个,更新 {updated} 个", + }; + } + catch (Exception ex) + { + return new BackupResult { Ok = false, Message = "合并失败:" + ex.Message }; + } + } + + /// PBKDF2-SHA256 派生 256 位密钥。 + private static byte[] DeriveKey(string passphrase, byte[] salt, int iterations) + => Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(passphrase), + salt, + iterations <= 0 ? KdfIterations : iterations, + HashAlgorithmName.SHA256, + 32); + + /// 导出文件名建议:TraeTools-账号备份-yyyyMMdd-HHmm.json。 + public static string SuggestedFileName() + => $"TraeTools-账号备份-{DateTime.Now:yyyyMMdd-HHmm}.json"; +} diff --git a/Services/Login/CredentialImporter.cs b/Services/Login/CredentialImporter.cs new file mode 100644 index 0000000..558bbd5 --- /dev/null +++ b/Services/Login/CredentialImporter.cs @@ -0,0 +1,216 @@ +using System; +using System.Text; +using System.Text.Json; + +namespace TraeTools.Services.Login; + +/// 凭证类型:无法识别 / Cloud-IDE-Token(JWT) / X-Cloudide-Session(会话 Cookie)。 +public enum CredentialKind +{ + Unknown, + Token, + Session, +} + +/// +/// 登录凭证的清洗、识别与导入。 +/// +/// 存在的意义:用户从浏览器「开发者工具 → Application → Cookies」或 +/// 「Network → 请求头」复制出来的内容往往带外壳(`X-Cloudide-Session=xxx;`、 +/// `Authorization: Cloud-IDE-JWT xxx`、成对引号、换行),直接粘贴会因长度/格式判定 +/// 失败而报「Token 无效」。本类负责把这些外壳剥掉并判定真实类型, +/// 从而让「剪贴板一键导入」与「会话 Cookie 导入」在真实的复制粘贴场景下可用。 +/// +public static class CredentialImporter +{ + /// 会话 Cookie 名(Trae 网页端)。 + private const string SessionCookieName = "X-Cloudide-Session"; + + /// Authorization 头的方案前缀。 + private const string JwtScheme = "Cloud-IDE-JWT"; + + /// JWT 最小可接受长度(Cloud-IDE-Token 实际远长于此)。 + private const int MinTokenLength = 40; + + /// 会话串最小可接受长度(低于此长度不足以成为有效会话)。 + private const int MinSessionLength = 16; + + /// + /// 剥掉常见外壳:Cookie 名与分号、Authorization 方案前缀、包裹引号、 + /// `--cookie "..."` 形式的命令行外壳、多余空白与换行。 + /// + public static string Normalize(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return ""; + var s = raw.Trim(); + + // 去掉可能整段粘贴进来的 `--cookie "..."` / `-H "Cookie: ..."` 外层 + s = s.Trim('"', '\''); + + // 取 Cookie 头里的目标字段:X-Cloudide-Session=<值> + var idx = s.IndexOf(SessionCookieName + "=", StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + s = s[(idx + SessionCookieName.Length + 1)..]; + // 值到分号/空白/换行结束 + var end = s.IndexOfAny(new[] { ';', '\r', '\n', ' ', '\t', '"', '\'' }); + if (end >= 0) s = s[..end]; + } + + // 去掉 Authorization 方案前缀 + s = s.Trim(); + if (s.StartsWith(JwtScheme, StringComparison.OrdinalIgnoreCase)) + s = s[JwtScheme.Length..].TrimStart(':', ' '); + + // 去掉可能残留的 `Cookie:` / `Authorization:` 头名 + foreach (var name in new[] { "Cookie:", "Authorization:", "Set-Cookie:" }) + { + if (s.StartsWith(name, StringComparison.OrdinalIgnoreCase)) + s = s[name.Length..].Trim(); + } + + // JWT 中间可能被换行折断(复制长 token 时常见),移除所有空白 + s = s.Replace("\r", "").Replace("\n", "").Replace("\t", "").Trim(); + return s.Trim('"', '\''); + } + + /// + /// 判定凭证类型:形如 `header.payload.signature` 且 payload 可解出 JSON 的判为 Token; + /// 其余足够长的非空串判为会话(Trae 的会话串是无结构的长随机串)。 + /// + public static CredentialKind Classify(string? raw) + { + var s = Normalize(raw); + if (s.Length == 0) return CredentialKind.Unknown; + + var parts = s.Split('.'); + if (parts.Length == 3 && LooksLikeJwt(s)) return CredentialKind.Token; + + return s.Length >= MinSessionLength ? CredentialKind.Session : CredentialKind.Unknown; + } + + /// 校验 Token 是否可接受(长度门槛 + 与旧值不同的判定沿用 TokenUtils 口径)。 + public static bool IsUsableToken(string? token) + => !string.IsNullOrEmpty(token) && token!.Length >= MinTokenLength; + + /// 校验会话串是否可接受。 + public static bool IsUsableSession(string? session) + => !string.IsNullOrEmpty(session) && session!.Length >= MinSessionLength; + + /// 把任意长度的凭证打码展示(首尾各保留 6 位)。 + public static string Mask(string? value) + { + if (string.IsNullOrEmpty(value)) return "(空)"; + if (value!.Length <= 16) return new string('*', 8); + return value[..6] + "…" + value[^6..]; + } + + /// JWT 形态判定:三段 base64url,且 payload 能解出 JSON 对象。 + private static bool LooksLikeJwt(string s) + { + var parts = s.Split('.'); + if (parts.Length != 3) return false; + foreach (var p in parts) + { + if (p.Length == 0) return false; + foreach (var c in p) + { + bool ok = char.IsLetterOrDigit(c) || c == '-' || c == '_'; + if (!ok) return false; + } + } + // payload 必须是 JSON(真实 JWT 一定如此;随机串撞出的三段式不会) + return TryDecodeBase64UrlJson(parts[1]); + } + + private static bool TryDecodeBase64UrlJson(string segment) + { + try + { + var padded = segment.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + var json = Encoding.UTF8.GetString(Convert.FromBase64String(padded)); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.ValueKind == JsonValueKind.Object; + } + catch + { + return false; + } + } + + /// + /// 导入结果:成功与否 + 面向用户的一句话说明 + 解析出的字段。 + /// + public sealed class ImportResult + { + public bool Ok { get; init; } + public string Message { get; init; } = ""; + public string? Token { get; init; } + public string? Session { get; init; } + public CredentialKind Kind { get; init; } + } + + /// + /// 导入一段用户提供的凭证文本: + /// · 识别为 Token → 直接作为 Token 返回; + /// · 识别为会话 → 调 GetUserToken 换取 Token(同时保留会话以便后续静默续期)。 + /// + public static async System.Threading.Tasks.Task ImportAsync( + string? raw, TraeCheckin.TraeApiClient? api, CredentialKind forced = CredentialKind.Unknown) + { + var s = Normalize(raw); + if (s.Length == 0) + return new ImportResult { Ok = false, Message = "内容为空,请先复制凭证" }; + + var kind = forced == CredentialKind.Unknown ? Classify(s) : forced; + + if (kind == CredentialKind.Token) + { + if (!IsUsableToken(s)) + return new ImportResult { Ok = false, Message = "Token 长度不足,请确认复制完整", Kind = kind }; + return new ImportResult + { + Ok = true, + Kind = kind, + Token = s, + Message = "已识别为 Cloud-IDE-Token", + }; + } + + if (kind == CredentialKind.Session) + { + if (!IsUsableSession(s)) + return new ImportResult { Ok = false, Message = "会话串长度不足,请确认复制完整", Kind = kind }; + + if (api == null) + return new ImportResult { Ok = false, Message = "网络组件未就绪,无法用会话换取 Token", Kind = kind }; + + var token = await api.GetUserTokenAsync(s); + if (string.IsNullOrEmpty(token)) + { + return new ImportResult + { + Ok = false, + Kind = kind, + Session = s, + Message = "已识别为会话 Cookie,但换取 Token 失败(会话可能已过期):" + (api.LastError ?? "未知原因"), + }; + } + return new ImportResult + { + Ok = true, + Kind = kind, + Token = token, + Session = s, + Message = "已识别为会话 Cookie,并成功换取 Token", + }; + } + + return new ImportResult { Ok = false, Message = "无法识别的凭证格式", Kind = CredentialKind.Unknown }; + } +} diff --git a/Services/Login/LocalClientImporter.cs b/Services/Login/LocalClientImporter.cs new file mode 100644 index 0000000..00bfa8e --- /dev/null +++ b/Services/Login/LocalClientImporter.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace TraeTools.Services.Login; + +/// 本机客户端导入结果。 +public sealed class LocalImportResult +{ + public bool Ok { get; init; } + public string Message { get; init; } = ""; + public string? Token { get; init; } + public string? Session { get; init; } + public string? DeviceId { get; init; } + /// 命中的数据来源描述(用于告诉用户「从哪里读到的」)。 + public string Source { get; init; } = ""; +} + +/// +/// 从本机 TRAE 官方客户端的数据目录直接读取登录态(免手动复制粘贴)。 +/// +/// 实现说明(务必了解其局限): +/// · TRAE 桌面端是 VS Code 系衍生,登录态主要落在 +/// `%APPDATA%\{客户端}\User\globalStorage\storage.json`(设备号等元信息) +/// 与 `%APPDATA%\{客户端}\Local Storage\leveldb\*`(JWT 等键值)。 +/// · 官方客户端并未承诺这些文件的格式,不同版本可能变化;因此本实现采取 +/// 「多来源、多策略、失败即明确报错」的方式,不做静默兜底—— +/// 找不到时明确告知用户改用「会话 Cookie 导入 / 手动粘贴」。 +/// · 全程只读,不修改客户端任何文件。 +/// +public static class LocalClientImporter +{ + /// 已知的 TRAE 客户端数据目录名(2026-10 起 TraeWork 与 TraeCode 合并,覆盖新旧命名)。 + private static readonly string[] ClientDirNames = + { + "TRAE", "TRAE CN", "TRAE SOLO CN", "Trae CN", "TRAE SOLO", "Trae", + }; + + /// 单个文件扫描上限(leveldb 可能较大,超出则只扫前若干 MB)。 + private const long MaxScanBytes = 24L * 1024 * 1024; + + /// 总共最多扫描的文件数(防止极端目录规模拖慢启动)。 + private const int MaxScanFiles = 200; + + /// JWT 形态匹配(header.payload.signature,base64url)。 + private static readonly Regex JwtPattern = new( + @"eyJ[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}", + RegexOptions.Compiled); + + /// 本机是否存在任一 TRAE 客户端数据目录。 + public static bool HasAnyClient() + { +#if ANDROID + return false; +#else + try + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + return ClientDirNames.Any(n => Directory.Exists(Path.Combine(appData, n))); + } + catch + { + return false; + } +#endif + } + + /// + /// 尝试导入。返回结果中的 Message 直接面向用户展示, + /// 无论成功失败都给出可执行的下一步建议。 + /// + /// 注意:本方法内部可能发起网络请求(用会话换 Token),因此必须是异步的—— + /// 若用同步等待(.GetAwaiter().GetResult())在 UI 线程上阻塞, + /// 而续体又要回到 UI 线程,就会直接死锁。 + /// + public static async Task TryImportAsync() + { +#if ANDROID + await Task.CompletedTask; + return new LocalImportResult + { + Ok = false, + Message = "手机端无法读取桌面客户端数据,请使用「会话 Cookie 导入」或手动粘贴。", + }; +#else + try + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var candidates = ClientDirNames + .Select(n => Path.Combine(appData, n)) + .Where(Directory.Exists) + .ToList(); + + if (candidates.Count == 0) + { + return new LocalImportResult + { + Ok = false, + Message = "未在本机找到 TRAE 客户端数据目录。请先在电脑上安装并登录 TRAE 客户端,或改用「打开浏览器登录 + 粘贴 Token」。", + }; + } + + string? deviceId = null; + string? token = null; + string? session = null; + string tokenSource = ""; + int scanned = 0; + + foreach (var dir in candidates) + { + // ---- 1) globalStorage/storage.json:设备号 + 可能的 JWT ---- + var storage = Path.Combine(dir, "User", "globalStorage", "storage.json"); + if (File.Exists(storage)) + { + scanned++; + var text = SafeReadText(storage); + deviceId ??= ExtractDeviceId(text); + if (token == null) + { + var hit = JwtPattern.Match(text); + if (hit.Success) + { + token = hit.Value; + tokenSource = Path.GetFileName(dir) + "\\storage.json"; + } + } + session ??= ExtractSession(text); + } + + // ---- 2) Local Storage/leveldb:JWT / 会话 主要来源 ---- + foreach (var root in new[] { "Local Storage\\leveldb", "Session Storage" }) + { + var ldb = Path.Combine(dir, root); + if (!Directory.Exists(ldb)) continue; + foreach (var file in SafeFiles(ldb)) + { + if (scanned++ > MaxScanFiles) break; + var text = SafeReadText(file, MaxScanBytes); + if (text.Length == 0) continue; + + if (token == null) + { + var hit = JwtPattern.Match(text); + if (hit.Success) + { + token = hit.Value; + tokenSource = Path.GetFileName(dir) + "\\" + Path.GetFileName(file); + } + } + session ??= ExtractSession(text); + if (token != null && session != null) break; + } + } + + if (token != null) break; + } + + if (token == null && session == null) + { + return new LocalImportResult + { + Ok = false, + DeviceId = deviceId, + Message = deviceId == null + ? "已找到 TRAE 客户端目录,但未能读取到登录凭证(可能尚未登录,或客户端已更新存储格式)。请改用「会话 Cookie 导入」。" + : "已找到客户端数据(含设备号),但未读取到登录凭证。请确认客户端已登录,或改用「会话 Cookie 导入」。", + }; + } + + // ---- 3) 只有会话时,用它换 Token ---- + string? finalToken = token; + string note; + if (finalToken == null && session != null) + { + var api = new TraeCheckin.TraeApiClient(); + finalToken = await api.GetUserTokenAsync(session); + note = finalToken == null + ? "已读取到会话,但换取 Token 失败(会话可能已过期)" + : "已由客户端会话换取 Token"; + } + else + { + note = "已从本机客户端读取 Token"; + } + + if (finalToken == null) + { + return new LocalImportResult + { + Ok = false, + Session = session, + DeviceId = deviceId, + Message = note, + }; + } + + return new LocalImportResult + { + Ok = true, + Token = finalToken, + Session = session, + DeviceId = deviceId, + Source = tokenSource, + Message = $"{note}(来源:{tokenSource})", + }; + } + catch (Exception ex) + { + return new LocalImportResult { Ok = false, Message = "读取本机客户端失败:" + ex.Message }; + } +#endif + } + +#if !ANDROID + private static IEnumerable SafeFiles(string dir) + { + try + { + return Directory.GetFiles(dir, "*", SearchOption.TopDirectoryOnly) + .OrderByDescending(f => SafeLength(f)) + .ToList(); + } + catch + { + return Array.Empty(); + } + } + + private static long SafeLength(string path) + { + try { return new FileInfo(path).Length; } catch { return 0; } + } + + /// 按 Latin-1 读取(逐字节映射,不丢信息),便于对二进制 leveldb 做文本匹配。 + private static string SafeReadText(string path, long maxBytes = MaxScanBytes) + { + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var len = Math.Min(fs.Length, maxBytes); + var buf = new byte[len]; + int read = 0; + while (read < len) + { + int n = fs.Read(buf, read, (int)(len - read)); + if (n <= 0) break; + read += n; + } + return Encoding.Latin1.GetString(buf, 0, read); + } + catch + { + return ""; + } + } + + /// 从 storage.json 里取 Aha 设备号(复用与配置加载同源的解析口径)。 + private static string? ExtractDeviceId(string text) + { + try + { + var m = Regex.Match(text, @"""iCubeAuthInfo://icube-dc:(?\d{8,})"""); + return m.Success ? m.Groups["id"].Value : null; + } + catch + { + return null; + } + } + + /// 在文本中定位 X-Cloudide-Session 的值(leveldb 中的分隔符不固定,取其后首个长串)。 + private static string? ExtractSession(string text) + { + try + { + const string name = "X-Cloudide-Session"; + int idx = text.IndexOf(name, StringComparison.OrdinalIgnoreCase); + if (idx < 0) return null; + int i = idx + name.Length; + // 跳过键值之间的分隔符 + while (i < text.Length && !IsSessionChar(text[i])) i++; + int start = i; + while (i < text.Length && IsSessionChar(text[i])) i++; + var value = text.Substring(start, i - start); + return value.Length >= 16 ? value : null; + } + catch + { + return null; + } + } + + /// 会话串的合法字符集(字母数字与常见 base64url 符号)。 + private static bool IsSessionChar(char c) + => char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.' || c == ':' || c == '+'; +#endif +} diff --git a/Services/Notify/EmailNotifier.cs b/Services/Notify/EmailNotifier.cs new file mode 100644 index 0000000..bff96f6 --- /dev/null +++ b/Services/Notify/EmailNotifier.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Security; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace TraeTools.Services.Notify; + +/// +/// 轻量 SMTP 客户端(自实现,不依赖 System.Net.Mail.SmtpClient)。 +/// +/// 为什么不用内置的 SmtpClient:它只支持「明文」与「STARTTLS」两种模式, +/// **不支持 465 端口的隐式 TLS**,而国内邮箱(QQ / 163 / 126 等)最常用的恰恰是 465。 +/// 自实现后可完整覆盖三种连接安全方式: +/// · ssl —— 连接后立即握手(465 / 994) +/// · starttls —— 先明文再升级(587 / 25) +/// · none —— 全程明文(内网中继 / 25) +/// 认证方式同时支持 AUTH LOGIN 与 AUTH PLAIN,按服务器 EHLO 宣告择优使用。 +/// 发信正文统一按 UTF-8 + Base64 编码,中文主题用 RFC 2047 encoded-word,杜绝乱码。 +/// +public static class EmailNotifier +{ + private static int _counter; + + /// 单次会话的读写超时。 + private const int IoTimeoutMs = 20_000; + + /// 发送一封纯文本邮件。 + public static async Task SendAsync( + string? host, + int port, + string? security, + string? user, + string? password, + string? from, + string? to, + string subject, + string body, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(host)) + return NotifyResult.Fail("SMTP 服务器地址为空"); + + var recipients = SplitAddresses(to); + if (recipients.Count == 0) + return NotifyResult.Fail("收件人为空"); + + var sender = string.IsNullOrWhiteSpace(from) ? user : from; + if (string.IsNullOrWhiteSpace(sender)) + return NotifyResult.Fail("发件人为空(也未填写用户名)"); + + var mode = (security ?? "ssl").ToLowerInvariant(); + if (port <= 0) port = mode == "ssl" ? 465 : mode == "starttls" ? 587 : 25; + + TcpClient? tcp = null; + Stream? stream = null; + try + { + tcp = new TcpClient { ReceiveTimeout = IoTimeoutMs, SendTimeout = IoTimeoutMs }; + await tcp.ConnectAsync(host!, port, ct); + stream = tcp.GetStream(); + stream.ReadTimeout = IoTimeoutMs; + stream.WriteTimeout = IoTimeoutMs; + + // 隐式 TLS:连上即握手 + if (mode == "ssl") + { + var ssl = new SslStream(stream, false); + // 不显式限制协议版本:不同平台/机型对 TLS1.3 的支持情况不一, + // 写死反而可能在旧系统上握手失败;交给平台按服务端能力协商更稳。 + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = host!, + }, ct); + stream = ssl; + stream.ReadTimeout = IoTimeoutMs; + stream.WriteTimeout = IoTimeoutMs; + } + + var reader = new StreamReader(stream, Encoding.ASCII, false, 4096, leaveOpen: true); + var writer = new StreamWriter(stream, Encoding.ASCII, 4096, leaveOpen: true) { NewLine = "\r\n", AutoFlush = false }; + + var greeting = await ReadReplyAsync(reader); + if (!greeting.StartsWith("220")) + return NotifyResult.Fail($"服务器未就绪:{FirstLine(greeting)}"); + + var hostName = DnsSafeHostName(); + await CommandAsync(writer, reader, $"EHLO {hostName}", "250"); + + // STARTTLS:明文会话内升级 + if (mode == "starttls") + { + await CommandAsync(writer, reader, "STARTTLS", "220"); + var ssl = new SslStream(stream, false); + // 不显式限制协议版本:不同平台/机型对 TLS1.3 的支持情况不一, + // 写死反而可能在旧系统上握手失败;交给平台按服务端能力协商更稳。 + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = host!, + }, ct); + stream = ssl; + stream.ReadTimeout = IoTimeoutMs; + stream.WriteTimeout = IoTimeoutMs; + reader = new StreamReader(stream, Encoding.ASCII, false, 4096, leaveOpen: true); + writer = new StreamWriter(stream, Encoding.ASCII, 4096, leaveOpen: true) { NewLine = "\r\n", AutoFlush = false }; + // 重新打招呼以获取升级后的能力集(含 AUTH 机制) + await CommandAsync(writer, reader, $"EHLO {hostName}", "250"); + } + + if (!string.IsNullOrEmpty(user)) + { + var authErr = await AuthenticateAsync(writer, reader, user!, password ?? ""); + if (authErr != null) + return NotifyResult.Fail(authErr); + } + + await CommandAsync(writer, reader, $"MAIL FROM:<{sender}>", "250"); + foreach (var rcpt in recipients) + { + // 单个收件人被拒时继续尝试其余收件人,只要有一个成功即可投递 + await CommandAsync(writer, reader, $"RCPT TO:<{rcpt}>", null); + } + + await CommandAsync(writer, reader, "DATA", "354"); + + var data = BuildMessage(sender!, recipients, subject, body); + await writer.WriteAsync(data); + writer.Flush(); + await CommandAsync(writer, reader, ".", "250"); + + try + { + await writer.WriteAsync("QUIT\r\n"); + writer.Flush(); + } + catch { /* 服务器可能已主动断开,忽略 */ } + + return NotifyResult.Ok0($"已发送至 {string.Join("、", recipients)}"); + } + catch (Exception ex) + { + return NotifyResult.Fail("发送失败:" + ex.Message); + } + finally + { + try { stream?.Dispose(); } catch { } + try { tcp?.Close(); } catch { } + } + } + + /// 按服务器宣告的机制选择 AUTH LOGIN / AUTH PLAIN。返回 null 表示成功。 + private static async Task AuthenticateAsync(StreamWriter writer, StreamReader reader, string user, string password) + { + var user64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(user)); + var pass64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(password)); + + // 先试 LOGIN(兼容面最广) + await SendAsync(writer, "AUTH LOGIN"); + var r1 = await ReadReplyAsync(reader); + if (r1.StartsWith("334")) + { + await SendAsync(writer, user64); + var r2 = await ReadReplyAsync(reader); + if (!r2.StartsWith("334")) + return "SMTP 认证失败(用户名阶段):" + FirstLine(r2); + await SendAsync(writer, pass64); + var r3 = await ReadReplyAsync(reader); + if (r3.StartsWith("235")) return null; + return "SMTP 认证失败(口令阶段,请确认使用的是「授权码」而非登录密码):" + FirstLine(r3); + } + + // 回退 PLAIN + await SendAsync(writer, "AUTH PLAIN " + Convert.ToBase64String( + Encoding.UTF8.GetBytes("\0" + user + "\0" + password))); + var r4 = await ReadReplyAsync(reader); + if (r4.StartsWith("235")) return null; + return "SMTP 认证失败(请确认使用「授权码」而非登录密码):" + FirstLine(r4); + } + + /// 构造 RFC 5322 邮件正文:中文主题用 encoded-word,正文用 UTF-8 Base64。 + private static string BuildMessage(string from, List to, string subject, string body) + { + var sb = new StringBuilder(); + sb.Append("From: ").Append(from).Append("\r\n"); + sb.Append("To: ").Append(string.Join(", ", to)).Append("\r\n"); + sb.Append("Subject: =?UTF-8?B?").Append(Convert.ToBase64String(Encoding.UTF8.GetBytes(subject))).Append("?=\r\n"); + sb.Append("Date: ").Append(DateTime.Now.ToString("r")).Append("\r\n"); + sb.Append("MIME-Version: 1.0\r\n"); + sb.Append("Content-Type: text/plain; charset=UTF-8\r\n"); + sb.Append("Content-Transfer-Encoding: base64\r\n"); + sb.Append("Message-ID: <").Append(NextMessageId()).Append(">\r\n"); + sb.Append("\r\n"); + + // 正文按 76 字符折行的 Base64,保证任何 SMTP 服务器都能安全传输中文 + var bodyB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(body)); + for (int i = 0; i < bodyB64.Length; i += 76) + sb.Append(bodyB64, i, Math.Min(76, bodyB64.Length - i)).Append("\r\n"); + + return sb.ToString(); + } + + private static string NextMessageId() + { + var n = Interlocked.Increment(ref _counter); + return $"{DateTime.Now:yyyyMMddHHmmss}.{n}@traetools"; + } + + /// 发送命令并校验响应码;expect 为 null 表示不校验(如 RCPT TO 允许个别失败)。 + private static async Task CommandAsync( + StreamWriter writer, StreamReader reader, string command, string? expect) + { + await SendAsync(writer, command); + var reply = await ReadReplyAsync(reader); + if (expect != null && !reply.StartsWith(expect)) + throw new InvalidOperationException($"服务器拒绝 `{Redact(command)}`:{FirstLine(reply)}"); + return reply; + } + + private static async Task SendAsync(StreamWriter writer, string line) + { + await writer.WriteAsync(line + "\r\n"); + await writer.FlushAsync(); + } + + /// 读取一条完整 SMTP 响应(处理 250-xxx 多行续行)。 + private static async Task ReadReplyAsync(StreamReader reader) + { + var sb = new StringBuilder(); + while (true) + { + var line = await reader.ReadLineAsync(); + if (line == null) break; + sb.AppendLine(line); + // 第 4 个字符为空格(或行长度不足)表示这是最后一行 + if (line.Length < 4 || line[3] != '-') break; + } + return sb.ToString(); + } + + private static string FirstLine(string reply) + => reply.Split('\n').FirstOrDefault(l => l.Trim().Length > 0)?.Trim() ?? "(无响应)"; + + /// 日志脱敏:认证相关命令不把 Base64 凭据写进异常信息。 + private static string Redact(string command) + => command.StartsWith("AUTH", StringComparison.OrdinalIgnoreCase) ? "AUTH ***" : command; + + /// 拆分收件人(支持逗号、分号、空白分隔)。 + public static List SplitAddresses(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return new List(); + return raw! + .Split(new[] { ',', ';', '\n', '\r', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries) + .Select(a => a.Trim()) + .Where(a => a.Contains('@')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// EHLO 用的本机名(RFC 要求为域名或地址字面量)。 + private static string DnsSafeHostName() + { + try + { + var name = System.Net.Dns.GetHostName(); + return string.IsNullOrWhiteSpace(name) ? "localhost" : name; + } + catch + { + return "localhost"; + } + } +} diff --git a/Services/Notify/NotifyDispatcher.cs b/Services/Notify/NotifyDispatcher.cs new file mode 100644 index 0000000..54232f9 --- /dev/null +++ b/Services/Notify/NotifyDispatcher.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TraeCheckin; +using TraeTools.Services; + +namespace TraeTools.Services.Notify; + +/// 单个渠道的推送结果(用于界面逐渠道显示成败)。 +public sealed class ChannelOutcome +{ + public required string Channel { get; init; } + public required bool Ok { get; init; } + public required string Message { get; init; } +} + +/// +/// 推送总调度:把一条消息扇出到全部已启用的渠道。 +/// +/// 设计原则: +/// · **互不影响**——各渠道并行发送,单个渠道失败不阻断其它渠道,也不影响签到主流程; +/// · **不静默**——每个渠道的成败都会被记录到日志,并在「测试推送」时逐条回显; +/// · **可开关**——总开关 NotifyOnCheckin 之外,每个渠道还有独立开关与配置。 +/// +public static class NotifyDispatcher +{ + /// 渠道标识(界面与日志共用同一套名称)。 + public const string ChFeishu = "飞书"; + public const string ChEmail = "邮箱"; + public const string ChWebhook = "通用 Webhook"; + public const string ChDingTalk = "钉钉"; + public const string ChWeCom = "企业微信"; + public const string ChTelegram = "Telegram"; + public const string ChSystem = "系统通知"; + + /// + /// 把所有渠道扇出执行;返回每个渠道的结果。 + /// 传入的 cfg 若为 null 则直接返回空列表。 + /// + public static async Task> SendAsync( + AppConfig? cfg, string title, string content, bool includeSystem = true, CancellationToken ct = default) + { + var outcomes = new List(); + if (cfg == null) return outcomes; + + var tasks = new List>(); + + if (!string.IsNullOrWhiteSpace(cfg.FeishuWebhook)) + tasks.Add(WrapAsync(ChFeishu, async () => + await FeishuNotifier.SendTextAsync(cfg.FeishuWebhook, title + "\n" + content) + ? NotifyResult.Ok0("已发送") : NotifyResult.Fail("发送失败(请检查 Webhook 地址)"))); + + if (cfg.EmailEnabled) + tasks.Add(WrapAsync(ChEmail, async () => await EmailNotifier.SendAsync( + cfg.SmtpHost, cfg.SmtpPort, cfg.SmtpSecurity, cfg.SmtpUser, cfg.SmtpPassword, + cfg.SmtpFrom, cfg.SmtpTo, title, content, ct))); + + if (cfg.WebhookEnabled) + tasks.Add(WrapAsync(ChWebhook, async () => await RobotNotifiers.SendWebhookAsync( + cfg.WebhookUrl, cfg.WebhookBodyTemplate, cfg.WebhookContentType, title, content, ct))); + + if (cfg.DingTalkEnabled) + tasks.Add(WrapAsync(ChDingTalk, async () => await RobotNotifiers.SendDingTalkAsync( + cfg.DingTalkWebhook, cfg.DingTalkSecret, title, content, ct))); + + if (cfg.WeComEnabled) + tasks.Add(WrapAsync(ChWeCom, async () => await RobotNotifiers.SendWeComAsync( + cfg.WeComWebhook, title, content, ct))); + + if (cfg.TelegramEnabled) + tasks.Add(WrapAsync(ChTelegram, async () => await RobotNotifiers.SendTelegramAsync( + cfg.TelegramBotToken, cfg.TelegramChatId, title, content, ct))); + + if (includeSystem && cfg.NotifySystemEnabled) + tasks.Add(Task.FromResult(SendSystem(title, content))); + + if (tasks.Count == 0) return outcomes; + + try + { + var done = await Task.WhenAll(tasks); + outcomes.AddRange(done); + } + catch { /* WrapAsync 已兜住异常;此处仅防御 */ } + + // 统一落日志:推送是否成功对用户排查很关键 + foreach (var o in outcomes) + AppLog.Write(o.Ok ? LogLevel.Info : LogLevel.Warn, "notify", "", + $"{o.Channel}:{(o.Ok ? "成功" : "失败")} {o.Message}"); + + return outcomes; + } + + /// 系统通知单独包一层(Send 是同步调用,且只应执行一次)。 + private static ChannelOutcome SendSystem(string title, string content) + { + var r = SystemNotifier.Send(title, content); + return new ChannelOutcome { Channel = ChSystem, Ok = r.Ok, Message = r.Message }; + } + + /// 把渠道调用包成「永不抛异常 + 带渠道名」的任务。 + private static async Task WrapAsync(string channel, Func> body) + { + try + { + var r = await body(); + return new ChannelOutcome { Channel = channel, Ok = r.Ok, Message = r.Message }; + } + catch (Exception ex) + { + return new ChannelOutcome { Channel = channel, Ok = false, Message = "异常:" + ex.Message }; + } + } + + /// + /// 签到结束后批量推送多账号结果。全失败时不推送(避免用无意义的消息打扰用户)。 + /// + public static async Task> SendCheckinBatchAsync( + AppConfig? cfg, IReadOnlyList<(string Name, bool Ok, double Gained, string Reason)> results) + { + if (cfg == null || results.Count == 0) return Array.Empty(); + if (!cfg.NotifyOnCheckin) return Array.Empty(); + if (!results.Any(r => r.Ok)) return Array.Empty(); + + var okCount = results.Count(r => r.Ok); + var title = $"Trae 签到结果({okCount}/{results.Count} 成功)"; + + var sb = new StringBuilder(); + sb.AppendLine($"时间:{DateTime.Now:yyyy-MM-dd HH:mm}"); + foreach (var r in results) + sb.AppendLine((r.Ok ? "✅ " : "⚠️ ") + r.Name + (r.Ok ? $" 获得 {r.Gained:0} 积分" : $" 失败:{r.Reason}")); + + return await SendAsync(cfg, title, sb.ToString().TrimEnd()); + } + + /// 把渠道结果汇总成一行人类可读文案(用于设置页状态条)。 + public static string Summarize(IReadOnlyList outcomes) + { + if (outcomes.Count == 0) return "没有启用任何推送渠道"; + var okCount = outcomes.Count(o => o.Ok); + var head = $"推送完成:成功 {okCount}/{outcomes.Count}"; + var failed = outcomes.Where(o => !o.Ok).ToList(); + if (failed.Count == 0) return head + " ✓"; + return head + ";失败:" + string.Join("、", failed.Select(f => f.Channel)); + } +} diff --git a/Services/Notify/RobotNotifiers.cs b/Services/Notify/RobotNotifiers.cs new file mode 100644 index 0000000..bce4f93 --- /dev/null +++ b/Services/Notify/RobotNotifiers.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using TraeTools.Services.Json; + +namespace TraeTools.Services.Notify; + +/// 推送结果(统一结构,便于界面显示每个渠道的成败)。 +public sealed class NotifyResult +{ + public bool Ok { get; init; } + public string Message { get; init; } = ""; + + public static NotifyResult Fail(string msg) => new() { Ok = false, Message = msg }; + public static NotifyResult Ok0(string msg = "已发送") => new() { Ok = true, Message = msg }; +} + +/// +/// 钉钉 / 企业微信共用的文本消息体(两者协议一致:{"msgtype":"text","text":{"content":"…"}})。 +/// +/// 为什么必须是具名类型:本项目的 JSON 一律走 NativeAOT 安全的**源生成**(见 Services/Json/AppJson.cs), +/// 而源生成器无法为匿名类型生成元数据 —— 用匿名类型会在裁剪/AOT 下静默失败。 +/// +internal sealed class TextRobotPayload +{ + [JsonPropertyName("msgtype")] + public string MsgType { get; init; } = "text"; + + [JsonPropertyName("text")] + public TextRobotBody Text { get; init; } = new(); +} + +/// 文本消息体( 的嵌套对象)。 +internal sealed class TextRobotBody +{ + [JsonPropertyName("content")] + public string Content { get; init; } = ""; +} + +/// Telegram sendMessage 请求体。 +internal sealed class TelegramSendPayload +{ + [JsonPropertyName("chat_id")] + public string ChatId { get; init; } = ""; + + [JsonPropertyName("text")] + public string Text { get; init; } = ""; + + [JsonPropertyName("disable_web_page_preview")] + public bool DisableWebPagePreview { get; init; } +} + +/// +/// 基于 HTTP 的推送渠道集合:通用 Webhook、钉钉机器人、企业微信群机器人、Telegram Bot。 +/// +/// 四者共同点是「一次 HTTP 请求即可完成投递」,因此不引入任何第三方 SDK, +/// 只用 HttpClient 与 System.Text.Json,两个平台行为完全一致。 +/// 全部渠道都做了「失败不抛异常、只返回结果」的约定,单个渠道故障不影响其它渠道与签到主流程。 +/// +public static class RobotNotifiers +{ + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) }; + + // ==================== 通用 Webhook ==================== + + /// + /// 通用 Webhook:把 {title} / {content} 占位符替换进用户自定义的请求体模板后 POST。 + /// 一份配置即可兼容 Server酱({"title":"…","desp":"{content}"})、 + /// PushPlus、Bark、以及任何接受 JSON 的自建服务。 + /// + public static async Task SendWebhookAsync( + string? url, string? bodyTemplate, string? contentType, string title, string content, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(url)) return NotifyResult.Fail("Webhook 地址为空"); + + var template = string.IsNullOrWhiteSpace(bodyTemplate) + ? "{\"title\":\"{title}\",\"content\":\"{content}\"}" + : bodyTemplate!; + + // 替换时对内容做 JSON 字符串转义,避免消息里的引号/换行把请求体破坏成非法 JSON + var body = template + .Replace("{title}", JsonEscape(title), StringComparison.Ordinal) + .Replace("{content}", JsonEscape(content), StringComparison.Ordinal); + + try + { + using var req = new HttpRequestMessage(HttpMethod.Post, url); + req.Content = new StringContent(body, Encoding.UTF8, + string.IsNullOrWhiteSpace(contentType) ? "application/json" : contentType!); + using var resp = await Http.SendAsync(req, ct); + var text = await SafeReadAsync(resp, ct); + return resp.IsSuccessStatusCode + ? NotifyResult.Ok0($"HTTP {(int)resp.StatusCode}") + : NotifyResult.Fail($"HTTP {(int)resp.StatusCode} {Truncate(text)}"); + } + catch (Exception ex) + { + return NotifyResult.Fail("请求失败:" + ex.Message); + } + } + + // ==================== 钉钉群机器人 ==================== + + /// + /// 钉钉自定义机器人。地址可直接粘贴完整 webhook; + /// 若机器人启用了「加签」安全设置,把密钥一并填入即可自动完成 HMAC-SHA256 签名 + /// (否则钉钉会返回 310000 sign not match)。 + /// + public static async Task SendDingTalkAsync( + string? webhook, string? secret, string title, string content, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(webhook)) return NotifyResult.Fail("钉钉 Webhook 为空"); + + var url = webhook!.Trim(); + if (!string.IsNullOrWhiteSpace(secret)) + { + try + { + var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var toSign = ts + "\n" + secret!.Trim(); + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret!.Trim())); + var sign = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(toSign))); + var sep = url.Contains('?') ? "&" : "?"; + url = $"{url}{sep}timestamp={ts}&sign={Uri.EscapeDataString(sign)}"; + } + catch (Exception ex) + { + return NotifyResult.Fail("加签失败:" + ex.Message); + } + } + + // 具名类型 + 源生成(匿名类型无法被源生成器登记) + var payload = AppJson.Serialize(new TextRobotPayload + { + Text = new TextRobotBody { Content = $"{title}\n{content}" }, + }); + return await PostJsonAsync(url, payload, "钉钉", ct); + } + + // ==================== 企业微信群机器人 ==================== + + /// 企业微信(群机器人):完整 webhook 地址已含 key 参数,直接 POST 即可。 + public static async Task SendWeComAsync( + string? webhook, string title, string content, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(webhook)) return NotifyResult.Fail("企业微信 Webhook 为空"); + var payload = AppJson.Serialize(new TextRobotPayload + { + Text = new TextRobotBody { Content = $"{title}\n{content}" }, + }); + return await PostJsonAsync(webhook!.Trim(), payload, "企业微信", ct); + } + + // ==================== Telegram Bot ==================== + + /// + /// Telegram Bot:POST https://api.telegram.org/bot<token>/sendMessage。 + /// 注意:该域名在国内网络下通常不可直连,需要代理; + /// 请求失败时会明确提示这一点,避免用户误以为是配置写错。 + /// + public static async Task SendTelegramAsync( + string? botToken, string? chatId, string title, string content, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(botToken)) return NotifyResult.Fail("Bot Token 为空"); + if (string.IsNullOrWhiteSpace(chatId)) return NotifyResult.Fail("Chat ID 为空"); + + var token = botToken!.Trim(); + // 容错:用户可能把整条 URL 粘进来,这里只取 token 部分 + if (token.Contains("api.telegram.org")) + { + var m = System.Text.RegularExpressions.Regex.Match(token, @"/bot(?[^/]+)/"); + if (m.Success) token = m.Groups["t"].Value; + } + + var url = $"https://api.telegram.org/bot{token}/sendMessage"; + var payload = AppJson.Serialize(new TelegramSendPayload + { + ChatId = chatId!.Trim(), + Text = $"{title}\n{content}", + DisableWebPagePreview = true, + }); + + return await PostJsonAsync(url, payload, "Telegram", ct); + } + + // ==================== 公共 ==================== + + private static async Task PostJsonAsync(string url, string payload, string label, CancellationToken ct) + { + try + { + using var req = new HttpRequestMessage(HttpMethod.Post, url); + req.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var resp = await Http.SendAsync(req, ct); + var text = await SafeReadAsync(resp, ct); + if (resp.IsSuccessStatusCode) + { + // 钉钉/企业微信即使 HTTP 200 也可能在响应体里报错(errcode != 0) + if (TryReadErrCode(text, out var code, out var msg) && code != 0) + return NotifyResult.Fail($"{label}返回错误 {code}:{msg}"); + return NotifyResult.Ok0($"{label} HTTP {(int)resp.StatusCode}"); + } + return NotifyResult.Fail($"{label} HTTP {(int)resp.StatusCode} {Truncate(text)}"); + } + catch (Exception ex) + { + // Telegram 的域名在国内网络下通常不可直连,失败时明确提示,避免用户误以为配置写错 + var hint = label == "Telegram" ? "(Telegram 在国内网络通常需代理,请确认网络环境)" : ""; + return NotifyResult.Fail($"{label}请求失败:{ex.Message}{hint}"); + } + } + + /// 解析钉钉/企业微信响应体中的 errcode/errmsg(Telegram 用 ok 字段,不适用)。 + private static bool TryReadErrCode(string text, out int code, out string message) + { + code = 0; + message = ""; + try + { + if (string.IsNullOrWhiteSpace(text)) return false; + using var doc = JsonDocument.Parse(text); + var root = doc.RootElement; + if (root.TryGetProperty("errcode", out var ec) && ec.TryGetInt32(out var c)) + { + code = c; + if (root.TryGetProperty("errmsg", out var em) && em.ValueKind == JsonValueKind.String) + message = em.GetString() ?? ""; + return true; + } + } + catch { /* 非 JSON 响应则忽略 */ } + return false; + } + + private static async Task SafeReadAsync(HttpResponseMessage resp, CancellationToken ct) + { + try { return await resp.Content.ReadAsStringAsync(ct); } + catch { return ""; } + } + + private static string Truncate(string? s) + { + if (string.IsNullOrEmpty(s)) return ""; + var one = s!.Replace("\r", " ").Replace("\n", " ").Trim(); + return one.Length <= 160 ? one : one[..160] + "…"; + } + + /// 把一段文本转义为可安全嵌入 JSON 字符串字面量的形式(不含两侧引号)。 + public static string JsonEscape(string? s) + { + if (string.IsNullOrEmpty(s)) return ""; + var sb = new StringBuilder(s!.Length + 16); + foreach (var ch in s) + { + switch (ch) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (ch < 0x20) sb.Append("\\u").Append(((int)ch).ToString("x4")); + else sb.Append(ch); + break; + } + } + return sb.ToString(); + } +} diff --git a/Services/Notify/SystemNotifier.cs b/Services/Notify/SystemNotifier.cs new file mode 100644 index 0000000..79b0d98 --- /dev/null +++ b/Services/Notify/SystemNotifier.cs @@ -0,0 +1,64 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Notifications; +using Avalonia.Threading; + +namespace TraeTools.Services.Notify; + +/// +/// 系统本地通知:走主窗口的 (右下角通知,进系统通知中心)。 +/// +/// 定位说明:这是「本机在跑的时候」的即时反馈,属补充手段—— +/// 云端签到由 GitHub Actions 执行,那一刻本机很可能并未开机, +/// 因此重要结果仍应依赖邮箱 / Webhook 等推送渠道。 +/// +public static class SystemNotifier +{ + /// 通知管理器按窗口懒创建:WindowNotificationManager 必须在 UI 线程构造。 + private static WindowNotificationManager? _manager; + + /// 本机通知所需权限是否已具备。桌面端无运行时权限概念,恒为 true。 + public static bool HasPermission() => true; + + /// 申请通知权限。桌面端为无操作,保留以统一各渠道的调用形态。 + public static bool RequestPermission() => true; + + /// 发送一条系统本地通知。 + public static NotifyResult Send(string title, string body) + { + try + { + var win = TraeTools.ViewModels.UiHost.MainWindow; + if (win == null) + return NotifyResult.Fail("系统通知不可用:主窗口尚未就绪(请先打开主界面再试)"); + + // 签到流程可能跑在后台线程,而通知管理器只能在 UI 线程使用,故切回 UI 线程。 + if (Dispatcher.UIThread.CheckAccess()) ShowCore(win, title, body); + else Dispatcher.UIThread.Post(() => ShowCore(win, title, body)); + + return NotifyResult.Ok0("已发送系统通知"); + } + catch (Exception ex) + { + return NotifyResult.Fail("系统通知失败:" + ex.Message); + } + } + + /// + /// 实际的投递动作。内部再兜一层 try/catch:通知失败绝不应该冒泡到签到主流程 + /// (调用方已返回成功,此处失败只影响这一次提示)。 + /// + private static void ShowCore(Window win, string title, string body) + { + try + { + _manager ??= new WindowNotificationManager(win) + { + Position = NotificationPosition.BottomRight, + MaxItems = 3, + }; + _manager.Show(new Notification(title, body)); + } + catch { /* 单次通知失败不影响业务 */ } + } +} diff --git a/Services/Security/SecurityAudit.cs b/Services/Security/SecurityAudit.cs new file mode 100644 index 0000000..6cc4c8b --- /dev/null +++ b/Services/Security/SecurityAudit.cs @@ -0,0 +1,517 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using Avalonia.Media; +using TraeCheckin; + +namespace TraeTools.Services.Security; + +/// 一条安全检查结论。等级:ok=通过 / warn=警告 / risk=风险。 +public sealed class SecurityFinding +{ + /// 检查项标题(如「配置加密」)。 + public string Title { get; init; } = ""; + /// 结论说明(人话,含可执行的处置建议)。 + public string Detail { get; init; } = ""; + /// 等级。 + public string Level { get; init; } = "ok"; + + public bool IsOk => Level == "ok"; + public bool IsWarn => Level == "warn"; + public bool IsRisk => Level == "risk"; + + public string LevelText => Level switch + { + "risk" => "风险", + "warn" => "警告", + _ => "通过", + }; + + public string Icon => Level switch + { + "risk" => "⛔", + "warn" => "⚠️", + _ => "✅", + }; + + // 与项目其它状态色保持同一套取值(AccountInfo.StatusFgBrush / CloudStep.StatusFgBrush) + + /// 前景色(图标与文字)。 + public IBrush LevelFgBrush => Level switch + { + "risk" => new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), + "warn" => new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B)), + _ => new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)), + }; + + /// 背景色(淡底徽章)。 + public IBrush LevelBgBrush => Level switch + { + "risk" => new SolidColorBrush(Color.FromArgb(0x1A, 0xEF, 0x44, 0x44)), + "warn" => new SolidColorBrush(Color.FromArgb(0x1A, 0xF5, 0x9E, 0x0B)), + _ => new SolidColorBrush(Color.FromArgb(0x1A, 0x10, 0xB9, 0x81)), + }; +} + +/// +/// 账号与本地安全检查。 +/// +/// 只做**本地可判定的**检查,不做网络探测,也不修改任何数据—— +/// 目的是让用户一眼看清「凭证有没有明文躺在磁盘上、设备号是不是会被风控认成同一台机器、 +/// 会话还能撑多久、日志里有没有漏出完整凭证」。 +/// +/// ⚠️ 报告里**永不输出敏感值本身**:只出现字段名、长度与结论。 +/// +public static class SecurityAudit +{ + /// 敏感字段名(config.json 中应以密文存放的键)。 + private static readonly string[] SensitiveKeys = + { + "Token", "Session", "GitHubToken", "DeviceId", "FeishuWebhook", + "SmtpPassword", "WebhookUrl", "DingTalkWebhook", "DingTalkSecret", + "WeComWebhook", "TelegramBotToken", + }; + + /// JWT 形态的正则(用于判断明文凭证 / 日志泄漏)。 + private static readonly Regex JwtPattern = + new(@"eyJ[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}", RegexOptions.Compiled); + + /// 运行全部检查,返回按「风险→警告→通过」排序的结论列表。 + public static List Run(AppConfig? cfg) + { + var list = new List(); + Safe(list, () => list.Add(CheckVaultVersion(cfg))); + Safe(list, () => list.Add(CheckCryptoRoundTrip())); + Safe(list, () => list.Add(CheckPlaintextResidue())); + Safe(list, () => list.Add(CheckDeviceIds(cfg))); + Safe(list, () => list.Add(CheckTokenLifetime(cfg))); + Safe(list, () => list.Add(CheckDataDirPrivacy())); + Safe(list, () => list.Add(CheckLogLeak())); + + // 风险在前、通过在后,便于用户先看要紧的 + var rank = new Dictionary { ["risk"] = 0, ["warn"] = 1, ["ok"] = 2 }; + return list.OrderBy(f => rank.TryGetValue(f.Level, out var r) ? r : 3).ToList(); + } + + /// 单个检查抛异常时降级为一条警告,绝不让整份报告失败。 + private static void Safe(List list, Action action) + { + try { action(); } + catch (Exception ex) + { + list.Add(new SecurityFinding + { + Title = "检查项异常", + Detail = "该项检查未能完成:" + ex.Message, + Level = "warn", + }); + } + } + + // ---------- 1. 配置加密状态 ---------- + + private static SecurityFinding CheckVaultVersion(AppConfig? cfg) + { + const string sealedTag = "dpapi-v1"; + if (cfg == null) + return new SecurityFinding { Title = "配置加密", Detail = "配置未初始化,无法判断。", Level = "warn" }; + + bool sealedVault = string.Equals(cfg.SecretsVaultVersion, sealedTag, StringComparison.Ordinal); + return new SecurityFinding + { + Title = "配置加密", + Detail = sealedVault + ? $"凭证已启用平台加密存储({VaultName},仅当前用户可解密);配置文件位于用户私有目录。" + : "配置文件仍是旧版明文格式,下次保存时会自动加密。建议先在设置页保存一次配置以完成迁移。", + Level = sealedVault ? "ok" : "risk", + }; + } + + /// 当前平台加密机制名称(仅用于报告文案)。本 PR 只覆盖 Windows 版主程序。 + private static string VaultName => "Windows DPAPI"; + + // ---------- 2. 加解密自检 ---------- + + private static SecurityFinding CheckCryptoRoundTrip() + { + const string sample = "traetools-selftest-2026"; + var enc = ProtectSample(sample); + if (string.IsNullOrEmpty(enc)) + return new SecurityFinding + { + Title = "加解密自检", + Detail = "平台加密不可用(Protect 返回空),凭证将以明文落盘。请检查系统密钥服务是否正常。", + Level = "risk", + }; + if (enc == sample) + return new SecurityFinding + { + Title = "加解密自检", + Detail = "加密函数未真正加密(密文与明文相同),凭证实际上以明文落盘。", + Level = "risk", + }; + var back = UnprotectSample(enc); + bool ok = string.Equals(back, sample, StringComparison.Ordinal); + return new SecurityFinding + { + Title = "加解密自检", + Detail = ok + ? $"加解密往返正常(示例密文长度 {enc.Length},可还原)。" + : "加密后无法解密还原:凭证落盘后取不回来(表现为每次启动都要重新登录、" + + "设备号退化成密文并被当成设备号发出去触发风控 9074)。", + Level = ok ? "ok" : "risk", + }; + } + + /// + /// 自检用:与 AppConfig 的落盘加密保持同一机制(当前 Windows 用户作用域 DPAPI)。 + /// 刻意**不复用** AppConfig 的私有方法——自检若依赖被测对象本身,就失去了独立验证的意义。 + /// + private static string? ProtectSample(string plain) + { + try + { + var bytes = ProtectedData.Protect( + System.Text.Encoding.UTF8.GetBytes(plain), null, DataProtectionScope.CurrentUser); + return Convert.ToBase64String(bytes); + } + catch { return null; } + } + + private static string? UnprotectSample(string enc) + { + try + { + var bytes = ProtectedData.Unprotect( + Convert.FromBase64String(enc), null, DataProtectionScope.CurrentUser); + return System.Text.Encoding.UTF8.GetString(bytes); + } + catch { return null; } + } + + // ---------- 3. 明文残留扫描 ---------- + + private static SecurityFinding CheckPlaintextResidue() + { + var path = DataPaths.ConfigPath; + if (!File.Exists(path)) + return new SecurityFinding { Title = "明文残留", Detail = "尚未生成配置文件,无残留可查。", Level = "ok" }; + + var plain = new List(); + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var root = doc.RootElement; + + foreach (var key in SensitiveKeys) + { + if (TryFindValue(root, key, out var v) && LooksPlaintext(v)) + plain.Add(key); + } + // 账号数组里的敏感字段 + if (root.TryGetProperty("Accounts", out var accs) && accs.ValueKind == JsonValueKind.Array) + { + int idx = 0; + foreach (var a in accs.EnumerateArray()) + { + if (a.ValueKind != JsonValueKind.Object) { idx++; continue; } + foreach (var key in new[] { "Token", "Session", "DeviceId" }) + { + if (a.TryGetProperty(key, out var v) && LooksPlaintext(v.GetString())) + plain.Add($"Accounts[{idx}].{key}"); + } + idx++; + } + } + } + catch (Exception ex) + { + return new SecurityFinding + { + Title = "明文残留", + Detail = "配置文件无法解析(" + ex.Message + "),建议在设置页重置软件后重新登录。", + Level = "warn", + }; + } + + if (plain.Count == 0) + return new SecurityFinding + { + Title = "明文残留", + Detail = $"配置文件中 {SensitiveKeys.Length} 个敏感字段及其账号副本均以密文存放,未发现明文泄漏。", + Level = "ok", + }; + + // DeviceId 是 16 位数字,加密失败时会按设计退回明文(下次加载可自愈),单独降级为警告 + var onlyDevice = plain.All(p => p.EndsWith("DeviceId", StringComparison.Ordinal)); + return new SecurityFinding + { + Title = "明文残留", + Detail = "以下字段在配置文件中没有加密(只列字段名,不显示内容):" + string.Join("、", plain) + + (onlyDevice + ? "。设备号为 16 位数字,加密失败时会按设计退回明文(下次保存自动重加密),风险有限。" + : "。存在凭证或推送地址明文落盘,建议立即在设置页重新保存一次配置。"), + Level = onlyDevice ? "warn" : "risk", + }; + } + + /// 在 JSON 中按不区分大小写查找属性(兼容历史命名)。 + private static bool TryFindValue(JsonElement root, string name, out string? value) + { + value = null; + if (root.ValueKind != JsonValueKind.Object) return false; + foreach (var p in root.EnumerateObject()) + { + if (!string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) continue; + value = p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() : null; + return true; + } + return false; + } + + /// + /// 判断落盘值是否为明文。只做形态判断,不解密: + /// · 含 "://"(推送地址)/ 含 "." 且像 JWT → 明文 + /// · 纯 base64 且长度足够 → 视为 DPAPI 密文 + /// · 16 位纯数字(DeviceId 明文兜底)→ 明文 + /// + private static bool LooksPlaintext(string? stored) + { + if (string.IsNullOrEmpty(stored)) return false; + if (JwtPattern.IsMatch(stored)) return true; + if (stored.Contains("://", StringComparison.Ordinal)) return true; + if (stored.Length == 16 && stored.All(char.IsDigit)) return true; + // 纯 base64 且无 '.'(JWT 一定带点)→ 认作平台密文 + bool base64ish = stored.Length >= 24 && stored.Length % 4 == 0 && + stored.All(c => char.IsLetterOrDigit(c) || c == '+' || c == '/' || c == '='); + return !base64ish; + } + + /// + /// 判断一个值是否是「本该是明文、却被当成明文存下来的密文」。 + /// 用于识别解密失败后残留的坏设备号(超长 base64)。 + /// + private static bool LooksLikeCiphertext(string? value) + { + if (string.IsNullOrEmpty(value)) return false; + // DPAPI 密文是长 base64;设备号最长 16 位,超过 32 位基本可以断定不是设备号 + return value.Length > 32; + } + + // ---------- 4. 设备号合规 ---------- + + private static SecurityFinding CheckDeviceIds(AppConfig? cfg) + { + var accounts = cfg?.Accounts ?? new List(); + if (accounts.Count == 0) + return new SecurityFinding { Title = "设备号", Detail = "暂无账号。", Level = "ok" }; + + var bad = new List(); + var residual = new List(); // 疑似密文残留(旧版解密缺陷留下的坏数据) + var dup = new List(); + var seen = new Dictionary(StringComparer.Ordinal); + + foreach (var a in accounts) + { + var name = DisplayName(a); + var id = a.DeviceId ?? ""; + if (id.Length != 16 || !id.All(char.IsDigit)) + { + bad.Add(name); + if (LooksLikeCiphertext(id)) residual.Add(name); + } + if (id.Length > 0) + { + if (seen.TryGetValue(id, out var other)) dup.Add($"{other} ↔ {name}"); + else seen[id] = name; + } + } + + if (residual.Count > 0) + return new SecurityFinding + { + Title = "设备号", + Detail = "以下账号的设备号是**加密后的密文**而不是 16 位数字(旧版解密失败后把密文当明文存了下来所致):" + + string.Join("、", residual) + + "。这种值会被原样发给签到接口,直接触发风控 9074「参与用户太多」。" + + "点该账号的「随机生成」→「保存设备号」即可修复,之后重新「云端部署」。", + Level = "risk", + }; + if (dup.Count > 0) + return new SecurityFinding + { + Title = "设备号", + Detail = "以下账号共用同一个设备号,会被风控识别成同一台设备并触发「参与用户太多(9074)」:" + + string.Join("、", dup) + "。请为每个账号更换独立设备号。", + Level = "risk", + }; + if (bad.Count > 0) + return new SecurityFinding + { + Title = "设备号", + Detail = "以下账号的设备号不是 16 位数字:" + string.Join("、", bad) + + "。请在账号详情里自定义或随机生成一个 16 位数字设备号。", + Level = "warn", + }; + + return new SecurityFinding + { + Title = "设备号", + Detail = $"{accounts.Count} 个账号的设备号均为合法的 16 位数字且互不重复。", + Level = "ok", + }; + } + + // ---------- 5. Token / 会话有效期 ---------- + + private static SecurityFinding CheckTokenLifetime(AppConfig? cfg) + { + var accounts = cfg?.Accounts ?? new List(); + if (accounts.Count == 0) + return new SecurityFinding { Title = "登录态有效期", Detail = "暂无账号。", Level = "ok" }; + + var expiredNoSession = new List(); + var expiredWithSession = new List(); + var expiringSoon = new List(); + int noSession = 0, unknown = 0; + + foreach (var a in accounts) + { + var name = DisplayName(a); + if (string.IsNullOrEmpty(a.Session)) noSession++; + var exp = TokenUtils.TryGetExpiry(a.Token); + if (exp == null) { unknown++; continue; } + var left = exp.Value - DateTime.Now; + if (left <= TimeSpan.Zero) + { + if (string.IsNullOrEmpty(a.Session)) expiredNoSession.Add(name); + else expiredWithSession.Add(name); + } + else if (left < TimeSpan.FromHours(1)) expiringSoon.Add(name); + } + + if (expiredNoSession.Count > 0) + return new SecurityFinding + { + Title = "登录态有效期", + Detail = "以下账号的 Token 已过期且没有会话 Cookie 可自动续期,必须重新登录:" + + string.Join("、", expiredNoSession) + "。", + Level = "risk", + }; + if (expiredWithSession.Count > 0) + return new SecurityFinding + { + Title = "登录态有效期", + Detail = "以下账号的 Token 已过期,但可凭会话 Cookie 自动换新(签到或保活时会自动完成):" + + string.Join("、", expiredWithSession) + "。建议为该账号开启保活。", + Level = "warn", + }; + if (expiringSoon.Count > 0) + return new SecurityFinding + { + Title = "登录态有效期", + Detail = "以下账号的 Token 将在 1 小时内过期(开启保活可自动续期):" + string.Join("、", expiringSoon) + "。", + Level = "warn", + }; + if (noSession > 0) + return new SecurityFinding + { + Title = "登录态有效期", + Detail = $"有 {noSession} 个账号没有会话 Cookie,Token 过期后无法自动续期,也无法保活。" + + "建议用「会话 Cookie 导入」方式重新添加这些账号。", + Level = "warn", + }; + return new SecurityFinding + { + Title = "登录态有效期", + Detail = unknown > 0 + ? $"Token 均未过期;其中 {unknown} 个账号的 Token 不是标准 JWT(可能是 PAT),无法读取有效期。" + : "全部账号的 Token 均在有效期内,且都持有会话 Cookie 可自动续期。", + Level = "ok", + }; + } + + // ---------- 6. 数据目录私有性 ---------- + + private static SecurityFinding CheckDataDirPrivacy() + { + var root = DataPaths.Root; + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + bool underUserProfile = !string.IsNullOrEmpty(appData) && + root.StartsWith(appData, StringComparison.OrdinalIgnoreCase); + + return new SecurityFinding + { + Title = "数据目录", + Detail = underUserProfile + ? $"数据存放在当前用户私有目录({root}),其它系统用户默认无权访问。" + : $"数据目录不在用户私有目录内({root}),同机其它用户可能读取到配置文件,建议改回默认位置。", + Level = underUserProfile ? "ok" : "warn", + }; + } + + // ---------- 7. 日志凭证泄漏 ---------- + + private static SecurityFinding CheckLogLeak() + { + try + { + var files = AppLog.EnumerateFiles(); + if (files.Count == 0) + return new SecurityFinding { Title = "日志脱敏", Detail = "暂无日志文件。", Level = "ok" }; + + var hit = new List(); + int scanned = 0; + foreach (var f in files.Take(12)) + { + scanned++; + var tail = AppLog.ReadTail(f.Path, 64 * 1024); + if (JwtPattern.IsMatch(tail)) hit.Add(f.Title); + } + + if (hit.Count > 0) + return new SecurityFinding + { + Title = "日志脱敏", + Detail = "以下日志文件里出现了完整凭证(JWT 形态):" + string.Join("、", hit) + + "。请在「日志 → 全部」中删除这些文件,并避免把日志原文发到公开渠道。", + Level = "risk", + }; + return new SecurityFinding + { + Title = "日志脱敏", + Detail = $"已抽查最近 {scanned} 个日志文件(各取末尾 64KB),未发现完整 Token 或会话 Cookie。", + Level = "ok", + }; + } + catch (Exception ex) + { + return new SecurityFinding { Title = "日志脱敏", Detail = "日志检查失败:" + ex.Message, Level = "warn" }; + } + } + + // ---------- 汇总 ---------- + + /// 一句话汇总(界面标题右侧展示)。 + public static string Summarize(IEnumerable findings) + { + var list = findings.ToList(); + int risk = list.Count(f => f.IsRisk); + int warn = list.Count(f => f.IsWarn); + if (risk > 0) return $"{risk} 项风险、{warn} 项警告"; + if (warn > 0) return $"{warn} 项警告"; + return $"全部 {list.Count} 项通过"; + } + + private static string DisplayName(TraeAccount a) + { + if (!string.IsNullOrWhiteSpace(a.Name)) return a.Name!; + if (!string.IsNullOrWhiteSpace(a.ScreenName)) return a.ScreenName!; + if (!string.IsNullOrWhiteSpace(a.MobileMasked)) return a.MobileMasked!; + return a.Id.Length > 6 ? a.Id[..6] : a.Id; + } +} diff --git a/Services/ThemeService.cs b/Services/ThemeService.cs new file mode 100644 index 0000000..e4c6f55 --- /dev/null +++ b/Services/ThemeService.cs @@ -0,0 +1,50 @@ +using Avalonia; +using Avalonia.Styling; + +namespace TraeTools.Services; + +/// +/// 主题模式:把配置里的三态字符串 ThemeMode 与 Avalonia 的 +/// 对应起来,并负责在启动时应用到当前应用。 +/// +/// 配色本身只存在于 Styles/AppTheme.axaml 的 ThemeDictionaries(Light/Dark 两套取值), +/// 本类不做任何颜色定义 —— 切换 即全局生效。 +/// +public static class ThemeService +{ + /// 可选的三种模式(界面下拉直接使用)。 + public static readonly string[] Modes = { "system", "light", "dark" }; + + /// 把任意输入规整为合法模式;非法值回退 "system"。 + public static string Normalize(string? mode) => mode switch + { + "light" or "dark" or "system" => mode, + _ => "system", + }; + + /// 模式字符串 → Avalonia 主题变体。 + public static ThemeVariant ToVariant(string? mode) => Normalize(mode) switch + { + "light" => ThemeVariant.Light, + "dark" => ThemeVariant.Dark, + _ => ThemeVariant.Default, // 跟随系统 + }; + + /// 当前生效的模式字符串(从应用实际状态反推,而非读配置)。 + public static string Current => Application.Current?.RequestedThemeVariant switch + { + var v when v == ThemeVariant.Light => "light", + var v when v == ThemeVariant.Dark => "dark", + _ => "system", + }; + + /// + /// 把配置里的模式应用到当前应用。启动时调用一次即可; + /// 设置页切换主题后再次调用本方法即时生效。必须在 UI 线程调用。 + /// + public static void Apply(string? mode) + { + if (Application.Current is not { } app) return; + app.RequestedThemeVariant = ToVariant(mode); + } +} diff --git a/Styles/AppTheme.axaml b/Styles/AppTheme.axaml index 2218d6f..cda6245 100644 --- a/Styles/AppTheme.axaml +++ b/Styles/AppTheme.axaml @@ -1,45 +1,191 @@ + - - #FF1E293B - #FF3B82F6 - #1A3B82F6 - #FFF1F5F9 - #FFFFFFFF - #FF0F172A - #FF64748B - #FF94A3B8 - #FFE2E8F0 - #FF10B981 - #FFF59E0B - #FFEF4444 - #FF1E293B - #FF334155 - - - - - - - - - - - - - - - - - + + + + + + #FF1E293B + #FF3B82F6 + #FF5B95F8 + #FF2A6BD4 + #1A3B82F6 + #FF8B5CF6 + #FFF1F5F9 + #FFFFFFFF + #FFF9FAFB + #FFE8F1FE + #FF64748B + #FFDBEAFE + #FF0F172A + #FF64748B + #FF94A3B8 + #FFE2E8F0 + #FF10B981 + #1A10B981 + #FFF59E0B + #1AF59E0B + #FFEF4444 + #1AEF4444 + #FFFEF7E8 + #FFF3D9A8 + #FF92400E + + #FF0F172A + #FFCBD5E1 + #0F3B82F6 + #1F3B82F6 + #FF1E293B + #FF334155 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #FF0B1220 + #FF60A5FA + #FF7CB8FF + #FF3B82F6 + #3360A5FA + #FFB07CFF + #FF0F1420 + #FF181F30 + #FF1E2740 + #FF1B2542 + #FF93A6D4 + #FF2C3A5E + #FFEDF1FA + #FFA6B0C3 + #FF6D7994 + #FF273046 + #FF4CC491 + #264CC491 + #FFF0A64B + #26F0A64B + #FFFF7079 + #26FF7079 + #33E0A64B + #4DE0A64B + #FFF0C36A + #FF0B101C + #FF67E8F9 + #2660A5FA + #4060A5FA + #FF151B2A + #FF232C44 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ViewLocator.cs b/ViewLocator.cs index da787c1..0e2fccf 100644 --- a/ViewLocator.cs +++ b/ViewLocator.cs @@ -25,6 +25,7 @@ public class ViewLocator : IDataTemplate UsageViewModel => new UsageView(), SettingsViewModel => new SettingsView(), SwitchViewModel => new SwitchView(), + LogViewModel => new LogView(), _ => new TextBlock { Text = "Not Found: " + param.GetType().FullName }, }; } diff --git a/ViewModels/LogViewModel.cs b/ViewModels/LogViewModel.cs new file mode 100644 index 0000000..4a91f75 --- /dev/null +++ b/ViewModels/LogViewModel.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Input.Platform; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using TraeTools.Services; + +namespace TraeTools.ViewModels; + +/// +/// 日志页视图模型:两种视角 + 可选的自动滚动。 +/// +/// · **最近**:订阅 ,本进程产生的日志随写随到(内存缓冲); +/// · **全部**:列出磁盘上按模块/月份归档的全部日志文件(模块日志 + 崩溃日志),点击查看内容。 +/// +/// 「自动滚动」是一个可关闭的开关:关闭后新日志仍然进入列表,但视图停在用户当前查看的位置—— +/// 这是必需的,否则用户想翻看早先的记录时会被不断涌入的新日志推着往下走。 +/// +public partial class LogViewModel : ViewModelBase +{ + /// + /// 「最近」列表最多保留的行数(超出丢弃最旧的)。 + /// 上限偏保守:Android 端为软件渲染,行数过多会明显拖慢滚动。 + /// + private const int MaxRecentRows = 300; + + /// 订阅的是静态事件,必须手动退订,否则 VM 无法回收。 + private bool _subscribed; + + public ObservableCollection Recent { get; } = new(); + + public ObservableCollection Files { get; } = new(); + + /// 当前标签页:0=最近,1=全部。 + [ObservableProperty] + private int _selectedTab; + + /// 「最近」标签页是否选中(供视图绑定,避免在 XAML 里对 int 做取反)。 + public bool IsRecentTab => SelectedTab == 0; + + /// 「全部」标签页是否选中。 + public bool IsAllTab => SelectedTab != 0; + + partial void OnSelectedTabChanged(int value) + { + OnPropertyChanged(nameof(IsRecentTab)); + OnPropertyChanged(nameof(IsAllTab)); + } + + /// 是否自动滚动到最新一条(最近)/内容末尾(全部)。 + [ObservableProperty] + private bool _autoScroll = true; + + partial void OnAutoScrollChanged(bool value) + { + try + { + var cfg = MainViewModel.AppConfig; + if (cfg != null && cfg.LogAutoScroll != value) + { + cfg.LogAutoScroll = value; + cfg.Save(); + } + } + catch { /* 偏好保存失败不影响功能 */ } + // 重新打开自动滚动时立即归位到最新 + if (value) RequestScrollToEnd(); + } + + /// 「全部」中选中的日志文件。 + [ObservableProperty] + private LogFileItem? _selectedFile; + + partial void OnSelectedFileChanged(LogFileItem? value) => _ = LoadSelectedFileAsync(value); + + [ObservableProperty] + private string _fileContent = ""; + + [ObservableProperty] + private string _fileHeader = ""; + + [ObservableProperty] + private bool _isFileLoading; + + /// 底部状态消息(自动消失语义与设置页一致,由视图直接展示)。 + [ObservableProperty] + private string _status = ""; + + /// 是否显示底部状态条。 + public bool HasStatus => !string.IsNullOrEmpty(Status); + + partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(HasStatus)); + + /// 最近列表是否为空(驱动空状态提示)。 + public bool IsRecentEmpty => Recent.Count == 0; + + /// 全部列表是否为空。 + public bool IsFilesEmpty => Files.Count == 0; + + /// 请求视图把滚动位置移到底部(由视图订阅后调用 ScrollToEnd)。 + public event Action? ScrollToEndRequested; + + /// 最近列表的统计文案:`N 条 · 错误 x 警告 y`。 + public string RecentSummary + { + get + { + int err = Recent.Count(e => e.Level == LogLevel.Error); + int warn = Recent.Count(e => e.Level == LogLevel.Warn); + var extra = err + warn > 0 ? $" · 错误 {err} 警告 {warn}" : ""; + return $"{Recent.Count} 条{extra}"; + } + } + + public LogViewModel() + { + try + { + AutoScroll = MainViewModel.AppConfig?.LogAutoScroll ?? true; + } + catch { /* 保持默认 true */ } + + Recent.CollectionChanged += (_, _) => + { + OnPropertyChanged(nameof(IsRecentEmpty)); + OnPropertyChanged(nameof(RecentSummary)); + }; + Files.CollectionChanged += (_, _) => OnPropertyChanged(nameof(IsFilesEmpty)); + + Subscribe(); + ReloadRecent(); + RefreshFiles(); + } + + /// 订阅进程内日志事件(幂等)。 + public void Subscribe() + { + if (_subscribed) return; + _subscribed = true; + AppLog.EntryAdded += OnEntryAdded; + } + + /// 退订(页面销毁时调用,避免静态事件持有 VM)。 + public void Unsubscribe() + { + if (!_subscribed) return; + _subscribed = false; + AppLog.EntryAdded -= OnEntryAdded; + } + + /// 新日志到达:切回 UI 线程后追加;超出上限则丢弃最旧一条。 + private void OnEntryAdded(LogEntry entry) + { + try + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => OnEntryAdded(entry)); + return; + } + + Recent.Add(entry); + while (Recent.Count > MaxRecentRows) Recent.RemoveAt(0); + if (AutoScroll) RequestScrollToEnd(); + } + catch { /* 界面更新失败不影响日志记录 */ } + } + + /// 用内存缓冲整体重建「最近」列表(进入页面时调用)。 + public void ReloadRecent() + { + try + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(ReloadRecent); + return; + } + + // 设置页可能改过「自动滚动」偏好,进页面时同步一次。 + // OnAutoScrollChanged 内部有相等判断,因此这里不会产生多余的落盘。 + try { AutoScroll = MainViewModel.AppConfig?.LogAutoScroll ?? AutoScroll; } + catch { /* 读配置失败则保留当前值 */ } + + Recent.Clear(); + foreach (var e in AppLog.Recent(MaxRecentRows)) Recent.Add(e); + if (AutoScroll) RequestScrollToEnd(); + } + catch { /* 忽略 */ } + } + + /// 重新扫描磁盘上的日志文件。 + [RelayCommand] + public void RefreshFiles() + { + try + { + var keep = SelectedFile?.Path; + Files.Clear(); + foreach (var f in AppLog.EnumerateFiles()) Files.Add(f); + + // 尽量保持原选中项,避免刷新后跳到第一个文件 + if (keep != null) + SelectedFile = Files.FirstOrDefault(f => f.Path == keep) ?? Files.FirstOrDefault(); + else if (SelectedFile == null && Files.Count > 0) + SelectedFile = Files[0]; + } + catch (Exception ex) + { + Status = "扫描日志文件失败:" + ex.Message; + } + } + + private async Task LoadSelectedFileAsync(LogFileItem? item) + { + if (item == null) + { + FileContent = ""; + FileHeader = ""; + return; + } + try + { + IsFileLoading = true; + FileHeader = $"{item.Path} · {item.SizeText} · 最后修改 {item.Modified:yyyy-MM-dd HH:mm}"; + // 文件可能很大且读取是同步 IO,放到线程池避免卡住 UI + FileContent = await Task.Run(() => AppLog.ReadTail(item.Path)); + } + catch (Exception ex) + { + FileContent = "(读取失败) " + ex.Message; + } + finally + { + IsFileLoading = false; + if (AutoScroll) RequestScrollToEnd(); + } + } + + /// 切到「最近」标签页。 + [RelayCommand] + private void ShowRecent() + { + SelectedTab = 0; + ReloadRecent(); + } + + /// 切到「全部」标签页(并重新扫描文件,保证看到最新落盘的文件)。 + [RelayCommand] + private void ShowAll() + { + SelectedTab = 1; + RefreshFiles(); + } + + /// 手动触发一次滚动到底(即使用户关掉了自动滚动)。 + [RelayCommand] + private void ScrollToLatest() + { + RequestScrollToEnd(); + Status = "已滚动到最新"; + } + + /// 删除选中的日志文件。 + [RelayCommand] + private void DeleteFile() + { + var item = SelectedFile; + if (item == null) + { + Status = "请先选择一个日志文件"; + return; + } + if (AppLog.DeleteFile(item.Path)) + { + Status = $"已删除 {item.FileName}"; + SelectedFile = null; + RefreshFiles(); + } + else + { + Status = "删除失败(文件可能正被占用)"; + } + } + + /// 清空全部日志(保留文件本身,仅置空内容)与内存缓冲。 + [RelayCommand] + private void ClearAll() + { + int n = AppLog.ClearAllFiles(); + Recent.Clear(); + FileContent = ""; + FileHeader = ""; + SelectedFile = null; + RefreshFiles(); + ReloadRecent(); + Status = $"已清空 {n} 个日志文件"; + } + + /// 复制「最近」列表的全部可见内容,便于一次性发回排查。 + [RelayCommand] + private async Task CopyRecentAsync() + { + try + { + var text = string.Join(Environment.NewLine, Recent.Select(e => + $"[{e.Time:yyyy-MM-dd HH:mm:ss}] [{e.LevelTag}] [{e.Account}] {e.Message}")); + // 剪贴板挂在 TopLevel(Window)上;上游 UiHost 只暴露 MainWindow,此处自行取一次 + var clip = UiHost.MainWindow?.Clipboard; + if (clip == null) + { + Status = "无法访问剪贴板"; + return; + } + await clip.SetTextAsync(text); + Status = $"已复制 {Recent.Count} 条日志到剪贴板"; + } + catch (Exception ex) + { + Status = "复制失败:" + ex.Message; + } + } + + private void RequestScrollToEnd() + { + try { ScrollToEndRequested?.Invoke(); } catch { /* 视图异常不影响 VM */ } + } +} diff --git a/ViewModels/MainViewModel.TopBar.cs b/ViewModels/MainViewModel.TopBar.cs new file mode 100644 index 0000000..fe8fcb6 --- /dev/null +++ b/ViewModels/MainViewModel.TopBar.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using TraeTools.Models; + +namespace TraeTools.ViewModels; + +/// +/// 的增量部分:顶栏的「当前账号 + 下拉切换」。 +/// +/// 上游原本只能在「账号切换」页里选账号再点切换;本文件把同一套能力提到顶栏, +/// 任何页面都能一眼看到当前账号并直接切走。 +/// +/// 关键约束:**不另起一套切换实现**——账号列表与选中项直接复用上游 +/// 的集合与选中属性,执行也走它既有的 +/// SwitchAccount 命令(内部复用 TraeSwitch 的 SwitcherService 冷切换编排)。 +/// +public partial class MainViewModel +{ + /// + /// 顶栏下拉的账号列表:直接复用账号切换页的集合,避免两处状态各自维护导致不一致。 + /// + public ObservableCollection TopAccounts => _switch.Accounts; + + /// + /// 顶栏下拉当前选中的账号。写入即同步到切换页的选中项(两边永远同一个值)。 + /// + public AccountInfo? TopSelectedAccount + { + get => _switch.SelectedAccount; + set + { + if (ReferenceEquals(_switch.SelectedAccount, value)) return; + _switch.SelectedAccount = value; + OnPropertyChanged(); + } + } + + /// 当前真正生效(激活)的账号文案,用于顶栏右侧提示;无账号时给出明确说明。 + public string TopActiveAccountText + { + get + { + var cfg = AppConfig; + if (cfg == null || cfg.Accounts.Count == 0) return "尚未添加账号"; + var acc = cfg.Accounts.FirstOrDefault(a => a.Id == cfg.ActiveAccountId) ?? cfg.Accounts[0]; + var name = string.IsNullOrEmpty(acc.Name) ? $"账号@{acc.Id[..Math.Min(6, acc.Id.Length)]}" : acc.Name; + return $"当前生效:{name}"; + } + } + + /// 切换是否进行中(顶栏按钮据此禁用,避免并发切换)。 + public bool TopIsSwitching => _switch.IsSwitching; + + /// + /// 刷新顶栏展示。账号增删、激活账号变化后调用(调用方负责在 UI 线程上调用)。 + /// + public void RefreshTopBar() + { + try + { + OnPropertyChanged(nameof(TopAccounts)); + OnPropertyChanged(nameof(TopSelectedAccount)); + OnPropertyChanged(nameof(TopActiveAccountText)); + OnPropertyChanged(nameof(TopIsSwitching)); + } + catch { /* 顶栏刷新失败不影响功能 */ } + } + + /// + /// 顶栏「切换」:先跳到账号切换页(切换过程的 5 步进度与日志在那里可见), + /// 再执行上游既有的冷切换命令。 + /// + [RelayCommand] + private async Task SwitchToSelectedAccountAsync() + { + try + { + if (TopSelectedAccount == null) + { + Navigate("switch"); + return; + } + + Navigate("switch"); + OnPropertyChanged(nameof(TopIsSwitching)); + await _switch.SwitchAccountCommand.ExecuteAsync(null); + } + catch { /* 失败原因由切换页日志呈现 */ } + finally + { + RefreshTopBar(); + } + } +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index a879a68..f245d10 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -18,6 +18,7 @@ public partial class MainViewModel : ViewModelBase private readonly CloudViewModel _cloud; private readonly SettingsViewModel _settings; private readonly UsageViewModel _usage; + private readonly LogViewModel _log; [ObservableProperty] private ViewModelBase? _currentPage; @@ -129,7 +130,9 @@ public MainViewModel() _switch = new SwitchViewModel(); _cloud = new CloudViewModel(); _settings = new SettingsViewModel(); + _settings.LoadExtendedSettings(); // 载入本 PR 新增的推送/日志/主题/设备号配置 _usage = new UsageViewModel(); + _log = new LogViewModel(); _dashboard.NavigateRequested += p => Navigate(p); _checkin.NavigateRequested += p => Navigate(p); @@ -137,6 +140,19 @@ public MainViewModel() _cloud.NavigateRequested += p => Navigate(p); _settings.NavigateRequested += p => Navigate(p); _usage.NavigateRequested += p => Navigate(p); + _log.NavigateRequested += p => Navigate(p); + + // 顶栏账号下拉:默认选中当前激活账号(账号列表由 SwitchViewModel 在构造时载入) + try + { + var cfg0 = AppConfig; + var active = cfg0?.Accounts.FirstOrDefault(a => a.Id == cfg0.ActiveAccountId) + ?? cfg0?.Accounts.FirstOrDefault(); + if (active != null) + _switch.SelectedAccount = _switch.Accounts.FirstOrDefault(a => a.Id == active.Id); + RefreshTopBar(); + } + catch { /* 顶栏初始化失败不影响主流程 */ } CurrentPage = _dashboard; CurrentPageKey = "dashboard"; @@ -167,6 +183,7 @@ public MainViewModel() public bool IsCloudActive => CurrentPageKey == "cloud"; public bool IsSettingsActive => CurrentPageKey == "settings"; public bool IsUsageActive => CurrentPageKey == "usage"; + public bool IsLogActive => CurrentPageKey == "log"; partial void OnCurrentPageKeyChanged(string value) { @@ -176,6 +193,7 @@ partial void OnCurrentPageKeyChanged(string value) OnPropertyChanged(nameof(IsCloudActive)); OnPropertyChanged(nameof(IsSettingsActive)); OnPropertyChanged(nameof(IsUsageActive)); + OnPropertyChanged(nameof(IsLogActive)); } /// 账号管理切换激活账号后:仪表盘完整刷新 + 签到页 + 账号状态同步 + 用量页联动。 @@ -188,6 +206,7 @@ public static async void NotifyActiveAccountChanged() vm._checkin.Reload(); vm._usage.Reload(); await vm._settings.RefreshAccountStatesAsync(); + vm.RefreshTopBar(); // 顶栏「当前生效」文案与下拉选中项随之更新 } catch { /* 异步刷新失败不应导致崩溃 */ } } @@ -282,6 +301,7 @@ private void Navigate(string page) CurrentPageKey = "cloud"; break; case "settings": + _settings.LoadExtendedSettings(); // 每次进入都从配置重读,避免多页面改配置后界面过期 _ = _settings.RefreshAccountStatesAsync(); CurrentPage = _settings; CurrentPageKey = "settings"; @@ -292,6 +312,13 @@ private void Navigate(string page) CurrentPage = _usage; CurrentPageKey = "usage"; break; + case "log": + // 进页面时重建「最近」内存缓冲并重扫磁盘文件(日志文件可能被其它进程追加) + _log.ReloadRecent(); + _log.RefreshFiles(); + CurrentPage = _log; + CurrentPageKey = "log"; + break; } } } \ No newline at end of file diff --git a/ViewModels/SettingsViewModel.Extended.cs b/ViewModels/SettingsViewModel.Extended.cs new file mode 100644 index 0000000..e9485f8 --- /dev/null +++ b/ViewModels/SettingsViewModel.Extended.cs @@ -0,0 +1,399 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using TraeCheckin; +using TraeTools.Services; +using TraeTools.Services.Notify; +using TraeTools.Services.Security; + +namespace TraeTools.ViewModels; + +/// +/// 的增量部分:六渠道推送配置、日志与外观、自定义设备号、安全检查。 +/// +/// 为什么拆成 partial 文件而不是直接改 SettingsViewModel.cs: +/// 本次 PR 的硬约束是「只加功能、不动上游架构」,把新增内容集中在独立文件里, +/// 上游那份 880 行的文件保持**零修改**,评审时一眼能分清哪些是新增、哪些是原有。 +/// +public partial class SettingsViewModel +{ + /// 加载期间为 true:抑制「改属性即保存」的副作用,避免加载过程写坏配置。 + private bool _extLoading; + + // ==================== 推送总开关 ==================== + + /// 签到完成后是否推送(关掉则所有渠道都不发)。 + [ObservableProperty] + private bool _notifyOnCheckin = true; + + // ==================== 渠道:系统本地通知 ==================== + + [ObservableProperty] + private bool _notifySystemEnabled; + + /// 本机系统通知是否可用(桌面端恒 true;留作界面提示用)。 + public bool SystemNotifySupported => SystemNotifier.HasPermission(); + + // ==================== 渠道:自定义 SMTP 邮箱 ==================== + + [ObservableProperty] + private bool _emailEnabled; + + [ObservableProperty] + private string _smtpHost = ""; + + /// SMTP 端口,可自定义(465=隐式 SSL、587=STARTTLS、25=明文)。 + [ObservableProperty] + private int _smtpPort = 465; + + [ObservableProperty] + private string _smtpSecurity = "ssl"; + + [ObservableProperty] + private string _smtpUser = ""; + + [ObservableProperty] + private string _smtpPassword = ""; + + [ObservableProperty] + private string _smtpFrom = ""; + + [ObservableProperty] + private string _smtpTo = ""; + + /// 连接安全方式下拉项。 + public string[] SmtpSecurityOptions { get; } = { "ssl", "starttls", "none" }; + + // ==================== 渠道:通用 Webhook ==================== + + [ObservableProperty] + private bool _webhookEnabled; + + [ObservableProperty] + private string _webhookUrl = ""; + + [ObservableProperty] + private string _webhookBodyTemplate = "{\"title\":\"{title}\",\"content\":\"{content}\"}"; + + [ObservableProperty] + private string _webhookContentType = "application/json"; + + // ==================== 渠道:钉钉机器人 ==================== + + [ObservableProperty] + private bool _dingTalkEnabled; + + [ObservableProperty] + private string _dingTalkWebhook = ""; + + /// 加签密钥(机器人安全设置选择「加签」时必填)。 + [ObservableProperty] + private string _dingTalkSecret = ""; + + // ==================== 渠道:企业微信机器人 ==================== + + [ObservableProperty] + private bool _weComEnabled; + + [ObservableProperty] + private string _weComWebhook = ""; + + // ==================== 渠道:Telegram Bot ==================== + + [ObservableProperty] + private bool _telegramEnabled; + + [ObservableProperty] + private string _telegramBotToken = ""; + + [ObservableProperty] + private string _telegramChatId = ""; + + // ==================== 日志与外观 ==================== + + /// 日志页是否自动滚动到最新一行。 + [ObservableProperty] + private bool _logAutoScroll = true; + + /// 主题模式:"system" / "light" / "dark"。 + [ObservableProperty] + private string _themeMode = "system"; + + /// 主题模式下拉项。 + public string[] ThemeModeOptions { get; } = { "system", "light", "dark" }; + + /// 切换主题即时生效并落盘(不等「保存」按钮,符合用户对主题切换的预期)。 + partial void OnThemeModeChanged(string value) + { + if (_extLoading) return; + try + { + ThemeService.Apply(value); + var cfg = MainViewModel.AppConfig; + if (cfg != null) + { + cfg.ThemeMode = ThemeService.Normalize(value); + cfg.Save(); + } + } + catch { /* 主题切换失败不影响其它设置 */ } + } + + // ==================== 自定义设备号 ==================== + + /// 用户手填的 16 位设备号(与选中账号绑定)。 + [ObservableProperty] + private string _customDeviceId = ""; + + // ==================== 安全检查 ==================== + + [ObservableProperty] + private bool _securityAuditRunning; + + [ObservableProperty] + private string _securitySummary = ""; + + /// 安全检查结论列表(只读报告,不含任何敏感值本身)。 + public ObservableCollection SecurityFindings { get; } = new(); + + public bool HasSecurityFindings => SecurityFindings.Count > 0; + + // ==================================================================== + // 加载 / 保存 + // ==================================================================== + + /// + /// 从 AppConfig 载入增量设置。由 MainViewModel 在构造后与每次进入设置页时调用 + /// (上游 SettingsViewModel 的构造函数不便改动,故用显式方法对外暴露)。 + /// + public void LoadExtendedSettings() + { + _extLoading = true; + try + { + var cfg = MainViewModel.AppConfig; + if (cfg == null) return; + + NotifyOnCheckin = cfg.NotifyOnCheckin; + NotifySystemEnabled = cfg.NotifySystemEnabled; + + EmailEnabled = cfg.EmailEnabled; + SmtpHost = cfg.SmtpHost; + SmtpPort = cfg.SmtpPort; + SmtpSecurity = string.IsNullOrWhiteSpace(cfg.SmtpSecurity) ? "ssl" : cfg.SmtpSecurity; + SmtpUser = cfg.SmtpUser; + SmtpPassword = cfg.SmtpPassword ?? ""; + SmtpFrom = cfg.SmtpFrom; + SmtpTo = cfg.SmtpTo; + + WebhookEnabled = cfg.WebhookEnabled; + WebhookUrl = cfg.WebhookUrl ?? ""; + WebhookBodyTemplate = cfg.WebhookBodyTemplate; + WebhookContentType = cfg.WebhookContentType; + + DingTalkEnabled = cfg.DingTalkEnabled; + DingTalkWebhook = cfg.DingTalkWebhook ?? ""; + DingTalkSecret = cfg.DingTalkSecret ?? ""; + + WeComEnabled = cfg.WeComEnabled; + WeComWebhook = cfg.WeComWebhook ?? ""; + + TelegramEnabled = cfg.TelegramEnabled; + TelegramBotToken = cfg.TelegramBotToken ?? ""; + TelegramChatId = cfg.TelegramChatId; + + LogAutoScroll = cfg.LogAutoScroll; + ThemeMode = ThemeService.Normalize(cfg.ThemeMode); + + CustomDeviceId = cfg.Accounts.FirstOrDefault(a => a.Id == SelectedAccount?.Id)?.DeviceId ?? ""; + } + catch { /* 载入失败保留默认值 */ } + finally { _extLoading = false; } + } + + /// 把界面上的增量设置写回 AppConfig 并落盘。 + private void PersistExtendedSettings() + { + var cfg = MainViewModel.AppConfig; + if (cfg == null) return; + + cfg.NotifyOnCheckin = NotifyOnCheckin; + cfg.NotifySystemEnabled = NotifySystemEnabled; + + cfg.EmailEnabled = EmailEnabled; + cfg.SmtpHost = SmtpHost.Trim(); + cfg.SmtpPort = SmtpPort is > 0 and <= 65535 ? SmtpPort : 465; + cfg.SmtpSecurity = string.IsNullOrWhiteSpace(SmtpSecurity) ? "ssl" : SmtpSecurity; + cfg.SmtpUser = SmtpUser.Trim(); + cfg.SmtpPassword = string.IsNullOrEmpty(SmtpPassword) ? null : SmtpPassword; + cfg.SmtpFrom = SmtpFrom.Trim(); + cfg.SmtpTo = SmtpTo.Trim(); + + cfg.WebhookEnabled = WebhookEnabled; + cfg.WebhookUrl = string.IsNullOrWhiteSpace(WebhookUrl) ? null : WebhookUrl.Trim(); + cfg.WebhookBodyTemplate = WebhookBodyTemplate; + cfg.WebhookContentType = WebhookContentType.Trim(); + + cfg.DingTalkEnabled = DingTalkEnabled; + cfg.DingTalkWebhook = string.IsNullOrWhiteSpace(DingTalkWebhook) ? null : DingTalkWebhook.Trim(); + cfg.DingTalkSecret = string.IsNullOrWhiteSpace(DingTalkSecret) ? null : DingTalkSecret.Trim(); + + cfg.WeComEnabled = WeComEnabled; + cfg.WeComWebhook = string.IsNullOrWhiteSpace(WeComWebhook) ? null : WeComWebhook.Trim(); + + cfg.TelegramEnabled = TelegramEnabled; + cfg.TelegramBotToken = string.IsNullOrWhiteSpace(TelegramBotToken) ? null : TelegramBotToken.Trim(); + cfg.TelegramChatId = TelegramChatId.Trim(); + + cfg.LogAutoScroll = LogAutoScroll; + cfg.ThemeMode = ThemeService.Normalize(ThemeMode); + + cfg.Save(); + } + + /// 保存全部推送渠道配置。 + [RelayCommand] + private void SaveNotifySettings() + { + try + { + PersistExtendedSettings(); + PushStatus = "推送配置已保存 ✓"; + } + catch (Exception ex) + { + PushStatus = "保存失败:" + ex.Message; + } + } + + /// + /// 向**所有已启用**的渠道发一条测试消息,逐渠道回显成败。 + /// 走与签到通知完全相同的 NotifyDispatcher,因此测试通过即代表真到点时会通。 + /// + [RelayCommand] + private async Task TestAllChannelsAsync() + { + try + { + PersistExtendedSettings(); + var cfg = MainViewModel.AppConfig; + if (cfg == null) + { + PushStatus = "配置未就绪"; + return; + } + + PushStatus = "正在测试推送…"; + var outcomes = await NotifyDispatcher.SendAsync( + cfg, + "【TraeTools】推送测试", + $"这是一条测试消息。\n时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}", + includeSystem: true); + + PushStatus = NotifyDispatcher.Summarize(outcomes) + + (outcomes.Count > 0 ? ":" + string.Join("、", outcomes.Select(o => o.Channel)) : ""); + } + catch (Exception ex) + { + PushStatus = "测试异常:" + ex.Message; + } + } + + // ==================================================================== + // 自定义设备号 + // ==================================================================== + + /// 为选中账号随机生成一个 16 位设备号(填充到输入框,仍需点「保存」才落盘)。 + [RelayCommand] + private void RandomizeCustomDeviceId() + { + try + { + CustomDeviceId = Random.Shared + .NextInt64(1_000_000_000_000_000L, 10_000_000_000_000_000L).ToString(); + PushStatus = "已生成新设备号,点「保存设备号」生效"; + } + catch (Exception ex) + { + PushStatus = "生成失败:" + ex.Message; + } + } + + /// + /// 保存手填的设备号。校验:16 位纯数字(Trae 风控要求 x-device-id 为数字设备号, + /// 用 GUID 会返回 9074)、且不得与其它账号重复(多账号共用同一设备号同样触发 9074)。 + /// + [RelayCommand] + private void SaveCustomDeviceId() + { + try + { + var cfg = MainViewModel.AppConfig; + var acc = cfg?.Accounts.FirstOrDefault(a => a.Id == SelectedAccount?.Id); + if (cfg == null || acc == null) + { + PushStatus = "请先选择账号"; + return; + } + + var id = (CustomDeviceId ?? "").Trim(); + if (id.Length != 16 || !id.All(char.IsDigit)) + { + PushStatus = "设备号必须是 16 位纯数字"; + return; + } + if (cfg.Accounts.Any(a => a.Id != acc.Id && a.DeviceId == id)) + { + PushStatus = "该设备号已被其它账号使用(多账号共用会触发风控 9074)"; + return; + } + + acc.DeviceId = id; + cfg.Save(); + DeviceIdText = id; // 联动上游既有的设备号展示 + PushStatus = $"设备号已保存:{id}(本地生效;云端需重新部署同步)"; + AccountHelpers.AppLog("account", "", $"自定义设备号:账号 {acc.Id[..Math.Min(6, acc.Id.Length)]} 设为 {id}"); + } + catch (Exception ex) + { + PushStatus = "保存失败:" + ex.Message; + } + } + + // ==================================================================== + // 安全检查 + // ==================================================================== + + /// + /// 运行「账号与本地安全检查」:只读本地可判定的项,不做网络探测、不修改任何数据。 + /// 报告里永不输出敏感值本身(只出现字段名、长度与结论)。 + /// + [RelayCommand] + private async Task RunSecurityAuditAsync() + { + try + { + SecurityAuditRunning = true; + var cfg = MainViewModel.AppConfig; + + // 读取磁盘文件 + 解密自检,放到后台线程,避免卡住界面 + var findings = await Task.Run(() => SecurityAudit.Run(cfg)); + + SecurityFindings.Clear(); + foreach (var f in findings) SecurityFindings.Add(f); + OnPropertyChanged(nameof(HasSecurityFindings)); + SecuritySummary = SecurityAudit.Summarize(findings); + } + catch (Exception ex) + { + SecuritySummary = "检查失败:" + ex.Message; + } + finally + { + SecurityAuditRunning = false; + } + } +} diff --git a/Views/CheckinView.axaml b/Views/CheckinView.axaml index d887e16..49533b4 100644 --- a/Views/CheckinView.axaml +++ b/Views/CheckinView.axaml @@ -89,18 +89,18 @@ - + - - - - - - - + + + + + + + @@ -124,20 +124,20 @@ - - + + - - + + - - + + - + @@ -156,11 +156,11 @@ - + - + diff --git a/Views/CloudView.axaml b/Views/CloudView.axaml index bda2ffe..4f4df34 100644 --- a/Views/CloudView.axaml +++ b/Views/CloudView.axaml @@ -31,8 +31,8 @@ +