From 6ba0f77ec9cd8771a39536c9ffbcb00c93f4533c Mon Sep 17 00:00:00 2001
From: akexin <315600306@qq.com>
Date: Tue, 13 Oct 2026 21:13:28 +0800
Subject: [PATCH 01/37] =?UTF-8?q?v1.4.0:=20Android=20=E6=89=8B=E6=9C=BA?=
=?UTF-8?q?=E7=89=88=20+=20TRAE=20=E5=8F=8C=E7=AB=AF=E5=90=88=E5=B9=B6?=
=?UTF-8?q?=E9=80=82=E9=85=8D=20+=20=E9=9D=9E=E5=BC=BA=E5=88=B6=E6=9B=B4?=
=?UTF-8?q?=E6=96=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Android 移植(完整功能,账号切换按平台隐藏):
- 多目标构建 net9.0-windows(单文件 exe)+ net9.0-android(APK)
- Platform/ 跨平台抽象:Keystore(RSA+AES-GCM) 替代 DPAPI、Intent 打开浏览器、
Android 数据目录(App 私有 files/TraeTools)、托盘/开机自启/进程控制平台分流
- 登录改造:Android 隐藏内嵌 WebView2 入口,保留浏览器+手动粘贴 Token 方案
- 账号切换仅 Windows 桌面端可用(Android 无本地客户端可操作),导航项按平台隐藏
- SQLite 增加 android 原生库(SQLitePCLRaw.lib.e_sqlite3.android)
TRAE 双端合并适配(2026-10-09 官方合并 TraeWork/TraeCode 为 TRAE):
- 设备号解析目录覆盖 TRAE/TRAE CN/TRAE SOLO CN/Trae CN
- 客户端进程名多名字匹配(TRAE,TRAE SOLO CN,Trae),exe/目录探测覆盖新旧
非强制更新:
- Android 检查更新仅提示并跳转 Release 页,不自动下载/安装;Windows 维持询问式
仓库指向:更新检查、云端签到 fork 源、PAT 提示等改为 akexin/TraeTools-Android
CI/Release:新增 android job(workload install → publish apk → 挂 Release)
---
.github/workflows/ci.yml | 28 ++++-
.github/workflows/release.yml | 38 +++++-
App.axaml.cs | 14 ++-
Platform/AutoStartManager.Android.cs | 14 +++
Platform/BrowserLauncher.cs | 27 ++++
Platform/CarrierDefaults.Android.cs | 27 ++++
Platform/MainActivity.cs | 31 +++++
Platform/Secrets.Android.cs | 119 ++++++++++++++++++
Platform/Secrets.Windows.cs | 30 +++++
Platform/Secrets.cs | 30 +++++
Program.cs | 2 +
Properties/AndroidManifest.xml | 9 ++
README.md | 65 +++++++---
Resources/drawable/ic_launcher_foreground.xml | 15 +++
Resources/mipmap-anydpi-v26/ic_launcher.xml | 5 +
.../mipmap-anydpi-v26/ic_launcher_round.xml | 5 +
Resources/values/colors.xml | 5 +
Resources/values/styles.xml | 10 ++
Services/Checkin/AppConfig.cs | 33 ++---
Services/Checkin/AutoStartManager.cs | 5 +-
Services/Checkin/GitHubApiClient.cs | 6 +-
Services/Checkin/UpdaterService.cs | 16 ++-
Services/DataPaths.cs | 21 +++-
Services/Switch/CarrierDefaults.cs | 43 ++++++-
Services/Switch/ClientController.cs | 41 +++++-
TraeTools.csproj | 62 +++++++--
ViewModels/CloudViewModel.cs | 6 +-
ViewModels/MainViewModel.cs | 3 +
ViewModels/SettingsViewModel.cs | 42 +++++--
ViewModels/UiHost.cs | 16 ++-
Views/DeviceAuthWindow.axaml.cs | 2 +-
Views/LoginWindow.axaml | 10 +-
Views/LoginWindow.axaml.cs | 23 ++--
Views/MainWindow.axaml | 5 +-
Views/MainWindow.axaml.cs | 38 +++++-
Views/PatInputWindow.axaml | 2 +-
Views/PatInputWindow.axaml.cs | 2 +-
Views/SettingsView.axaml | 4 +-
version.json | 6 +-
39 files changed, 725 insertions(+), 135 deletions(-)
create mode 100644 Platform/AutoStartManager.Android.cs
create mode 100644 Platform/BrowserLauncher.cs
create mode 100644 Platform/CarrierDefaults.Android.cs
create mode 100644 Platform/MainActivity.cs
create mode 100644 Platform/Secrets.Android.cs
create mode 100644 Platform/Secrets.Windows.cs
create mode 100644 Platform/Secrets.cs
create mode 100644 Properties/AndroidManifest.xml
create mode 100644 Resources/drawable/ic_launcher_foreground.xml
create mode 100644 Resources/mipmap-anydpi-v26/ic_launcher.xml
create mode 100644 Resources/mipmap-anydpi-v26/ic_launcher_round.xml
create mode 100644 Resources/values/colors.xml
create mode 100644 Resources/values/styles.xml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 92be151..92c9d28 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,7 +10,7 @@ permissions:
contents: read
jobs:
- build:
+ build-windows:
runs-on: windows-latest
steps:
- name: Checkout
@@ -21,12 +21,30 @@ jobs:
with:
dotnet-version: "9.0.x"
- - name: Build (Release)
- run: dotnet build TraeTools.csproj -c Release --nologo
+ - name: Build Windows (Release)
+ run: dotnet build TraeTools.csproj -c Release -f net9.0-windows --nologo
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
- name: trae-tools-build
+ name: trae-tools-build-windows
path: bin/Release/net9.0-windows/
- if-no-files-found: error
\ No newline at end of file
+ if-no-files-found: error
+
+ build-android:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 9
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "9.0.x"
+
+ # Android workload:含 Microsoft.Android SDK / OpenJDK 等构建依赖(首次安装约 5-10 分钟)
+ - name: Install Android workload
+ run: dotnet workload install android
+
+ - name: Build Android (Release)
+ run: dotnet build TraeTools.csproj -c Release -f net9.0-android --nologo
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 87c4147..9345b28 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -62,7 +62,8 @@ jobs:
echo "TraeTools ${GITHUB_REF_NAME}"
echo ""
echo "- 单文件自包含 Windows x64 程序(无需额外安装 .NET 运行时)"
- echo "- 应用内「检查更新」会下载本 Release 的 \`TraeTools.exe\` 自动替换升级"
+ echo "- Android 手机版 APK(Android 8.0+,见 TraeTools-Android.apk)"
+ echo "- 应用内「检查更新」为**非强制**:仅提示并引导下载,不会自动替换安装"
echo ""
if [ -n "${PREV}" ]; then
echo "## 更新差异(${PREV} → ${GITHUB_REF_NAME})"
@@ -84,4 +85,37 @@ jobs:
files: |
publish/TraeTools.exe
TraeTools-win-x64.zip
- generate_release_notes: false
\ No newline at end of file
+ generate_release_notes: false
+
+ publish-android:
+ runs-on: ubuntu-latest
+ needs: publish # 等 Windows job 创建好 Release 后再追加 APK 资产
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 9
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "9.0.x"
+
+ - name: Install Android workload
+ run: dotnet workload install android
+
+ # Android APK:debug 签名(可直接安装);签名 keystore 后续在仓库 Secrets 配置后改为 release 签名
+ - name: Publish (android apk)
+ run: dotnet publish TraeTools.csproj -c Release -f net9.0-android -p:AndroidPackageFormat=apk -o publish-android
+
+ - name: Collect apk
+ run: |
+ APK=$(find publish-android -name "*.apk" | head -1)
+ if [ -z "$APK" ]; then echo "::error::no apk produced"; exit 1; fi
+ cp "$APK" TraeTools-Android.apk
+ ls -lh TraeTools-Android.apk
+
+ - name: Upload apk to Release
+ uses: softprops/action-gh-release@v2
+ with:
+ name: "TraeTools ${{ github.ref_name }}"
+ files: TraeTools-Android.apk
+ generate_release_notes: false
diff --git a/App.axaml.cs b/App.axaml.cs
index eb816f8..77f89f2 100644
--- a/App.axaml.cs
+++ b/App.axaml.cs
@@ -9,7 +9,7 @@ namespace TraeTools;
public partial class App : Application
{
- /// 系统托盘图标全局引用(TrayIcon 无源头引用会被 GC 回收,故存于此)。
+ /// 系统托盘图标全局引用(TrayIcon 无源头引用会被 GC 回收,故存于此;仅 Windows 桌面端使用)。
public static TrayIcon? TrayRef;
public override void Initialize()
@@ -26,7 +26,17 @@ public override void OnFrameworkInitializationCompleted()
DataContext = new MainViewModel(),
};
}
+#if ANDROID
+ else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
+ {
+ // Android:单视图生命周期,MainWindow 直接作为主视图全屏展示
+ singleView.MainView = new MainWindow
+ {
+ DataContext = new MainViewModel(),
+ };
+ }
+#endif
base.OnFrameworkInitializationCompleted();
}
-}
\ No newline at end of file
+}
diff --git a/Platform/AutoStartManager.Android.cs b/Platform/AutoStartManager.Android.cs
new file mode 100644
index 0000000..e2c70f0
--- /dev/null
+++ b/Platform/AutoStartManager.Android.cs
@@ -0,0 +1,14 @@
+#if ANDROID
+namespace TraeCheckin;
+
+///
+/// Android 空实现:开机自启依赖 Windows 注册表(HKCU\...\Run)。
+/// Android 端自动签到仅在 App 运行期间有效;长期无人值守签到请使用「云端签到」(GitHub Actions)。
+///
+internal static class AutoStartManager
+{
+ public static bool IsEnabled() => false;
+
+ public static void SetEnabled(bool enabled) { /* Android 无注册表,空操作 */ }
+}
+#endif
diff --git a/Platform/BrowserLauncher.cs b/Platform/BrowserLauncher.cs
new file mode 100644
index 0000000..f4ae43d
--- /dev/null
+++ b/Platform/BrowserLauncher.cs
@@ -0,0 +1,27 @@
+using System.Diagnostics;
+
+namespace TraeTools.Platform;
+
+///
+/// 用系统默认浏览器打开 URL(跨平台封装)。
+/// Windows:ShellExecute;Android:Intent.ACTION_VIEW(无浏览器时静默失败)。
+///
+internal static class BrowserLauncher
+{
+ public static void Open(string? url)
+ {
+ if (string.IsNullOrWhiteSpace(url)) return;
+ try
+ {
+#if ANDROID
+ var uri = Android.Net.Uri.Parse(url)!;
+ var intent = new Android.Content.Intent(Android.Content.Intent.ActionView, uri);
+ intent.AddFlags(Android.Content.ActivityFlags.NewTask);
+ Android.App.Application.Context!.StartActivity(intent);
+#else
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+#endif
+ }
+ catch { /* 无浏览器/被拦截等场景忽略 */ }
+ }
+}
diff --git a/Platform/CarrierDefaults.Android.cs b/Platform/CarrierDefaults.Android.cs
new file mode 100644
index 0000000..964a1f7
--- /dev/null
+++ b/Platform/CarrierDefaults.Android.cs
@@ -0,0 +1,27 @@
+#if ANDROID
+namespace TraeSwitch.Services;
+
+///
+/// Android 空实现:账号切换依赖本机 TRAE 客户端的数据目录与进程,
+/// 该能力仅 Windows 桌面端提供(见 CarrierDefaults.cs 的 !ANDROID 分支)。
+///
+public static class CarrierDefaults
+{
+ public static string SettingsDir => TraeTools.Services.DataPaths.SwitchDir;
+
+ /// Android 无本地客户端,返回空串(界面按空值禁用相关操作)。
+ public static string DefaultUserDataDir => "";
+
+ public static string ResolveUserDataDir() => "";
+
+ public static string DefaultProcessName => "";
+
+ public static string DefaultClientExe => "";
+
+ public static void ResetDetection() { }
+
+ public static string? AutoDetectClientExe() => null;
+
+ public static List DetectFingerprint() => new();
+}
+#endif
diff --git a/Platform/MainActivity.cs b/Platform/MainActivity.cs
new file mode 100644
index 0000000..a15dcb7
--- /dev/null
+++ b/Platform/MainActivity.cs
@@ -0,0 +1,31 @@
+#if ANDROID
+using Android.App;
+using Android.Content.PM;
+using Android.Runtime;
+using Avalonia.Android;
+
+namespace TraeTools;
+
+/// Android 唯一 Activity:承载 Avalonia 单视图生命周期。
+[Activity(
+ Label = "TraeTools",
+ Theme = "@style/MyTheme.NoActionBar",
+ MainLauncher = true,
+ ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
+public class MainActivity : AvaloniaMainActivity
+{
+}
+
+/// Android Application:提供 Avalonia AppBuilder(Inter 字体)。
+[Application]
+public class TraeAndroidApplication : AvaloniaAndroidApplication
+{
+ protected TraeAndroidApplication(nint javaReference, JniHandleOwnership transfer)
+ : base(javaReference, transfer)
+ {
+ }
+
+ protected override Avalonia.AppBuilder CustomizeAppBuilder(Avalonia.AppBuilder builder)
+ => base.CustomizeAppBuilder(builder).WithInterFont();
+}
+#endif
diff --git a/Platform/Secrets.Android.cs b/Platform/Secrets.Android.cs
new file mode 100644
index 0000000..09513d0
--- /dev/null
+++ b/Platform/Secrets.Android.cs
@@ -0,0 +1,119 @@
+#if ANDROID
+using System.Security.Cryptography;
+using System.Text;
+using Android.Security.Keystore;
+using Java.Security;
+using Javax.Crypto;
+
+namespace TraeTools.Platform;
+
+///
+/// Android 实现:Keystore 生成 RSA-2048 密钥对(私钥不可导出),
+/// 每次加密生成随机 AES-256-GCM 密钥加密数据,RSA 公钥包裹 AES 密钥。
+/// 密文格式:"and-v1:" + Base64(RSA包裹的AES密钥) + ":" + Base64(nonce) + ":" + Base64(tag+ciphertext)。
+///
+internal static partial class Secrets
+{
+ private const string KeyAlias = "TraeTools.Secrets.RSA.v1";
+ private const int NonceSize = 12; // AES-GCM 推荐 nonce 长度
+ private const int TagSize = 16; // 128-bit 认证标签
+
+ static partial string? ProtectCore(string plain)
+ {
+ try
+ {
+ var plainBytes = Encoding.UTF8.GetBytes(plain);
+
+ // 随机 AES-256 会话密钥 + GCM 加密
+ var key = RandomNumberGenerator.GetBytes(32);
+ var nonce = RandomNumberGenerator.GetBytes(NonceSize);
+ var cipher = new byte[plainBytes.Length];
+ var tag = new byte[TagSize];
+ using (var aes = new AesGcm(key, TagSize))
+ aes.Encrypt(nonce, plainBytes, cipher, tag);
+
+ // RSA 公钥包裹 AES 密钥
+ var pub = GetOrCreatePublicKey();
+ if (pub == null) return null;
+ var wrapCipher = Cipher.GetInstance("RSA/ECB/PKCS1Padding")!;
+ wrapCipher.Init(Cipher.EncryptMode, pub);
+ var wrapped = wrapCipher.DoFinal(key);
+ if (wrapped == null) return null;
+
+ var payload = Convert.ToBase64String(wrapped) + ":" + Convert.ToBase64String(nonce);
+ var tagAndCipher = new byte[tag.Length + cipher.Length];
+ Buffer.BlockCopy(tag, 0, tagAndCipher, 0, tag.Length);
+ Buffer.BlockCopy(cipher, 0, tagAndCipher, tag.Length, cipher.Length);
+ payload += ":" + Convert.ToBase64String(tagAndCipher);
+ return AndroidPrefix + payload;
+ }
+ catch { return null; }
+ }
+
+ static partial string? UnprotectCore(string enc)
+ {
+ try
+ {
+ var body = enc[AndroidPrefix.Length..];
+ var parts = body.Split(':', 3);
+ if (parts.Length != 3) return null;
+
+ var wrapped = Convert.FromBase64String(parts[0]);
+ var nonce = Convert.FromBase64String(parts[1]);
+ var tagAndCipher = Convert.FromBase64String(parts[2]);
+ if (tagAndCipher.Length < TagSize) return null;
+
+ // RSA 私钥解出 AES 密钥
+ var priv = GetPrivateKey();
+ if (priv == null) return null;
+ var unwrapCipher = Cipher.GetInstance("RSA/ECB/PKCS1Padding")!;
+ unwrapCipher.Init(Cipher.DecryptMode, priv);
+ var key = unwrapCipher.DoFinal(wrapped);
+ if (key == null) return null;
+
+ var tag = new byte[TagSize];
+ var cipher = new byte[tagAndCipher.Length - TagSize];
+ Buffer.BlockCopy(tagAndCipher, 0, tag, 0, TagSize);
+ Buffer.BlockCopy(tagAndCipher, TagSize, cipher, 0, cipher.Length);
+
+ var plain = new byte[cipher.Length];
+ using (var aes = new AesGcm(key, TagSize))
+ aes.Decrypt(nonce, cipher, tag, plain);
+ return Encoding.UTF8.GetString(plain);
+ }
+ catch { return null; }
+ }
+
+ /// 取(不存在则创建)Keystore 中的 RSA 公钥。
+ private static IPublicKey? GetOrCreatePublicKey()
+ {
+ var keyStore = KeyStore.GetInstance("AndroidKeyStore")!;
+ keyStore.Load(null);
+ if (!keyStore.ContainsAlias(KeyAlias))
+ GenerateRsaKeyPair();
+ var entry = keyStore.GetEntry(KeyAlias, null) as KeyStore.PrivateKeyEntry;
+ return entry?.Certificate?.PublicKey;
+ }
+
+ /// 取 Keystore 中的 RSA 私钥。
+ private static IPrivateKey? GetPrivateKey()
+ {
+ var keyStore = KeyStore.GetInstance("AndroidKeyStore")!;
+ keyStore.Load(null);
+ var entry = keyStore.GetEntry(KeyAlias, null) as KeyStore.PrivateKeyEntry;
+ return entry?.PrivateKey;
+ }
+
+ private static void GenerateRsaKeyPair()
+ {
+ var generator = KeyPairGenerator.GetInstance(KeyProperties.KeyAlgorithmRsa, "AndroidKeyStore")!;
+ var spec = new KeyGenParameterSpec.Builder(KeyAlias, KeyStorePurpose.Encrypt | KeyStorePurpose.Decrypt)
+ .SetBlockModes(KeyProperties.BlockModeEcb)
+ .SetEncryptionPaddings(KeyProperties.EncryptionPaddingRsaPkcs1)
+ .SetKeySize(2048)
+ .Build();
+ generator.Initialize(spec);
+ generator.GenerateKeyPair();
+ }
+}
+#endif
diff --git a/Platform/Secrets.Windows.cs b/Platform/Secrets.Windows.cs
new file mode 100644
index 0000000..48ffbbf
--- /dev/null
+++ b/Platform/Secrets.Windows.cs
@@ -0,0 +1,30 @@
+#if !ANDROID
+using System.Security.Cryptography;
+using System.Text;
+
+namespace TraeTools.Platform;
+
+/// Windows 实现:DPAPI(当前用户作用域)加密敏感字段,密文为纯 Base64。
+internal static partial class Secrets
+{
+ static partial string? ProtectCore(string plain)
+ {
+ try
+ {
+ var bytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(plain), null, DataProtectionScope.CurrentUser);
+ return Convert.ToBase64String(bytes);
+ }
+ catch { return null; }
+ }
+
+ static partial string? UnprotectCore(string enc)
+ {
+ try
+ {
+ var bytes = ProtectedData.Unprotect(Convert.FromBase64String(enc), null, DataProtectionScope.CurrentUser);
+ return Encoding.UTF8.GetString(bytes);
+ }
+ catch { return null; }
+ }
+}
+#endif
diff --git a/Platform/Secrets.cs b/Platform/Secrets.cs
new file mode 100644
index 0000000..f8d060b
--- /dev/null
+++ b/Platform/Secrets.cs
@@ -0,0 +1,30 @@
+namespace TraeTools.Platform;
+
+///
+/// 敏感字段加密抽象(跨平台 partial):
+/// Windows:DPAPI(当前用户作用域),密文为纯 Base64(历史格式,无前缀)。
+/// Android:Keystore(RSA 包裹 AES-256-GCM 密钥),密文带 "and-v1:" 前缀。
+/// Protect 返回 null 表示加密失败(调用方按明文保存兜底或放弃保存)。
+///
+internal static partial class Secrets
+{
+ /// Android 密文前缀;Windows 上遇到该前缀的密文直接判定解密失败(换机迁移场景)。
+ public const string AndroidPrefix = "and-v1:";
+
+ public static string? Protect(string? plain)
+ {
+ if (string.IsNullOrEmpty(plain)) return plain;
+ return ProtectCore(plain);
+ }
+
+ public static string? Unprotect(string? enc)
+ {
+ if (string.IsNullOrEmpty(enc)) return enc;
+ // 跨平台密文互不通用:Windows 实例读到 Android 密文时明确失败,触发重新登录
+ if (enc.StartsWith(AndroidPrefix, StringComparison.Ordinal)) return null;
+ return UnprotectCore(enc);
+ }
+
+ static partial string? ProtectCore(string plain);
+ static partial string? UnprotectCore(string enc);
+}
diff --git a/Program.cs b/Program.cs
index d85cee5..dae820a 100644
--- a/Program.cs
+++ b/Program.cs
@@ -12,6 +12,7 @@ sealed class Program
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
+#if !ANDROID
[STAThread]
public static void Main(string[] args)
{
@@ -25,6 +26,7 @@ public static void Main(string[] args)
TraeTools.Services.DataPaths.Migrate();
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
+#endif
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
diff --git a/Properties/AndroidManifest.xml b/Properties/AndroidManifest.xml
new file mode 100644
index 0000000..b83ade4
--- /dev/null
+++ b/Properties/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/README.md b/README.md
index 2a2bf4f..ee27815 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,23 @@
# TraeTools
> Trae 一体化管理工具:多账号每日签到 · 云端自动签到 · 账号快速切换 · 用量统计
+> 支持 **Windows 桌面端** 与 **Android 手机端**(APK)
-TraeTools 是对 **TRAE-Checkin** 的重构版,并集成了 **Trea-Switch** 的账号切换能力,用 Avalonia 重写为现代化桌面应用(.NET 9)。一个工具覆盖 Trae 账号的「登录 → 签到 → 切换 → 用量查看 → 云端托管」全流程。
+TraeTools 是对 **TRAE-Checkin** 的重构版,并集成了 **Trea-Switch** 的账号切换能力,用 Avalonia 重写为现代化跨平台应用(.NET 9)。一个工具覆盖 Trae 账号的「登录 → 签到 → 切换 → 用量查看 → 云端托管」全流程。
+
+> **2026-10 客户端合并公告**:字节跳动已于 2026-10-09 将 TraeWork 与 TraeCode 合并为统一的 **TRAE** 客户端(Agent / IDE 双模式),TraeWork 于 2026-10-23 停服。本工具已同步适配:设备号解析、客户端进程/目录探测均覆盖新旧名称(TRAE / TRAE CN / TRAE SOLO CN),老用户升级客户端后无需任何操作。
### 气死我了,怎么老是给我爆9074
## 功能特性
- **多账号每日签到**:一页管理多个 Trae 账号,一键签到全部启用账号;自动签到可设定时间,到点自动执行
-- **云端签到(永不掉线)**:一键把签到任务部署到你自己的 GitHub fork(GitHub Actions 每天 08:00 运行),无需电脑开机
-- **账号切换**:为每个账号备份登录态(vault),一键冷切换客户端账号,失败自动回滚
+- **云端签到(永不掉线)**:一键把签到任务部署到你自己的 GitHub fork(GitHub Actions 每天 08:00 运行),无需电脑/手机开机
+- **账号切换**(仅 Windows 桌面端):为每个账号备份登录态(vault),一键冷切换客户端账号,失败自动回滚
- **用量统计**:按会话展示模型 / 输入输出 Token / 缓存命中率 / 积分消耗,并本地持久化;另含「积分快过期提醒」
- **账号资料**:展示每个账号的昵称 / 脱敏手机号 / 头像 / 学生认证徽章
- **飞书推送**:签到结果可实时推送到飞书机器人
-- **自动更新**:内置检查更新与静默替换安装
+- **应用更新(不强制)**:内置检查更新,发现新版仅提示并引导下载,绝不强制安装、不自动替换
## 界面
@@ -23,52 +26,71 @@ TraeTools 是对 **TRAE-Checkin** 的重构版,并集成了 **Trea-Switch**
| 仪表盘 | 剩余积分、今日签到状态、积分趋势图、账号概览 |
| 用量统计 | 逐会话用量 + 模型分布 + 汇聚统计,全部本地留档 |
| 每日签到 | 本月日历、连签天数、签到记录、一键/自动签到 |
-| 账号切换 | 建档(备份登录态)、一键切换、校验 |
+| 账号切换 | 建档(备份登录态)、一键切换、校验(**仅 Windows 桌面端**) |
| 云端签到 | GitHub 授权、一键部署、部署状态与日志 |
| 设置 | 自动签到配置、Token 信息、飞书推送、账号管理、关于 |
## 快速开始
-> **macOS 用户看这里**:macOS 原生版 TraeBar(菜单栏常驻多账号自动签到,Swift/AppKit 实现)在本仓库的 [`macos`](https://github.com/star620/TraeTools/tree/macos) 分支独立维护与发布,版本 tag 为 `macos-v*`,发布产物见 [Releases](https://github.com/star620/TraeTools/releases)。本分支(main)仅维护 Windows 版。
-
### 方式一:直接使用
-在 [Releases](https://github.com/star620/TraeTools/releases) 下载最新版运行(Windows,需 .NET 9 桌面运行时)。
+
+在 [Releases](https://github.com/akexin/TraeTools-Android/releases) 下载最新版:
+
+| 平台 | 产物 | 说明 |
+|---|---|---|
+| Windows | `TraeTools.exe` / `TraeTools-win-x64.zip` | 单文件自包含,无需安装 .NET 运行时 |
+| Android | `TraeTools-Android.apk` | 支持 Android 8.0(API 26)及以上,下载后需允许「安装未知来源应用」 |
+
+> Android 版与 Windows 版数据相互独立(加密机制不同),账号需在手机端重新登录一次。
### 方式二:源码编译
-需要 **.NET 9 SDK**:
+需要 **.NET 9 SDK**(Android 目标需额外安装 workload):
```bash
-git clone https://github.com/star620/TraeTools.git
-cd TraeTools
+git clone https://github.com/akexin/TraeTools-Android.git
+cd TraeTools-Android
+
+# Windows 桌面版
dotnet build -c Release
-```
-产物位于 `bin/Release/net9.0-windows/TraeTools.exe`。
+# Android 手机版(首次需安装 Android workload,约 1.5GB)
+dotnet workload install android
+dotnet publish TraeTools.csproj -c Release -f net9.0-android -o publish-android
+# 产物:publish-android/cn.akexin.traetools-Signed.apk
+```
## 使用指南
### 1. 添加 / 登录账号
-`设置 → 账号管理 → + 添加`:自动打开登录窗口(内嵌浏览器),手机号+验证码登录后自动读取凭证并保存;登录态约 14 天,失效可一键静默续期。
+
+- **Windows**:`设置 → 账号管理 → + 添加`:自动打开登录窗口(内嵌浏览器),手机号+验证码登录后自动读取凭证并保存;登录态约 14 天,失效可一键静默续期。
+- **Android**:`设置 → 账号管理 → + 添加` → 「打开浏览器登录」→ 在浏览器登录 trae.cn 后复制 **Cloud-IDE-Token** 粘贴保存(手机端无内嵌浏览器,手动录入为唯一方式)。登录态约 14 天,失效后重新粘贴即可。
### 2. 每日签到
-签到页点「一键签到」或开启「每日自动签到」;也可在仪表盘点「立即签到」。签到结果写入本地历史,日历与连签天数随之更新。
+
+签到页点「一键签到」或开启「每日自动签到」;也可在仪盘点「立即签到」。签到结果写入本地历史,日历与连签天数随之更新。
+
+> Android 端自动签到仅在 App 运行期间有效;需要无人值守签到请使用「云端签到」(GitHub Actions 每日 08:00 执行,与手机状态无关)。
### 3. 用量统计
+
进入「用量统计」页面自动同步近 7 天会话;每次会话展示模型、入/出 Token、缓存命中字节与积分消耗,数据按账号本地保留,可追溯历史趋势。
### 4. 云端签到(部署到 GitHub Actions)
+
1. `云端签到` 页点击「授权 GitHub」(设备码授权,无需密码,仅需 repo 权限)
2. 点击「一键部署到云端」:程序自动 fork 本仓库 → 写入每个账号的 `TRAE_SESSION` / `TRAE_DEVICE_ID` secret → 启用并触发验证运行
3. 验证成功即生效:**每天北京时间 08:00** 自动签到,即使本机关机也不中断
部署原理:fork 仓库中的 `.github/workflows/checkin.yml` 定时运行 `checkin.py`,脚本用 `X-Cloudide-Session` 换取新 JWT 后调用签到接口(仅依赖 Python 标准库)。
-> 从旧版 TRAE-Checkin 迁移:已部署过旧 fork 的用户,在新版「重新部署」时会提示自动删除旧 fork 并重建,无需手动去网页操作。旧 fork 仓库可自行删除。
+### 5. 账号切换(仅 Windows 桌面端)
-### 5. 账号切换
`账号切换` 页:为当前登录的客户端「建档」(备份登录态载体)→ 选择目标账号 → 「切换至此」一键冷切换;切换失败会自动回滚到原账号并重启客户端。账号删空后可直接「建档」输入新名称重新建立。
+> 该功能依赖操作本机 TRAE 客户端的数据目录与进程,Android 端不提供。
+
## 常见问题
| 现象 | 说明 / 处理 |
@@ -78,20 +100,23 @@ dotnet build -c Release
| 云端提示「未找到 workflow」 | 旧 fork 早于本仓库添加 checkin.yml:按提示一键「删除并重建 fork」即可自愈 |
| 签到 0 积分 | 当日已签过(重复签到不加分),或非会员当日基础 150 分已到账 |
| Token 失效 | 程序会用 `X-Cloudide-Session` 静默换新;彻底失效时提示重新登录 |
+| Android 安装 APK 被拦截 | 系统设置中允许浏览器/文件管理「安装未知来源应用」后重试 |
+| Android 与 Windows 账号不互通 | 两端加密机制不同(DPAPI / Keystore),数据独立,需分别登录 |
> 云端签到凭证 `TRAE_SESSION` 有效期约 14 天,过期后请在软件内重新登录并重新部署。
## 开发者
-- 技术栈:Avalonia 12 / .NET 9 / CommunityToolkit.Mvvm / Sodium.Core / WebView2
-- 目录:`ViewModels/` 页面逻辑、`Services/` 网络与领域服务、`Views/` 界面、`Models/` 数据模型
+- 技术栈:Avalonia 12 / .NET 9 / CommunityToolkit.Mvvm / Sodium.Core / WebView2(Windows)
+- 目录:`ViewModels/` 页面逻辑、`Services/` 网络与领域服务、`Views/` 界面、`Models/` 数据模型、`Platform/` 跨平台抽象(DPAPI/Keystore、浏览器启动等)
+- 多目标:`net9.0-windows`(WinExe 单文件)与 `net9.0-android`(APK),平台差异集中在 `Platform/` 与 `#if ANDROID` 条件编译
- 行为默认不对外请求官方非公开接口之外的能力;用量/积分接口为逆向网页接口,字段随官方可能变化,代码均做容错解析
## 免责声明
- 本项目为个人开源工具,与 ByteDance / Trae 官方无任何关系
- 使用自动签到、账号切换等功能请遵守 Trae 服务条款,风险自负
-- 请勿出售、外借或用于非法用途;账号凭证仅保存在你自己的电脑/GitHub secret 中
+- 请勿出售、外借或用于非法用途;账号凭证仅保存在你自己的设备/GitHub secret 中
## 致谢
diff --git a/Resources/drawable/ic_launcher_foreground.xml b/Resources/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..705ae07
--- /dev/null
+++ b/Resources/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/Resources/mipmap-anydpi-v26/ic_launcher.xml b/Resources/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..a8a8fa5
--- /dev/null
+++ b/Resources/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Resources/mipmap-anydpi-v26/ic_launcher_round.xml b/Resources/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..a8a8fa5
--- /dev/null
+++ b/Resources/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Resources/values/colors.xml b/Resources/values/colors.xml
new file mode 100644
index 0000000..f2637dd
--- /dev/null
+++ b/Resources/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #0F172A
+ #0F172A
+
diff --git a/Resources/values/styles.xml b/Resources/values/styles.xml
new file mode 100644
index 0000000..e690496
--- /dev/null
+++ b/Resources/values/styles.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/Services/Checkin/AppConfig.cs b/Services/Checkin/AppConfig.cs
index b154131..1a1c1cf 100644
--- a/Services/Checkin/AppConfig.cs
+++ b/Services/Checkin/AppConfig.cs
@@ -1,4 +1,3 @@
-using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -6,9 +5,9 @@ namespace TraeCheckin;
///
/// 本地配置:存储 token、设备号、自动签到设置。
-/// 保存到 %APPDATA%\TraeTools\config.json。
-/// 敏感字段(Token/Session/GitHubToken/DeviceId)以当前 Windows 用户 DPAPI 加密落盘,
-/// 加载时解密回内存;旧版明文在首次加载时自动迁移加密。
+/// 保存到数据根目录 TraeTools/config.json(Windows 为 %APPDATA%,Android 为 App 私有目录)。
+/// 敏感字段(Token/Session/GitHubToken/DeviceId)以平台机制加密落盘
+/// (Windows:DPAPI;Android:Keystore),加载时解密回内存;旧版明文在首次加载时自动迁移加密。
///
public class AppConfig
{
@@ -120,7 +119,7 @@ public static bool TryMigrateLegacy(AppConfig cfg)
}
///
- /// 生成一个 16 位十进制设备 ID(与 TraeWork Aha SDK 的设备号格式一致)。
+ /// 生成一个 16 位十进制设备 ID(与 TRAE 客户端 Aha SDK 的设备号格式一致)。
/// 签到接口风控要求 x-device-id 为数字设备号,使用 GUID/UUID 会触发 9074。
///
private static string GenerateDeviceId()
@@ -129,13 +128,15 @@ private static string GenerateDeviceId()
}
///
- /// 从本机 TraeWork 官方客户端的数据目录解析 Aha 设备 ID(16 位数字)。
+ /// 从本机 TRAE 官方客户端的数据目录解析 Aha 设备 ID(16 位数字)。
/// storage.json 中存在形如 "iCubeAuthInfo://icube-dc:3049374157909753" 的键。
+ /// 2026-10 起 TraeWork 与 TraeCode 已合并为统一的 TRAE 客户端,
+ /// 目录名覆盖新旧各版本(TRAE / TRAE CN / TRAE SOLO CN / Trae CN)。
///
internal static string? TryResolveAhaDeviceId()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
- var dirs = new[] { "TRAE SOLO CN", "Trae CN", "TRAE SOLO" };
+ var dirs = new[] { "TRAE", "TRAE CN", "TRAE SOLO CN", "Trae CN", "TRAE SOLO" };
foreach (var dir in dirs)
{
var path = Path.Combine(appData, dir, "User", "globalStorage", "storage.json");
@@ -237,22 +238,8 @@ private static void UnsealSecrets(AppConfig cfg)
}
/// DPAPI 加密(当前 Windows 用户作用域)。保护失败抛异常,由调用方兜底(保留旧文件)。
- private static string? EncryptSecret(string? plain)
- {
- if (string.IsNullOrEmpty(plain)) return plain;
- var bytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(plain), null, DataProtectionScope.CurrentUser);
- return Convert.ToBase64String(bytes);
- }
+ private static string? EncryptSecret(string? plain) => TraeTools.Platform.Secrets.Protect(plain);
/// DPAPI 解密;失败(如换 Windows 用户/重装系统)返回 null,触发重新登录。
- private static string? DecryptSecret(string? enc)
- {
- if (string.IsNullOrEmpty(enc)) return enc;
- try
- {
- var bytes = ProtectedData.Unprotect(Convert.FromBase64String(enc), null, DataProtectionScope.CurrentUser);
- return Encoding.UTF8.GetString(bytes);
- }
- catch { return null; }
- }
+ private static string? DecryptSecret(string? enc) => TraeTools.Platform.Secrets.Unprotect(enc);
}
\ No newline at end of file
diff --git a/Services/Checkin/AutoStartManager.cs b/Services/Checkin/AutoStartManager.cs
index 3856694..3593ea4 100644
--- a/Services/Checkin/AutoStartManager.cs
+++ b/Services/Checkin/AutoStartManager.cs
@@ -1,4 +1,5 @@
-using Microsoft.Win32;
+#if !ANDROID
+using Microsoft.Win32;
namespace TraeCheckin;
@@ -44,3 +45,5 @@ public static void SetEnabled(bool enabled)
catch { /* 静默失败 */ }
}
}
+
+#endif // !ANDROID
diff --git a/Services/Checkin/GitHubApiClient.cs b/Services/Checkin/GitHubApiClient.cs
index d9a638b..e1aa5d3 100644
--- a/Services/Checkin/GitHubApiClient.cs
+++ b/Services/Checkin/GitHubApiClient.cs
@@ -70,8 +70,8 @@ public class WorkflowRunInfo
public class GitHubApiClient
{
private const string ClientId = "Ov23lix0Kb9ldJHrOpKv";
- private const string SourceOwner = "star620";
- private const string SourceRepo = "TraeTools";
+ private const string SourceOwner = "akexin";
+ private const string SourceRepo = "TraeTools-Android";
public const string SessionSecretName = "TRAE_SESSION";
public const string DeviceIdSecretName = "TRAE_DEVICE_ID";
public const string FeishuWebhookSecretName = "FEISHU_WEBHOOK";
@@ -265,7 +265,7 @@ public static string BuildForkError(int statusCode)
? "fork 失败:GitHub 授权已失效(HTTP 401),请先「重新授权」再部署。"
: (statusCode == 403
? "fork 失败:Token 对源仓库没有 fork 权限(HTTP 403)。建 fork 不靠 Actions/Secrets/Contents 那批写权限——"
- + "请改为:① 细粒度 PAT → Repository access 选中 star620/TraeTools(若已 fork 另加你自己的 fork 仓库),"
+ + "请改为:① 细粒度 PAT → Repository access 选中 akexin/TraeTools-Android(若已 fork 另加你自己的 fork 仓库),"
+ "并在 Permissions 开启 Administration: Read and write(其中含 fork);"
+ "② classic PAT / 设备码授权 → 需包含 repo scope。按此调整后需重新生成 Token/重新授权。"
: $"fork 失败:HTTP {statusCode}");
diff --git a/Services/Checkin/UpdaterService.cs b/Services/Checkin/UpdaterService.cs
index 36e6473..52fa1d2 100644
--- a/Services/Checkin/UpdaterService.cs
+++ b/Services/Checkin/UpdaterService.cs
@@ -12,7 +12,7 @@ namespace TraeTools.Services;
///
public static class UpdaterService
{
- public const string Repo = "star620/TraeTools";
+ public const string Repo = "akexin/TraeTools-Android";
private static readonly HttpClient Http = CreateHttp();
private static HttpClient CreateHttp()
@@ -39,7 +39,11 @@ public static Version CurrentVersion
}
}
- public sealed record ReleaseInfo(string Tag, Version Version, string ExeUrl, long Size);
+ public sealed record ReleaseInfo(string Tag, Version Version, string ExeUrl, long Size)
+ {
+ /// Release 网页地址(Android 端跳转浏览器下载用)。
+ public string HtmlUrl { get; init; } = "";
+ }
// GitHub 匿名 API 每小时最多 60 次;自动检查若紧跟频繁启动会很快耗尽配额(返回 403)。
// 用 1 小时磁盘缓存:自动(静默)检查在窗口内直接沿用上次结论,不反复打 API。
@@ -116,7 +120,12 @@ private static void TouchCache()
{
foreach (var a in assets.EnumerateArray())
{
+#if ANDROID
+ // Android:下载 Release 中的 apk 资产(CI 产物 TraeTools-Android.apk)
+ if (a.GetProperty("name").GetString()?.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) == true)
+#else
if (string.Equals(a.GetProperty("name").GetString(), "TraeTools.exe", StringComparison.OrdinalIgnoreCase))
+#endif
{
exeUrl = a.GetProperty("browser_download_url").GetString();
size = a.TryGetProperty("size", out var s) ? s.GetInt64() : 0;
@@ -124,7 +133,8 @@ private static void TouchCache()
}
}
}
- return exeUrl == null ? null : new ReleaseInfo(tag.GetString()!, ver, exeUrl, size);
+ var htmlUrl = root.TryGetProperty("html_url", out var h) ? h.GetString() ?? "" : "";
+ return exeUrl == null ? null : new ReleaseInfo(tag.GetString()!, ver, exeUrl, size) { HtmlUrl = htmlUrl };
}
/// 把 "v1.2.3" / "1.2.3" / "1.2.3-beta" 解析为 Version;失败返回 false。
diff --git a/Services/DataPaths.cs b/Services/DataPaths.cs
index 1ead319..c1c1be6 100644
--- a/Services/DataPaths.cs
+++ b/Services/DataPaths.cs
@@ -3,15 +3,26 @@
namespace TraeTools.Services;
///
-/// 统一数据根目录(%APPDATA%\TraeTools),收敛历史遗留的 TraeCheckin / TraeSwitch 三个目录。
-/// WebView 缓存与抓包输出保持在 %LOCALAPPDATA%\TraeTools(缓存不进 APPDATA)。
+/// 统一数据根目录(Windows:%APPDATA%\TraeTools;Android:App 私有目录 files/TraeTools),
+/// 收敛历史遗留的 TraeCheckin / TraeSwitch 三个目录。
+/// WebView 缓存与抓包输出保持在 %LOCALAPPDATA%\TraeTools(缓存不进 APPDATA;Android 无此概念)。
/// 启动时调用 Migrate() 把旧目录数据自动搬入新位置(幂等、失败不阻塞)。
///
public static class DataPaths
{
- /// 根目录:%APPDATA%\TraeTools
- public static string Root =>
- Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TraeTools");
+ /// 根目录:Windows=%APPDATA%\TraeTools;Android=App files/TraeTools
+ public static string Root
+ {
+ get
+ {
+#if ANDROID
+ var ctx = Android.App.Application.Context!;
+ return System.IO.Path.Combine(ctx.FilesDir!.AbsolutePath, "TraeTools");
+#else
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TraeTools");
+#endif
+ }
+ }
/// 签到配置 config.json。
public static string ConfigPath => Path.Combine(Root, "config.json");
diff --git a/Services/Switch/CarrierDefaults.cs b/Services/Switch/CarrierDefaults.cs
index f2dd2ce..fd59eb0 100644
--- a/Services/Switch/CarrierDefaults.cs
+++ b/Services/Switch/CarrierDefaults.cs
@@ -1,12 +1,31 @@
+#if !ANDROID
namespace TraeSwitch.Services;
public static class CarrierDefaults
{
public static string SettingsDir => TraeTools.Services.DataPaths.SwitchDir;
+ /// 历史默认用户数据目录(TraeWork 时代)。2026-10 起新旧客户端已合并为 TRAE,
+ /// 若该目录不存在会自动回落到新 TRAE 数据目录(见 ResolveUserDataDir)。
public static string DefaultUserDataDir =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TRAE SOLO CN");
+ /// 新版 TRAE 客户端用户数据目录名(2026-10 TraeWork 与 TraeCode 合并后统一为 TRAE)。
+ private static readonly string[] NewUserDataDirNames = { "TRAE", "TRAE CN" };
+
+ /// 解析实际使用的客户端用户数据目录:历史目录优先,不存在则取新版目录中第一个存在的。
+ public static string ResolveUserDataDir()
+ {
+ if (Directory.Exists(DefaultUserDataDir)) return DefaultUserDataDir;
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ foreach (var name in NewUserDataDirNames)
+ {
+ var path = Path.Combine(appData, name);
+ if (Directory.Exists(path)) return path;
+ }
+ return DefaultUserDataDir;
+ }
+
///
/// 默认 Trae 客户端可执行文件路径。
/// 优先自动探测常见安装位置(已缓存结果),全部未命中时回退到历史默认 D 盘路径,
@@ -28,7 +47,9 @@ public static string DefaultClientExe
}
}
- public static string DefaultProcessName => "TRAE SOLO CN";
+ /// 默认进程名:2026-10 起 TraeWork 与 TraeCode 合并为统一的 TRAE 客户端;
+ /// 过渡期内旧客户端(TRAE SOLO CN)仍在运行,多进程名逗号分隔,均会参与匹配。
+ public static string DefaultProcessName => "TRAE,TRAE SOLO CN,Trae";
private static readonly object DetectLock = new();
private static string _detectedExe = "";
@@ -40,12 +61,15 @@ public static string DefaultClientExe
public static string? AutoDetectClientExe()
{
const string exeName = "TRAE SOLO CN.exe";
+ const string newExeName = "TRAE.exe";
foreach (var dir in CandidateClientDirs())
{
try
{
var exact = Path.Combine(dir, exeName);
if (File.Exists(exact)) return exact;
+ var exactNew = Path.Combine(dir, newExeName);
+ if (File.Exists(exactNew)) return exactNew;
// 目录内 exe:名字含 TRAE(如 Trae.exe)优先;否则目录里只有一个 exe 时采用它(#25:Program Files 安装可能主程序名不同)
var exes = Directory.GetFiles(dir, "*.exe");
@@ -59,16 +83,22 @@ public static string DefaultClientExe
return null;
}
- /// 候选客户端目录:历史默认 + 各常见位置 + 任意固定盘(C/D/E…)的 Program Files / Program Files (x86)。
+ /// 候选客户端目录:历史默认 + 各常见位置 + 任意固定盘(C/D/E…)的 Program Files / Program Files (x86)。
+ /// 2026-10 双端合并后同时覆盖新版 TRAE 与旧版 TRAE SOLO CN 的目录名。
private static List CandidateClientDirs()
{
var dirs = new List
{
@"D:\TRAE SOLO CN", // 历史默认(D 盘安装)
- Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TRAE SOLO CN"), // 绿色版/便携版
+ @"D:\TRAE", // 新版默认(D 盘安装)
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TRAE SOLO CN"), // 绿色版/便携版(旧)
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TRAE"), // 绿色版/便携版(新)
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "TRAE SOLO CN"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "TRAE"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "TRAE SOLO CN"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "TRAE"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TRAE SOLO CN"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TRAE"),
};
try
{
@@ -76,7 +106,9 @@ private static List CandidateClientDirs()
{
if (!drv.IsReady || drv.DriveType != DriveType.Fixed) continue;
dirs.Add(Path.Combine(drv.RootDirectory.FullName, "Program Files", "TRAE SOLO CN"));
+ dirs.Add(Path.Combine(drv.RootDirectory.FullName, "Program Files", "TRAE"));
dirs.Add(Path.Combine(drv.RootDirectory.FullName, "Program Files (x86)", "TRAE SOLO CN"));
+ dirs.Add(Path.Combine(drv.RootDirectory.FullName, "Program Files (x86)", "TRAE"));
}
}
catch { /* 枚举磁盘失败忽略 */ }
@@ -91,7 +123,7 @@ private static List CandidateClientDirs()
public static List DetectFingerprint()
{
var found = new List();
- var root = DefaultUserDataDir;
+ var root = ResolveUserDataDir();
if (!Directory.Exists(root)) return found;
try
{
@@ -127,4 +159,5 @@ private static List ScanLevelDbDirs(string root, int depth, List
}
return result;
}
-}
\ No newline at end of file
+}
+#endif // !ANDROID
\ No newline at end of file
diff --git a/Services/Switch/ClientController.cs b/Services/Switch/ClientController.cs
index d211fae..8e31c27 100644
--- a/Services/Switch/ClientController.cs
+++ b/Services/Switch/ClientController.cs
@@ -9,10 +9,25 @@ public interface IClientController
bool IsRunning();
}
-/// 真实进程控制:按进程名结束全部实例并重新拉起客户端。
+#if !ANDROID
+/// 真实进程控制:按进程名结束全部实例并重新拉起客户端。
+/// 进程名支持逗号分隔的多个名字(2026-10 TraeWork/TraeCode 合并为 TRAE 后,
+/// 过渡期内新旧客户端进程名并存,均参与匹配)。
public sealed class ClientController(string processName, string exePath) : IClientController
{
+ /// 拆分后的进程名列表(去空、去重)。
+ private string[] ProcessNames => processName
+ .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
public void KillAll()
+ {
+ foreach (var name in ProcessNames)
+ KillByName(name);
+ }
+
+ private void KillByName(string processName)
{
// 拿到的 Process 对象使用后必须 Dispose,否则常驻托盘下句柄持续累积(#33)
Process[] procs;
@@ -21,7 +36,8 @@ public void KillAll()
foreach (var p in procs)
{
try { p.Kill(entireProcessTree: true); p.WaitForExit(10_000); } catch { /* 已退出则忽略 */ }
- finally { try { p.Dispose(); } catch { /* 忽略 */ } }
+ finally { try { p.Dispose(); } catch { /* 忽略 */ }
+ }
}
}
@@ -32,18 +48,31 @@ public void Launch()
Process.Start(new ProcessStartInfo(exePath) { UseShellExecute = true });
}
- public bool IsRunning() => GetProcessCount() > 0;
+ public bool IsRunning() => ProcessNames.Sum(GetProcessCount) > 0;
/// 查询进程数并立即释放全部 Process 句柄(GetProcessesByName 返回的对象必须 Dispose)。
- private int GetProcessCount()
+ private int GetProcessCount(string name)
{
try
{
- Process[] procs = Process.GetProcessesByName(processName);
- int n = procs.Length;
+ var procs = Process.GetProcessesByName(name);
+ var n = procs.Length;
foreach (var p in procs) try { p.Dispose(); } catch { /* 忽略 */ }
return n;
}
catch { return 0; }
}
}
+#endif // !ANDROID
+
+#if ANDROID
+namespace TraeSwitch.Services;
+
+/// Android 空实现:账号切换需要操作本地 Trae 客户端进程/文件,仅 Windows 桌面端支持。
+public sealed class ClientController(string processName, string exePath) : IClientController
+{
+ public void KillAll() { }
+ public void Launch() => throw new PlatformNotSupportedException("账号切换仅支持 Windows 桌面端");
+ public bool IsRunning() => false;
+}
+#endif
diff --git a/TraeTools.csproj b/TraeTools.csproj
index 040086c..23b581d 100644
--- a/TraeTools.csproj
+++ b/TraeTools.csproj
@@ -1,42 +1,74 @@
WinExe
- net9.0-windows
- true
+
+ net9.0-windows;net9.0-android
enable
enable
- app.manifest
- Assets\avalonia-logo.ico
TraeTools
TraeTools
1.0.0
1.0.0
- CA1416;MSB3277
+ CA1416;MSB3277;XA0121;CS0436
+
+
+
+
+ true
+ app.manifest
+ Assets\avalonia-logo.ico
true
true
true
true
-
+
+
+
+ Exe
+ 26
+ cn.akexin.traetools
+ 1.4.0
+ 10
+ apk
+ false
+
+ true
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
PreserveNewest
+
@@ -46,12 +78,15 @@
$(_VJsonVer)
$(_VJsonVer)
+ $(_VJsonVer)
+
-
+
+
@@ -60,9 +95,10 @@
+
+
-
+
-
-
diff --git a/ViewModels/CloudViewModel.cs b/ViewModels/CloudViewModel.cs
index c8e09f8..97ce2a9 100644
--- a/ViewModels/CloudViewModel.cs
+++ b/ViewModels/CloudViewModel.cs
@@ -275,7 +275,7 @@ private async void OpenAuthPage(GitHubDeviceCode code)
}
catch { /* 复制失败不阻断 */ }
- try { Process.Start(new ProcessStartInfo(code.VerificationUri) { UseShellExecute = true }); }
+ try { TraeTools.Platform.BrowserLauncher.Open(code.VerificationUri); }
catch (Exception ex)
{
AppendLog("自动打开浏览器失败,请手动访问:" + code.VerificationUri + "(" + ex.Message + ")");
@@ -591,9 +591,9 @@ private void OpenActions()
{
try
{
- var owner = string.IsNullOrEmpty(GitHubUser) ? "star620" : GitHubUser;
+ var owner = string.IsNullOrEmpty(GitHubUser) ? "akexin" : GitHubUser;
var url = $"https://github.com/{owner}/{ForkRepo}/actions";
- Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ TraeTools.Platform.BrowserLauncher.Open(url);
AppendLog($"已在浏览器打开:{url}");
}
catch (Exception ex)
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index a879a68..f5c4fde 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -37,6 +37,9 @@ public partial class MainViewModel : ViewModelBase
/// 界面展示的应用版本号,与"关于/更新检查"(TraeTools.Services.AppVersion)同源。
public string AppVersion => Services.AppVersion.Display;
+ /// 账号切换是否可用:仅 Windows 桌面端(Android 无本地 Trae 客户端进程/文件可操作)。
+ public bool IsSwitchSupported => !OperatingSystem.IsAndroid();
+
public MainViewModel()
{
// 初始化真实服务(try-catch,失败则为 null,页面 VM 用 mock 兜底)
diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs
index ef555c8..f254f5b 100644
--- a/ViewModels/SettingsViewModel.cs
+++ b/ViewModels/SettingsViewModel.cs
@@ -97,11 +97,14 @@ private async Task AutoClearStatusAsync(CancellationToken ct)
private AccountInfo? _selectedAccount;
// 关于区块属性
- public string OriginalRepoUrl => "https://github.com/star620/TraeTools";
- public string ForkRepoUrl => "https://github.com/star620/TraeTools";
- public string IssuesUrl => "https://github.com/star620/TraeTools/issues";
+ public string OriginalRepoUrl => "https://github.com/akexin/TraeTools-Android";
+ public string ForkRepoUrl => "https://github.com/akexin/TraeTools-Android";
+ public string IssuesUrl => "https://github.com/akexin/TraeTools-Android/issues";
public string AppVersion => TraeTools.Services.AppVersion.Display;
+ /// 开机自启 / 托盘驻留是否可用:仅 Windows 桌面端(Android 无注册表自启与系统托盘)。
+ public bool IsWindowsDesktopOnlyFeaturesSupported => OperatingSystem.IsWindows();
+
public SettingsViewModel()
{
// 从 AutoStartManager 读取开机自启状态
@@ -580,7 +583,7 @@ private void OpenOriginalRepo()
{
try
{
- Process.Start(new ProcessStartInfo(OriginalRepoUrl) { UseShellExecute = true });
+ TraeTools.Platform.BrowserLauncher.Open(OriginalRepoUrl);
}
catch { /* 忽略 */ }
}
@@ -590,7 +593,7 @@ private void OpenForkRepo()
{
try
{
- Process.Start(new ProcessStartInfo(ForkRepoUrl) { UseShellExecute = true });
+ TraeTools.Platform.BrowserLauncher.Open(ForkRepoUrl);
}
catch { /* 忽略 */ }
}
@@ -600,7 +603,7 @@ private void OpenIssues()
{
try
{
- Process.Start(new ProcessStartInfo(IssuesUrl) { UseShellExecute = true });
+ TraeTools.Platform.BrowserLauncher.Open(IssuesUrl);
}
catch { /* 忽略 */ }
}
@@ -628,8 +631,7 @@ private async Task ResetAgreement()
try
{
var flag = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "TraeTools", "eula_accepted");
+ TraeTools.Services.DataPaths.Root, "eula_accepted");
if (File.Exists(flag)) File.Delete(flag);
}
catch { /* 删除失败不阻塞退出 */ }
@@ -718,7 +720,7 @@ private async Task ResetSoftware()
}
///
- /// 为源仓库点 star:已授权 GitHub 时调用 GitHub API 点赞(star620/TRAE-Checkin);
+ /// 为源仓库点 star:已授权 GitHub 时调用 GitHub API 点赞(akexin/TraeTools-Android);
/// 未授权或仓库 owner 本人时打开仓库主页由用户手动点赞。
///
[RelayCommand]
@@ -731,14 +733,14 @@ private async Task StarSourceRepo()
if (cfg == null || gh == null)
{
// 服务未初始化:直接打开仓库主页
- Process.Start(new ProcessStartInfo(OriginalRepoUrl) { UseShellExecute = true });
+ TraeTools.Platform.BrowserLauncher.Open(OriginalRepoUrl);
return;
}
if (string.IsNullOrEmpty(cfg.GitHubToken))
{
PushStatus = "尚未授权 GitHub,已打开仓库主页,请手动点赞";
- Process.Start(new ProcessStartInfo(OriginalRepoUrl) { UseShellExecute = true });
+ TraeTools.Platform.BrowserLauncher.Open(OriginalRepoUrl);
return;
}
@@ -753,7 +755,7 @@ private async Task StarSourceRepo()
{
// 仓库 owner 本人无需自赞,打开主页浏览
PushStatus = "您是该仓库的维护者,无需为自己点赞";
- Process.Start(new ProcessStartInfo(OriginalRepoUrl) { UseShellExecute = true });
+ TraeTools.Platform.BrowserLauncher.Open(OriginalRepoUrl);
return;
}
@@ -796,6 +798,21 @@ private async Task CheckUpdate()
PushStatus = $"发现新版本 {rel.Tag}";
var owner = UiHost.MainWindow;
+#if ANDROID
+ // Android:不自动下载/安装(应用内无法自行替换 APK,且不做强制更新)。
+ // 仅提示并引导到 Release 页面,用户自主决定是否下载安装。
+ var ask = new PromptWindow("发现新版本",
+ $"发现新版本 {rel.Tag}\n(当前 {AppVersion},约 {Math.Max(1, (long)(rel.Size / 1048576.0))} MB)\n\n是否前往 GitHub 下载页获取新版 APK?(不会自动安装,下载后手动安装即可)",
+ "前往下载", "以后再说");
+ bool gotoDownload = owner != null ? await ask.ShowDialog(owner) : false;
+ if (!gotoDownload)
+ {
+ PushStatus = "已取消更新(当前版本可继续使用)";
+ return;
+ }
+ TraeTools.Platform.BrowserLauncher.Open(rel.HtmlUrl);
+ PushStatus = "已打开下载页:下载 APK 后手动安装即可";
+#else
var ask = new PromptWindow("发现新版本",
$"发现新版本 {rel.Tag}\n(当前 {AppVersion},约 {Math.Max(1, (long)(rel.Size / 1048576.0))} MB)\n\n是否下载并自动安装?",
"下载并安装", "取消");
@@ -832,6 +849,7 @@ private async Task CheckUpdate()
{
PushStatus = "更新失败:" + ex.Message;
}
+#endif // !ANDROID
}
catch (Exception ex)
{
diff --git a/ViewModels/UiHost.cs b/ViewModels/UiHost.cs
index 64d4654..f7f8b14 100644
--- a/ViewModels/UiHost.cs
+++ b/ViewModels/UiHost.cs
@@ -8,5 +8,17 @@ namespace TraeTools.ViewModels;
internal static class UiHost
{
public static Window? MainWindow
- => (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
-}
\ No newline at end of file
+ {
+ get
+ {
+ var lifetime = Application.Current?.ApplicationLifetime;
+ if (lifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ return desktop.MainWindow;
+#if ANDROID
+ if (lifetime is ISingleViewApplicationLifetime singleView && singleView.MainView is Window w)
+ return w;
+#endif
+ return null;
+ }
+ }
+}
diff --git a/Views/DeviceAuthWindow.axaml.cs b/Views/DeviceAuthWindow.axaml.cs
index b02215d..f05d7e7 100644
--- a/Views/DeviceAuthWindow.axaml.cs
+++ b/Views/DeviceAuthWindow.axaml.cs
@@ -57,7 +57,7 @@ private async Task InitAsync()
private void OpenPage(GitHubDeviceCode code)
{
- try { Process.Start(new ProcessStartInfo(code.VerificationUri) { UseShellExecute = true }); }
+ try { TraeTools.Platform.BrowserLauncher.Open(code.VerificationUri); }
catch (Exception ex) { StatusText.Text = "自动打开浏览器失败,请手动访问:" + code.VerificationUri + "(" + ex.Message + ")"; }
}
diff --git a/Views/LoginWindow.axaml b/Views/LoginWindow.axaml
index e582e4e..af4891d 100644
--- a/Views/LoginWindow.axaml
+++ b/Views/LoginWindow.axaml
@@ -11,12 +11,12 @@
-
-
+
+
推荐:点击下方「自动登录」将弹出内嵌浏览器窗口,登录成功后自动读取凭证,无需手动复制。
-
@@ -24,8 +24,8 @@
-
-
+
+
备选:手动打开浏览器登录后复制 Cloud-IDE-Token 粘贴保存。
public partial class LoginWindow : Window
{
@@ -21,6 +21,12 @@ public partial class LoginWindow : Window
public LoginWindow()
{
InitializeComponent();
+#if ANDROID
+ // Android 无内嵌浏览器:隐藏自动登录区,手动方案升级为唯一方式
+ AutoLoginButton.IsVisible = false;
+ AutoLoginHint.IsVisible = false;
+ ManualHint.Text = "手机端请点击下方「打开浏览器登录」,登录后按开发者教程复制 Cloud-IDE-Token 粘贴保存。";
+#endif
}
public LoginWindow(TraeAccount? account)
@@ -29,6 +35,7 @@ public LoginWindow(TraeAccount? account)
_account = account;
}
+#if !ANDROID
/// 内嵌 WebView2 自动登录:独立 STA 线程宿主,自动读取 Cloud-IDE-Token 与 X-Cloudide-Session。
private async void OnAutoLoginClick(object? sender, RoutedEventArgs e)
{
@@ -59,7 +66,7 @@ private async void OnAutoLoginClick(object? sender, RoutedEventArgs e)
"TraeTools", "WebView", "account_" + (_account.Id.Length > 12 ? _account.Id[..12] : _account.Id));
// 接口探索已完成;false=正常登录(自动识别并关闭窗口)。需要再抓包时改为 true
- var result = await WebViewLoginHost.RunAsync(webViewDir, _account.Token, debugCapture: false);
+ var result = await TraeTools.Services.Login.WebViewLoginHost.RunAsync(webViewDir, _account.Token, debugCapture: false);
if (result == null)
{
@@ -84,11 +91,11 @@ private async void OnAutoLoginClick(object? sender, RoutedEventArgs e)
_busy = false;
}
}
+#endif
private void OnOpenBrowserClick(object? sender, RoutedEventArgs e)
{
- try { Process.Start(new ProcessStartInfo("https://www.trae.cn") { UseShellExecute = true }); }
- catch { /* 打开失败忽略 */ }
+ BrowserLauncher.Open("https://www.trae.cn");
}
private void OnSaveClick(object? sender, RoutedEventArgs e)
@@ -119,4 +126,4 @@ private void OnSaveClick(object? sender, RoutedEventArgs e)
}
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
-}
\ No newline at end of file
+}
diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml
index 1ce8050..9083b70 100644
--- a/Views/MainWindow.axaml
+++ b/Views/MainWindow.axaml
@@ -7,9 +7,6 @@
x:Class="TraeTools.Views.MainWindow"
x:DataType="vm:MainViewModel"
Title="TraeTools"
- Width="1100" Height="720"
- MinWidth="940" MinHeight="600"
- WindowStartupLocation="CenterScreen"
Background="{StaticResource ContentBgBrush}"
FontFamily="Inter,Microsoft YaHei UI,Segoe UI">
@@ -55,7 +52,9 @@
+
-
+
-
+
@@ -164,70 +171,83 @@
-
+
-
-
+
+
+
+
+ Background="{DynamicResource ContentBgBrush}" Padding="28,32">
-
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
@@ -243,8 +263,8 @@
diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml
index f548b45..4274420 100644
--- a/Views/MainWindow.axaml
+++ b/Views/MainWindow.axaml
@@ -8,7 +8,7 @@
x:Class="TraeTools.Views.MainWindow"
x:DataType="vm:MainViewModel"
Title="TraeTools"
- Background="{StaticResource ContentBgBrush}"
+ Background="{DynamicResource ContentBgBrush}"
FontFamily="Inter,Microsoft YaHei UI,Segoe UI">
diff --git a/Views/PatInputWindow.axaml b/Views/PatInputWindow.axaml
index 6192753..6ba616e 100644
--- a/Views/PatInputWindow.axaml
+++ b/Views/PatInputWindow.axaml
@@ -5,13 +5,13 @@
mc:Ignorable="d" d:DesignWidth="520"
x:Class="TraeTools.Views.PatInputWindow"
Width="520" Height="330" MaxWidth="620"
- Background="White"
+ Background="{DynamicResource CardBgBrush}"
FontFamily="Microsoft YaHei UI,Segoe UI">
-
+
-
+
-
+
diff --git a/Views/SettingsView.axaml b/Views/SettingsView.axaml
index b7276c9..8546f13 100644
--- a/Views/SettingsView.axaml
+++ b/Views/SettingsView.axaml
@@ -14,9 +14,9 @@
@@ -24,55 +24,66 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -80,23 +91,23 @@
-
+
-
+
-
-
+
+
-
-
+
@@ -106,8 +117,8 @@
Command="{Binding RegenerateDeviceIdCommand}" />
-
+
@@ -115,17 +126,17 @@
-
+
-
+
-
+
@@ -136,43 +147,58 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+ ScrollViewer.VerticalScrollBarVisibility="Auto" MaxHeight="220"
+ IsVisible="{Binding !HasNoAccounts}">
-
+ Background="{DynamicResource RowBgBrush}" BorderBrush="{DynamicResource BorderBrushKey}" BorderThickness="1"
+ Padding="12,9" Margin="2,4" HorizontalAlignment="Stretch">
@@ -187,12 +213,12 @@
-
+
-
+
-
+
@@ -215,18 +241,18 @@
-
+
-
-
+
+
+ Foreground="{DynamicResource DangerBrush}" />
@@ -238,60 +264,60 @@
-
+
-
-
+
+
+ Foreground="{DynamicResource AccentBrush}" Padding="12,6" />
-
+
-
+
-
-
+
+
+ Foreground="{DynamicResource AccentBrush}" Padding="12,6" />
-
+
-
+
-
-
+
+
+ Foreground="{DynamicResource AccentBrush}" Padding="12,6" />
-
+
-
+
-
-
+
+
+ Foreground="{DynamicResource AccentBrush}" Padding="12,6" />
@@ -301,20 +327,13 @@
-
-
-
+
-
-
-
-
-
-
-
diff --git a/Views/SwitchView.axaml b/Views/SwitchView.axaml
index adb34b8..2ecff38 100644
--- a/Views/SwitchView.axaml
+++ b/Views/SwitchView.axaml
@@ -13,7 +13,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 15b9905..e7a1776 100644
--- a/Views/UsageView.axaml
+++ b/Views/UsageView.axaml
@@ -13,91 +13,89 @@
-
+
-
+
-
+
-
+
-
-
+
+
-
+
-
+
-
-
+
+
-
+
-
-
+
+
-
-
+
-
-
+
+
-
+
-
+
-
+
-
+
-
-
@@ -107,26 +105,26 @@
-
+
-
+
-
+
-
+
@@ -202,11 +213,11 @@
-
+
-
+
-
+
-
-
-
-
+
@@ -382,9 +370,9 @@
-
+
-
+
diff --git a/ViewModels/UsageViewModel.cs b/ViewModels/UsageViewModel.cs
index c14d4f5..aafd602 100644
--- a/ViewModels/UsageViewModel.cs
+++ b/ViewModels/UsageViewModel.cs
@@ -59,12 +59,19 @@ public partial class UsageViewModel : ViewModelBase
public ObservableCollection Sessions { get; } = new();
public ObservableCollection ModelStats { get; } = new();
+ /// 会话/模型分布是否为空:驱动列表区轻量空提示(不显示大空卡)。
+ public bool HasNoSessions => Sessions.Count == 0;
+ public bool HasNoModels => ModelStats.Count == 0;
+
private const int MaxSessionsShown = 500; // 列表展示上限(汇总仍按全量)
private const int FetchDays = 7;
private const int ExpireWarnDays = 7; // 提前多少天提醒积分过期
public UsageViewModel()
{
+ // 集合增删时刷新空状态标记
+ Sessions.CollectionChanged += (_, __) => OnPropertyChanged(nameof(HasNoSessions));
+ ModelStats.CollectionChanged += (_, __) => OnPropertyChanged(nameof(HasNoModels));
Reload();
}
diff --git a/Views/CheckinView.axaml b/Views/CheckinView.axaml
index f36e21f..588617a 100644
--- a/Views/CheckinView.axaml
+++ b/Views/CheckinView.axaml
@@ -19,13 +19,12 @@
-
+
-
+
@@ -26,14 +26,14 @@
+
+
+
@@ -68,23 +80,6 @@
-
-
-
-
-
-
@@ -263,7 +258,7 @@
diff --git a/Views/SettingsView.axaml b/Views/SettingsView.axaml
index 8546f13..4e96ace 100644
--- a/Views/SettingsView.axaml
+++ b/Views/SettingsView.axaml
@@ -12,7 +12,13 @@
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -241,8 +249,8 @@
-
-
+
+
@@ -264,8 +272,8 @@
-
-
+
+
@@ -275,12 +283,12 @@
Foreground="{DynamicResource AccentBrush}" Padding="12,6" />
-
+
-
-
+
+
@@ -290,12 +298,12 @@
Foreground="{DynamicResource AccentBrush}" Padding="12,6" />
-
+
-
-
+
+
@@ -305,12 +313,12 @@
Foreground="{DynamicResource AccentBrush}" Padding="12,6" />
-
+
-
-
+
+
diff --git a/Views/UsageView.axaml b/Views/UsageView.axaml
index e7a1776..280e478 100644
--- a/Views/UsageView.axaml
+++ b/Views/UsageView.axaml
@@ -13,15 +13,29 @@
-
+
+
+
+
-
+
@@ -30,57 +44,76 @@
-
-
-
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -105,7 +138,7 @@
-
+
@@ -113,14 +146,20 @@
+
+
+ ScrollViewer.HorizontalScrollBarVisibility="Disabled"
+ IsVisible="{Binding !HasNoSessions}">
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/CheckinView.axaml.cs b/Views/CheckinView.axaml.cs
deleted file mode 100644
index 0e3c1e5..0000000
--- a/Views/CheckinView.axaml.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Avalonia.Controls;
-
-namespace TraeTools.Views;
-
-public partial class CheckinView : UserControl
-{
- public CheckinView()
- {
- InitializeComponent();
- }
-}
diff --git a/Views/CloudView.axaml b/Views/CloudView.axaml
deleted file mode 100644
index c6982d6..0000000
--- a/Views/CloudView.axaml
+++ /dev/null
@@ -1,127 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/CloudView.axaml.cs b/Views/CloudView.axaml.cs
deleted file mode 100644
index 8c44e35..0000000
--- a/Views/CloudView.axaml.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Avalonia.Controls;
-
-namespace TraeTools.Views;
-
-public partial class CloudView : UserControl
-{
- public CloudView()
- {
- InitializeComponent();
- }
-}
diff --git a/Views/DashboardView.axaml b/Views/DashboardView.axaml
deleted file mode 100644
index 28212eb..0000000
--- a/Views/DashboardView.axaml
+++ /dev/null
@@ -1,263 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/DashboardView.axaml.cs b/Views/DashboardView.axaml.cs
deleted file mode 100644
index 0e85a13..0000000
--- a/Views/DashboardView.axaml.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using Avalonia.Controls;
-using Avalonia.Input;
-using TraeTools.ViewModels;
-
-namespace TraeTools.Views;
-
-public partial class DashboardView : UserControl
-{
- public DashboardView()
- {
- InitializeComponent();
- }
-
- /// 趋势点命中:即时更新悬浮冒泡(替代有延迟的原生 ToolTip)。
- private void TrendHoverEnter(object? sender, PointerEventArgs e)
- {
- if (sender is Control c && c.DataContext is DashboardViewModel.TrendPoint p && DataContext is DashboardViewModel vm)
- vm.SetTrendHover(p);
- }
-
- private void TrendHoverExit(object? sender, PointerEventArgs e)
- {
- if (DataContext is DashboardViewModel vm) vm.SetTrendHover(null);
- }
-}
diff --git a/Views/HomeView.axaml b/Views/HomeView.axaml
new file mode 100644
index 0000000..83dce01
--- /dev/null
+++ b/Views/HomeView.axaml
@@ -0,0 +1,278 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/HomeView.axaml.cs b/Views/HomeView.axaml.cs
new file mode 100644
index 0000000..26766a1
--- /dev/null
+++ b/Views/HomeView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+
+namespace TraeTools.Views;
+
+/// 极简单页(本地签到 + 云端托管 + 最近记录)。逻辑全部在 HomeViewModel。
+public partial class HomeView : UserControl
+{
+ public HomeView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Views/MainView.axaml b/Views/MainView.axaml
index 7111dac..048bb68 100644
--- a/Views/MainView.axaml
+++ b/Views/MainView.axaml
@@ -3,7 +3,7 @@
xmlns:vm="using:TraeTools.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="720"
+ mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="760"
x:Class="TraeTools.Views.MainView"
x:DataType="vm:MainViewModel"
Background="{DynamicResource ContentBgBrush}"
@@ -14,255 +14,151 @@
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/SwitchView.axaml.cs b/Views/SwitchView.axaml.cs
deleted file mode 100644
index cd72b2b..0000000
--- a/Views/SwitchView.axaml.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Avalonia.Controls;
-
-namespace TraeTools.Views;
-
-public partial class SwitchView : UserControl
-{
- public SwitchView()
- {
- InitializeComponent();
- }
-}
diff --git a/Views/UsageView.axaml b/Views/UsageView.axaml
deleted file mode 100644
index 06259ae..0000000
--- a/Views/UsageView.axaml
+++ /dev/null
@@ -1,233 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/UsageView.axaml.cs b/Views/UsageView.axaml.cs
deleted file mode 100644
index 90475ca..0000000
--- a/Views/UsageView.axaml.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Avalonia.Controls;
-
-namespace TraeTools.Views;
-
-public partial class UsageView : UserControl
-{
- public UsageView()
- {
- InitializeComponent();
- }
-}
\ No newline at end of file
From bb4cb4bddec2f29ffc5b98fd5c4dfd5dd85dcb44 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 14 Oct 2026 05:46:59 +0000
Subject: [PATCH 28/37] chore(release): bump version to v2.0.0
---
TraeTools.csproj | 2 +-
version.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/TraeTools.csproj b/TraeTools.csproj
index 4fae015..6e91a6f 100644
--- a/TraeTools.csproj
+++ b/TraeTools.csproj
@@ -43,7 +43,7 @@
26
cn.akexin.traetools
1.4.4
- 14
+ 15
apk
false
- 1.0.0
- 1.0.0
CA1416;MSB3277;XA0121;CS0436
+
+
+ <_VJsonRaw Condition="Exists('$(MSBuildProjectDirectory)\version.json')">$([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\version.json'))
+ <_VJsonVer>$([System.Text.RegularExpressions.Regex]::Match('$(_VJsonRaw)', '"version"\s*:\s*"([^"]+)"').Groups[1].Value)
+ $(_VJsonVer)
+ 1.0.0
+ $(_VJsonVer)
+ 1.0.0
+
+ <_VersionSourceMessage>Version = $(_VJsonVer)
+
+
true
@@ -42,8 +55,12 @@
Exe
26
cn.akexin.traetools
- 1.4.4
- 15
+
+ $(_VJsonVer)
+ 1.0.0
+
+ 16
apk
false
-
-
- <_VJsonContent>$([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\version.json'))
- <_VJsonVer>$([System.Text.RegularExpressions.Regex]::Match('$(_VJsonContent)', '"version"\s*:\s*"([^"]+)"').Groups[1].Value)
-
-
- $(_VJsonVer)
- $(_VJsonVer)
- $(_VJsonVer)
-
-
+
+
+
diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs
index 171966a..06491dd 100644
--- a/ViewModels/SettingsViewModel.cs
+++ b/ViewModels/SettingsViewModel.cs
@@ -119,8 +119,10 @@ private async Task AutoClearStatusAsync(CancellationToken ct)
private AccountInfo? _selectedAccount;
// 关于区块属性
- public string OriginalRepoUrl => "https://github.com/akexin/TraeTools-Android";
- public string ForkRepoUrl => "https://github.com/akexin/TraeTools-Android";
+ // 「源仓库」统一指向本项目自身的仓库。
+ // 历史上这里有两行——「打开原仓库」与「打开我的 Fork 仓库」——分别对应上游 fork 与被 fork 的关系,
+ // 但 2.0 起两者实际是同一个地址(本项目仓库),显示重复且用词误导,故合并为单行「项目仓库」。
+ public string RepoUrl => "https://github.com/akexin/TraeTools-Android";
public string IssuesUrl => "https://github.com/akexin/TraeTools-Android/issues";
public string AppVersion => TraeTools.Services.AppVersion.Display;
@@ -605,21 +607,11 @@ private void DeleteAccount()
}
}
[RelayCommand]
- private void OpenOriginalRepo()
+ private void OpenRepo()
{
try
{
- TraeTools.Platform.BrowserLauncher.Open(OriginalRepoUrl);
- }
- catch { /* 忽略 */ }
- }
-
- [RelayCommand]
- private void OpenForkRepo()
- {
- try
- {
- TraeTools.Platform.BrowserLauncher.Open(ForkRepoUrl);
+ TraeTools.Platform.BrowserLauncher.Open(RepoUrl);
}
catch { /* 忽略 */ }
}
@@ -759,14 +751,14 @@ private async Task StarSourceRepo()
if (cfg == null || gh == null)
{
// 服务未初始化:直接打开仓库主页
- TraeTools.Platform.BrowserLauncher.Open(OriginalRepoUrl);
+ TraeTools.Platform.BrowserLauncher.Open(RepoUrl);
return;
}
if (string.IsNullOrEmpty(cfg.GitHubToken))
{
PushStatus = "尚未授权 GitHub,已打开仓库主页,请手动点赞";
- TraeTools.Platform.BrowserLauncher.Open(OriginalRepoUrl);
+ TraeTools.Platform.BrowserLauncher.Open(RepoUrl);
return;
}
@@ -781,7 +773,7 @@ private async Task StarSourceRepo()
{
// 仓库 owner 本人无需自赞,打开主页浏览
PushStatus = "您是该仓库的维护者,无需为自己点赞";
- TraeTools.Platform.BrowserLauncher.Open(OriginalRepoUrl);
+ TraeTools.Platform.BrowserLauncher.Open(RepoUrl);
return;
}
diff --git a/Views/SettingsView.axaml b/Views/SettingsView.axaml
index 4e96ace..cf7ce25 100644
--- a/Views/SettingsView.axaml
+++ b/Views/SettingsView.axaml
@@ -270,31 +270,17 @@
-
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/signing/README.md b/signing/README.md
index 3480223..f7682d5 100644
--- a/signing/README.md
+++ b/signing/README.md
@@ -4,24 +4,50 @@
| 项 | 值 |
|---|---|
-| 文件 | `traetools.keystore`(PKCS#12,PBE-SHA1-3DES) |
+| 文件 | `traetools.keystore`(PKCS#12,PBE-SHA1-3DES legacy 编码) |
| 别名(alias) | `traetools` |
| 存储口令 / 密钥口令 | `traetools` |
-| 证书主体 | `CN=TraeTools, O=akexin, C=CN`(自签名,有效期 30 年) |
+| 证书主体 | `CN=TraeTools, O=akexin, C=CN`(自签名,RSA 4096 / SHA256withRSA,有效期 30 年) |
+| 指纹基准 | `EXPECTED_CERT_SHA256.txt`(release.yml 的校验门禁比对此值) |
## 为什么提交到仓库
APK 升级安装要求「新旧版本签名一致」。GitHub Actions 每次运行的
`~/.android/debug.keystore` 都是临时生成的,用它签名会导致**每个版本签名不同、
-无法覆盖安装**。因此固定使用本密钥,保证从 v1.4.0 起的各版本签名一致。
+无法覆盖安装**。因此固定使用本密钥,保证同一密钥期内各版本签名一致。
+
+## 更换密钥
+
+不要手工创建密钥:本机(及多数 CI 环境)没有 JRE 时,用 OpenSSL/Python 等工具生成的
+PKCS#12 在 Java 侧的 alias 映射存在不确定性,容易出现「CI 签名步骤失败」或
+「指纹对不上」的问题。
+
+请改用仓库内置的手动工作流:
+
+```
+Actions → Rotate Keystore → Run workflow
+```
+
+它用构建机上的 `keytool` 生成密钥(alias / 口令 / 编码格式与 `release.yml` 完全一致),
+自动更新本目录的 `EXPECTED_CERT_SHA256.txt`,并把两者提交回当前分支。
+
+> ⚠️ 更换密钥后,**所有已安装用户都无法覆盖安装**,必须先卸载旧版再装新版。
+> 这是签名机制的硬约束,无法绕过。
+
+## 签名校验门禁
+
+`release.yml` 的 `publish-android` job 在发布前会做三项硬校验,任一不通过即中断发布:
+
+1. `apksigner verify --verbose --print-certs` 退出码为 0(签名整体有效);
+2. 签名证书 SHA-256 与 `EXPECTED_CERT_SHA256.txt` **完全一致**(防止密钥被换或产物来源不对);
+3. 结构性校验 **v1(JAR) + v2 + v3 三种签名方案齐全**(直接解析 APK 二进制,不依赖
+ apksigner 的文本输出格式——不同 build-tools 版本行格式不一致)。
+
+历史上这里只是 `apksigner verify ... | head -8 || true`,既不校验退出码也不比对指纹,
+签名出问题时 CI 依然全绿,只能在用户手机上表现为「签名验证不通过」。
## 安全边界(请阅读)
- 该密钥**随公开仓库发布**,仅用于保证签名一致性,**不具备任何保密性**;
任何人持有它都可以签发与官方版本同签名的 APK。
- 请不要用它签名任何需要保密的发布渠道(Google Play 等)。
-- 如需换成自己的私有密钥:重新生成 keystore(`keytool -genkeypair ...`),
- 替换本文件,并同步更新 `.github/workflows/release.yml` 中的
- `AndroidSigningKeyStore` / `AndroidSigningKeyAlias` / `AndroidSigningKeyPass` /
- `AndroidSigningStorePass` 四个参数。
- ⚠️ 更换密钥后,旧版本无法直接覆盖安装(需先卸载旧版)。
diff --git a/version.json b/version.json
index 5b19489..c648275 100644
--- a/version.json
+++ b/version.json
@@ -1,5 +1,5 @@
{
- "version": "2.0.0",
+ "version": "2.0.1",
"channel": "stable",
"buildDate": "2026-10-14"
}
From 6f4cf19b132d97e6459fabb49bc6f9fc37caef64 Mon Sep 17 00:00:00 2001
From: akexin <315600306+akexin@users.noreply.github.com>
Date: Wed, 14 Oct 2026 07:38:49 +0000
Subject: [PATCH 30/37] =?UTF-8?q?chore(signing):=20=E8=BD=AE=E6=8D=A2=20AP?=
=?UTF-8?q?K=20=E7=AD=BE=E5=90=8D=E5=AF=86=E9=92=A5=EF=BC=88keytool=20?=
=?UTF-8?q?=E7=94=9F=E6=88=90=EF=BC=8Calias=3Dtraetools=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
由 .github/workflows/rotate-keystore.yml 手动触发。轮换后所有已安装用户必须卸载旧版才能安装新版。同时更新 signing/EXPECTED_CERT_SHA256.txt(release.yml 的签名校验门禁基准)。
---
signing/EXPECTED_CERT_SHA256.txt | 1 +
signing/traetools.keystore | Bin 2512 -> 4149 bytes
2 files changed, 1 insertion(+)
create mode 100644 signing/EXPECTED_CERT_SHA256.txt
diff --git a/signing/EXPECTED_CERT_SHA256.txt b/signing/EXPECTED_CERT_SHA256.txt
new file mode 100644
index 0000000..3ac0ac9
--- /dev/null
+++ b/signing/EXPECTED_CERT_SHA256.txt
@@ -0,0 +1 @@
+4E48F499E5BA24F28A3B34671756F93A7684EDBD02CC00FADAFFF2DD73782774
diff --git a/signing/traetools.keystore b/signing/traetools.keystore
index 680b9c0d751df47e1f09b31a1fab275db3078d27..cec24788c3007c940d976b9195fc6f4bead24e1e 100644
GIT binary patch
literal 4149
zcmY+Gbx<1&lg81Y#kIIwf#9x%;1t*5Zlyqq6p93wVu9iitZ2~}3Y6mR#a)X8NpTC5
zkGr}1?!MXC*_q$&K7Z}(JU|GM02(Sf5JGqez~PQkjk(!MK2bp|%td
z)yY)Rkk17}Lr3Y=dDc<|-_R!y>8cN2s)OQimWw8CsLE4$rnx4sr6@CYV8yN^ilg;+
z=aRr8CT@}$-)`E>R-L;loinyAF_7pwTl{JH3T&>GUdjzAM$3%J6>4
z;*zRm0y7e8e#dhfnoyWCT^l`&pw&GNmft+794A)5*K=ntaB5UCiNVYeDy*X}{|?FG
zC?$Dh@QmXzj*f`r`l&?Y$cV4@{kG!O$Ljg5?z6#S})`Zcy;(2vZ}?KX}W
z$3cQd<89I^YsKG!+fRh8*U9WWw;f1mFkiz9fnqSa+=CG?jnPr(OWm^3GHJW1ZcNYPrp}iZ!|D^Ep)UYcS
z1ADn)!@&Ehg3)SMI@Wf=&d9qL(~0(|%4P{hM0e-KMC8esNKH@e4ukU;PhZ#6HM@eG
z1dYW-rciM-rG^7m#l;W64@np(b2;rY|Ec}nCi+}_`)zOGHD
zG
z-!E5}u}44O7sz_5eK|Uoh+Yy(ISGh9SA5C^KGR2
zFwtR+i;|gyNY3c0!0SoON$0xqZ!zQ7@XSi?-J8B
zSx?lh{i(&d
zDMh&ux4Mcq^UKAOVbkPRd4-KoWf9-Rj<~(Sp^`SM{KiV=wnh1C768ceWx5BqW5*f
zPupAYd*=9&`yV=^=8d;c!7R>m52Jals0Rkqql%l@^Z{d&wNq*_bv)6lCXNWs#yD;1
z#A9z^9ooNz=l*NuOw>90_5)$tiljwS^+Wo0fA^Apizm=wX;epuqHb4H%Q6w&)p7!C
z=O8q*miK=P6qMdfW8$qXd%7O_?ku^ZBfBz+sya0I4v+?nlT~=jJfpVht`=xoNrdP2
z?tm4x;9gsE0l6*L(CxGW&W>7p>3qY%;1uEW6)1|XgdWFo3<$OI7g^ZlI`a%%c>?%%
zk1Cc8Px=Gmj^MzD8B}m?SBk-69VL&pA#_%whXX450M=jwojIS=c-ru7y06>Wc
z&cn#G`pckp!xB?|2A51a7et-Prh}@!ZXh{|<>2XizV*?MZ}%O2oW(AV(ktsbg+d}p
z#2%;rei+kn)Z3wPWEg7OS1*xadBh2SA{1XyM9MGck*a0M2bvrE`&|kkr(aD3*y2|3
zU2NQ7Ei3xtG&8m8I5CRKiKj9IY-7l6gs#4+cZmqs_<^ENkm$F=CSNJZpIGIS5oftR
z#j5Y)RtVbMTZ_56gpkAm#{(=5eP5>@uzVup%2v17QWlxBym$JA^)jVOCZg93E3WSE
zz+GYXom_XuU@i40B8%UwHj%2-c(q9b3+3-fB$Cuv?
zb~Jg**@T%u4qTpUbKyhEh5b5e(0$n5YSMCvRm(Au1LL8eUFy~OtT%ZC`xEax9sF?F
zPL1}AUH;WWtEWlWmi+ivG$oGhWSb~EW2PcS)VJ42B(f$!rUwm
zT&tCbJP?Q=c9T_CoLVtRRK?Tl-xV%?gu4OG78ks$qe}XD+$hj@sbj#xD{TqFd*PGc
z&3UXxuIQT;UGlFv+KP0;&R_c;Sux1gq4y6OOEH6_6dBYw2N-1I9|?s#gWu8+*uCk3
z`$xZXXiXW~Vh>`(wZ|akv-e0riTAXL9w`Afi45AnQ;x)|x3s;v$;fH1Hl3oC5v=1J
zr0oaonLl@Z|A`b-t5RIF`zwz#>Ni(@-AqWc-lQDk*1jEXx}qycWy=XJ;eQRf{{c
zSS3+HMweXgri9+lE2taByuSRDg3bKh1yH8Vn+8Or@hoq!Du_0yb+jR`_z*S{6V4z2
z0y6#Es^O3ckkON%fKj|rY)~AYtLJmLqxk&)5pn@C3=#tu4+lm8F$n<)Q6UjQF=0_r
zAOv&u-#SK{2>pG>>E2f1PCP5^iyNH!!w1f;jPr
zxZSG2!E4~Bm9MGid>UXtd-E_~%X*~0nl}QSPT*?lrFkX#boaFy&{JfF9~ORDa7iU@
z)8ehqs{Ll-SE7s}qYC-j6jCQL?yFsXxag_6pB_UT=0^=L?AD_BJ0Y@q@6(JmfgY6F
zY;aiiF`Z)Ul(oA${h;Cc#dR|EBjy0{FYCvK^I>5zlWTX*8HLzsLLBxSzWDWFsBzm)
z$AWaKy{->~`nU;f35w>g-H(ycgZMmVdNe{svY^~{wxBJyqSS)Erby6S7WX17Hf{&m
z!}+Rl$>YqL&3wGbZpqY{q(=nHAia4rm&ulb)U*t1lBiJfk)9Fq$-aJrXt+D)6>=(
zmOC+uIyh_aTmBrEg98#m*<3+`Ow94+b=gt=8MxO{f5)X>6uchQ+yuFDqZO|+T3BL3
zo=sx&lU>zmijr>>tQPyIqt&T_EyLdX{Bm1No$=PIv0QE=ynSfXDXH8RB}h)DI4%;8
zPv8TZeu_vKM>INr^56e+%TH7Qdar%#DTZ#iX`xA@mssQdGo1r5h~^yd>8$qhPac~F
z#4BWR_3+j4@XgtyGt_3Av?!gOxIc7-yw0E>)z3o@L2{M0|8#ANV1Wz*08|}d1F46p
zw%?61ib4u}gcSGWbvji%wI7NU2e|Q$4xB+NNRy5)r>Z54fcma;P~B@EE&-I5+(Xc9
zYx|3tZu2oyr@q$5+Mu4uG7@KKNw(5eb3g@lL&bh8%C@&TL9e_gtgGADM8-yi3og2E
z=UCHTKE$FstzElZM|?UJxsJ@Wk?R#|4v(#t=YowGveQa0|wT~5>^
zV=0YIfm)xZXG0h#3o0crLiy;qrNtIfT`BQT^vNjHgSVDNkE2*g0VzM>=xneW1ZO}&
z<3N()%w|{x-bWGtbs2X#KeHi`7)QU$_f1BQi
z#e1>DoJbqXA#R6x#_7r;K0m4u(BVvxsz!>SlE=V={Mit8Qy5i++pd?9AJTpkt282)
zHR2K3d`7_g)0%j32CW8#$w$sSW=;=
z5!aIyWD$|a_}K9@_d$0n_7E@S$qHpxux1z|a!lJB`qZ*=>>t}I`ChzNC9|JFhnd6v
zt(R%b@Vc>!n37Dnle|999x+fB$OOazVDb>4qrJpL!63uLDh{4QLi@HMGlvyk!(oDP
h*n${j0$c6(@XFlS>;&YdGu=UP86K7u8aiqe>|aZs*9QOq
literal 2512
zcmZXUc{CJ`7RP7B*s{&Y43o+37iO|#nHH5AOCrV+gDlCekR=8~cCwGMjb%c}Sh9EJ_UC2cv4$ghyd>$K$u1#lzvO8mOXIJptr!C
zOD~qSMBurPHb%zwW_(MpsKj-s8jZaAS=@WOgW*l@YB!(%WhD(b}>
zB5G)8Sbwhv0oj&S=cQr`YCBw>U!eN?+N5exwg*uK(tsvA==Sr*@Gt>z-BhtmOh`2Y
z>66(PYM8O7SQ&7oS3h|^cA=zi^|Zx-SGsgG@;}(c`Aa)w0
z%5hioCox;?X+*z=HTQn&qp8kIguxIG{jw1aox}&D
zC%nE3sk_HBTzTs)40pu6_WSJeUJuv&T0&?=G~-HkO1spK@{O8{IY)&s0_2=keVdw+L#vqT5k172w+5<*cb_R^UC@T|Flo{
z!J@*SdVIn%@-HbxzDZJa2q}8+BUkmzoSDLC*d&d7xEU37HBW}q)9*H~v|b!_LEs{J
zsKi8gUN1dx!tDkS#CdV}m_SYj%V*Fx}4=
zoh32TDaqfyO7Hp7Lf|PecY^@;a-`uoO1@GrxUeZld*f+>eZU-{nUH
zT2nyDiJlp&xcJ*v{
zb#vHqy7<@a*mm)4T?QL4Yr3Z`TGU5Xt)~n`e~K`AlM|}S))qT#b}Q66H+EgQLwbj~
z)6qyWhl5a7NRNKe;dQ1|KhoMmObdBlW%WE+BQ4o>SdZ7!=6W}+(q-qmit@qMdwJX+
zhvw}=CUY*fzo*|(rT9Q&6>qykz(WWnNkNpOUbrym4fU3#m*0CSM
zOF&rM4jTTKuMF`G7PC~YCN@(fV!8wG7nJdpoBo1Zm>OvSi~S80G})b+HGC!8{v27w
zvIO`OK`Y9g=!xq`5hOOt%MASMm;IDz){hFGnJoVr@Z)wamY-tPlQP1L0R^KjX%8te
zF><aqS$Ix6_ET=82e6X&zMWtdnAXDy}j`llRZ3g5J
zpvuYgi`BAgh1d?vo!!ocwV%5)mm~BgdA&k-2J9a?mk$0B1DhjNLT%IhV>ow&Mbh^)
zrC~qJPW&VRESJXMIF|vz;2SB{kvZJ=^a|9c>KP
z+)A?^Iapy=E^O|HoQ!0C`j+3B%7H~$t^jULO%dGPJUKf>jCh=Tk>s73V(L=e){x)f
z%Uh!z8OU&>&%%dwI?;bM<27{TTn%AI0eM`ps-)e+(8-LguF0Umh?1jLJ{;@C$o`87
zjY^TYy#ZeHUsf@ugVGY!SoKUPdeZY@R63rrsuQ#36FGPJwQ!zxEO|51@Vn_2r9d|N
z0pRp3TxJyvu5r8eIL6TBi8B#cLs!7-ySkC1|3YHi-V8Fx;AD+boj{`mf!sQt+)Z?^
zg!TeZ<95mQ#)~VmI&;WPiK0gC9bwaoI#0x~SSY7?e#30`@vv(bw=DV>@
z{8uE?#NVePHM4xs{*A(pMCZ~yx(yD+WMr>^muT&x%eAuHdfn3=eCRV>XX+M_@uq!E
zs6Ukle0TF$>hXekrD_7}6w8-H<=RjLE^f&JNy1v-f5C
zcBNaFq^W2JEDnpo!q}jH@Pa`|C;%c9t!@4-?K&Tr5VbXH{HzWU_7ffl;rL|T(*7}$
Jd3-Mi;GfS}hYSD!
From 25a69b554b049b0408a3733943914bfe34af941a Mon Sep 17 00:00:00 2001
From: akexin <315600306+akexin@users.noreply.github.com>
Date: Wed, 14 Oct 2026 15:48:29 +0800
Subject: [PATCH 31/37] =?UTF-8?q?fix(ci):=20=E6=94=B9=E4=B8=BA=E6=98=BE?=
=?UTF-8?q?=E5=BC=8F=E4=B8=89=E6=96=B9=E6=A1=88=E7=AD=BE=E5=90=8D=20APK=20?=
=?UTF-8?q?+=20=E4=BF=AE=E6=AD=A3=E6=8C=87=E7=BA=B9=E6=AF=94=E5=AF=B9?=
=?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=86=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
第一次 v2.0.1 发布被新增的签名门禁拦下,暴露两个真问题(都值得修):
1. v1(JAR) 签名丢失
`dotnet publish -p:AndroidKeyStore=true` 产出的包,v2.0.0 含 v1 签名,
而换密钥后的 v2.0.1 为 `Verified using v1 scheme (JAR signing): false`。
即 v1 是否产出取决于 .NET for Android 的内部启发式(与 apksigner 的
minSdk 默认值交互),不是确定行为。
改为:publish 用 AndroidKeyStore=false 只产未签名包,再由工作流显式
`zipalign -f -p -P 16 4` + `apksigner sign --v1/--v2/--v3 true` 签名。
行为从此确定,且不再依赖打包器的隐式决策。
(-P 16 保证 .so 16KB 页对齐,Android 15+ 有要求;不支持 -P 的旧
build-tools 回退 -p 4。)
2. 指纹比对误报
rotate-keystore 里 keytool 输出大写、release 里 apksigner 输出小写,
直接字符串比较判定"不一致"。改为两侧统一 `tr -cd 0-9A-Fa-f | tr a-f A-F`
归一化后再比。
此次为上一提交 6f4cf19 的发布链路修复,v2.0.1 的 tag 与不完整 Release
已删除,修复后会重新打 tag 触发完整发布。
---
.github/workflows/release.yml | 69 +++++++++++++++++++++++++----------
1 file changed, 50 insertions(+), 19 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cd35ef6..5219b34 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -104,30 +104,59 @@ jobs:
- name: Install Android workload
run: dotnet workload install android
- # Android APK:使用仓库内置签名密钥(signing/traetools.keystore)签名。
- # 不用 SDK 默认的临时 debug keystore——它每次运行都重新生成,会导致各版本签名
- # 不一致、无法覆盖安装。AndroidKeyStore=true 时产出的签名产物名为 *-Signed.apk。
+ # Android APK:产出**未签名**包,随后由下面的步骤用仓库内置密钥
+ # (signing/traetools.keystore)显式签名。
+ #
+ # 为什么不交给 dotnet publish 的 AndroidKeyStore=true 自动签名:
+ # 实测两版行为不一致——v2.0.0 的产物含 v1(JAR) 签名,换密钥后的 v2.0.1
+ # 产物 `Verified using v1 scheme: false`,即 v1 是否产出取决于 .NET for Android
+ # 的内部启发式(与 apksigner 的默认值/minSdk 判定交互),不可控也不可预期。
+ # 显式调用 apksigner 并写死 --v1/--v2/--v3 三个开关,行为才是确定的。
+ #
# EnableWindowsTargeting:多目标项目还原需一并还原 net10.0-windows,ubuntu 上必须显式开启
- - name: Publish (android apk, signed)
+ - name: Publish (android apk, unsigned)
run: |
dotnet publish TraeTools.csproj -c Release -f net10.0-android \
-p:EnableWindowsTargeting=true \
-p:AndroidPackageFormat=apk \
- -p:AndroidKeyStore=true \
- -p:AndroidSigningKeyStore="$PWD/signing/traetools.keystore" \
- -p:AndroidSigningKeyAlias=traetools \
- -p:AndroidSigningKeyPass=traetools \
- -p:AndroidSigningStorePass=traetools \
+ -p:AndroidKeyStore=false \
-o publish-android
+ echo "== publish-android 产物 =="
+ find publish-android -name "*.apk" -printf "%s\t%p\n" | sort -rn || true
- - name: Collect apk
+ - name: Sign APK explicitly with repo keystore (v1 + v2 + v3)
run: |
- echo "== publish-android 中的 APK 候选(未签名的 *.apk 不可安装,必须取 *-Signed.apk)=="
- find publish-android -name "*.apk" -printf "%s\t%p\n" | sort -rn || true
- APK=$(find publish-android -iname "*-Signed.apk" | head -1)
- if [ -z "$APK" ]; then echo "::error::no signed apk produced"; exit 1; fi
- echo "selected: $APK"
- cp "$APK" TraeTools-Android.apk
+ set -euo pipefail
+ SRC=$(find publish-android -name "cn.akexin.traetools.apk" | head -1)
+ [ -n "$SRC" ] || SRC=$(find publish-android -name "*.apk" ! -iname "*-Signed.apk" | head -1)
+ [ -n "$SRC" ] || { echo "::error::未找到未签名 APK"; exit 1; }
+ echo "unsigned apk: $SRC"
+ ls -lh "$SRC"
+
+ BT=$(ls -d "${ANDROID_HOME:-/usr/local/lib/android/sdk}"/build-tools/* 2>/dev/null | sort -V | tail -1)
+ ZA="$BT/zipalign"; AS="$BT/apksigner"
+ [ -x "$ZA" ] && [ -x "$AS" ] || { echo "::error::zipalign/apksigner not found"; exit 1; }
+
+ # zipalign 必须在签名之前。四参形式对齐到 4 字节并启用页对齐;
+ # -P 16 指定 .so 按 16KB 页对齐(Android 15+ 对含原生库的包有要求),
+ # 老版本 build-tools 不认识 -P 时回退到经典的 -p 4。
+ if "$ZA" -f -p -P 16 4 "$SRC" aligned.apk 2>/dev/null; then
+ echo "zipalign 模式: -f -p -P 16 4"
+ else
+ echo "zipalign 模式: -f -p 4(回退,build-tools 不支持 -P)"
+ "$ZA" -f -p 4 "$SRC" aligned.apk
+ fi
+
+ # 三方案全开:v1 兼容只认 JAR 签名的老设备/第三方安装器,v2/v3 是 Android 7/9+ 的主验签路径
+ "$AS" sign \
+ --ks signing/traetools.keystore --ks-type PKCS12 \
+ --ks-pass pass:traetools \
+ --ks-key-alias traetools \
+ --key-pass pass:traetools \
+ --v1-signing-enabled true \
+ --v2-signing-enabled true \
+ --v3-signing-enabled true \
+ --out TraeTools-Android.apk aligned.apk
ls -lh TraeTools-Android.apk
# ===== 签名校验门禁 =====
@@ -144,9 +173,11 @@ jobs:
# 退出码是主判据:apksigner 对签名无效/缺方案会返回非 0
"$BT/apksigner" verify --verbose --print-certs TraeTools-Android.apk | tee verify.txt
- # 指纹比对:基准由 rotate-keystore.yml 生成,避免两处手工同步
- EXPECTED=$(tr -d ': \r\n' < signing/EXPECTED_CERT_SHA256.txt 2>/dev/null || true)
- ACTUAL=$(sed -n 's/.*certificate SHA-256 digest: *//p' verify.txt | head -1 | tr -d ': \r\n')
+ # 指纹比对:基准由 rotate-keystore.yml 生成,避免两处手工同步。
+ # 必须做大小写归一——keytool 输出大写、apksigner 输出小写,
+ # 直接字符串比较会因大小写差异误报"不一致"(实测踩过)。
+ EXPECTED=$(tr -cd '0-9A-Fa-f' < signing/EXPECTED_CERT_SHA256.txt 2>/dev/null | tr 'a-f' 'A-F')
+ ACTUAL=$(sed -n 's/.*certificate SHA-256 digest: *//p' verify.txt | head -1 | tr -cd '0-9A-Fa-f' | tr 'a-f' 'A-F')
echo "expected = ${EXPECTED:-<缺失>}"
echo "actual = ${ACTUAL:-<未解析到>}"
[ -n "$EXPECTED" ] || { echo "::error::缺少 signing/EXPECTED_CERT_SHA256.txt"; exit 1; }
From 8386199a307d3615b10d5a2002102d95788630b1 Mon Sep 17 00:00:00 2001
From: akexin <315600306+akexin@users.noreply.github.com>
Date: Wed, 14 Oct 2026 15:54:27 +0800
Subject: [PATCH 32/37] =?UTF-8?q?fix(ci):=20=E7=AD=BE=E5=90=8D=E6=94=B9?=
=?UTF-8?q?=E4=B8=BA=E3=80=8C.NET=20=E6=89=93=E5=8C=85=E5=90=8E=E9=87=8D?=
=?UTF-8?q?=E7=AD=BE=E3=80=8D+=20=E4=BF=AE=E6=AD=A3=E7=AD=BE=E5=90=8D?=
=?UTF-8?q?=E5=9D=97=E8=A7=A3=E6=9E=90=E7=AA=97=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
第二次发布仍被门禁拦下,两处问题:
1. 方案结构校验的读取窗口太小(本次真正的失败点)
原脚本固定读「文件尾部 64KB」,但本包中央目录本身约 73KB,
而 APK Signing Block 位于中央目录**之前**,于是 rfind 落空,
误报 "APK Signing Block: 未找到"、v2/v3 判定为缺失。
(同一次日志里 v1 判定为「存在」,说明上一步的显式三方案签名其实是成功的。)
改为先读尾部 EOCD 取中央目录偏移,再自该偏移向前多读 64KB。
已用 v2.0.0 的实测数据在本地回归:旧逻辑取不到,新逻辑正确解析出
v2 / v3 / source stamp。
2. 自行 zipalign 会把 16KB 页对齐降级为 4KB
上一版改为「未签名包 + zipalign + 签名」,但实测该 build-tools 的 zipalign
不支持 -P 16(16KB 页对齐,Android 15+ 对含原生库的包有要求),
只能回退 -p 4,等于把 .NET 打包器原有的对齐策略降级。
改为:仍由 .NET 打包器签名(它自带对齐流程),然后对其产物**重签**
以强制 v1+v2+v3——重签不触碰 zip 结构,对齐策略保持不变。
两轮失败都是新增门禁抓出来的真实缺陷,不是误报。
---
.github/workflows/release.yml | 73 +++++++++++++++++++++--------------
1 file changed, 44 insertions(+), 29 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5219b34..5f1d5be 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -104,50 +104,43 @@ jobs:
- name: Install Android workload
run: dotnet workload install android
- # Android APK:产出**未签名**包,随后由下面的步骤用仓库内置密钥
- # (signing/traetools.keystore)显式签名。
+ # Android APK:先由 .NET 打包器签名(它会按自己的策略完成 zipalign),
+ # 再**重签**一次以强制三种签名方案齐全。
#
- # 为什么不交给 dotnet publish 的 AndroidKeyStore=true 自动签名:
- # 实测两版行为不一致——v2.0.0 的产物含 v1(JAR) 签名,换密钥后的 v2.0.1
- # 产物 `Verified using v1 scheme: false`,即 v1 是否产出取决于 .NET for Android
- # 的内部启发式(与 apksigner 的默认值/minSdk 判定交互),不可控也不可预期。
- # 显式调用 apksigner 并写死 --v1/--v2/--v3 三个开关,行为才是确定的。
+ # 为什么不自己 zipalign + 签名:.NET 打包出的包已经过它的对齐流程,
+ # 而本机 build-tools 的 zipalign 不支持 -P 16(16KB 页对齐,Android 15+ 对含
+ # 原生库的包有要求),自行 zipalign 会把 16KB 对齐**降级**成 4KB。
+ # 只做重签则完全不触碰 zip 结构,对齐策略保持不变。
#
# EnableWindowsTargeting:多目标项目还原需一并还原 net10.0-windows,ubuntu 上必须显式开启
- - name: Publish (android apk, unsigned)
+ - name: Publish (android apk, signed by dotnet)
run: |
dotnet publish TraeTools.csproj -c Release -f net10.0-android \
-p:EnableWindowsTargeting=true \
-p:AndroidPackageFormat=apk \
- -p:AndroidKeyStore=false \
+ -p:AndroidKeyStore=true \
+ -p:AndroidSigningKeyStore="$PWD/signing/traetools.keystore" \
+ -p:AndroidSigningKeyAlias=traetools \
+ -p:AndroidSigningKeyPass=traetools \
+ -p:AndroidSigningStorePass=traetools \
-o publish-android
echo "== publish-android 产物 =="
find publish-android -name "*.apk" -printf "%s\t%p\n" | sort -rn || true
- - name: Sign APK explicitly with repo keystore (v1 + v2 + v3)
+ - name: Re-sign APK (force v1 + v2 + v3)
run: |
set -euo pipefail
- SRC=$(find publish-android -name "cn.akexin.traetools.apk" | head -1)
- [ -n "$SRC" ] || SRC=$(find publish-android -name "*.apk" ! -iname "*-Signed.apk" | head -1)
- [ -n "$SRC" ] || { echo "::error::未找到未签名 APK"; exit 1; }
- echo "unsigned apk: $SRC"
- ls -lh "$SRC"
+ SRC=$(find publish-android -iname "*-Signed.apk" | head -1)
+ [ -n "$SRC" ] || SRC=$(find publish-android -name "*.apk" | head -1)
+ [ -n "$SRC" ] || { echo "::error::未找到 APK"; exit 1; }
+ echo "input apk: $SRC"
BT=$(ls -d "${ANDROID_HOME:-/usr/local/lib/android/sdk}"/build-tools/* 2>/dev/null | sort -V | tail -1)
- ZA="$BT/zipalign"; AS="$BT/apksigner"
- [ -x "$ZA" ] && [ -x "$AS" ] || { echo "::error::zipalign/apksigner not found"; exit 1; }
-
- # zipalign 必须在签名之前。四参形式对齐到 4 字节并启用页对齐;
- # -P 16 指定 .so 按 16KB 页对齐(Android 15+ 对含原生库的包有要求),
- # 老版本 build-tools 不认识 -P 时回退到经典的 -p 4。
- if "$ZA" -f -p -P 16 4 "$SRC" aligned.apk 2>/dev/null; then
- echo "zipalign 模式: -f -p -P 16 4"
- else
- echo "zipalign 模式: -f -p 4(回退,build-tools 不支持 -P)"
- "$ZA" -f -p 4 "$SRC" aligned.apk
- fi
+ AS="$BT/apksigner"
+ [ -x "$AS" ] || { echo "::error::apksigner not found"; exit 1; }
- # 三方案全开:v1 兼容只认 JAR 签名的老设备/第三方安装器,v2/v3 是 Android 7/9+ 的主验签路径
+ # 不传给 v1 的既有签名文件做剥离:apksigner 重签时自行替换签名块与
+ # META-INF 下的 JAR 签名文件。
"$AS" sign \
--ks signing/traetools.keystore --ks-type PKCS12 \
--ks-pass pass:traetools \
@@ -156,9 +149,19 @@ jobs:
--v1-signing-enabled true \
--v2-signing-enabled true \
--v3-signing-enabled true \
- --out TraeTools-Android.apk aligned.apk
+ --out TraeTools-Android.apk "$SRC"
ls -lh TraeTools-Android.apk
+ # 对齐检查(信息性,不阻断:对齐由 .NET 打包器负责)
+ ZA="$BT/zipalign"
+ if [ -x "$ZA" ]; then
+ if "$ZA" -c -v 4 TraeTools-Android.apk >/dev/null 2>&1; then
+ echo "✅ zipalign 4 字节对齐通过"
+ else
+ echo "::warning::zipalign 检查未通过(信息性,不影响签名有效性)"
+ fi
+ fi
+
# ===== 签名校验门禁 =====
# 旧实现只 `apksigner verify --print-certs | head -8 || true`:既不校验退出码,
# 也不比对指纹,等于「打印一下就放行」。签名一旦出问题(密钥被换、方案缺失、
@@ -210,10 +213,22 @@ jobs:
ok = ok and v1
# --- v2 / v3:APK Signing Block 中的方案 ID ---
+ # ⚠️ 不能只读「文件尾部 64KB」:中央目录本身在本包里就有 ~73KB,
+ # 签名块位于中央目录**之前**,固定窗口会取不到它而误报「缺失」。
+ # 正确做法:先从尾部 EOCD 读中央目录偏移,再自该偏移向前多读 64KB。
total = os.path.getsize(APK)
+ cd_off = total
with open(APK, "rb") as f:
f.seek(max(0, total - 65536))
+ last = f.read()
+ e = last.rfind(b"PK\x05\x06")
+ if e >= 0:
+ cd_off = struct.unpack_from("
Date: Wed, 14 Oct 2026 16:29:06 +0800
Subject: [PATCH 33/37] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20README.md?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index b34eed6..1518b6a 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,9 @@
-# TraeTools
+# TraeTools-Android
> 极简 Trae 签到助手:本地一键签到 · 云端自动托管
> 支持 **Windows 桌面端** 与 **Android 手机端**(APK)
-TraeTools 是对 **TRAE-Checkin** 的重构版,用 Avalonia 重写为现代化跨平台应用(.NET 10)。
+TraeTools-android 是对 **TRAE-Tools** 的重构安卓版,用 Avalonia 重写为现代化跨平台应用(.NET 10)。
**2.0 做了产品减法**:去除用量统计、账号切换与繁复设置,界面收敛为**单页仪表盘**——本地签到、云端托管、最近记录三块,专注、清晰、一次点完。
> **2.0 极简重构(Less is More)**:原「仪表盘 / 用量统计 / 每日签到 / 账号切换 / 云端签到 / 设置」六页导航已合并为**单页 + 二级设置**;用量统计与账号切换两块能力整体下线。已登录账号、签到历史与云端部署状态**完全保留**,升级后无需重新配置。
@@ -114,6 +114,7 @@ dotnet publish TraeTools.csproj -c Release -f net10.0-android -o publish-android
## 致谢
- [TRAE-Checkin](https://github.com/star620/TRAE-Checkin):本项目的前身,云端签到体系(checkin.py + GitHub Actions 流程)源自该仓库
+[TRAETOOLS](https://github.com/star620/TraeTools):本项目只是编译了安卓版本
> 2.0 起已移除对 [Trea-Switch](https://github.com/star620/Trea-Switch)(账号切换 / vault 建档)能力的集成与依赖。
From 1c56376149b6e12e6dae9e845a5cbe59b7246041 Mon Sep 17 00:00:00 2001
From: akexin
Date: Wed, 14 Oct 2026 17:54:40 +0800
Subject: [PATCH 34/37] =?UTF-8?q?feat:=20=E5=A4=9A=E8=B4=A6=E5=8F=B7?=
=?UTF-8?q?=E5=88=87=E6=8D=A2=20+=20=E5=9B=9B=E7=A7=8D=E7=99=BB=E5=BD=95?=
=?UTF-8?q?=E6=96=B9=E5=BC=8F=20+=20=E5=85=AD=E6=B8=A0=E9=81=93=E6=8E=A8?=
=?UTF-8?q?=E9=80=81=20+=20=E6=97=A5=E5=BF=97=E9=A1=B5=20+=20=E5=85=A8?=
=?UTF-8?q?=E5=B1=80=E6=B7=B1=E8=89=B2=E6=A8=A1=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
本轮把「获取登录态」和「结果送达」两端都补全,并解决了长期存在的深色模式失效问题。
本地已用 .NET 10.0.402 SDK 对 net10.0-windows 目标做过 Debug/Release 双配置编译校验
(0 错误 0 警告)。
### 登录方式(原仅内嵌浏览器 / 手动粘贴)
- Services/Login/CredentialImporter.cs:清洗并识别粘贴内容,剥掉
`X-Cloudide-Session=`、`Authorization: Cloud-IDE-JWT`、Cookie/Set-Cookie 头名、
成对引号与折断换行等外壳;JWT 与会话按结构判定;会话自动换取 Token 并保留用于静默续期
- Services/Login/LocalClientImporter.cs:只读扫描本机 TRAE 客户端数据目录
(storage.json / Local Storage/leveldb)提取设备号、Token 与会话,多来源多策略,
失败即给出可执行的下一步建议
- Services/Login/AccountBackup.cs:账号导出/导入。口令派生密钥
(PBKDF2-SHA256 20 万次)+ AES-256-GCM 认证加密,不依赖 DPAPI/Keystore 的机器绑定,
因而可跨设备迁移;导入按账号去重合并,不删除本地已有账号
- Views/LoginWindow:重写为四方式入口 + 内容类型下拉(自动识别/Token/会话)
### 账号切换(2.0 曾整体下线)
- 顶栏账号胶囊(首字头像 + 当前账号名)展开账号列表,点选即切换;
只有一个账号时点击直接进账号管理页
- 设置页账号列表恢复「点选即切换为当前账号」
- 切换后签到引擎、首页状态与设置页账号状态整体重载
### 推送通知(原仅飞书)
- Services/Notify/NotifyDispatcher.cs:统一扇出,渠道彼此隔离,单个失败不阻断其它与签到主流程
- Services/Notify/EmailNotifier.cs:自实现 SMTP。内置 SmtpClient 不支持 465 隐式 TLS,
而国内邮箱普遍使用 465,故自行实现 ssl / starttls / none 三种模式,
并支持 AUTH LOGIN 与 AUTH PLAIN 回退、RFC 2047 中文主题
- Services/Notify/RobotNotifiers.cs:通用 Webhook({title}/{content} 模板,
兼容 Server酱/PushPlus/Bark)、钉钉(含加签 HMAC-SHA256)、企业微信、Telegram
- Services/Notify/SystemNotifier.cs:Windows 主窗口通知 / Android 通知栏
- 设置页新增「推送通知」卡片:总开关 + 六渠道独立开关与配置 + 一键测试全部
### 日志
- Services/AppLog.cs:内存环形缓冲(800 条)+ 按月分文件落盘,
格式 `[时间] [级别] [账号] 消息`,兼容旧格式与崩溃日志格式
- Views/LogView + ViewModels/LogViewModel:最近(本进程实时)/ 全部(历史归档文件)
双视角,可复制、删除单个文件、清空全部
- 「自动滚动」可关闭并记忆偏好:关闭后新日志仍入列表,但视图停在当前位置,
翻看早先记录时不会被不断涌入的新日志推着走
### 深色模式
- 主题改为三态(跟随系统 / 浅色 / 深色),App.ApplyThemeMode 统一切换
- 修复 AppConfig.CloneForStorage 的逐字段白名单拷贝导致 DarkMode 永不落盘
(用户此前「切了深色、重启又变回浅色」的根因),改为 JSON 深拷贝
- LoginWindow / EulaWindow / PromptWindow / InputWindow / PatInputWindow /
DeviceAuthWindow 六处对话框的硬编码浅色全部替换为主题令牌
### 修复的真实缺陷(均由本地编译/静态核对发现)
- AppConfig.CloneForStorage 误用 this.Accounts 而非 copy.Accounts,
会就地加密运行中账号的 Token/Session/DeviceId —— 保存一次设置后,
内存里的 Token 变成 DPAPI 密文,后续所有接口调用发出密文、签到全部失败
- SystemNotifier 桌面端调用 TrayIcon.ShowNotification,但 Avalonia 12 的 TrayIcon
已无此方法(0.10 后移除);改走 WindowNotificationManager,经 App.DesktopNotifier 桥接
- EmailNotifier.SendAsync 返回 Task,而 NotifyDispatcher.WrapAsync
要求 Task,类型不兼容;统一为 NotifyResult
- 剪贴板调用缺少 `using Avalonia.Input.Platform`(扩展方法所在命名空间);
且 Avalonia 12 已把读取入口由 GetTextAsync 改名为 TryGetTextAsync
- LogView/SettingsView 的 XML 注释中出现 `--`,属非法 XML,Avalonia 编译期直接报 AVLN1001
- FeishuWebhook 由明文改为加密后,老配置解密失败会得到 null 而静默清空推送地址;
改为解密失败时保留原值
---
App.axaml | 5 +-
App.axaml.cs | 45 ++-
Models/AccountInfo.cs | 31 ++
Platform/CrashLog.cs | 6 +
Platform/MainActivity.cs | 18 +
Properties/AndroidManifest.xml | 6 +
README.md | 107 ++++-
Services/AppLog.cs | 455 ++++++++++++++++++++++
Services/Checkin/AppConfig.cs | 176 ++++++---
Services/Login/AccountBackup.cs | 266 +++++++++++++
Services/Login/CredentialImporter.cs | 216 +++++++++++
Services/Login/LocalClientImporter.cs | 301 ++++++++++++++
Services/Notify/EmailNotifier.cs | 276 +++++++++++++
Services/Notify/NotifyDispatcher.cs | 149 +++++++
Services/Notify/RobotNotifiers.cs | 240 ++++++++++++
Services/Notify/SystemNotifier.cs | 151 +++++++
ViewModels/AccountHelpers.cs | 24 +-
ViewModels/CheckinViewModel.cs | 24 +-
ViewModels/LogViewModel.cs | 325 ++++++++++++++++
ViewModels/MainViewModel.cs | 157 +++++++-
ViewModels/SettingsViewModel.cs | 540 ++++++++++++++++++++++++--
ViewModels/UiHost.cs | 49 ++-
Views/DeviceAuthWindow.axaml | 14 +-
Views/EulaWindow.axaml | 12 +-
Views/InputWindow.axaml | 8 +-
Views/LogView.axaml | 196 ++++++++++
Views/LogView.axaml.cs | 69 ++++
Views/LoginWindow.axaml | 104 +++--
Views/LoginWindow.axaml.cs | 197 ++++++++--
Views/MainView.axaml | 90 ++++-
Views/MainWindow.axaml.cs | 10 +
Views/PatInputWindow.axaml | 8 +-
Views/PromptWindow.axaml | 2 +-
Views/SettingsView.axaml | 261 ++++++++++++-
34 files changed, 4294 insertions(+), 244 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 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..4bbb791 100644
--- a/App.axaml
+++ b/App.axaml
@@ -1,8 +1,9 @@
+ xmlns:local="using:TraeTools">
+
diff --git a/App.axaml.cs b/App.axaml.cs
index 99f5fbf..e2c598d 100644
--- a/App.axaml.cs
+++ b/App.axaml.cs
@@ -18,16 +18,43 @@ public partial class App : Avalonia.Application
public static TrayIcon? TrayRef;
///
- /// 应用全局主题(亮/暗):切换 RequestedThemeVariant,AppTheme.axaml 中
- /// ThemeDictionaries 内的令牌随之整体切换;设置页深色模式开关实时调用。
+ /// 桌面端「系统本地通知」入口,由 MainWindow 在构造时挂上、关闭时摘掉。
+ ///
+ /// 为什么不直接调 TrayIcon:Avalonia 12 的 TrayIcon **没有** ShowNotification 方法
+ /// (该方法在 0.10 之后已移除),桌面通知统一走 WindowNotificationManager,
+ /// 它绑定在具体窗口上,因此必须由 MainWindow 提供实例。
+ /// 参数为 (标题, 正文)。Android 端不使用此委托(走原生通知栏)。
///
- public static void ApplyTheme(bool dark)
+ public static System.Action? DesktopNotifier;
+
+ ///
+ /// 应用全局主题(三态):ThemeMode 取 "system"(跟随系统)/ "light" / "dark",
+ /// 切换 RequestedThemeVariant 后 AppTheme.axaml 的 ThemeDictionaries 令牌整体生效。
+ /// 设置页的主题选择与启动时各调用一次。
+ ///
+ public static void ApplyThemeMode(string? mode)
{
var app = Current;
if (app == null) return;
- app.RequestedThemeVariant = dark ? ThemeVariant.Dark : ThemeVariant.Light;
+ try
+ {
+ app.RequestedThemeVariant = (mode ?? "system").ToLowerInvariant() switch
+ {
+ "light" => ThemeVariant.Light,
+ "dark" => ThemeVariant.Dark,
+ // ThemeVariant.Default 不是具体配色,而是「交给平台/系统决定」的占位值
+ _ => ThemeVariant.Default,
+ };
+ }
+ catch
+ {
+ // 主题切换失败(极端平台差异)不应影响启动
+ }
}
+ /// 兼容旧调用的布尔重载:true=深色,false=浅色。
+ public static void ApplyTheme(bool dark) => ApplyThemeMode(dark ? "dark" : "light");
+
public override void Initialize()
{
try
@@ -54,8 +81,8 @@ public override void OnFrameworkInitializationCompleted()
{
DataContext = new MainViewModel(),
};
- // 配置在 MainViewModel 构造时加载:视图显示前应用保存的亮/暗主题偏好
- ApplyTheme(MainViewModel.AppConfig?.DarkMode ?? false);
+ // 配置在 MainViewModel 构造时加载:视图显示前应用保存的主题偏好(跟随系统/浅色/深色)
+ ApplyThemeMode(MainViewModel.AppConfig?.ThemeMode);
CrashLog.Boot("App: desktop MainWindow created");
}
#if ANDROID
@@ -79,8 +106,8 @@ public override void OnFrameworkInitializationCompleted()
{
DataContext = new MainViewModel(),
};
- // 配置在 MainViewModel 构造时加载:视图显示前应用保存的亮/暗主题偏好
- ApplyTheme(MainViewModel.AppConfig?.DarkMode ?? false);
+ // 配置在 MainViewModel 构造时加载:视图显示前应用保存的主题偏好(跟随系统/浅色/深色)
+ ApplyThemeMode(MainViewModel.AppConfig?.ThemeMode);
CrashLog.Boot("App: single-view MainView created");
}
catch (System.Exception ex)
@@ -112,7 +139,7 @@ private static Control BuildErrorView(
try
{
lifetime.MainView = new MainView { DataContext = new MainViewModel() };
- ApplyTheme(MainViewModel.AppConfig?.DarkMode ?? false);
+ ApplyThemeMode(MainViewModel.AppConfig?.ThemeMode);
CrashLog.Boot("App: retry create MainView ok");
}
catch (System.Exception ex)
diff --git a/Models/AccountInfo.cs b/Models/AccountInfo.cs
index 573cb67..8c543f1 100644
--- a/Models/AccountInfo.cs
+++ b/Models/AccountInfo.cs
@@ -99,6 +99,37 @@ public class CheckinRecord
: (Result == "—" ? new SolidColorBrush(Avalonia.Media.Color.FromRgb(0x94, 0xA3, 0xB8)) : new SolidColorBrush(Avalonia.Media.Color.FromRgb(0xEF, 0x44, 0x44)));
}
+///
+/// 顶栏账号切换器的列表项。
+/// 刻意做得比 轻:切换器只需要「一眼看清是哪个号、当前选中哪个」,
+/// 不加载头像、不查询签到状态,避免顶栏展开时发起网络请求。
+///
+public sealed class AccountSwitchItem
+{
+ /// 源账号 Id。
+ public required string Id { get; init; }
+ /// 展示名(昵称或脱敏手机号,均无则回退短 Id)。
+ public required string Display { get; init; }
+ /// 副标题:脱敏手机号 / 学生标记等辅助识别信息。
+ public string Subtitle { get; init; } = "";
+ /// 首字母头像占位字符。
+ public string Initial { get; init; } = "?";
+ /// 是否为当前激活账号(决定列表中的高亮与右侧「当前」标记)。
+ public bool IsCurrent { get; init; }
+ /// 今日是否已签到(用轻量符号提示,不触发网络请求)。
+ public bool CheckedInToday { get; init; }
+ ///
+ /// 点击本项切换到该账号的命令(由 MainViewModel 注入)。
+ /// 采用「命令挂在数据项上」而非在模板里向父级 DataContext 反查:
+ /// 后者依赖 $parent 绑定语法,数据模板层级一变就会静默失效。
+ ///
+ public System.Windows.Input.ICommand? SelectCommand { get; set; }
+ /// 是否已签到今日(驱动行内 ✓ 标记的显示)。
+ public bool ShowCheckMark => CheckedInToday;
+ /// 是否有副标题(脱敏手机号等):无数据时整行隐藏,避免出现空行。
+ public bool HasSubtitle => !string.IsNullOrEmpty(Subtitle);
+}
+
/// 签到页账号勾选项:IsChecked 与 TraeAccount.Enabled 双向同步(勾谁签谁,手动/自动一致)。
public sealed partial class AccountCheckItem : ObservableObject
{
diff --git a/Platform/CrashLog.cs b/Platform/CrashLog.cs
index b3f9628..70bf6dc 100644
--- a/Platform/CrashLog.cs
+++ b/Platform/CrashLog.cs
@@ -101,6 +101,9 @@ public static void Boot(string message)
global::Android.Util.Log.Info("TraeTools", message);
#endif
Append($"[BOOT {DateTime.Now:HH:mm:ss.fff}] {message}");
+ // 镜像进统一日志的「最近」缓冲:崩溃/启动轨迹也有自己的落盘文件,
+ // 故此处只进内存不落盘,避免同一事件在两个文件里重复出现
+ TraeTools.Services.AppLog.Mirror(TraeTools.Services.LogLevel.Info, "boot", message);
}
catch { /* 日志失败不影响主流程 */ }
}
@@ -118,6 +121,9 @@ public static void Write(string source, Exception? ex, bool terminating = false)
#endif
Append(sb.ToString());
MarkCrashed(ex);
+ TraeTools.Services.AppLog.Mirror(
+ terminating ? TraeTools.Services.LogLevel.Error : TraeTools.Services.LogLevel.Warn,
+ "crash", $"[{source}] {(ex?.Message ?? "(非异常对象)")}");
}
catch { /* 忽略 */ }
}
diff --git a/Platform/MainActivity.cs b/Platform/MainActivity.cs
index f998cf8..e4fda98 100644
--- a/Platform/MainActivity.cs
+++ b/Platform/MainActivity.cs
@@ -5,6 +5,10 @@
using Avalonia;
using Avalonia.Android;
+// Android 13(API 33)起,发送通知需要 POST_NOTIFICATIONS 权限。
+// 该权限在 Properties/AndroidManifest.xml 中声明(与本项目既有的 INTERNET 声明同一处,
+// 不用 [assembly: UsesPermission] 特性,避免依赖绑定库中特性构造函数的签名)。
+
namespace TraeTools;
/// Android 唯一 Activity:承载 Avalonia 单视图生命周期。
@@ -15,11 +19,18 @@ namespace TraeTools;
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
public class MainActivity : AvaloniaMainActivity
{
+ ///
+ /// 当前 Activity 引用:通知权限申请(RequestPermissions)必须由 Activity 发起,
+ /// 而应用只有这一个 Activity,用静态引用比从 Avalonia 生命周期反查更直接可靠。
+ ///
+ public static MainActivity? Current { get; private set; }
+
protected override void OnCreate(Android.OS.Bundle? savedInstanceState)
{
try
{
base.OnCreate(savedInstanceState);
+ Current = this;
TraeTools.Platform.CrashLog.Boot("MainActivity.OnCreate completed");
}
catch (System.Exception ex)
@@ -29,6 +40,13 @@ protected override void OnCreate(Android.OS.Bundle? savedInstanceState)
TraeTools.Platform.CrashReportActivity.Launch(ex.ToString());
}
}
+
+ protected override void OnDestroy()
+ {
+ // 避免持有已销毁的 Activity 造成内存泄漏
+ if (ReferenceEquals(Current, this)) Current = null;
+ base.OnDestroy();
+ }
}
/// Android Application:提供 Avalonia AppBuilder(Inter 字体)。
diff --git a/Properties/AndroidManifest.xml b/Properties/AndroidManifest.xml
index b83ade4..4fae144 100644
--- a/Properties/AndroidManifest.xml
+++ b/Properties/AndroidManifest.xml
@@ -1,6 +1,12 @@
+
+
极简 Trae 签到助手:本地一键签到 · 云端自动托管
+> 极简 Trae 签到助手:本地一键签到 · 云端自动托管 · 多账号切换 · 多渠道推送
> 支持 **Windows 桌面端** 与 **Android 手机端**(APK)
TraeTools-android 是对 **TRAE-Tools** 的重构安卓版,用 Avalonia 重写为现代化跨平台应用(.NET 10)。
-**2.0 做了产品减法**:去除用量统计、账号切换与繁复设置,界面收敛为**单页仪表盘**——本地签到、云端托管、最近记录三块,专注、清晰、一次点完。
+**2.0 做了产品减法**:去除用量统计与繁复设置,界面收敛为**单页仪表盘**——本地签到、云端托管、最近记录三块,专注、清晰、一次点完。
-> **2.0 极简重构(Less is More)**:原「仪表盘 / 用量统计 / 每日签到 / 账号切换 / 云端签到 / 设置」六页导航已合并为**单页 + 二级设置**;用量统计与账号切换两块能力整体下线。已登录账号、签到历史与云端部署状态**完全保留**,升级后无需重新配置。
+> **2.0 极简重构(Less is More)**:原「仪表盘 / 用量统计 / 每日签到 / 账号切换 / 云端签到 / 设置」六页导航已合并为**单页 + 二级设置**;用量统计整体下线。已登录账号、签到历史与云端部署状态**完全保留**,升级后无需重新配置。
+
+> **2.1 版本(多账号 · 多推送 · 可诊断)**:在保持单页仪表盘的前提下,把 2.0 减掉的多账号能力以更轻的形态加回——顶栏一个账号胶囊即可切换;同时补齐四种登录方式、七条推送渠道、完整日志页与全局深色模式。
+
+## 2.1.0 更新内容
+
+| 能力 | 说明 |
+|---|---|
+| **多账号切换** | 顶栏账号胶囊(头像首字 + 当前账号名)一键展开账号列表,点选即切换;设置页账号列表同样支持点选切换。切换后签到引擎、仪表盘与云端状态整体重载 |
+| **四种获取登录态的方式** | ① 内嵌浏览器自动登录(Windows)② 从剪贴板智能识别导入(自动区分 Token / 会话 Cookie,自动剥掉 `X-Cloudide-Session=`、`Authorization:`、引号与换行等外壳)③ 读取本机 TRAE 客户端(Windows,免手动复制)④ 手动粘贴(可选指定内容类型,手机端主路径) |
+| **会话 Cookie 导入** | 直接粘贴 `X-Cloudide-Session` 即可,程序自动换取 Token 并保留会话用于后续静默续期 |
+| **账号导入 / 导出** | 导出为口令加密的备份文件(PBKDF2-SHA256 20 万次 + AES-256-GCM),跨设备迁移不受 DPAPI / Keystore 机器绑定限制;导入按账号去重合并,不会覆盖本地已有账号 |
+| **七条推送渠道** | 飞书(沿用)、**邮箱 SMTP**(可自定义服务器与端口,支持 465 隐式 TLS / 587 STARTTLS / 25 明文)、**通用 Webhook**(兼容 Server酱 / PushPlus / Bark,请求体模板可改)、**钉钉**(支持加签)、**企业微信**、**Telegram Bot**、**系统本地通知**。渠道间互不影响,单个失败不阻断其它 |
+| **日志页** | 顶栏日志图标进入。分「最近」(本次运行实时日志,随写随到)与「全部」(按模块/月份归档的历史文件 + 崩溃日志,可逐文件查看、删除、清空)两个视角;**自动滚动可开关**(关掉后翻看历史不会被新日志推着走) |
+| **深色模式全局适配** | 主题改为**三态**(跟随系统 / 浅色 / 深色),全部界面与对话框统一走主题令牌 |
> **2026-10 客户端合并公告**:字节跳动已于 2026-10-09 将 TraeWork 与 TraeCode 合并为统一的 **TRAE** 客户端(Agent / IDE 双模式),TraeWork 于 2026-10-23 停服。本工具已同步适配:设备号解析、客户端进程/目录探测均覆盖新旧名称(TRAE / TRAE CN / TRAE SOLO CN),老用户升级客户端后无需任何操作。
## 功能特性
- **本地一键签到**:打开即点,秒签;多账号时出现极简账号勾选行(勾谁签谁,自动签到与此一致)
+- **多账号管理**:顶栏胶囊一键切换当前账号;账号资料、Token、设备号彼此独立,切换后自动重载
- **云端托管签到(永不掉线)**:一键把签到任务部署到你自己的 GitHub fork(GitHub Actions 每天 08:00 运行),无需电脑/手机开机;部署进度以竖向流程清单可视化
-- **极简运行日志**:首页仅展示最近 3 条签到记录,只做追溯、不做统计
+- **四种登录方式**:内嵌浏览器 / 剪贴板智能识别 / 读取本机 TRAE 客户端 / 手动粘贴(含会话 Cookie 导入)
+- **七条推送渠道**:飞书、邮箱 SMTP(可自定义端口)、通用 Webhook、钉钉(加签)、企业微信、Telegram、系统本地通知
+- **日志页**:最近日志与历史归档日志双视角,自动滚动可开关
+- **深色模式**:跟随系统 / 浅色 / 深色 三态可选,界面与对话框全局适配
+- **账号备份**:口令加密导出 / 导入,跨设备迁移账号
- **云端状态徽章**:顶栏常驻「🟢 云端运行中 / 🟡 待部署 / 🔴 云端未部署」,点击展开云端详情
- **账号资料**:展示每个账号的昵称 / 脱敏手机号 / 头像 / 学生认证徽章(在设置中管理)
-- **飞书推送**:签到结果可实时推送到飞书机器人
- **应用更新(不强制)**:内置检查更新,发现新版仅提示;Windows 端经用户确认后才下载并后台替换,Android 端仅引导至下载页手动安装——均不强制,可随时取消
## 界面
| 区域 | 说明 |
|---|---|
-| 顶栏 | 小号加粗标题「签到助手」+ 云端状态徽章(可点击展开详情)+ 设置图标 |
+| 顶栏 | 小号加粗标题「签到助手」+ **账号切换胶囊** + 云端状态徽章(可点击展开详情)+ 日志图标 + 设置图标;进入二级页时左侧变为返回按钮 |
| 本地签到卡片 | 主标题 + 副文案 + 主色胶囊按钮「一键签到」;多账号时附账号勾选 |
| 云端自动签到卡片 | 授权引导 / 竖向部署流程清单(GitHub 授权 → Secret 写入 → 定时任务)+ 一键授权部署 CTA + 次数弱化的「打开 Actions 面板 / 粘贴 Token(PAT)」 |
| 最近记录 | 极简列表,仅最近 3 条(时间 / 账号 / 结果),无卡片包裹,仅淡分割线 |
-| 设置(二级页) | 自动签到配置、Token 信息、飞书推送、账号管理、关于 |
+| 日志(二级页) | 「最近 / 全部」两个标签页 + 自动滚动开关;「全部」侧上方为文件列表、下方为文件内容 |
+| 设置(二级页) | 自动签到配置、主题模式、Token 信息、**推送通知**(七渠道)、账号管理、**账号备份**、关于 |
## 快速开始
@@ -67,16 +86,33 @@ dotnet publish TraeTools.csproj -c Release -f net10.0-android -o publish-android
### 1. 添加 / 登录账号
-- **Windows**:`设置 → 账号管理 → + 添加`:自动打开登录窗口(内嵌浏览器),手机号+验证码登录后自动读取凭证并保存;登录态约 14 天,失效可一键静默续期。
-- **Android**:`设置 → 账号管理 → + 添加` → 「打开浏览器登录」→ 在浏览器登录 trae.cn 后复制 **Cloud-IDE-Token** 粘贴保存(手机端无内嵌浏览器,手动录入为唯一方式)。登录态约 14 天,失效后重新粘贴即可。
+`设置 → 账号管理 → + 添加` 打开登录窗口,共提供四种方式(按平台自动给出可用项):
+
+| 方式 | 平台 | 说明 |
+|---|---|---|
+| 内嵌浏览器自动登录 | Windows | 手机号 + 验证码登录后自动读取凭证并保存,无需手工复制 |
+| 从剪贴板识别导入 | 全平台 | 先复制凭证再点此按钮;自动判定是 Token 还是会话 Cookie,并剥掉 `X-Cloudide-Session=`、`Authorization:`、成对引号、换行等外壳 |
+| 读取本机 TRAE 客户端 | Windows | 直接扫描本机 TRAE 数据目录(`%APPDATA%\TRAE*\User\`、`Local Storage\leveldb`)提取登录态,全程只读 |
+| 手动粘贴 | 全平台(手机端主路径) | 粘贴后保存,可在下拉框指定内容类型(自动识别 / Token / 会话 Cookie) |
-### 2. 本地签到
+- 粘贴**会话 Cookie**(`X-Cloudide-Session` 的值)时,程序会用它换取 Token,并保留会话用于后续静默续期——这是手机端最省事的方式。
+- 登录态约 14 天,失效可一键静默续期。
+- 手机端流程:`+ 添加` → 「打开浏览器登录」→ 在浏览器登录 trae.cn → 复制凭证 → 回到 App 粘贴。
+
+### 2. 切换当前账号
+
+- **顶栏胶囊**:点标题右侧的账号胶囊展开账号列表,点选即切换(当前账号带勾选标记,已签到今日的账号带 ✓)。
+- **设置页**:`设置 → 账号管理` 中直接点选某一行账号同样完成切换。
+- 只有一个账号时点击胶囊会直接跳到账号管理页。
+- 切换后签到引擎、首页状态与云端状态会整体重载,无需重启。
+
+### 3. 本地签到
首页「本地签到」卡片点「一键签到」即可;多个账号时卡片内会出现勾选行,勾选即为要签的账号。也可在设置中开启「每日自动签到」,到点自动执行。每次结果会写入本地历史,并出现在首页「最近记录」。
> Android 端自动签到仅在 App 运行期间有效;需要无人值守签到请使用「云端签到」(GitHub Actions 每日 08:00 执行,与手机状态无关)。
-### 3. 云端签到(部署到 GitHub Actions)
+### 4. 云端签到(部署到 GitHub Actions)
1. 首页「云端自动签到」卡片点击主按钮(首次为「授权 GitHub」;设备码授权,无需密码,仅需 repo 权限)
2. 再次点击(此时为「一键部署到云端」):程序自动 fork 本仓库 → 写入每个账号的 `TRAE_SESSION` / `TRAE_DEVICE_ID` secret → 启用并触发验证运行;部署进度在卡片内的竖向流程清单上实时更新
@@ -84,6 +120,40 @@ dotnet publish TraeTools.csproj -c Release -f net10.0-android -o publish-android
部署原理:fork 仓库中的 `.github/workflows/checkin.yml` 定时运行 `checkin.py`,脚本用 `X-Cloudide-Session` 换取新 JWT 后调用签到接口(仅依赖 Python 标准库)。
+### 5. 推送通知
+
+`设置 → 推送通知`,最上方是总开关(签到完成后是否推送),下面每个渠道各有独立开关与配置。配好后点「测试全部已启用渠道」可一次性验证;邮箱渠道还可在展开区内单独点「发送测试邮件」。
+
+| 渠道 | 需要填写 | 备注 |
+|---|---|---|
+| 飞书 | Webhook 地址 | 沿用 2.0 的配置 |
+| 邮箱 SMTP | 服务器、端口、安全方式、用户名、密码/授权码、发件人、收件人 | 465 走隐式 TLS、587 走 STARTTLS、25 为明文;国内邮箱(QQ/163 等)需填**授权码**而非登录密码 |
+| 通用 Webhook | 地址、请求体模板、Content-Type | 模板中用 `{title}` / `{content}` 占位;一份配置即可兼容 Server酱、PushPlus、Bark 等 |
+| 钉钉 | Webhook 地址、加签密钥(可选) | 机器人若启用「加签」必须填密钥,否则会被拒绝 |
+| 企业微信 | Webhook 地址 | 群机器人地址已含 key,直接粘贴完整地址 |
+| Telegram | Bot Token、Chat ID | `api.telegram.org` 在国内网络通常需代理,失败时会有明确提示 |
+| 系统本地通知 | 无 | Windows 显示在主窗口右下角;Android 走通知栏,13+ 首次开启时会申请通知权限 |
+
+> 各渠道**互不影响**:一个渠道配错不会阻断其它渠道,也不会影响签到本身;每个渠道的成败都会写进日志页。
+
+### 6. 查看日志
+
+点顶栏的日志图标进入(返回按钮回到首页)。
+
+- **最近**:本次运行产生的日志,随写随到;顶部显示条数与「错误 N 警告 M」。可「复制最近」一次性取走全部内容用于排查。
+- **全部**:磁盘上按模块 / 月份归档的历史日志(含崩溃日志),上方选文件、下方看内容;可单个删除,或「清空全部」。
+- **自动滚动**:默认开启;**关闭后**新日志仍会进入列表,但视图停在当前位置——翻看早先记录时不会被不断涌入的新日志推着走。右上角开关即可切换,偏好会记住。
+
+### 7. 账号备份(跨设备迁移)
+
+`设置 → 账号备份`:
+
+1. 填一个**至少 6 位**的口令(它用于加密备份文件,务必记住——口令丢了备份无法恢复)
+2. 点「导出备份」:优先弹出系统保存框存成 `.json`;平台不支持文件选择器时自动退化为「复制到剪贴板」,由你自行粘贴保存
+3. 在另一台设备上:「选择备份文件」或直接把备份内容粘贴到文本框 → 填同一口令 → 「导入账号」
+
+备份文件用 PBKDF2-SHA256(20 万次)派生密钥 + AES-256-GCM 加密,因此**可以安全地经网盘 / 聊天工具传输**;也正是因为不依赖本机的 DPAPI / Keystore,它才能跨设备使用。导入采用合并策略:同一账号刷新凭证,新账号追加,**不会删除本地已有账号**。
+
## 常见问题
| 现象 | 说明 / 处理 |
@@ -94,15 +164,23 @@ dotnet publish TraeTools.csproj -c Release -f net10.0-android -o publish-android
| 签到 0 积分 | 当日已签过(重复签到不加分),或非会员当日基础 150 分已到账 |
| Token 失效 | 程序会用 `X-Cloudide-Session` 静默换新;彻底失效时提示重新登录 |
| Android 安装 APK 被拦截 | 系统设置中允许浏览器/文件管理「安装未知来源应用」后重试 |
-| Android 与 Windows 账号不互通 | 两端加密机制不同(DPAPI / Keystore),数据独立,需分别登录 |
+| Android 与 Windows 账号不互通 | 两端加密机制不同(DPAPI / Keystore),数据独立,需分别登录;可用「账号备份」导出后在另一端导入 |
+| 邮箱推送失败,提示认证错误 | 国内邮箱几乎都需要填**授权码**(在邮箱网页端「设置 → 账户」中生成)而不是登录密码 |
+| 切换账号后要重新登录 | 每个账号的 Token 各自独立;新加的账号需要单独登录一次,之后即可随意切换 |
+| 找不到「读取本机 TRAE 客户端」按钮 | 该方式仅 Windows 可用;未装客户端或客户端未登录时也会给出明确提示 |
+| Telegram 推送超时 | `api.telegram.org` 在国内网络通常不可直连,需自备代理 |
+| 深色模式没有跟着系统变 | 主题是**三态**:`设置 → 主题模式` 选「跟随系统」才会随系统切换;选「浅色 / 深色」则为固定值 |
+| 日志页一直往下跑,看不了上面的内容 | 关掉日志页右上角的「自动滚动」开关即可(偏好会记住) |
> 云端签到凭证 `TRAE_SESSION` 有效期约 14 天,过期后请在软件内重新登录并重新部署。
## 开发者
- 技术栈:Avalonia 12 / .NET 10 / CommunityToolkit.Mvvm / Sodium.Core / WebView2(Windows)
-- 目录:`ViewModels/` 页面逻辑、`Services/` 网络与领域服务、`Views/` 界面、`Models/` 数据模型、`Platform/` 跨平台抽象(DPAPI/Keystore、浏览器启动等)
+- 目录:`ViewModels/` 页面逻辑、`Services/` 网络与领域服务(`Login/` 登录导入与备份、`Notify/` 推送渠道、`AppLog.cs` 统一日志)、`Views/` 界面、`Models/` 数据模型、`Platform/` 跨平台抽象(DPAPI/Keystore、浏览器启动等)
- 多目标:`net10.0-windows`(WinExe 单文件)与 `net10.0-android`(APK),平台差异集中在 `Platform/` 与 `#if ANDROID` 条件编译
+- **主题**:`Styles/AppTheme.axaml` 用 `ThemeDictionaries` 提供 Light/Dark 两套令牌,界面一律用 `{DynamicResource XxxBrush}` 引用,不写死颜色;切换入口是 `App.ApplyThemeMode`
+- **日志**:`Services/AppLog.cs` 为唯一写入点(内存环形缓冲 + 按月分文件落盘),格式 `[时间] [级别] [账号] 消息`,向后兼容旧格式
- 行为默认不对外请求官方非公开接口之外的能力;用量/积分接口为逆向网页接口,字段随官方可能变化,代码均做容错解析
## 免责声明
@@ -116,7 +194,8 @@ dotnet publish TraeTools.csproj -c Release -f net10.0-android -o publish-android
- [TRAE-Checkin](https://github.com/star620/TRAE-Checkin):本项目的前身,云端签到体系(checkin.py + GitHub Actions 流程)源自该仓库
[TRAETOOLS](https://github.com/star620/TraeTools):本项目只是编译了安卓版本
-> 2.0 起已移除对 [Trea-Switch](https://github.com/star620/Trea-Switch)(账号切换 / vault 建档)能力的集成与依赖。
+> 2.0 起已移除对 [Trea-Switch](https://github.com/star620/Trea-Switch)(账号切换 / vault 建档)的**集成与依赖**。
+> 2.1 的多账号切换是重新实现的轻量方案(复用本程序自身的账号模型与 `ActiveAccountId`),不涉及 vault 字节级冷切换,仍未依赖 Trea-Switch。
## 许可证
diff --git a/Services/AppLog.cs b/Services/AppLog.cs
new file mode 100644
index 0000000..62b8d65
--- /dev/null
+++ b/Services/AppLog.cs
@@ -0,0 +1,455 @@
+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(TraeTools.Platform.CrashLog.LogDirectory, list); } catch { /* 崩溃日志目录不可用则跳过 */ }
+ try { Collect(TraeTools.Services.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 30c9358..38f256f 100644
--- a/Services/Checkin/AppConfig.cs
+++ b/Services/Checkin/AppConfig.cs
@@ -48,6 +48,63 @@ public class AppConfig
/// 深色模式:true 时启动即应用暗色主题(设置页可实时切换)。
public bool DarkMode { get; set; }
+ ///
+ /// 主题模式三态:"system"=跟随系统 / "light"=浅色 / "dark"=深色。
+ /// 空值代表旧版配置,由 迁移得到(见 NormalizeTheme)。
+ ///
+ public string ThemeMode { get; set; } = "";
+
+ /// 日志页「自动滚动」偏好(最近日志与全部日志共用)。
+ public bool LogAutoScroll { get; set; } = true;
+
+ // ==================== 推送通知配置 ====================
+
+ /// 签到完成后是否触发推送(总开关,关闭后各渠道一律不发送)。
+ public bool NotifyOnCheckin { get; set; } = true;
+
+ /// 系统本地通知:Windows 通知中心 / Android 通知栏。
+ public bool NotifySystemEnabled { get; set; }
+
+ /// 邮箱(SMTP)推送总开关。
+ public bool EmailEnabled { get; set; }
+ public string SmtpHost { get; set; } = "";
+ /// SMTP 端口:465=隐式 SSL,587=STARTTLS,25=明文。
+ public int SmtpPort { get; set; } = 465;
+ /// 连接安全方式:"ssl"=隐式 TLS / "starttls"=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 推送总开关(兼容 Server酱 / PushPlus / Bark 等)。
+ public bool WebhookEnabled { get; set; }
+ /// 目标地址(含令牌,加密落盘)。
+ 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; }
+ /// 钉钉「加签」密钥(可选;填写后启用 HMAC-SHA256 签名)。
+ 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 +144,9 @@ public static AppConfig Load()
cfg.Save();
}
// 旧明文首次加载:立即加密落盘,避免明文长期留存
- if (needSeal) cfg.Save();
+ // 主题模式迁移:旧配置只有 DarkMode 布尔值,此处补齐三态 ThemeMode
+ bool themeMigrated = cfg.NormalizeTheme();
+ if (needSeal || themeMigrated) cfg.Save();
return cfg;
}
}
@@ -174,60 +233,73 @@ public void Save()
/// 敏感字段加密版本号。
private const string SecretVaultVersionV1 = "dpapi-v1";
- /// 生成一份敏感字段(Token/Session/GitHubToken/DeviceId)已 DPAPI 加密的副本。
+ /// 敏感字段清单(落盘前加密、加载后解密;新增敏感字段只需加到这里)。
+ private static readonly JsonSerializerOptions CloneOptions = new();
+
+ ///
+ /// 生成一份「敏感字段已加密」的落盘副本。
+ ///
+ /// 实现方式为 JSON 深拷贝(而非逐字段白名单拷贝):历史上的白名单写法要求每新增一个
+ /// 配置项都必须在拷贝清单里再写一遍,一旦遗漏该配置就永远无法落盘
+ /// (DarkMode 曾因此丢失,深色偏好重启即失效)。JSON 深拷贝天然覆盖全部属性,
+ /// 从根本上消除这类缺陷;随后只对敏感字段做加密覆盖。
+ ///
private AppConfig CloneForStorage()
{
- return new AppConfig
+ // 先做 JSON 深拷贝:copy 与 this 完全独立,之后的加密只作用在 copy 上
+ var copy = JsonSerializer.Deserialize(JsonSerializer.Serialize(this, CloneOptions), CloneOptions)
+ ?? new AppConfig();
+
+ // ⚠️ 以下所有加密都必须读 copy 自己的值、写 copy 自己的字段。
+ // 这里刻意全部写成 copy.xxx:一旦误写成裸的 Accounts / Token(即 this 的成员),
+ // 就会**就地加密运行中实例**的敏感字段 —— 表现为「保存一次设置之后,
+ // 内存里的 Token 变成 DPAPI 密文,后续所有接口调用发出密文、签到全部失败」。
+ copy.SecretsVaultVersion = SecretVaultVersionV1;
+ copy.Token = EncryptSecret(copy.Token);
+ copy.Session = EncryptSecret(copy.Session);
+ copy.GitHubToken = EncryptSecret(copy.GitHubToken);
+ // DeviceId 非可空:加密失败(Protect 返回 null)时退回明文,而不是写入 null。
+ // 明文 DeviceId 在下次加载时解密会失败并被原样保留,属于可自愈的降级,比写入 null 安全。
+ copy.DeviceId = EncryptSecret(copy.DeviceId) ?? copy.DeviceId;
+ // 推送渠道中内嵌令牌的字段同样按密文落盘
+ copy.FeishuWebhook = EncryptSecret(copy.FeishuWebhook);
+ copy.SmtpPassword = EncryptSecret(copy.SmtpPassword);
+ copy.WebhookUrl = EncryptSecret(copy.WebhookUrl);
+ copy.DingTalkWebhook = EncryptSecret(copy.DingTalkWebhook);
+ copy.DingTalkSecret = EncryptSecret(copy.DingTalkSecret);
+ copy.WeComWebhook = EncryptSecret(copy.WeComWebhook);
+ copy.TelegramBotToken = EncryptSecret(copy.TelegramBotToken);
+
+ // 账号里的敏感字段:逐项覆写 copy.Accounts 中「深拷贝出来的新对象」
+ foreach (var a in copy.Accounts)
{
- SecretsVaultVersion = SecretVaultVersionV1,
- Token = EncryptSecret(Token),
- Session = EncryptSecret(Session),
- TokenUpdatedAt = TokenUpdatedAt,
- GitHubToken = EncryptSecret(GitHubToken),
- GitHubLogin = GitHubLogin,
- FeishuWebhook = FeishuWebhook,
- DeviceId = EncryptSecret(DeviceId),
- AutoCheckinEnabled = AutoCheckinEnabled,
- AutoCheckinTime = AutoCheckinTime,
- CheckinIntervalSeconds = CheckinIntervalSeconds,
- LastCheckinDate = LastCheckinDate,
- LastRemaining = LastRemaining,
- ActiveAccountId = ActiveAccountId,
- WindowLeft = WindowLeft,
- WindowTop = WindowTop,
- WindowWidth = WindowWidth,
- WindowHeight = WindowHeight,
- EulaAccepted = EulaAccepted,
- StarAskedAfterDeploy = StarAskedAfterDeploy,
- MinimizeToTray = MinimizeToTray,
- Accounts = Accounts.Select(a => new TraeAccount
- {
- Id = a.Id,
- Name = a.Name,
- Token = EncryptSecret(a.Token),
- Session = EncryptSecret(a.Session),
- DeviceId = EncryptSecret(a.DeviceId),
- AccountUid = a.AccountUid,
- TokenUpdatedAt = a.TokenUpdatedAt,
- LastCheckinDate = a.LastCheckinDate,
- RemainingCredits = a.RemainingCredits,
- Enabled = a.Enabled,
- IsMember = a.IsMember,
- ScreenName = a.ScreenName,
- MobileMasked = a.MobileMasked,
- AvatarUrl = a.AvatarUrl,
- IsStudent = a.IsStudent,
- }).ToList(),
- };
+ a.Token = EncryptSecret(a.Token);
+ a.Session = EncryptSecret(a.Session);
+ // 同 DeviceId:非可空,加密失败时保留明文而不是赋 null
+ a.DeviceId = EncryptSecret(a.DeviceId) ?? a.DeviceId;
+ }
+
+ return copy;
}
- /// 已加密配置加载后:解密敏感字段回内存(仅 dpapi-v1 生效;旧明文保持原样等迁移)。
+ ///
+ /// 已加密配置加载后:解密敏感字段回内存(仅 dpapi-v1 生效;旧明文保持原样等迁移)。
+ ///
private static void UnsealSecrets(AppConfig cfg)
{
if (cfg.SecretsVaultVersion != SecretVaultVersionV1) return;
cfg.Token = DecryptSecret(cfg.Token);
cfg.Session = DecryptSecret(cfg.Session);
cfg.GitHubToken = DecryptSecret(cfg.GitHubToken);
+ // 飞书 webhook 在旧版本是以明文存放的(当时不在加密清单内),
+ // 因此解密失败时必须保留原值,否则老配置的推送地址会被静默清空。
+ cfg.FeishuWebhook = DecryptSecret(cfg.FeishuWebhook) ?? cfg.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)
@@ -239,6 +311,22 @@ private static void UnsealSecrets(AppConfig cfg)
}
}
+ ///
+ /// 主题模式规整:把旧版仅有的 DarkMode 布尔值迁移为三态字符串;
+ /// 取值非法时回退 "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 加密(当前 Windows 用户作用域)。保护失败抛异常,由调用方兜底(保留旧文件)。
private static string? EncryptSecret(string? plain) => TraeTools.Platform.Secrets.Protect(plain);
diff --git a/Services/Login/AccountBackup.cs b/Services/Login/AccountBackup.cs
new file mode 100644
index 0000000..62c488f
--- /dev/null
+++ b/Services/Login/AccountBackup.cs
@@ -0,0 +1,266 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+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;
+
+ private static readonly JsonSerializerOptions FileJson = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ /// 备份文件容器。
+ private 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; } = "";
+ }
+
+ /// 明文负载。
+ private 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,
+ };
+ var plain = JsonSerializer.SerializeToUtf8Bytes(payload, FileJson);
+
+ 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 = JsonSerializer.Serialize(envelope, FileJson),
+ 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 = JsonSerializer.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 = JsonSerializer.Deserialize(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..e7ec11b
--- /dev/null
+++ b/Services/Notify/RobotNotifiers.cs
@@ -0,0 +1,240 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+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 };
+}
+
+///
+/// 基于 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 = JsonSerializer.Serialize(new
+ {
+ msgtype = "text",
+ text = new { 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 = JsonSerializer.Serialize(new
+ {
+ msgtype = "text",
+ text = new { 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 = JsonSerializer.Serialize(new
+ {
+ chat_id = chatId!.Trim(),
+ text = $"{title}\n{content}",
+ disable_web_page_preview = 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..9c150a0
--- /dev/null
+++ b/Services/Notify/SystemNotifier.cs
@@ -0,0 +1,151 @@
+using System;
+
+namespace TraeTools.Services.Notify;
+
+///
+/// 系统本地通知:Windows 用系统托盘气泡(进通知中心),Android 用通知栏。
+///
+/// 定位说明:这是「本机在跑的时候」的即时反馈,属补充手段——
+/// 云端签到由 GitHub Actions 执行,那一刻本机很可能并未开机,
+/// 因此重要结果仍应依赖邮箱 / Webhook 等推送渠道。
+///
+public static class SystemNotifier
+{
+#if ANDROID
+ private const string ChannelId = "traetools_checkin";
+ private const string ChannelName = "签到通知";
+#endif
+
+ /// 本机通知所需权限是否已具备(Android 13+ 需运行时授权;其余平台恒为 true)。
+ public static bool HasPermission()
+ {
+#if ANDROID
+ try
+ {
+ // Android 13(API 33)起发通知需要 POST_NOTIFICATIONS 运行时权限
+ if (OperatingSystem.IsAndroidVersionAtLeast(33))
+ {
+ var ctx = Android.App.Application.Context;
+ return ctx.CheckSelfPermission("android.permission.POST_NOTIFICATIONS")
+ == Android.Content.PM.Permission.Granted;
+ }
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+#else
+ return true;
+#endif
+ }
+
+ ///
+ /// 申请通知权限(Android 13+)。用户从设置页打开「系统本地通知」开关时调用;
+ /// 其它平台为无操作。返回是否已经/仍然具备权限(授权是异步弹窗,此处只反映发起前的状态)。
+ ///
+ public static bool RequestPermission()
+ {
+#if ANDROID
+ try
+ {
+ if (!OperatingSystem.IsAndroidVersionAtLeast(33)) return true;
+ if (HasPermission()) return true;
+ TraeTools.MainActivity.Current?.RequestPermissions(
+ new[] { "android.permission.POST_NOTIFICATIONS" }, 1001);
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+#else
+ return true;
+#endif
+ }
+
+ /// 发送一条系统本地通知。
+ public static NotifyResult Send(string title, string body)
+ {
+#if ANDROID
+ return SendAndroid(title, body);
+#else
+ return SendDesktop(title, body);
+#endif
+ }
+
+#if !ANDROID
+ ///
+ /// 桌面端:走主窗口的 WindowNotificationManager(右下角通知)。
+ /// 该管理器必须在 UI 线程上创建与调用,签到流程可能跑在后台线程,故此处做线程切换。
+ /// 未持有窗口实例时明确报错,而不是静默失败 —— 避免用户以为「已开启但没收到」。
+ ///
+ private static NotifyResult SendDesktop(string title, string body)
+ {
+ try
+ {
+ var show = TraeTools.App.DesktopNotifier;
+ if (show == null)
+ return NotifyResult.Fail("系统通知不可用:主窗口尚未就绪(请先打开主界面再试)");
+
+ if (Avalonia.Threading.Dispatcher.UIThread.CheckAccess())
+ show(title, body);
+ else
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => show(title, body));
+
+ return NotifyResult.Ok0("已发送系统通知");
+ }
+ catch (Exception ex)
+ {
+ return NotifyResult.Fail("系统通知失败:" + ex.Message);
+ }
+ }
+#endif
+
+#if ANDROID
+ /// Android:创建通知渠道后投递;未授权时给出可执行的提示。
+ private static NotifyResult SendAndroid(string title, string body)
+ {
+ try
+ {
+ var ctx = Android.App.Application.Context;
+ var mgr = ctx.GetSystemService(Android.Content.Context.NotificationService)
+ as Android.App.NotificationManager;
+ if (mgr == null) return NotifyResult.Fail("系统通知服务不可用");
+
+ if (OperatingSystem.IsAndroidVersionAtLeast(26))
+ {
+ // 渠道重复创建是幂等的,Android 会忽略同名渠道的重复定义
+ mgr.CreateNotificationChannel(new Android.App.NotificationChannel(
+ ChannelId, ChannelName, Android.App.NotificationImportance.Default)
+ {
+ Description = "签到结果与云端状态提醒",
+ });
+ }
+
+ if (!HasPermission())
+ return NotifyResult.Fail("未授予通知权限,请到「系统设置 → 应用 → TraeTools → 通知」中开启");
+
+ var builder = new Android.App.Notification.Builder(ctx, ChannelId)
+ .SetContentTitle(title)
+ .SetContentText(body)
+ .SetStyle(new Android.App.Notification.BigTextStyle().BigText(body))
+ // 使用系统内置图标,无需额外打包 drawable 资源
+ .SetSmallIcon(Android.Resource.Drawable.IcDialogInfo)
+ .SetAutoCancel(true);
+
+ mgr.Notify(NextId(), builder.Build());
+ return NotifyResult.Ok0("已发送系统通知");
+ }
+ catch (Exception ex)
+ {
+ return NotifyResult.Fail("系统通知失败:" + ex.Message);
+ }
+ }
+
+ private static int _id = 1000;
+
+ /// 每条通知用递增 ID,避免后一条覆盖前一条(便于查看多次签到结果)。
+ private static int NextId() => System.Threading.Interlocked.Increment(ref _id);
+#endif
+}
diff --git a/ViewModels/AccountHelpers.cs b/ViewModels/AccountHelpers.cs
index c1e7e1a..c455619 100644
--- a/ViewModels/AccountHelpers.cs
+++ b/ViewModels/AccountHelpers.cs
@@ -223,26 +223,16 @@ public static void AppendHistory(TraeCheckin.TraeAccount acc, double gained)
// ==================== 通用分类日志(排查问题用) ====================
- /// 日志文件锁(独立于 HistoryIoLock,避免签到高峰争用)。
- private static readonly object LogLock = new();
-
///
- /// 写入一条分类调试日志到 {category}_log_yyyyMM.txt。
- /// 各模块按 category 归档:checkin / account / cloud / switch / usage。
+ /// 写入一条分类调试日志。
+ ///
+ /// 实现已收敛到统一的 :它同时维护
+ /// 内存环形缓冲(供「日志 → 最近」实时查看)与按月落盘的
+ /// `{category}_log_yyyyMM.txt`(供「日志 → 全部」按文件浏览)。
+ /// 本方法保留原签名,既有大量调用点无需改动。
///
public static void AppLog(string category, string accountName, string message)
- {
- try
- {
- lock (LogLock)
- {
- var logFile = Path.Combine(LogsDir, $"{category}_log_{DateTime.Now:yyyyMM}.txt");
- var name = string.IsNullOrEmpty(accountName) ? "?" : accountName;
- File.AppendAllText(logFile, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{name}] {message}{Environment.NewLine}");
- }
- }
- catch { /* 日志写入失败不影响业务 */ }
- }
+ => TraeTools.Services.AppLog.Info(category, accountName, message);
/// 签到日志快捷方法(兼容已有调用)。
public static void CheckinLog(string accountName, string message)
diff --git a/ViewModels/CheckinViewModel.cs b/ViewModels/CheckinViewModel.cs
index d5af904..5f51a91 100644
--- a/ViewModels/CheckinViewModel.cs
+++ b/ViewModels/CheckinViewModel.cs
@@ -343,7 +343,7 @@ public async Task TryAutoCheckinAsync()
RebuildCheckList(); // 勾选与账号保持同步(新增账号也能进入自动签到)
var (any, results) = await CheckinAllAccountsAsync();
if (any) { StatusMessage = "自动签到完成 ✓"; RefreshTodayState(); ReloadCalendar(); LoadHistory(); }
- if (results.Any(r => r.Ok)) await NotifyFeishuBatchAsync(results); // 全失败不打扰
+ await NotifyBatchAsync(results); // 全失败时不推送(由调度器内部判定)
return any;
}
catch
@@ -354,20 +354,18 @@ public async Task TryAutoCheckinAsync()
- /// 自动签到结束后,把本轮多账号结果批量推送到飞书(对齐 TraeCheckin.NotifyFeishuBatchAsync)。
- private async Task NotifyFeishuBatchAsync(List<(string Name, bool Ok, double Gained, string Reason)> results)
+ ///
+ /// 把本轮多账号签到结果推送到所有已启用的渠道(飞书 / 邮箱 / Webhook / 钉钉 / 企业微信 / Telegram / 系统通知)。
+ ///
+ /// 原先只推飞书,现已改为统一调度:各渠道并行、互不影响,单个渠道失败不会中断其它渠道,
+ /// 也不会影响签到主流程;每个渠道的成败都会写进日志,便于排查。
+ /// 是否推送、推给谁,由设置页各渠道开关与总开关(NotifyOnCheckin)决定。
+ ///
+ private async Task NotifyBatchAsync(List<(string Name, bool Ok, double Gained, string Reason)> results)
{
try
{
- var cfg = MainViewModel.AppConfig;
- if (cfg == null || string.IsNullOrWhiteSpace(cfg.FeishuWebhook) || results.Count == 0) return;
- var sb = new System.Text.StringBuilder();
- sb.AppendLine("Trae 多账号签到结果");
- foreach (var r in results)
- {
- sb.AppendLine((r.Ok ? "✅ " : "⚠️ ") + r.Name + (r.Ok ? $" 获得 {r.Gained:0} 积分" : $" 失败:{r.Reason}"));
- }
- await TraeCheckin.FeishuNotifier.SendTextAsync(cfg.FeishuWebhook, sb.ToString());
+ await TraeTools.Services.Notify.NotifyDispatcher.SendCheckinBatchAsync(MainViewModel.AppConfig, results);
}
catch { /* 推送失败不影响签到 */ }
}
@@ -688,7 +686,7 @@ private async Task DoCheckin()
RefreshTodayState();
ReloadCalendar();
LoadHistory();
- if (results.Count > 0) await NotifyFeishuBatchAsync(results);
+ if (results.Count > 0) await NotifyBatchAsync(results);
if (any) await MainViewModel.NotifyAsyncRefresh();
}
catch (Exception ex)
diff --git a/ViewModels/LogViewModel.cs b/ViewModels/LogViewModel.cs
new file mode 100644
index 0000000..d0fcbf7
--- /dev/null
+++ b/ViewModels/LogViewModel.cs
@@ -0,0 +1,325 @@
+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}"));
+ var clip = UiHost.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 44748fd..cfc5383 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -28,16 +28,135 @@ public partial class MainViewModel : ViewModelBase
{
private readonly CheckinViewModel _checkin;
private readonly SettingsViewModel _settings;
+ private readonly LogViewModel _logs;
/// 单页仪表盘(本地签到 + 云端托管 + 最近记录)。
public HomeViewModel Home { get; }
+ /// 日志页(最近日志 / 全部日志)。
+ public LogViewModel Logs => _logs;
+
[ObservableProperty]
private ViewModelBase? _currentPage;
[ObservableProperty]
private string _currentPageKey = "home";
+ // ==================== 顶栏账号切换器 ====================
+
+ /// 可切换的账号列表(轻量项,不含头像与在线状态)。
+ public System.Collections.ObjectModel.ObservableCollection SwitchAccounts { get; } = new();
+
+ /// 切换器是否展开。
+ [ObservableProperty]
+ private bool _isAccountSwitcherOpen;
+
+ /// 顶栏显示的当前账号名。
+ [ObservableProperty]
+ private string _currentAccountName = "未登录";
+
+ /// 顶栏账号头像占位字符(当前账号名首字)。
+ [ObservableProperty]
+ private string _currentAccountInitial = "?";
+
+ /// 账号数大于 1 时才有切换的必要。
+ [ObservableProperty]
+ private bool _canSwitchAccount;
+
+ /// 点击顶栏账号区:展开/收起切换器(无账号时不展开)。
+ [RelayCommand]
+ private void ToggleAccountSwitcher()
+ {
+ if (!CanSwitchAccount)
+ {
+ // 只有一个账号时点击无意义,直接进设置页的账号管理更实用
+ Navigate("settings");
+ return;
+ }
+ RefreshSwitchAccounts();
+ IsAccountSwitcherOpen = !IsAccountSwitcherOpen;
+ }
+
+ /// 把某个账号设为当前激活账号:签到引擎、仪表盘、云端状态与设置页同步刷新。
+ [RelayCommand]
+ private void SwitchAccount(string? id)
+ {
+ if (string.IsNullOrEmpty(id)) return;
+ var cfg = AppConfig;
+ if (cfg == null) return;
+ if (!cfg.Accounts.Any(a => a.Id == id))
+ {
+ IsAccountSwitcherOpen = false;
+ return;
+ }
+
+ try
+ {
+ if (cfg.ActiveAccountId != id)
+ {
+ cfg.ActiveAccountId = id;
+ cfg.Save();
+ var name = DisplayNameOf(cfg.Accounts.First(a => a.Id == id));
+ AccountHelpers.AppLog("account", name, "切换当前账号");
+ }
+ }
+ catch { /* 保存失败也继续刷新界面 */ }
+
+ IsAccountSwitcherOpen = false;
+ RefreshSwitchAccounts();
+ // 复用既有通知链路:重载签到引擎 + 刷新单页与设置页账号状态
+ NotifyActiveAccountChanged();
+ }
+
+ /// 重建顶栏切换器列表与当前账号文案。
+ public void RefreshSwitchAccounts()
+ {
+ try
+ {
+ var cfg = AppConfig;
+ SwitchAccounts.Clear();
+ if (cfg == null || cfg.Accounts.Count == 0)
+ {
+ CurrentAccountName = "未登录";
+ CurrentAccountInitial = "?";
+ CanSwitchAccount = false;
+ return;
+ }
+
+ var active = cfg.Accounts.FirstOrDefault(a => a.Id == cfg.ActiveAccountId) ?? cfg.Accounts[0];
+ CurrentAccountName = DisplayNameOf(active);
+ CurrentAccountInitial = CurrentAccountName.Length > 0 ? CurrentAccountName[0].ToString() : "?";
+ CanSwitchAccount = cfg.Accounts.Count > 1;
+
+ foreach (var a in cfg.Accounts)
+ {
+ var display = DisplayNameOf(a);
+ var accId = a.Id;
+ SwitchAccounts.Add(new Models.AccountSwitchItem
+ {
+ Id = accId,
+ Display = display,
+ Subtitle = string.IsNullOrEmpty(a.MobileMasked) ? "" : a.MobileMasked!,
+ Initial = display.Length > 0 ? display[0].ToString() : "?",
+ IsCurrent = a.Id == active.Id,
+ CheckedInToday = a.LastCheckinDate.HasValue && a.LastCheckinDate.Value.Date == DateTime.Today,
+ // 每个列表项携带自己的切换命令(闭包捕获账号 Id),避免模板里跨层级绑定父 VM
+ SelectCommand = new RelayCommand(() => SwitchAccount(accId)),
+ });
+ }
+ }
+ catch { /* 列表构建失败保留原值 */ }
+ }
+
+ /// 账号展示名:备注名 → 平台昵称 → 脱敏手机号 → 短 Id。
+ private static string DisplayNameOf(TraeCheckin.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;
+ }
+
///
/// 紧凑模式(手机屏适配):宿主宽度 < 640 逻辑像素时由 MainView 按实际 Bounds 切换。
/// 重构后不再有侧栏/底部标签栏,紧凑模式仅用于收紧顶栏与内容区边距。
@@ -121,15 +240,21 @@ public MainViewModel()
Instance = this;
+ // 把今天已落盘的日志回填进内存缓冲:进程重启后「最近日志」不至于是一片空白
+ try { TraeTools.Services.AppLog.SeedFromTodayFiles(); } catch { /* 回填失败不影响功能 */ }
+
// 引擎实例只创建一份:定时自动签到与页面按钮必须共享同一状态
_checkin = new CheckinViewModel();
var cloud = new CloudViewModel();
_settings = new SettingsViewModel();
+ _logs = new LogViewModel();
Home = new HomeViewModel(_checkin, cloud);
_settings.NavigateRequested += p => Navigate(p);
+ RefreshSwitchAccounts();
+
CurrentPage = Home;
CurrentPageKey = "home";
@@ -152,11 +277,19 @@ public MainViewModel()
public bool IsHomeActive => CurrentPageKey == "home";
public bool IsSettingsActive => CurrentPageKey == "settings";
+ public bool IsLogsActive => CurrentPageKey == "logs";
+
+ /// 是否处于任一二级页(设置 / 日志):决定顶栏显示「返回」还是各入口图标。
+ public bool IsSubPageActive => IsSettingsActive || IsLogsActive;
partial void OnCurrentPageKeyChanged(string value)
{
OnPropertyChanged(nameof(IsHomeActive));
OnPropertyChanged(nameof(IsSettingsActive));
+ OnPropertyChanged(nameof(IsLogsActive));
+ OnPropertyChanged(nameof(IsSubPageActive));
+ // 切换器在离开首页时收起,避免残留展开态
+ if (value != "home") IsAccountSwitcherOpen = false;
}
/// 账号管理切换激活账号后:签到引擎重载 + 单页日志与状态同步。
@@ -167,6 +300,7 @@ public static async void NotifyActiveAccountChanged()
{
vm._checkin.Reload();
vm.Home.Refresh();
+ vm.RefreshSwitchAccounts();
await vm._settings.RefreshAccountStatesAsync();
}
catch { /* 异步刷新失败不应导致崩溃 */ }
@@ -217,11 +351,12 @@ public async Task QuickCheckinAsync()
}
acc.LastCheckinDate = DateTime.Now;
cfg.LastCheckinDate = DateTime.Now;
+ double gained = 0;
try
{
// 解析本次所得并写入签到历史(与签到页/自动签到同口径)
var after = await api.GetStatusAsync(acc.Token, acc.DeviceId);
- double gained = TraeCheckin.CheckinEvaluator.ResolveGainedCredits(after ?? result, acc.IsMember);
+ gained = TraeCheckin.CheckinEvaluator.ResolveGainedCredits(after ?? result, acc.IsMember);
AccountHelpers.AppendHistory(acc, gained);
AccountHelpers.CheckinLog(name, $"[托盘快签] 签到成功,获得 {gained} 积分");
}
@@ -238,6 +373,16 @@ public async Task QuickCheckinAsync()
catch { /* 积分刷新失败不影响签到结果 */ }
try { cfg.Save(); } catch { /* 忽略 */ }
Home.RefreshLogs();
+ // 托盘快签同样走统一推送管道(受设置页各渠道开关与总开关约束)
+ try
+ {
+ await TraeTools.Services.Notify.NotifyDispatcher.SendCheckinBatchAsync(
+ cfg, new List<(string Name, bool Ok, double Gained, string Reason)>
+ {
+ (name, true, gained, ""),
+ });
+ }
+ catch { /* 推送失败不影响签到结果 */ }
return true;
}
catch
@@ -247,7 +392,7 @@ public async Task QuickCheckinAsync()
}
///
- /// 导航:重构后仅有两个目的地——首页(单页仪表盘)与设置(二级页)。
+ /// 导航:三个目的地——首页(单页仪表盘)、设置(二级页)、日志(二级页)。
/// 其余页面(仪表盘/用量/签到/切换/云端)已从产品移除。
///
[RelayCommand]
@@ -260,8 +405,16 @@ private void Navigate(string page)
CurrentPage = _settings;
CurrentPageKey = "settings";
break;
+ case "logs":
+ _logs.Subscribe();
+ _logs.ReloadRecent();
+ _logs.RefreshFiles();
+ CurrentPage = _logs;
+ CurrentPageKey = "logs";
+ break;
default:
Home.Refresh(); // 每次回到首页都按当前账号真实验证刷新
+ RefreshSwitchAccounts();
CurrentPage = Home;
CurrentPageKey = "home";
break;
diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs
index 06491dd..6c60a60 100644
--- a/ViewModels/SettingsViewModel.cs
+++ b/ViewModels/SettingsViewModel.cs
@@ -32,19 +32,30 @@ public partial class SettingsViewModel : ViewModelBase
[ObservableProperty]
private bool _minimizeToTray = true;
- /// 深色模式:切换立即应用全局主题并持久化到 AppConfig(App.ApplyTheme)。
+ ///
+ /// 主题模式选择索引:0=跟随系统 / 1=浅色 / 2=深色。
+ /// 用索引而非字符串绑定,避免 ComboBox 需要 ItemTemplate 与 SelectedItem 比对逻辑。
+ ///
[ObservableProperty]
- private bool _darkModeEnabled;
+ private int _themeModeIndex;
- partial void OnDarkModeEnabledChanged(bool value)
+ partial void OnThemeModeIndexChanged(int value)
{
+ var mode = value switch
+ {
+ 1 => "light",
+ 2 => "dark",
+ _ => "system",
+ };
try
{
- App.ApplyTheme(value);
+ App.ApplyThemeMode(mode);
var cfg = MainViewModel.AppConfig;
- if (cfg != null && cfg.DarkMode != value)
+ if (cfg != null && cfg.ThemeMode != mode)
{
- cfg.DarkMode = value;
+ cfg.ThemeMode = mode;
+ // 同步旧字段:若用户回退到不识别 ThemeMode 的旧版本,亮/暗仍能保持一致
+ cfg.DarkMode = mode == "dark";
cfg.Save();
}
}
@@ -72,8 +83,90 @@ private static string MaskToken(string? token)
[ObservableProperty]
private string _feishuWebhook = "";
+ // ==================== 推送通知配置 ====================
+
+ /// 签到完成后是否推送(总开关,关闭后所有渠道静默)。
+ [ObservableProperty]
+ private bool _notifyOnCheckin = true;
+
+ /// 系统本地通知开关(Windows 通知中心 / Android 通知栏)。
+ [ObservableProperty]
+ private bool _notifySystemEnabled;
+
+ /// 打开系统通知时顺带申请 Android 13+ 的通知权限。
+ partial void OnNotifySystemEnabledChanged(bool value)
+ {
+ if (!value) return;
+ try
+ {
+ if (!TraeTools.Services.Notify.SystemNotifier.RequestPermission())
+ PushStatus = "已尝试申请通知权限,请在弹出的系统对话框中允许";
+ }
+ catch { /* 权限申请失败不影响开关本身 */ }
+ }
+
+ // ---- 邮箱(SMTP) ----
[ObservableProperty]
- private bool _pushEnabled = true;
+ private bool _emailEnabled;
+ [ObservableProperty]
+ private string _smtpHost = "";
+ [ObservableProperty]
+ private int _smtpPort = 465;
+ /// 连接安全方式索引:0=SSL(465) / 1=STARTTLS(587) / 2=无加密(25)。
+ [ObservableProperty]
+ private int _smtpSecurityIndex;
+ [ObservableProperty]
+ private string _smtpUser = "";
+ [ObservableProperty]
+ private string _smtpPassword = "";
+ [ObservableProperty]
+ private string _smtpFrom = "";
+ [ObservableProperty]
+ private string _smtpTo = "";
+
+ // ---- 通用 Webhook ----
+ [ObservableProperty]
+ private bool _webhookEnabled;
+ [ObservableProperty]
+ private string _webhookUrl = "";
+ [ObservableProperty]
+ private string _webhookBodyTemplate = "{\"title\":\"{title}\",\"content\":\"{content}\"}";
+ [ObservableProperty]
+ private string _webhookContentType = "application/json";
+
+ // ---- 钉钉 / 企业微信 ----
+ [ObservableProperty]
+ private bool _dingTalkEnabled;
+ [ObservableProperty]
+ private string _dingTalkWebhook = "";
+ [ObservableProperty]
+ private string _dingTalkSecret = "";
+ [ObservableProperty]
+ private bool _weComEnabled;
+ [ObservableProperty]
+ private string _weComWebhook = "";
+
+ // ---- Telegram ----
+ [ObservableProperty]
+ private bool _telegramEnabled;
+ [ObservableProperty]
+ private string _telegramBotToken = "";
+ [ObservableProperty]
+ private string _telegramChatId = "";
+
+ // ==================== 账号备份 ====================
+
+ /// 备份口令(同时用于导出加密与导入解密,不落盘、仅本次会话有效)。
+ [ObservableProperty]
+ private string _backupPassphrase = "";
+
+ /// 备份内容文本:导出时填入结果,导入时可手工粘贴。
+ [ObservableProperty]
+ private string _backupText = "";
+
+ /// 是否正在执行备份读写(防重复触发)。
+ [ObservableProperty]
+ private bool _backupBusy;
[ObservableProperty]
private string _pushStatus = "";
@@ -156,7 +249,13 @@ public SettingsViewModel()
AutoCheckinTime = cfg.AutoCheckinTime;
CheckinIntervalSeconds = Math.Clamp(cfg.CheckinIntervalSeconds > 0 ? cfg.CheckinIntervalSeconds : 5, 1, 60);
MinimizeToTray = cfg.MinimizeToTray;
- DarkModeEnabled = cfg.DarkMode;
+ ThemeModeIndex = cfg.ThemeMode switch
+ {
+ "light" => 1,
+ "dark" => 2,
+ _ => 0,
+ };
+ LoadNotifySettings(cfg);
var acc = cfg.Accounts.FirstOrDefault(a => a.Id == cfg.ActiveAccountId)
?? cfg.Accounts.FirstOrDefault();
@@ -263,8 +362,11 @@ private void LoadAvatars()
}
///
- /// 选中账号变化 → Token 面板联动显示该账号的凭证与更新时间;
- /// 同时把该账号设为全局激活账号,仪表盘/签到页随之切换显示。
+ /// 选中账号变化 → Token 面板联动显示该账号的凭证与更新时间,
+ /// 并把该账号设为**全局激活账号**(首页仪表盘、签到引擎、云端状态随之切换)。
+ ///
+ /// 说明:2.0 曾把这里改成「仅查看、不切换」,导致设置页无法切换账号;
+ /// 现按需求恢复为点击即切换——设置页是账号列表的天然入口,多一个入口对低频操作更友好。
///
partial void OnSelectedAccountChanged(AccountInfo? value)
{
@@ -277,7 +379,15 @@ partial void OnSelectedAccountChanged(AccountInfo? value)
TokenString = MaskToken(acc.Token);
TokenUpdateTime = acc.TokenUpdatedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "—";
DeviceIdText = acc.DeviceId;
- // 设置页仅用于查看/编辑该账号,不再切换全局激活账号
+
+ // 切换全局激活账号(仅当确有变化时才保存与刷新,避免无谓的连锁刷新)
+ if (cfg != null && cfg.ActiveAccountId != acc.Id)
+ {
+ cfg.ActiveAccountId = acc.Id;
+ try { cfg.Save(); } catch { /* 保存失败不影响界面联动 */ }
+ foreach (var item in Accounts) item.IsCurrent = item.Id == acc.Id;
+ MainViewModel.NotifyActiveAccountChanged();
+ }
}
else
{
@@ -424,13 +534,11 @@ private void SaveSettings()
var cfg = MainViewModel.AppConfig;
if (cfg != null)
{
- cfg.AutoCheckinEnabled = AutoCheckinEnabled;
- cfg.AutoCheckinTime = AutoCheckinTime;
- cfg.CheckinIntervalSeconds = Math.Clamp(CheckinIntervalSeconds, 1, 60);
- cfg.FeishuWebhook = FeishuWebhook;
- cfg.MinimizeToTray = MinimizeToTray;
+ ApplyToConfig(cfg);
cfg.Save();
- AccountHelpers.AppLog("account", "", $"设置已保存:自动签到={AutoCheckinEnabled},时间={AutoCheckinTime},间隔={cfg.CheckinIntervalSeconds}秒,托盘={MinimizeToTray}");
+ AccountHelpers.AppLog("account", "",
+ $"设置已保存:自动签到={AutoCheckinEnabled},时间={AutoCheckinTime},间隔={cfg.CheckinIntervalSeconds}秒,托盘={MinimizeToTray}," +
+ $"推送={DescribeEnabledChannels(cfg)}");
}
PushStatus = "已保存 ✓";
@@ -441,56 +549,201 @@ private void SaveSettings()
}
}
+ /// 把设置页的全部字段写回配置对象(不落盘,由调用方决定何时 Save)。
+ private void ApplyToConfig(AppConfig cfg)
+ {
+ cfg.AutoCheckinEnabled = AutoCheckinEnabled;
+ cfg.AutoCheckinTime = AutoCheckinTime;
+ cfg.CheckinIntervalSeconds = Math.Clamp(CheckinIntervalSeconds, 1, 60);
+ cfg.FeishuWebhook = FeishuWebhook;
+ cfg.MinimizeToTray = MinimizeToTray;
+
+ cfg.NotifyOnCheckin = NotifyOnCheckin;
+ cfg.NotifySystemEnabled = NotifySystemEnabled;
+
+ cfg.EmailEnabled = EmailEnabled;
+ cfg.SmtpHost = SmtpHost.Trim();
+ cfg.SmtpPort = SmtpPort <= 0 ? 465 : SmtpPort;
+ cfg.SmtpSecurity = SmtpSecurityIndex switch
+ {
+ 1 => "starttls",
+ 2 => "none",
+ _ => "ssl",
+ };
+ cfg.SmtpUser = SmtpUser.Trim();
+ cfg.SmtpPassword = SmtpPassword;
+ cfg.SmtpFrom = SmtpFrom.Trim();
+ cfg.SmtpTo = SmtpTo.Trim();
+
+ cfg.WebhookEnabled = WebhookEnabled;
+ cfg.WebhookUrl = WebhookUrl.Trim();
+ cfg.WebhookBodyTemplate = WebhookBodyTemplate;
+ cfg.WebhookContentType = WebhookContentType.Trim();
+
+ cfg.DingTalkEnabled = DingTalkEnabled;
+ cfg.DingTalkWebhook = DingTalkWebhook.Trim();
+ cfg.DingTalkSecret = DingTalkSecret.Trim();
+
+ cfg.WeComEnabled = WeComEnabled;
+ cfg.WeComWebhook = WeComWebhook.Trim();
+
+ cfg.TelegramEnabled = TelegramEnabled;
+ cfg.TelegramBotToken = TelegramBotToken.Trim();
+ cfg.TelegramChatId = TelegramChatId.Trim();
+ }
+
+ /// 从配置对象读回全部推送相关字段。
+ private void LoadNotifySettings(AppConfig cfg)
+ {
+ NotifyOnCheckin = cfg.NotifyOnCheckin;
+ NotifySystemEnabled = cfg.NotifySystemEnabled;
+
+ EmailEnabled = cfg.EmailEnabled;
+ SmtpHost = cfg.SmtpHost ?? "";
+ SmtpPort = cfg.SmtpPort > 0 ? cfg.SmtpPort : 465;
+ SmtpSecurityIndex = (cfg.SmtpSecurity ?? "ssl").ToLowerInvariant() switch
+ {
+ "starttls" => 1,
+ "none" => 2,
+ _ => 0,
+ };
+ SmtpUser = cfg.SmtpUser ?? "";
+ SmtpPassword = cfg.SmtpPassword ?? "";
+ SmtpFrom = cfg.SmtpFrom ?? "";
+ SmtpTo = cfg.SmtpTo ?? "";
+
+ WebhookEnabled = cfg.WebhookEnabled;
+ WebhookUrl = cfg.WebhookUrl ?? "";
+ if (!string.IsNullOrWhiteSpace(cfg.WebhookBodyTemplate)) WebhookBodyTemplate = cfg.WebhookBodyTemplate;
+ if (!string.IsNullOrWhiteSpace(cfg.WebhookContentType)) WebhookContentType = cfg.WebhookContentType;
+
+ DingTalkEnabled = cfg.DingTalkEnabled;
+ DingTalkWebhook = cfg.DingTalkWebhook ?? "";
+ DingTalkSecret = cfg.DingTalkSecret ?? "";
+
+ WeComEnabled = cfg.WeComEnabled;
+ WeComWebhook = cfg.WeComWebhook ?? "";
+
+ TelegramEnabled = cfg.TelegramEnabled;
+ TelegramBotToken = cfg.TelegramBotToken ?? "";
+ TelegramChatId = cfg.TelegramChatId ?? "";
+ }
+
+ /// 把当前启用的渠道拼成一句用于日志/状态条的描述。
+ private string DescribeEnabledChannels(AppConfig cfg)
+ {
+ var list = new System.Collections.Generic.List();
+ if (!string.IsNullOrWhiteSpace(cfg.FeishuWebhook)) list.Add("飞书");
+ if (cfg.EmailEnabled) list.Add("邮箱");
+ if (cfg.WebhookEnabled) list.Add("Webhook");
+ if (cfg.DingTalkEnabled) list.Add("钉钉");
+ if (cfg.WeComEnabled) list.Add("企业微信");
+ if (cfg.TelegramEnabled) list.Add("Telegram");
+ if (cfg.NotifySystemEnabled) list.Add("系统通知");
+ return list.Count == 0 ? "无" : string.Join("、", list);
+ }
+
+ ///
+ /// 测试推送:先把界面上的当前填写值写进配置(否则测的是上次保存的旧值,容易误判),
+ /// 再向所有已启用渠道发送一条测试消息,并逐渠道汇总结果。
+ ///
[RelayCommand]
- private async Task CopyToken()
+ private async Task TestNotifyAll()
{
+ var cfg = MainViewModel.AppConfig;
+ if (cfg == null)
+ {
+ PushStatus = "配置未就绪";
+ return;
+ }
try
{
- var cfg = MainViewModel.AppConfig;
- var acc = cfg?.Accounts.FirstOrDefault(a => a.Id == SelectedAccount?.Id);
- var token = acc?.Token;
- if (string.IsNullOrEmpty(token))
+ ApplyToConfig(cfg);
+ var enabled = DescribeEnabledChannels(cfg);
+ if (enabled == "无")
{
- PushStatus = "当前账号尚未登录,无 Token 可复制";
+ PushStatus = "尚未启用任何推送渠道";
return;
}
- var top = UiHost.MainWindow;
- if (top?.Clipboard is { } cb)
+
+ PushStatus = $"正在测试 {enabled}…";
+ var outcomes = await TraeTools.Services.Notify.NotifyDispatcher.SendAsync(
+ cfg, "TraeTools 推送测试",
+ $"这是一条测试消息,用于确认推送渠道配置正确。\n发送时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}");
+
+ PushStatus = TraeTools.Services.Notify.NotifyDispatcher.Summarize(outcomes);
+ }
+ catch (Exception ex)
+ {
+ PushStatus = "测试异常:" + ex.Message;
+ }
+ }
+
+ /// 测试邮件:只测邮箱渠道,便于在配置 SMTP 时快速验证(不必等其它渠道)。
+ [RelayCommand]
+ private async Task TestEmail()
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(SmtpHost))
{
- await cb.SetTextAsync(token);
- PushStatus = "Token 已复制到剪贴板";
+ PushStatus = "请先填写 SMTP 服务器地址";
+ return;
}
- else
+ if (string.IsNullOrWhiteSpace(SmtpTo))
{
- PushStatus = "无法访问剪贴板";
+ PushStatus = "请先填写收件人";
+ return;
}
+ PushStatus = "正在发送测试邮件…";
+ var r = await TraeTools.Services.Notify.EmailNotifier.SendAsync(
+ SmtpHost.Trim(), SmtpPort <= 0 ? 465 : SmtpPort,
+ SmtpSecurityIndex switch { 1 => "starttls", 2 => "none", _ => "ssl" },
+ SmtpUser.Trim(), SmtpPassword, SmtpFrom.Trim(), SmtpTo.Trim(),
+ "TraeTools 邮件推送测试",
+ $"这是一条测试邮件。\n发送时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}");
+ PushStatus = r.Ok ? "测试邮件已发送 ✓" : r.Message;
}
catch (Exception ex)
{
- PushStatus = "复制失败:" + ex.Message;
+ PushStatus = "邮件测试异常:" + ex.Message;
}
}
[RelayCommand]
- private async Task TestPush()
+ private async Task CopyToken()
{
try
{
- if (string.IsNullOrEmpty(FeishuWebhook))
+ var cfg = MainViewModel.AppConfig;
+ var acc = cfg?.Accounts.FirstOrDefault(a => a.Id == SelectedAccount?.Id);
+ var token = acc?.Token;
+ if (string.IsNullOrEmpty(token))
{
- PushStatus = "Webhook 为空";
+ PushStatus = "当前账号尚未登录,无 Token 可复制";
return;
}
- bool ok = await FeishuNotifier.SendTextAsync(FeishuWebhook, "【TraeTools】飞书推送测试消息");
- PushStatus = ok ? "测试成功 ✓" : "测试失败";
+ var cb = UiHost.Clipboard;
+ if (cb != null)
+ {
+ await cb.SetTextAsync(token);
+ PushStatus = "Token 已复制到剪贴板";
+ }
+ else
+ {
+ PushStatus = "无法访问剪贴板";
+ }
}
- catch
+ catch (Exception ex)
{
- PushStatus = "测试异常";
+ PushStatus = "复制失败:" + ex.Message;
}
}
- /// 添加/登录账号:打开登录窗口(内嵌 WebView2 自动登录优先),成功后写入配置并更新列表。
+ ///
+ /// 添加/登录账号:打开登录窗口(四种方式:内嵌浏览器自动登录 / 剪贴板识别 /
+ /// 读取本机客户端 / 手动粘贴),成功后写入配置并更新列表。
+ ///
[RelayCommand]
private async Task AddAccount()
{
@@ -543,7 +796,9 @@ private async Task AddAccount()
await AccountHelpers.RefreshProfileAsync(acc, force: true);
PushStatus = "账号登录成功 ✓,Token 已保存";
PopulateAccounts();
- // 新账号加入后让仪表盘/用量等全局页立即呈现新账号
+ // 让列表选中新账号:Token 面板与「当前」标记立即对齐(会顺带把新账号设为激活)
+ SelectedAccount = Accounts.FirstOrDefault(a => a.Id == acc.Id) ?? SelectedAccount;
+ // 新账号加入后让仪表盘/顶栏切换器等全局页立即呈现新账号
MainViewModel.NotifyActiveAccountChanged();
}
else
@@ -606,6 +861,213 @@ private void DeleteAccount()
PushStatus = "删除账号失败:" + ex.Message;
}
}
+ // ==================== 账号备份(导出 / 导入) ====================
+
+ ///
+ /// 导出全部账号为加密备份。
+ /// 优先弹系统文件保存框;平台不支持文件选择器时退化为「复制到剪贴板」,
+ /// 保证任何平台都能把备份内容取走。
+ ///
+ [RelayCommand]
+ private async Task ExportAccounts()
+ {
+ if (BackupBusy) return;
+ var cfg = MainViewModel.AppConfig;
+ if (cfg == null)
+ {
+ PushStatus = "配置未就绪";
+ return;
+ }
+ if (BackupPassphrase.Length < 6)
+ {
+ PushStatus = "请先填写备份口令(至少 6 位),它用于加密备份文件";
+ return;
+ }
+
+ BackupBusy = true;
+ try
+ {
+ var r = TraeTools.Services.Login.AccountBackup.Export(cfg.Accounts, BackupPassphrase);
+ if (!r.Ok || r.Text == null)
+ {
+ PushStatus = r.Message;
+ return;
+ }
+ BackupText = r.Text;
+
+ // 尝试走系统文件保存框
+ try
+ {
+ var sp = UiHost.Root?.StorageProvider;
+ if (sp is { CanSave: true })
+ {
+ var file = await sp.SaveFilePickerAsync(new Avalonia.Platform.Storage.FilePickerSaveOptions
+ {
+ Title = "保存账号备份",
+ SuggestedFileName = TraeTools.Services.Login.AccountBackup.SuggestedFileName(),
+ DefaultExtension = "json",
+ FileTypeChoices = new[]
+ {
+ new Avalonia.Platform.Storage.FilePickerFileType("TraeTools 账号备份")
+ {
+ Patterns = new[] { "*.json" },
+ },
+ },
+ });
+ if (file != null)
+ {
+ await using var stream = await file.OpenWriteAsync();
+ stream.SetLength(0);
+ await using var writer = new System.IO.StreamWriter(stream, System.Text.Encoding.UTF8);
+ await writer.WriteAsync(r.Text);
+ PushStatus = $"备份已保存:{file.Name}({r.Imported} 个账号)";
+ AccountHelpers.AppLog("account", "", $"导出账号备份:{r.Imported} 个账号 → {file.Name}");
+ return;
+ }
+ PushStatus = "已取消保存";
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ AccountHelpers.AppLog("account", "", "文件保存框不可用,退化为剪贴板:" + ex.Message);
+ }
+
+ // 退化方案:复制到剪贴板
+ var clip = UiHost.Clipboard;
+ if (clip != null)
+ {
+ await clip.SetTextAsync(r.Text);
+ PushStatus = $"文件保存不可用,备份内容({r.Imported} 个账号)已复制到剪贴板,请自行粘贴保存到 .json 文件";
+ }
+ else
+ {
+ PushStatus = "备份已生成但无法保存:文件选择器与剪贴板均不可用";
+ }
+ }
+ catch (Exception ex)
+ {
+ PushStatus = "导出失败:" + ex.Message;
+ }
+ finally
+ {
+ BackupBusy = false;
+ }
+ }
+
+ /// 选择备份文件并把内容读进文本框(随后由「导入」按钮统一处理)。
+ [RelayCommand]
+ private async Task PickBackupFile()
+ {
+ try
+ {
+ var sp = UiHost.Root?.StorageProvider;
+ if (sp is not { CanOpen: true })
+ {
+ PushStatus = "当前平台不支持文件选择器,请把备份内容直接粘贴到下方文本框";
+ return;
+ }
+ var files = await sp.OpenFilePickerAsync(new Avalonia.Platform.Storage.FilePickerOpenOptions
+ {
+ Title = "选择账号备份文件",
+ AllowMultiple = false,
+ FileTypeFilter = new[]
+ {
+ new Avalonia.Platform.Storage.FilePickerFileType("TraeTools 账号备份")
+ {
+ Patterns = new[] { "*.json", "*.txt" },
+ },
+ },
+ });
+ if (files.Count == 0)
+ {
+ PushStatus = "已取消选择";
+ return;
+ }
+ var file = files[0];
+ await using var stream = await file.OpenReadAsync();
+ using var reader = new System.IO.StreamReader(stream, System.Text.Encoding.UTF8);
+ BackupText = await reader.ReadToEndAsync();
+ PushStatus = $"已读取备份文件:{file.Name}(请填写口令后点「导入账号」)";
+ }
+ catch (Exception ex)
+ {
+ PushStatus = "读取备份文件失败:" + ex.Message;
+ }
+ }
+
+ /// 用文本框中的备份内容 + 口令执行导入(合并去重,不覆盖本地已有账号)。
+ [RelayCommand]
+ private async Task ImportAccounts()
+ {
+ if (BackupBusy) return;
+ var cfg = MainViewModel.AppConfig;
+ if (cfg == null)
+ {
+ PushStatus = "配置未就绪";
+ return;
+ }
+ if (string.IsNullOrWhiteSpace(BackupText))
+ {
+ PushStatus = "请先选择备份文件,或把备份内容粘贴到文本框";
+ return;
+ }
+ if (string.IsNullOrEmpty(BackupPassphrase))
+ {
+ PushStatus = "请输入导出该备份时使用的口令";
+ return;
+ }
+
+ // 覆盖式导入会移除本地账号,属于高风险操作,先确认
+ var ask = new PromptWindow(
+ "导入账号备份",
+ $"将把备份中的账号合并到本机(同账号刷新凭证,新账号追加),不会删除现有账号。\n\n" +
+ $"当前本机已有 {cfg.Accounts.Count} 个账号。\n\n是否继续?",
+ "导入", "取消");
+ bool ok = await OverlayDialog.ShowAsync(ask, UiHost.MainWindow);
+ if (!ok)
+ {
+ PushStatus = "已取消导入";
+ return;
+ }
+
+ BackupBusy = true;
+ try
+ {
+ var (readOk, message, accounts) = TraeTools.Services.Login.AccountBackup.Read(BackupText, BackupPassphrase);
+ if (!readOk || accounts.Count == 0)
+ {
+ PushStatus = message;
+ return;
+ }
+
+ var merged = TraeTools.Services.Login.AccountBackup.Merge(cfg, accounts);
+ if (!merged.Ok)
+ {
+ PushStatus = merged.Message;
+ return;
+ }
+
+ cfg.Save();
+ PopulateAccounts();
+ PushStatus = merged.Message;
+
+ // 顺手刷新账号资料与状态,并让首页/切换器同步看到新账号
+ await RefreshAccountStatesAsync();
+ MainViewModel.NotifyActiveAccountChanged();
+
+ AccountHelpers.AppLog("account", "", $"导入账号备份:{merged.Message}");
+ }
+ catch (Exception ex)
+ {
+ PushStatus = "导入失败:" + ex.Message;
+ }
+ finally
+ {
+ BackupBusy = false;
+ }
+ }
+
[RelayCommand]
private void OpenRepo()
{
diff --git a/ViewModels/UiHost.cs b/ViewModels/UiHost.cs
index fba659a..ae8c172 100644
--- a/ViewModels/UiHost.cs
+++ b/ViewModels/UiHost.cs
@@ -4,7 +4,7 @@
namespace TraeTools.ViewModels;
-/// 解析当前主窗口,供页面 VM 弹模态窗口(ShowDialog 需要 owner)。
+/// 解析当前主窗口 / 剪贴板,供页面 VM 弹模态窗口与复制文本(ShowDialog 需要 owner)。
internal static class UiHost
{
public static Window? MainWindow
@@ -23,4 +23,51 @@ public static Window? MainWindow
return null;
}
}
+
+ ///
+ /// 当前根 TopLevel(桌面端为主窗口,Android 为承载单视图的根),
+ /// 文件选择器(IStorageProvider)等能力必须经它获取。
+ /// 刻意不叫 TopLevel,避免属性名与类型名同名带来的解析歧义。
+ ///
+ public static TopLevel? Root
+ {
+ get
+ {
+ try
+ {
+ var lifetime = Avalonia.Application.Current?.ApplicationLifetime;
+ if (lifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ return desktop.MainWindow;
+#if ANDROID
+ if (lifetime is ISingleViewApplicationLifetime singleView && singleView.MainView is Control root)
+ return TopLevel.GetTopLevel(root);
+#endif
+ }
+ catch { /* 获取失败时返回 null,调用方回退到剪贴板方案 */ }
+ return null;
+ }
+ }
+
+ ///
+ /// 剪贴板:桌面端取自主窗口;单视图平台(Android)没有 Window,
+ /// 需从根视图反查 TopLevel 才能拿到剪贴板——这是「复制 Token / 复制日志」在手机端可用的前提。
+ ///
+ public static Avalonia.Input.Platform.IClipboard? Clipboard
+ {
+ get
+ {
+ try
+ {
+ var lifetime = Avalonia.Application.Current?.ApplicationLifetime;
+ if (lifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ return desktop.MainWindow?.Clipboard;
+#if ANDROID
+ if (lifetime is ISingleViewApplicationLifetime singleView && singleView.MainView is Control root)
+ return TopLevel.GetTopLevel(root)?.Clipboard;
+#endif
+ }
+ catch { /* 剪贴板不可用时返回 null,调用方给出提示 */ }
+ return null;
+ }
+ }
}
diff --git a/Views/DeviceAuthWindow.axaml b/Views/DeviceAuthWindow.axaml
index 37c14d9..19f9c06 100644
--- a/Views/DeviceAuthWindow.axaml
+++ b/Views/DeviceAuthWindow.axaml
@@ -6,21 +6,21 @@
FontFamily="Microsoft YaHei UI,Segoe UI">
-
-
+
-
+
-
+
-
+
-
+
diff --git a/Views/EulaWindow.axaml b/Views/EulaWindow.axaml
index 264512e..2bb0f77 100644
--- a/Views/EulaWindow.axaml
+++ b/Views/EulaWindow.axaml
@@ -11,13 +11,13 @@
-
-
+
+
-
-
+
TRAETOOLS 最终用户许可协议(EULA)
@@ -65,10 +65,10 @@ TRAETOOLS 最终用户许可协议(EULA)
-
+
+ FontSize="12" Foreground="{DynamicResource TextMainBrush}" HorizontalAlignment="Left" />
diff --git a/Views/InputWindow.axaml b/Views/InputWindow.axaml
index 17b6ab4..2da8a44 100644
--- a/Views/InputWindow.axaml
+++ b/Views/InputWindow.axaml
@@ -8,14 +8,14 @@
Background="{DynamicResource CardBgBrush}"
FontFamily="Microsoft YaHei UI,Segoe UI">
-
-
+
+
-
-
+
diff --git a/Views/LogView.axaml b/Views/LogView.axaml
new file mode 100644
index 0000000..f3212e2
--- /dev/null
+++ b/Views/LogView.axaml
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/LogView.axaml.cs b/Views/LogView.axaml.cs
new file mode 100644
index 0000000..ef433e7
--- /dev/null
+++ b/Views/LogView.axaml.cs
@@ -0,0 +1,69 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using TraeTools.ViewModels;
+
+namespace TraeTools.Views;
+
+///
+/// 日志页视图。
+///
+/// 唯一需要代码配合的地方是「自动滚动」:VM 不知道滚动位置,
+/// 因此当它追加了新日志(或新打开了日志文件)时会触发 ,
+/// 本视图收到后把对应的 ScrollViewer 滚到底部。
+///
+/// 滚动必须延迟到布局完成之后执行:若在集合变更的同一帧内调用 ScrollToEnd,
+/// 新增行尚未参与测量,Extent 还是旧值,滚动会停在倒数第二行附近。
+/// 这里用 DispatcherPriority.Background 排队,等本帧布局结束再滚动。
+///
+public partial class LogView : UserControl
+{
+ private LogViewModel? _vm;
+
+ public LogView()
+ {
+ InitializeComponent();
+
+ // 用 lambda 订阅,避免显式书写事件参数类型
+ // (DetachedFromVisualTree 的参数类型命名空间在各版本间有过调整)
+ DataContextChanged += (_, _) => Rebind();
+ DetachedFromVisualTree += (_, _) => Release();
+ }
+
+ /// DataContext 变化时重新挂接:ViewModel 由 MainViewModel 长期持有,视图可能被多次创建。
+ private void Rebind()
+ {
+ Release();
+ _vm = DataContext as LogViewModel;
+ if (_vm != null) _vm.ScrollToEndRequested += OnScrollToEndRequested;
+ }
+
+ /// 离开可视树时退订,避免静态/长生命周期对象持有视图造成泄漏。
+ private void Release()
+ {
+ if (_vm != null) _vm.ScrollToEndRequested -= OnScrollToEndRequested;
+ _vm = null;
+ }
+
+ private void OnScrollToEndRequested()
+ {
+ try
+ {
+ // 等本帧布局完成再滚:否则新增行的 Extent 尚未更新,滚不到底
+ Dispatcher.UIThread.Post(ScrollNow, DispatcherPriority.Background);
+ }
+ catch { /* 视图异常不影响日志功能 */ }
+ }
+
+ private void ScrollNow()
+ {
+ try
+ {
+ var target = _vm?.IsRecentTab == true
+ ? this.FindControl("RecentScroll")
+ : this.FindControl("FileScroll");
+ target?.ScrollToEnd();
+ }
+ catch { /* 忽略 */ }
+ }
+}
diff --git a/Views/LoginWindow.axaml b/Views/LoginWindow.axaml
index 4886754..02fec0f 100644
--- a/Views/LoginWindow.axaml
+++ b/Views/LoginWindow.axaml
@@ -1,45 +1,90 @@
-
-
+
+
+
+
-
-
-
-推荐:点击下方「自动登录」将弹出内嵌浏览器窗口,登录成功后自动读取凭证,无需手动复制。
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
-
-
-备选:手动打开浏览器登录后复制 Cloud-IDE-Token 粘贴保存。
-
-
+
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
@@ -49,4 +94,3 @@
-
diff --git a/Views/LoginWindow.axaml.cs b/Views/LoginWindow.axaml.cs
index f84ea30..fbb38ad 100644
--- a/Views/LoginWindow.axaml.cs
+++ b/Views/LoginWindow.axaml.cs
@@ -1,17 +1,25 @@
using System;
using System.IO;
+using System.Threading.Tasks;
using Avalonia.Controls;
+using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using TraeCheckin;
using TraeTools.Platform;
+using TraeTools.Services.Login;
using TraeTools.ViewModels;
namespace TraeTools.Views;
///
-/// 登录窗口:
-/// Windows 桌面端:内嵌 WebView2 自动登录(自动读取 token + session)+ 手动粘贴备选;
-/// Android:无 WebView2,隐藏自动登录入口,仅保留「打开浏览器登录 + 粘贴 Token」手动方案。
+/// 登录窗口,共提供四种获取登录态的方式:
+/// 1. 内嵌浏览器自动登录(仅 Windows 桌面端,登录后自动读取凭证);
+/// 2. 从剪贴板识别导入(自动判定是 JWT 还是会话 Cookie);
+/// 3. 读取本机 TRAE 客户端(仅 Windows 桌面端,免手动复制);
+/// 4. 手动粘贴(全平台,Android 端的主要方式;可选指定粘贴内容的类型)。
+///
+/// 所有方式最终都落到同一个出口:把凭证写入 并返回成功,
+/// 由调用方(设置页 AddAccount)负责判重、补设备号与持久化。
///
public partial class LoginWindow : UserControl
{
@@ -25,10 +33,11 @@ public LoginWindow()
{
InitializeComponent();
#if ANDROID
- // Android 无内嵌浏览器:隐藏自动登录区,手动方案升级为唯一方式
+ // Android 无内嵌浏览器,也无桌面客户端数据可读:隐藏前两种方式,手动粘贴成为主要路径
AutoLoginButton.IsVisible = false;
AutoLoginHint.IsVisible = false;
- ManualHint.Text = "手机端请点击下方「打开浏览器登录」,登录后按开发者教程复制 Cloud-IDE-Token 粘贴保存。";
+ LocalImportButton.IsVisible = false;
+ ManualHint.Text = "手机端无内嵌浏览器:请点「打开浏览器登录」,登录 trae.cn 后复制 Cloud-IDE-Token 或会话 Cookie 粘贴保存。";
#endif
}
@@ -38,30 +47,27 @@ public LoginWindow(TraeAccount? account)
_account = account;
}
+ // ==================== 方式一:内嵌浏览器自动登录 ====================
+
// 注意:XAML 中该按钮绑定了 Click="OnAutoLoginClick",事件处理器在编译期解析,
// 因此 Android 端也必须存在同名方法(按钮本身已隐藏,仅作为兜底提示)。
private async void OnAutoLoginClick(object? sender, RoutedEventArgs e)
{
#if ANDROID
- AutoStatus.Text = "Android 端不支持内嵌浏览器自动登录,请使用下方「打开浏览器登录 + 粘贴 Token」方案。";
+ AutoStatus.Text = "Android 端不支持内嵌浏览器自动登录,请使用「从剪贴板识别」或手动粘贴。";
await Task.CompletedTask;
return;
#else
if (_busy || _account == null) return;
- if (string.IsNullOrEmpty(_account.Token))
- {
- // 首次登录:为账号准备独立的 WebView2 用户数据目录(账号间登录态隔离)
- _ = _account;
- }
- // 未安装 WebView2 运行库时内嵌登录不可用,明确提示改用下方手动粘贴方案
+ // 未安装 WebView2 运行库时内嵌登录不可用,明确提示改用其它方式
try
{
_ = Microsoft.Web.WebView2.Core.CoreWebView2Environment.GetAvailableBrowserVersionString();
}
catch
{
- AutoStatus.Text = "未检测到 WebView2 运行库,请使用下方「手动方案」粘贴 Cloud-IDE-Token";
+ AutoStatus.Text = "未检测到 WebView2 运行库,请使用「从剪贴板识别」或手动粘贴";
return;
}
@@ -73,7 +79,7 @@ private async void OnAutoLoginClick(object? sender, RoutedEventArgs e)
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TraeTools", "WebView", "account_" + (_account.Id.Length > 12 ? _account.Id[..12] : _account.Id));
- // 接口探索已完成;false=正常登录(自动识别并关闭窗口)。需要再抓包时改为 true
+ // 接口探索已完成;false=正常登录(自动识别并关闭窗口)
var result = await TraeTools.Services.Login.WebViewLoginHost.RunAsync(webViewDir, _account.Token, debugCapture: false);
if (result == null)
@@ -82,13 +88,7 @@ private async void OnAutoLoginClick(object? sender, RoutedEventArgs e)
return;
}
- _account.Token = result.Token;
- _account.Session = result.Session;
- _account.TokenUpdatedAt = DateTime.Now;
- var uid = TokenUtils.ParseAccountUid(result.Token);
- if (!string.IsNullOrEmpty(uid)) _account.AccountUid = uid;
- MainViewModel.AppConfig?.Save();
- OverlayDialog.CloseDialog(this, true);
+ ApplyCredentials(result.Token, result.Session, "自动登录成功");
}
catch (Exception ex)
{
@@ -101,37 +101,172 @@ private async void OnAutoLoginClick(object? sender, RoutedEventArgs e)
#endif
}
- private void OnOpenBrowserClick(object? sender, RoutedEventArgs e)
+ // ==================== 方式二:从剪贴板识别导入 ====================
+
+ private async void OnClipboardImportClick(object? sender, RoutedEventArgs e)
{
- BrowserLauncher.Open("https://www.trae.cn");
+ if (_busy) return;
+ _busy = true;
+ ImportStatus.Text = "正在读取剪贴板…";
+ try
+ {
+ var clip = UiHost.Clipboard;
+ if (clip == null)
+ {
+ ImportStatus.Text = "无法访问剪贴板";
+ return;
+ }
+ // 注意:Avalonia 12 把读取剪贴板的入口改名为 TryGetTextAsync(旧名 GetTextAsync 已移除),
+ // 写入仍是 SetTextAsync;两者都是 Avalonia.Input.Platform 下的扩展方法,需 using 该命名空间。
+ var text = await clip.TryGetTextAsync();
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ ImportStatus.Text = "剪贴板为空,请先复制凭证";
+ return;
+ }
+ TokenBox.Text = text;
+ await ImportAsync(text, CredentialKind.Unknown, ImportStatus);
+ }
+ catch (Exception ex)
+ {
+ ImportStatus.Text = "读取剪贴板失败:" + ex.Message;
+ }
+ finally
+ {
+ _busy = false;
+ }
+ }
+
+ // ==================== 方式三:读取本机 TRAE 客户端 ====================
+
+ private async void OnLocalImportClick(object? sender, RoutedEventArgs e)
+ {
+ if (_busy) return;
+ _busy = true;
+ ImportStatus.Text = "正在读取本机 TRAE 客户端数据…";
+ try
+ {
+ // 异步:内部可能用会话换 Token(同步阻塞在 UI 线程上会死锁)
+ var r = await LocalClientImporter.TryImportAsync();
+ if (!r.Ok || string.IsNullOrEmpty(r.Token))
+ {
+ ImportStatus.Text = r.Message;
+ return;
+ }
+ ApplyCredentials(r.Token, r.Session, r.Message);
+ }
+ catch (Exception ex)
+ {
+ ImportStatus.Text = "读取本机客户端失败:" + ex.Message;
+ }
+ finally
+ {
+ _busy = false;
+ }
}
- private void OnSaveClick(object? sender, RoutedEventArgs e)
+ // ==================== 方式四:手动粘贴保存 ====================
+
+ private async void OnSaveClick(object? sender, RoutedEventArgs e)
{
+ if (_account == null)
+ {
+ StatusText.Text = "无当前账号,无法保存";
+ return;
+ }
+
var pasted = TokenBox.Text?.Trim();
if (string.IsNullOrWhiteSpace(pasted))
{
- StatusText.Text = "请先粘贴 Cloud-IDE-Token";
+ StatusText.Text = "请先粘贴 Cloud-IDE-Token 或会话 Cookie";
return;
}
- var initial = _account?.Token;
- if (!TokenUtils.ShouldAcceptNewToken(initial, pasted))
+
+ if (_busy) return;
+ _busy = true;
+ StatusText.Text = "正在校验…";
+ try
{
- StatusText.Text = "Token 无效(长度不足或与当前账号相同),请重新复制";
- return;
+ var forced = MethodCombo.SelectedIndex switch
+ {
+ 1 => CredentialKind.Token,
+ 2 => CredentialKind.Session,
+ _ => CredentialKind.Unknown,
+ };
+
+ var r = await CredentialImporter.ImportAsync(pasted, MainViewModel.CheckinApi, forced);
+ if (!r.Ok || string.IsNullOrEmpty(r.Token))
+ {
+ StatusText.Text = r.Message;
+ return;
+ }
+
+ ApplyCredentials(r.Token, r.Session, r.Message);
+ StatusText.Text = r.Message;
+ }
+ catch (Exception ex)
+ {
+ StatusText.Text = "保存失败:" + ex.Message;
}
+ finally
+ {
+ _busy = false;
+ }
+ }
+
+ // ==================== 公共 ====================
+
+ ///
+ /// 把解析得到的凭证写入当前账号并关闭窗口。
+ /// 这里只做「长度是否足够」的可用性校验:重复导入同一凭证是合法操作
+ /// (例如用同一会话重新换一次 Token),不应像 WebView 抓取路径那样按「必须与旧值不同」拒绝。
+ ///
+ private void ApplyCredentials(string? token, string? session, string statusMessage)
+ {
if (_account == null)
{
StatusText.Text = "无当前账号,无法保存";
return;
}
- _account.Token = pasted;
+ if (!CredentialImporter.IsUsableToken(token))
+ {
+ StatusText.Text = "凭证长度不足,请确认复制完整";
+ return;
+ }
+
+ _account.Token = token;
+ if (!string.IsNullOrEmpty(session)) _account.Session = session;
_account.TokenUpdatedAt = DateTime.Now;
- var uid = TokenUtils.ParseAccountUid(pasted);
+
+ var uid = TokenUtils.ParseAccountUid(_account.Token);
if (!string.IsNullOrEmpty(uid)) _account.AccountUid = uid;
+
MainViewModel.AppConfig?.Save();
+
+ StatusText.Text = statusMessage;
+ AutoStatus.Text = statusMessage;
+ ImportStatus.Text = statusMessage;
+
OverlayDialog.CloseDialog(this, true);
}
+ /// 剪贴板 / 会话导入的统一入口(供方式二与方式三复用业务逻辑)。
+ private async Task ImportAsync(string raw, CredentialKind forced, TextBlock statusTarget)
+ {
+ var r = await CredentialImporter.ImportAsync(raw, MainViewModel.CheckinApi, forced);
+ if (!r.Ok || string.IsNullOrEmpty(r.Token))
+ {
+ statusTarget.Text = r.Message;
+ return;
+ }
+ ApplyCredentials(r.Token, r.Session, r.Message);
+ statusTarget.Text = r.Message;
+ }
+
+ private void OnOpenBrowserClick(object? sender, RoutedEventArgs e)
+ {
+ BrowserLauncher.Open("https://www.trae.cn");
+ }
+
private void OnCancelClick(object? sender, RoutedEventArgs e) => OverlayDialog.CloseDialog(this, false);
}
diff --git a/Views/MainView.axaml b/Views/MainView.axaml
index 048bb68..9f73e35 100644
--- a/Views/MainView.axaml
+++ b/Views/MainView.axaml
@@ -50,6 +50,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -406,6 +435,11 @@
+
+
@@ -423,6 +457,198 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/check_bindings.py b/tools/check_bindings.py
new file mode 100644
index 0000000..f3feaf9
--- /dev/null
+++ b/tools/check_bindings.py
@@ -0,0 +1,175 @@
+# -*- coding: utf-8 -*-
+"""绑定严格校验。
+
+本项目大量使用 x:DataType + 反射式混合绑定,且 XAML 里的绑定名写错**不会**在
+编译期报错(DataTemplate 内部更是退化为运行时反射),只会在界面上静默失效。
+本脚本把每个 View 的绑定与其 ViewModel 声明的成员做交叉比对:
+
+· DataTemplate 之外的绑定 —— 必须能在该 View 对应的 ViewModel(或其基类文件)里找到;
+ 找不到即视为可疑(打错字、或忘了加属性)。
+· DataTemplate 之内的绑定 —— 数据项类型未知,允许回退到「全项目任意类型都声明过」。
+
+命令绑定(XxxCommand)由 [RelayCommand] 源生成,脚本会把注解的方法名换算成命令名。
+"""
+import os
+import re
+import sys
+import glob
+
+REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "repo"))
+
+ATTR_BIND_RE = re.compile(r"\{Binding\s+([^}]+)\}")
+
+SKIP_TOKENS = {
+ "Path", "Mode", "Converter", "ConverterParameter", "StringFormat",
+ "RelativeSource", "Source", "FallbackValue", "TargetNullValue",
+ "UpdateSourceTrigger", "ElementName", "Async", "Priority",
+ "$parent", "$self", "AncestorType",
+}
+
+DECL_PATTERNS = [
+ # 显式属性/字段/方法声明
+ re.compile(
+ r"\b(?:public|private|internal|protected|static|readonly|partial|sealed|override|"
+ r"virtual|new|async|required|\s)+"
+ r"(?:[A-Za-z_][A-Za-z0-9_<>,\[\]\?\.\s]*?)\s+"
+ r"([A-Za-z_][A-Za-z0-9_]*)\s*(?:\{|=>|\(|;|=)"
+ ),
+]
+
+
+def declared_names(src):
+ names = set()
+
+ # [ObservableProperty] private string _foo; -> Foo
+ for m in re.finditer(r"\[ObservableProperty\][\s\S]{0,300}?\b_([A-Za-z_][A-Za-z0-9_]*)\s*[;=]", src):
+ raw = m.group(1)
+ names.add(raw[0].upper() + raw[1:])
+ names.add(raw)
+
+ for pat in DECL_PATTERNS:
+ for m in pat.finditer(src):
+ names.add(m.group(1))
+
+ # [RelayCommand] 方法 -> XxxCommand
+ # 注意修饰符链(private void / public async Task)允许出现空格,故用 (\w+\s+)+ 而不是单个 [\w]+
+ for m in re.finditer(
+ r"\[RelayCommand[^\]]*\]\s*(?:partial\s+)?(?:[\w<>\[\]\.\?,]+\s+)+"
+ r"([A-Za-z_][A-Za-z0-9_]*)\s*\(",
+ src,
+ ):
+ n = m.group(1)
+ names.add(n + "Command")
+ names.add(n)
+ base = n[:-5] if n.endswith("Async") else n
+ names.add(base + "Command")
+ names.add(base)
+
+ for m in re.finditer(r"\bevent\s+[\w<>\[\]\.\?]+\s+([A-Za-z_][A-Za-z0-9_]*)", src):
+ names.add(m.group(1))
+
+ # 属性表达式体里的计算属性(上面正则已覆盖大部分)
+ for m in re.finditer(r"\b([A-Z][A-Za-z0-9_]*)\s*=>", src):
+ names.add(m.group(1))
+
+ return names
+
+
+def collect_bindings(axaml_path):
+ """返回 [(绑定表达式, 行号, 是否在 DataTemplate 内)]"""
+ with open(axaml_path, encoding="utf-8-sig") as f:
+ lines = f.readlines()
+
+ out = []
+ depth = 0 # DataTemplate 嵌套深度
+ for i, line in enumerate(lines, 1):
+ opened = line.count("")
+ in_tpl = depth > 0 or opened > 0
+
+ for m in ATTR_BIND_RE.finditer(line):
+ body = m.group(1)
+ cand = None
+ for p in [x.strip() for x in body.split(",")]:
+ if not p:
+ continue
+ if "=" in p:
+ k, v = p.split("=", 1)
+ if k.strip() == "Path":
+ cand = v.strip()
+ continue
+ cand = p
+ break
+ if not cand:
+ continue
+ root = re.split(r"[\.\[\s]", cand.lstrip("!"))[0]
+ if not root or root in SKIP_TOKENS:
+ continue
+ out.append((cand, i, in_tpl))
+
+ depth += opened - closed
+ if depth < 0:
+ depth = 0
+ return out
+
+
+def main():
+ # 全项目声明名(供 DataTemplate 内回退)
+ global_names = set()
+ models_names = set()
+ vm_names = {}
+ for cs in glob.glob(os.path.join(REPO, "**", "*.cs"), recursive=True):
+ if os.sep + "obj" + os.sep in cs or os.sep + "bin" + os.sep in cs:
+ continue
+ try:
+ src = open(cs, encoding="utf-8-sig").read()
+ except Exception:
+ continue
+ names = declared_names(src)
+ global_names |= names
+ rel = os.path.relpath(cs, REPO)
+ if rel.startswith("Models") or rel.startswith("Services"):
+ models_names |= names
+ if rel.startswith("ViewModels"):
+ vm_names.setdefault(os.path.basename(cs)[:-3], names)
+
+ # ViewModelBase 的属性对所有 VM 可见
+ base_names = set()
+ vb = os.path.join(REPO, "ViewModels", "ViewModelBase.cs")
+ if os.path.exists(vb):
+ base_names = declared_names(open(vb, encoding="utf-8-sig").read())
+
+ problems = []
+ total = 0
+ dtype_re = re.compile(r'x:DataType="(?:vm:)?([A-Za-z_][A-Za-z0-9_]*)"')
+ for view in sorted(glob.glob(os.path.join(REPO, "Views", "*.axaml"))):
+ head = open(view, encoding="utf-8-sig").read()
+ m = dtype_re.search(head)
+ # 以视图自己声明的 x:DataType 为准(ViewLocator 是「把 View 换成 ViewModel」,
+ # 不是追加,且对话框类视图根本没有对应 ViewModel)
+ owned = set()
+ if m:
+ owned = vm_names.get(m.group(1), set())
+ owned |= base_names
+
+ for cand, line, in_tpl in collect_bindings(view):
+ total += 1
+ root = re.split(r"[\.\[\s]", cand.lstrip("!"))[0]
+ if root in owned:
+ continue
+ if in_tpl and (root in global_names or root in models_names):
+ continue
+ problems.append((os.path.basename(view), line, cand, in_tpl))
+
+ print("校验 %d 个绑定表达式" % total)
+ if problems:
+ print("\n可疑绑定(VM 与模板类型里都找不到):")
+ for v, ln, c, t in problems:
+ print(" %-24s L%-5d %-42s %s" % (v, ln, c, "[模板内]" if t else ""))
+ else:
+ print("全部绑定均可解析 ✓")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/lint_cs.py b/tools/lint_cs.py
new file mode 100644
index 0000000..5c3ffe4
--- /dev/null
+++ b/tools/lint_cs.py
@@ -0,0 +1,296 @@
+# -*- coding: utf-8 -*-
+"""C# 源码字符级结构自检。
+
+为多目标 Avalonia 项目做编译前的廉价防线:不依赖 SDK,直接扫源码,
+找出「花括号/圆括号不平衡」「字符串或注释未闭合」「文件被截断」「编码损坏」
+这类低级但致命的错误。
+
+关键点是必须正确跳过:
+ - 行注释 / 块注释
+ - 逐字字符串 @"..."(内部 "" 表示一个引号)
+ - 原始字符串字面量(C# 11,连续三个及以上双引号包围,引号数可更多)
+ - 插值字符串 $、$@、以及插值原始字符串(内部可能有 {},其中还可能嵌字符串)
+ - 字符字面量 '...' 与转义
+
+实现方式:单遍状态机。插值字符串里的 {} 内容按代码处理(递归计数),
+从而不会把插值表达式里的括号漏掉或误算。
+"""
+import glob
+import os
+import sys
+
+REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "repo"))
+
+# 需要参与配对统计的括号
+PAIRS = {"{": "}", "(": ")", "[": "]"}
+
+
+def scan(src):
+ """返回 (counts, errors)。
+ counts: {'{':open数, '}':close数, ...},仅统计真正的代码区
+ errors: 结构性问题列表
+ """
+ counts = {c: 0 for c in "{}()[]"}
+ errors = []
+
+ i = 0
+ n = len(src)
+ line = 1
+
+ # 插值洞栈:每个元素是该洞的 "起始括号深度快照",用来知道何时洞结束
+ # 简化处理:插值洞不需要精确配对,因为我们把洞内容当作普通代码扫描,
+ # 洞结束时由外层状态机通过 '}' 识别(同时要避免与代码块混淆)。
+ # 这里用 brace_stack 记录插值洞的嵌套:进入 $"{ 时 push,遇到配对的 } pop。
+ interp_depth = [] # 每个插值洞的 { 深度(用 counts['{'] 的快照近似)
+
+ while i < n:
+ ch = src[i]
+ if ch == "\n":
+ line += 1
+
+ # ---- 注释 ----
+ if ch == "/" and i + 1 < n and src[i + 1] == "/":
+ j = src.find("\n", i)
+ i = n if j < 0 else j
+ continue
+ if ch == "/" and i + 1 < n and src[i + 1] == "*":
+ j = src.find("*/", i + 2)
+ if j < 0:
+ errors.append("块注释未闭合(从第 %d 行开始)" % line)
+ return counts, errors
+ line += src.count("\n", i, j)
+ i = j + 2
+ continue
+
+ # ---- 原始字符串 """...""" ----
+ if ch == '"' and src.startswith('"""', i):
+ k = 3
+ while i + k < n and src[i + k] == '"':
+ k += 1
+ quote = '"' * k
+ j = src.find(quote, i + k)
+ if j < 0:
+ errors.append("原始字符串未闭合(第 %d 行)" % line)
+ return counts, errors
+ line += src.count("\n", i, j)
+ i = j + k
+ continue
+
+ # ---- 逐字字符串 @"..." ----
+ if ch == "@" and i + 1 < n and src[i + 1] == '"':
+ i += 2
+ closed = False
+ while i < n:
+ if src[i] == '"':
+ if i + 1 < n and src[i + 1] == '"':
+ i += 2
+ continue
+ i += 1
+ closed = True
+ break
+ if src[i] == "\n":
+ line += 1
+ i += 1
+ if not closed:
+ errors.append("逐字字符串未闭合(第 %d 行)" % line)
+ return counts, errors
+ continue
+
+ # ---- 插值字符串 $"..." / $@"..." / $"""...""" ----
+ if ch == "$":
+ j = i + 1
+ verbatim = False
+ if j < n and src[j] == "@":
+ verbatim = True
+ j += 1
+ if j < n and src[j] == '"':
+ if src.startswith('"""', j):
+ k = 3
+ while j + k < n and src[j + k] == '"':
+ k += 1
+ quote = '"' * k
+ end = src.find(quote, j + k)
+ if end < 0:
+ errors.append("插值原始字符串未闭合(第 %d 行)" % line)
+ return counts, errors
+ # 洞内容当作代码扫描:交给主循环(从 j+k 开始)
+ # 为简化,直接递归扫描洞内容
+ inner = src[j + k:end]
+ sub_counts, sub_err = scan(inner)
+ for key in counts:
+ counts[key] += sub_counts[key]
+ errors.extend(sub_err)
+ line += src.count("\n", i, end)
+ i = end + k
+ continue
+ # 普通/逐字插值字符串:逐个字符走,遇到 { 进入洞
+ i = j + 1
+ depth = 0
+ closed = False
+ while i < n:
+ c = src[i]
+ if c == "\n":
+ line += 1
+ if not verbatim:
+ break
+ if c == "\\" and not verbatim:
+ i += 2
+ continue
+ if c == '"':
+ if verbatim and i + 1 < n and src[i + 1] == '"':
+ i += 2
+ continue
+ if depth == 0:
+ i += 1
+ closed = True
+ break
+ if c == "{" and i + 1 < n and src[i + 1] == "{":
+ i += 2
+ continue
+ if c == "}" and i + 1 < n and src[i + 1] == "}":
+ i += 2
+ continue
+ if c == "{":
+ # 插值洞:找到配对的 }
+ d = 1
+ k2 = i + 1
+ while k2 < n and d > 0:
+ cc = src[k2]
+ if cc == "{":
+ d += 1
+ elif cc == "}":
+ d -= 1
+ elif cc == '"':
+ # 洞里的字符串
+ k3 = k2 + 1
+ while k3 < n:
+ if src[k3] == "\\":
+ k3 += 2
+ continue
+ if src[k3] == '"':
+ break
+ if src[k3] == "\n":
+ line += 1
+ k3 += 1
+ k2 = k3
+ elif cc == "\n":
+ line += 1
+ k2 += 1
+ inner = src[i + 1:k2 - 1]
+ sub_counts, sub_err = scan(inner)
+ for key in counts:
+ counts[key] += sub_counts[key]
+ errors.extend(sub_err)
+ i = k2
+ continue
+ i += 1
+ if not closed:
+ errors.append("插值字符串未闭合(第 %d 行)" % line)
+ return counts, errors
+ continue
+
+ # ---- 字符字面量 ----
+ if ch == "'":
+ # 可能是 char,也可能是泛型/间隔符误用;C# 里 ' 仅用于 char
+ j = i + 1
+ if j < n and src[j] == "\\":
+ j += 2
+ else:
+ j += 1
+ if j < n and src[j] == "'":
+ i = j + 1
+ continue
+ # 不是合法的 char 字面量,按普通字符处理
+ i += 1
+ continue
+
+ # ---- 普通字符串 ----
+ if ch == '"':
+ i += 1
+ closed = False
+ while i < n:
+ if src[i] == "\\":
+ i += 2
+ continue
+ if src[i] == '"':
+ i += 1
+ closed = True
+ break
+ if src[i] == "\n":
+ break
+ i += 1
+ if not closed:
+ errors.append("字符串未闭合(第 %d 行)" % line)
+ return counts, errors
+ continue
+
+ # ---- 括号统计 ----
+ if ch in counts:
+ counts[ch] += 1
+ i += 1
+
+ return counts, errors
+
+
+def check(path):
+ problems = []
+ raw = open(path, "rb").read()
+ try:
+ src = raw.decode("utf-8-sig")
+ except UnicodeDecodeError as e:
+ return ["非 UTF-8 编码: %s" % e]
+
+ if "\ufffd" in src:
+ problems.append("含替换字符 U+FFFD(编码损坏)")
+
+ counts, errors = scan(src)
+ problems.extend(errors)
+
+ for op, cl in PAIRS.items():
+ if counts[op] != counts[cl]:
+ problems.append("%s%s 不平衡: %d 个 %s / %d 个 %s"
+ % (op, cl, counts[op], op, counts[cl], cl))
+
+ tail = src.rstrip()
+ last_line = tail.rsplit("\n", 1)[-1].strip()
+ # 条件编译收尾(#endif / #pragma warning restore 等)是合法写法,不算异常
+ if tail and not last_line.startswith("#") and not tail.endswith(("}", ";", "*/")):
+ problems.append("结尾异常: %r" % tail[-40:])
+
+ return problems
+
+
+def main():
+ files = sorted(glob.glob(os.path.join(REPO, "**", "*.cs"), recursive=True))
+ files = [f for f in files
+ if os.sep + "obj" + os.sep not in f and os.sep + "bin" + os.sep not in f]
+
+ # 只报告本次改动相关的文件,减少噪声
+ import subprocess
+ try:
+ out = subprocess.run(["git", "status", "--porcelain"], cwd=REPO,
+ capture_output=True, text=True, encoding="utf-8").stdout
+ changed = set()
+ for ln in out.splitlines():
+ p = ln[3:].strip().strip('"')
+ if p.endswith(".cs"):
+ changed.add(os.path.normpath(os.path.join(REPO, p)))
+ except Exception:
+ changed = set()
+
+ total = 0
+ for f in files:
+ p = check(f)
+ if p:
+ total += 1
+ tag = " [本次改动]" if os.path.normpath(f) in changed else " [既有文件]"
+ print(" %s%s" % (os.path.relpath(f, REPO), tag))
+ for msg in p:
+ print(" - %s" % msg)
+
+ print("共检查 %d 个 .cs 文件,%d 个存在结构问题。" % (len(files), total))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/lint_xaml_comments.py b/tools/lint_xaml_comments.py
new file mode 100644
index 0000000..4c14d1f
--- /dev/null
+++ b/tools/lint_xaml_comments.py
@@ -0,0 +1,50 @@
+"""扫描所有 .axaml 的 XML 注释,找出非法写法。
+
+XML 规范不允许注释内容里出现连续两个短横线 `--`,也不允许注释以 `-` 结尾。
+Avalonia 的 XAML 预处理会直接抛 `AVLN1001: An XML comment cannot contain '--'`
+并**中止构建**,但报错只给行号不给原因,很容易反复踩(用 `----` 画分隔线时必踩)。
+
+用法:
+ python tools/lint_xaml_comments.py [仓库根目录]
+退出码非 0 表示存在非法注释。
+"""
+import io
+import os
+import re
+import sys
+
+ROOT = sys.argv[1] if len(sys.argv) > 1 else "."
+
+COMMENT_RE = re.compile(r"", re.S)
+
+SKIP_DIRS = {"obj", "bin", ".git", "node_modules"}
+
+
+def scan(root: str) -> int:
+ bad = 0
+ for dirpath, dirnames, filenames in os.walk(root):
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
+ for name in filenames:
+ if not name.endswith(".axaml"):
+ continue
+ path = os.path.join(dirpath, name)
+ try:
+ text = io.open(path, encoding="utf-8").read()
+ except Exception as exc: # 编码异常也值得报出来
+ print(f"{path}: 无法读取({exc})")
+ bad += 1
+ continue
+ for m in COMMENT_RE.finditer(text):
+ inner = m.group(1)
+ if "--" in inner or inner.endswith("-"):
+ line = text[: m.start()].count("\n") + 1
+ snippet = inner.strip().replace("\n", " ")[:70]
+ print(f"{path}:{line}: 非法注释 -> {snippet}")
+ bad += 1
+ return bad
+
+
+if __name__ == "__main__":
+ n = scan(ROOT)
+ print(f"\n非法注释总数: {n}")
+ sys.exit(1 if n else 0)
diff --git a/version.json b/version.json
index e15c94e..72e772c 100644
--- a/version.json
+++ b/version.json
@@ -1,5 +1,5 @@
{
- "version": "2.1.0",
+ "version": "2.2.0",
"channel": "stable",
"buildDate": "2026-10-14"
}
From 464f9b7bb975a60f9a3e607b1dab601ca972c4e0 Mon Sep 17 00:00:00 2001
From: akexin <315600306+akexin@users.noreply.github.com>
Date: Wed, 14 Oct 2026 18:54:54 +0800
Subject: [PATCH 37/37] =?UTF-8?q?fix(android):=20UnprotectCore=20=E5=A2=9E?=
=?UTF-8?q?=E5=8A=A0=E5=B9=B3=E5=8F=B0=E5=89=8D=E7=BC=80=E6=A0=A1=E9=AA=8C?=
=?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E6=8A=8A=E6=98=8E=E6=96=87=E5=BD=93?=
=?UTF-8?q?=E5=AF=86=E6=96=87=E7=9B=B2=E5=88=87=E8=A7=A3=E6=9E=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Platform/Secrets.Android.cs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/Platform/Secrets.Android.cs b/Platform/Secrets.Android.cs
index 9c12690..86a0028 100644
--- a/Platform/Secrets.Android.cs
+++ b/Platform/Secrets.Android.cs
@@ -54,6 +54,10 @@ internal static partial class Secrets
private static partial string? UnprotectCore(string enc)
{
+ // 必须带本平台前缀:历史上出现过「未加密的明文被当作密文送来」的情况
+ // (旧版飞书 Webhook 就是明文存的),此时直接判失败让上层保留原值,
+ // 不要盲切前 7 个字符去解析。
+ if (!enc.StartsWith(AndroidPrefix, StringComparison.Ordinal)) return null;
try
{
var body = enc[AndroidPrefix.Length..];