From bb03c08a0e368a0032253c578230a5cec114a479 Mon Sep 17 00:00:00 2001
From: akexin <315600306+akexin@users.noreply.github.com>
Date: Thu, 15 Oct 2026 17:53:54 +0800
Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=BB=9F?=
=?UTF-8?q?=E4=B8=80=E6=97=A5=E5=BF=97=E3=80=81=E5=85=AD=E6=B8=A0=E9=81=93?=
=?UTF-8?q?=E6=8E=A8=E9=80=81=E3=80=81=E5=9B=9B=E7=A7=8D=E7=99=BB=E5=BD=95?=
=?UTF-8?q?=E6=96=B9=E5=BC=8F=E4=B8=8E=E5=AE=89=E5=85=A8=E6=A3=80=E6=9F=A5?=
=?UTF-8?q?=EF=BC=88=E6=9C=8D=E5=8A=A1=E5=B1=82=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
按维护者返工要求,只加功能、不动上游架构。本提交仅落服务层,UI 接入随后。
新增(全部为新文件):
- Services/AppLog.cs 统一日志(分级 + 内存环形缓冲 + 按月分文件)
- Services/Notify/NotifyDispatcher 推送扇出:互不影响 / 不静默 / 可开关
- Services/Notify/EmailNotifier 自定义 SMTP(可自定义端口与 ssl|starttls|none)
- Services/Notify/RobotNotifiers 通用 Webhook / 钉钉(含加签)/ 企业微信 / Telegram
- Services/Notify/SystemNotifier 系统本地通知(走 UiHost.MainWindow)
- Services/Login/CredentialImporter 会话 Cookie 导入 + 剪贴板智能识别
- Services/Login/LocalClientImporter 读取本机 TRAE 客户端凭证
- Services/Login/AccountBackup 账号导出/导入(PBKDF2 + AES-256-GCM,可跨机迁移)
- Services/Security/SecurityAudit 账号与本地安全检查(只读,不改数据)
修改上游文件(均为最小增量):
- Services/Checkin/AppConfig.cs 末尾追加主题/日志/六渠道配置项;新增 NormalizeTheme()
并把新字段同步补进 CloneForStorage 白名单与 UnsealSecrets(推送凭证按密文落盘)
- Services/Checkin/TokenUtils.cs 纯新增 TryGetExpiry/TryReadExp/DecodePayload(+56 行)
- Services/Json/AppJson.cs 登记新增 JSON 类型(推送请求体、备份容器与负载)
遵循仓库既有规范:
- JSON 一律走 AppJson/AppJsonIndented 源生成,不引入反射序列化。
原匿名类型无法被源生成器登记,改为具名类型(TextRobotPayload/TelegramSendPayload)。
上游 AppConfig 的 DeviceId 非空可空不匹配(CS8601)一并修掉。
- 未触碰 TraeTools.LoginHost、Services/Switch/*、UpdaterService、version.json。
构建:dotnet build -c Release → 0 error / 0 warning(IL2026 与 CS8601 均为 0)。
---
Services/AppLog.cs | 454 ++++++++++++++++++++++
Services/Checkin/AppConfig.cs | 128 ++++++-
Services/Checkin/TokenUtils.cs | 56 +++
Services/Json/AppJson.cs | 13 +
Services/Login/AccountBackup.cs | 265 +++++++++++++
Services/Login/CredentialImporter.cs | 216 +++++++++++
Services/Login/LocalClientImporter.cs | 301 +++++++++++++++
Services/Notify/EmailNotifier.cs | 276 ++++++++++++++
Services/Notify/NotifyDispatcher.cs | 149 ++++++++
Services/Notify/RobotNotifiers.cs | 276 ++++++++++++++
Services/Notify/SystemNotifier.cs | 64 ++++
Services/Security/SecurityAudit.cs | 517 ++++++++++++++++++++++++++
12 files changed, 2711 insertions(+), 4 deletions(-)
create mode 100644 Services/AppLog.cs
create mode 100644 Services/Login/AccountBackup.cs
create mode 100644 Services/Login/CredentialImporter.cs
create mode 100644 Services/Login/LocalClientImporter.cs
create mode 100644 Services/Notify/EmailNotifier.cs
create mode 100644 Services/Notify/NotifyDispatcher.cs
create mode 100644 Services/Notify/RobotNotifiers.cs
create mode 100644 Services/Notify/SystemNotifier.cs
create mode 100644 Services/Security/SecurityAudit.cs
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;
+ }
+}
From 1d3ac8a63d14e28cbee1d7bc244c3af0506151ed Mon Sep 17 00:00:00 2001
From: akexin <315600306+akexin@users.noreply.github.com>
Date: Thu, 15 Oct 2026 17:59:55 +0800
Subject: [PATCH 2/5] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=E8=BF=90?=
=?UTF-8?q?=E8=A1=8C=E6=97=A5=E5=BF=97=E9=A1=B5=E4=B8=8E=20Light/Dark=20?=
=?UTF-8?q?=E4=B8=BB=E9=A2=98=E4=BB=A4=E7=89=8C=EF=BC=88=E5=90=AB=E4=B8=BB?=
=?UTF-8?q?=E9=A2=98=E5=88=87=E6=8D=A2=E6=8E=A5=E7=BA=BF=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
日志页(维护者返工清单第 4 项):
- ViewModels/LogViewModel.cs 「最近」(内存缓冲 + 实时订阅)/「全部」(磁盘文件逐份查看)
- Views/LogView.axaml(.cs) 可选自动滚动;滚动在布局完成后排队执行,避免停在倒数第二行
- ViewLocator.cs 登记 LogViewModel → LogView(显式映射,AOT 安全)
- ViewModels/MainViewModel.cs 新增 _log 页面与 "log" 导航分支、IsLogActive
- Views/MainWindow.axaml 侧边栏新增「运行日志」入口(置于设置之前)
主题(Light/Dark 双套,仍只有一份配色表):
- Styles/AppTheme.axaml 从「单套硬编码浅色」改造为 ResourceDictionary.ThemeDictionaries,
同一批语义色按 Light / Dark 定义。**Light 的取值与改造前逐字节一致**,
因此既有页面在浅色下外观零变化;新增令牌(RowBg/StatusWarn*/Code*/HoverTint/渐变等)
供新页面使用。组件样式改用 DynamicResource。
- Services/ThemeService.cs 新增:ThemeMode(system|light|dark) ↔ ThemeVariant 映射与应用
- App.axaml 去掉写死的 RequestedThemeVariant="Light"(默认即跟随系统)
- App.axaml.cs 窗口创建前应用配置里的主题,避免先浅后深闪一帧
- Views/*.axaml(7 个) + App.axaml:StaticResource → DynamicResource 共 128 处。
必须改:StaticResource 只在解析时查一次,主题切换后不刷新(改了才会跟随主题)。
该改动只换引用方式、不换取值,浅色外观不变。
构建:dotnet build -c Release → 0 error / 0 warning。
---
App.axaml | 9 +-
App.axaml.cs | 9 +-
Services/ThemeService.cs | 50 ++++++
Styles/AppTheme.axaml | 268 ++++++++++++++++++++++-------
ViewLocator.cs | 1 +
ViewModels/LogViewModel.cs | 326 ++++++++++++++++++++++++++++++++++++
ViewModels/MainViewModel.cs | 12 ++
Views/CheckinView.axaml | 34 ++--
Views/CloudView.axaml | 30 ++--
Views/DashboardView.axaml | 28 ++--
Views/LogView.axaml | 196 ++++++++++++++++++++++
Views/LogView.axaml.cs | 69 ++++++++
Views/MainWindow.axaml | 13 +-
Views/SettingsView.axaml | 68 ++++----
Views/SwitchView.axaml | 44 ++---
Views/UsageView.axaml | 46 ++---
16 files changed, 1015 insertions(+), 188 deletions(-)
create mode 100644 Services/ThemeService.cs
create mode 100644 ViewModels/LogViewModel.cs
create mode 100644 Views/LogView.axaml
create mode 100644 Views/LogView.axaml.cs
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/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..4e6eb15 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
+ #FFF8FAFC
+ #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..6d089ef
--- /dev/null
+++ b/ViewModels/LogViewModel.cs
@@ -0,0 +1,326 @@
+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;
+ }
+
+ 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.cs b/ViewModels/MainViewModel.cs
index a879a68..69da88f 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;
@@ -130,6 +131,7 @@ public MainViewModel()
_cloud = new CloudViewModel();
_settings = new SettingsViewModel();
_usage = new UsageViewModel();
+ _log = new LogViewModel();
_dashboard.NavigateRequested += p => Navigate(p);
_checkin.NavigateRequested += p => Navigate(p);
@@ -137,6 +139,7 @@ public MainViewModel()
_cloud.NavigateRequested += p => Navigate(p);
_settings.NavigateRequested += p => Navigate(p);
_usage.NavigateRequested += p => Navigate(p);
+ _log.NavigateRequested += p => Navigate(p);
CurrentPage = _dashboard;
CurrentPageKey = "dashboard";
@@ -167,6 +170,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 +180,7 @@ partial void OnCurrentPageKeyChanged(string value)
OnPropertyChanged(nameof(IsCloudActive));
OnPropertyChanged(nameof(IsSettingsActive));
OnPropertyChanged(nameof(IsUsageActive));
+ OnPropertyChanged(nameof(IsLogActive));
}
/// 账号管理切换激活账号后:仪表盘完整刷新 + 签到页 + 账号状态同步 + 用量页联动。
@@ -292,6 +297,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/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 @@
-
-
+
+
@@ -41,7 +41,7 @@
-
+
@@ -65,7 +65,7 @@
-
+
@@ -78,31 +78,31 @@
-
+
-
+
-
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+
@@ -113,7 +113,7 @@
-
+
diff --git a/Views/DashboardView.axaml b/Views/DashboardView.axaml
index 6ab3acb..b73b10b 100644
--- a/Views/DashboardView.axaml
+++ b/Views/DashboardView.axaml
@@ -19,7 +19,7 @@
@@ -68,7 +68,7 @@
-
+
@@ -88,7 +88,7 @@
-
+
@@ -96,7 +96,7 @@
-
+
@@ -104,8 +104,8 @@
-
-
+
+
@@ -119,7 +119,7 @@
-
+
@@ -127,9 +127,9 @@
-
-
-
+
+
+
diff --git a/Views/SettingsView.axaml b/Views/SettingsView.axaml
index e223fa9..92b2604 100644
--- a/Views/SettingsView.axaml
+++ b/Views/SettingsView.axaml
@@ -14,7 +14,7 @@
@@ -44,35 +44,35 @@
-
+
-
+
-
+
-
+
-
+
@@ -90,13 +90,13 @@
-
-
+
+
-
-
+
@@ -107,7 +107,7 @@
+ Foreground="{DynamicResource TextSubBrush}" TextWrapping="Wrap" />
@@ -119,13 +119,13 @@
-
+
-
+
@@ -159,7 +159,7 @@
@@ -42,27 +42,27 @@
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -74,7 +74,7 @@
-
+
@@ -103,11 +103,11 @@
@@ -121,14 +121,14 @@
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
@@ -170,7 +170,7 @@
-
+
diff --git a/Views/UsageView.axaml b/Views/UsageView.axaml
index c611e3e..5c0f511 100644
--- a/Views/UsageView.axaml
+++ b/Views/UsageView.axaml
@@ -15,8 +15,8 @@