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 @@ - - + + 推荐:点击下方「自动登录」将弹出内嵌浏览器窗口,登录成功后自动读取凭证,无需手动复制。 - 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/MainView.axaml.cs b/Views/MainView.axaml.cs new file mode 100644 index 0000000..049ca69 --- /dev/null +++ b/Views/MainView.axaml.cs @@ -0,0 +1,159 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; +using TraeTools.ViewModels; + +namespace TraeTools.Views; + +/// +/// 应用主视图(UserControl,双平台通用)。 +/// ⚠️ 关键:Android 是 Avalonia 单视图平台,new Window() 会在 +/// Avalonia.Android.WindowingPlatformStub.CreateWindow() 抛 NotSupportedException, +/// 因此 Android 的 MainView 必须是 UserControl(不是 Window);Windows 端由 +/// (Window)包一层承载本视图。 +/// 这里承载双平台共享逻辑:覆盖层宿主挂接、自动签到定时器、首次启动 EULA。 +/// +public partial class MainView : UserControl +{ + /// 自动签到定时器(双平台):每 30 秒检查一次,到点且当天未签时自动签到。 + private readonly DispatcherTimer _autoCheckinTimer; + private bool _tickBusy; + private bool _started; + private bool _eulaChecked; + + public MainView() + { + InitializeComponent(); + + // 覆盖层宿主:Android 用于承载对话框,桌面端挂上也不使用(IsOverlayMode=false) + OverlayDialog.Attach(OverlayRoot, OverlayContent); + TraeTools.Platform.CrashLog.Boot("MainView: ctor, overlay attached"); + + _autoCheckinTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) }; + _autoCheckinTimer.Tick += async (_, _) => + { + if (_tickBusy) return; + _tickBusy = true; + try + { + if (DataContext is MainViewModel vm) + { + await vm.AutoCheckinIfDue(); +#if !ANDROID + await vm.RefreshSnapshotIfNeeded(); +#endif + } + } + catch (Exception ex) + { + // 定时任务异常不允许带崩 UI(Android 上未捕获异常会直接闪退) + TraeTools.Platform.CrashLog.Write("AutoCheckin timer tick", ex); + } + finally + { + _tickBusy = false; + } + }; + + // EULA 需要 owner 窗口居中(桌面端),故放到 Loaded:此时视觉树与宿主窗口均已就绪。 + // (构造期 desktop.MainWindow 尚未赋值,取 owner 会失败导致协议弹不出来) + Loaded += OnLoaded; + } + + private void OnLoaded(object? sender, RoutedEventArgs e) + { + if (_eulaChecked) return; + _eulaChecked = true; + TraeTools.Platform.CrashLog.Boot("MainView: loaded"); + try + { + _ = EulaCheckAsync(); + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("MainView.OnLoaded", ex); + } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (_started) return; + _started = true; + TraeTools.Platform.CrashLog.Boot("MainView: attached to visual tree"); + try + { + // Android 端自动签到仅 App 运行期间有效;无人值守场景请使用「云端签到」 + _autoCheckinTimer.Start(); + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("MainView.OnAttachedToVisualTree", ex); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + try + { + _autoCheckinTimer.Stop(); + } + catch + { + // 停止定时器失败不影响退出 + } + base.OnDetachedFromVisualTree(e); + } + + /// + /// 首次启动弹出免责声明协议(EULA)。同意标记独立存放于数据根目录 eula_accepted, + /// 与 TraeCheckin 的已同意配置互相独立,保证本程序首次启动必定展示协议。 + /// + private async Task EulaCheckAsync() + { + try + { + var flag = System.IO.Path.Combine( + TraeTools.Services.DataPaths.Root, "eula_accepted"); + if (System.IO.File.Exists(flag)) return; + + TraeTools.Platform.CrashLog.Boot("EULA: showing agreement"); + var eula = new EulaWindow(); + bool agreed; + try + { + // owner:桌面端用宿主窗口居中;Android 上为 null(走覆盖层,忽略 owner) + var owner = TopLevel.GetTopLevel(this) as Window; + agreed = await OverlayDialog.ShowAsync(eula, owner); + } + catch (Exception ex) + { + // 兜底:Android 上对话框呈现失败则记录并视为同意,保证应用可用(日志留痕) + TraeTools.Platform.CrashLog.Write("EULA dialog failed", ex); + agreed = true; + } + TraeTools.Platform.CrashLog.Boot($"EULA: agreed={agreed}"); + if (agreed) + { + try + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(flag)!); + System.IO.File.WriteAllText(flag, DateTime.UtcNow.ToString("o")); + } + catch { /* 标记写失败下次仍会弹,可接受 */ } + } + else if (TopLevel.GetTopLevel(this) is Window host) + { + // 不同意:桌面端关闭主窗口;Android 单视图无法关闭,保留界面 + host.Close(); + } + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("MainView.EulaCheckAsync", ex); + } + } +} diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml index 3d27af6..f548b45 100644 --- a/Views/MainWindow.axaml +++ b/Views/MainWindow.axaml @@ -1,6 +1,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs index 6b15a73..3c780f0 100644 --- a/Views/MainWindow.axaml.cs +++ b/Views/MainWindow.axaml.cs @@ -2,21 +2,22 @@ using Avalonia.Controls; using Avalonia.Controls.Notifications; using Avalonia.Platform; -using Avalonia.Threading; using TraeTools.ViewModels; namespace TraeTools.Views; +/// +/// Windows 桌面端主窗口:只负责桌面专属行为(尺寸/图标/托盘/关闭最小化), +/// 界面内容与业务逻辑在 (UserControl)中,供 Android 单视图复用。 +/// ⚠️ Android 上不能实例化本类:Avalonia 单视图平台的 WindowingPlatformStub.CreateWindow() +/// 会抛 NotSupportedException,Android 端请用 MainView。 +/// public partial class MainWindow : Window { - /// 自动签到定时器(双平台):每 30 秒检查一次,到点且当天未签时自动签到。 - private readonly DispatcherTimer _autoCheckinTimer; #if !ANDROID private TrayIcon? _trayIcon; /// true=用户从托盘「退出」真正退出;false=关闭窗口仅最小化到托盘。 private bool _realExit; - /// 计时器重入守卫:上一轮任务(可能数十秒)未结束前跳过本轮 Tick(#33)。 - private bool _tickBusy; private readonly WindowNotificationManager _notify; #endif @@ -27,14 +28,8 @@ public MainWindow() #endif InitializeComponent(); -#if ANDROID - // Android:挂接覆盖层对话框宿主(单视图平台不支持 Window.ShowDialog) - OverlayDialog.Attach(OverlayRoot, OverlayContent); - TraeTools.Platform.CrashLog.Boot("MainWindow: overlay attached"); -#endif - #if !ANDROID - // 桌面端固定窗口尺寸与居中(Android 端为全屏单视图,Width/Height 不适用) + // 桌面端固定窗口尺寸与居中 Width = 1100; Height = 720; MinWidth = 940; @@ -48,40 +43,7 @@ public MainWindow() Icon = new WindowIcon(iconStream); } catch { /* 图标加载失败不影响主程序 */ } -#endif - - _autoCheckinTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) }; - // 防重入:上一轮未结束则跳过本轮,避免长任务在常驻托盘场景下叠加(#33) - _autoCheckinTimer.Tick += async (_, _) => - { -#if !ANDROID - if (_tickBusy) return; - _tickBusy = true; -#endif - try - { - if (DataContext is MainViewModel vm) - { - await vm.AutoCheckinIfDue(); -#if !ANDROID - await vm.RefreshSnapshotIfNeeded(); -#endif - } - } - catch (Exception ex) - { - // 定时任务异常不允许带崩 UI(Android 上 async void 未捕获异常会直接闪退) - TraeTools.Platform.CrashLog.Write("AutoCheckin timer tick", ex); - } - finally - { -#if !ANDROID - _tickBusy = false; -#endif - } - }; -#if !ANDROID BuildTray(); Closing += OnWindowClosing; #endif @@ -91,22 +53,10 @@ protected override void OnOpened(EventArgs e) { base.OnOpened(e); TraeTools.Platform.CrashLog.Boot("MainWindow.OnOpened"); - try - { - OnOpenedEulaCheck(); - // Android 端自动签到仅 App 运行期间有效;无人值守场景请使用「云端签到」 - _autoCheckinTimer.Start(); - } - catch (Exception ex) - { - // 启动后处理(EULA / 定时器)失败也不能带崩 UI - TraeTools.Platform.CrashLog.Write("MainWindow.OnOpened", ex); - } } protected override void OnClosed(EventArgs e) { - _autoCheckinTimer.Stop(); #if !ANDROID _trayIcon?.Dispose(); #endif @@ -168,7 +118,7 @@ private void ShowAndActivate() Activate(); } - private async Task TrayCheckinAsync() + private async System.Threading.Tasks.Task TrayCheckinAsync() { if (DataContext is MainViewModel vm) { @@ -180,48 +130,4 @@ private async Task TrayCheckinAsync() } } #endif - - /// - /// 首次启动弹出免责声明协议(EULA)。同意标记独立存放于数据根目录 eula_accepted, - /// 与 TraeCheckin 的已同意配置互相独立,保证本程序首次启动必定展示协议; - /// 不同意则直接退出。 - /// - private async void OnOpenedEulaCheck() - { - var flag = System.IO.Path.Combine( - TraeTools.Services.DataPaths.Root, "eula_accepted"); - if (System.IO.File.Exists(flag)) return; - - TraeTools.Platform.CrashLog.Boot("EULA: showing agreement"); - var eula = new EulaWindow(); - bool agreed; - try - { - // Android:单视图平台走覆盖层;桌面:原生 ShowDialog - agreed = await OverlayDialog.ShowAsync(eula, this); - } - catch (Exception ex) - { - // 兜底:Android 上对话框呈现失败则记录并视为同意,保证应用可用(日志留痕) - TraeTools.Platform.CrashLog.Write("EULA dialog failed", ex); - agreed = true; - } - TraeTools.Platform.CrashLog.Boot($"EULA: agreed={agreed}"); - if (agreed) - { - try - { - System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(flag)!); - System.IO.File.WriteAllText(flag, DateTime.UtcNow.ToString("o")); - } - catch { /* 标记写失败下次仍会弹,可接受 */ } - } - else - { -#if !ANDROID - _realExit = true; -#endif - Close(); - } - } } diff --git a/Views/OverlayDialog.cs b/Views/OverlayDialog.cs index 931f774..e02aff3 100644 --- a/Views/OverlayDialog.cs +++ b/Views/OverlayDialog.cs @@ -1,29 +1,33 @@ using System; using System.Threading.Tasks; +using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.Presenters; namespace TraeTools.Views; /// -/// 单视图平台(Android)的模态对话框宿主。 -/// Avalonia 的 SingleViewApplicationLifetime 不支持再创建 Window(官方文档明确: -/// "If your app uses SingleViewApplicationLifetime, you cannot create a new Window -/// to host a dialog"),直接调用 ShowDialog 会失败并使 async void 崩溃。 -/// Android 上把对话框窗口的内容临时"借用"到 MainWindow 顶部的覆盖层呈现, -/// 按钮关闭时还原内容并以 TCS 返回结果;桌面端仍走原生 ShowDialog。 +/// 双平台模态对话框宿主。 +/// +/// ⚠️ 关键背景:Android 是 Avalonia 单视图平台,`new Window()` 会在 +/// Avalonia.Android.WindowingPlatformStub.CreateWindow() 抛 NotSupportedException, +/// 因此**所有对话框都必须是 UserControl**: +/// - Android:把 UserControl 直接挂到 MainView 的覆盖层(ZIndex 999)呈现,用 +/// TaskCompletionSource 模拟模态返回; +/// - 桌面端:由 (真正的 Window)包装后 ShowDialog。 /// public static class OverlayDialog { private static Panel? _root; private static ContentPresenter? _host; - private static Window? _current; + private static UserControl? _current; private static TaskCompletionSource? _tcs; /// 当前是否处于覆盖层对话框模式(仅 Android)。 public static bool IsOverlayMode => OperatingSystem.IsAndroid(); - /// MainWindow 启动时挂接覆盖层(仅 Android 需要;桌面端可安全跳过)。 + /// MainView 启动时挂接覆盖层(桌面端挂上也不使用)。 public static void Attach(Panel root, ContentPresenter host) { _root = root; @@ -31,13 +35,36 @@ public static void Attach(Panel root, ContentPresenter host) } /// - /// 双平台对话框入口:桌面走原生 ShowDialog(保持原行为),Android 走覆盖层。 - /// 返回对话框通过 CloseDialog 关闭时携带的结果。 + /// 双平台对话框入口:桌面端用 DialogHostWindow 包装后原生 ShowDialog, + /// Android 走覆盖层。返回对话框通过 CloseDialog 关闭时携带的结果。 /// - public static async Task ShowAsync(Window dialog, Window owner) + public static async Task ShowAsync(UserControl dialog, Window? owner) { if (!IsOverlayMode) - return await dialog.ShowDialog(owner); + { + try + { + var host = new DialogHostWindow(); + host.AttachDialog(dialog); + // ShowDialog 需要 owner;缺省时退回桌面端主窗口 + var target = owner ?? (Avalonia.Application.Current?.ApplicationLifetime + as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + if (target == null) + { + TraeTools.Platform.CrashLog.Write("OverlayDialog.ShowAsync", + new InvalidOperationException("桌面端无可用 owner 窗口,无法显示对话框")); + return default!; + } + var result = await host.ShowDialog(target); + host.DetachDialog(); + return result is T typed ? typed : default!; + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("OverlayDialog.ShowAsync(desktop)", ex); + return default!; + } + } try { @@ -48,55 +75,68 @@ public static async Task ShowAsync(Window dialog, Window owner) catch (Exception ex) { // 覆盖层呈现失败时降级返回默认值:绝不让对话框问题带崩应用(Android 上即闪退) - TraeTools.Platform.CrashLog.Write("OverlayDialog.ShowAsync", ex); + TraeTools.Platform.CrashLog.Write("OverlayDialog.ShowAsync(overlay)", ex); return default!; } } - /// 对话框按钮统一关闭入口:Android 走覆盖层还原,桌面走 Window.Close(result)。 - public static void CloseDialog(Window dialog, object? result) + /// 对话框按钮统一关闭入口:Android 走覆盖层还原,桌面走宿主 Window.Close(result)。 + public static void CloseDialog(UserControl dialog, object? result) { if (IsOverlayMode && ReferenceEquals(_current, dialog)) { Finish(dialog, result); return; } - dialog.Close(result); + + try + { + var host = TopLevel.GetTopLevel(dialog) as Window; + if (host != null) + { + host.Close(result); + return; + } + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("OverlayDialog.CloseDialog", ex); + } } - private static TaskCompletionSource ShowCore(Window dialog) + private static TaskCompletionSource ShowCore(UserControl dialog) { if (_root == null || _host == null) - throw new InvalidOperationException("OverlayDialog 尚未 Attach(Android 主窗口未就绪)"); + throw new InvalidOperationException("OverlayDialog 尚未 Attach(主视图未就绪)"); if (_tcs != null) throw new InvalidOperationException("已有对话框在显示中,不允许叠加"); + // 手机屏幕较窄:清掉 XAML 中为桌面端写死的 Width,改为填满覆盖层卡片 + try + { + dialog.Width = double.NaN; + dialog.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch; + } + catch + { + // 尺寸调整失败不影响显示 + } + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _tcs = tcs; _current = dialog; - - // 把窗口内容临时移入覆盖层;显式设置 DataContext,阻断从视觉树继承到 MainViewModel - var content = dialog.Content as Control; - dialog.Content = null; - if (content != null) content.DataContext = dialog.DataContext; - _host.Content = content; + _host.Content = dialog; _root.IsVisible = true; return tcs; } - private static void Finish(Window dialog, object? result) + private static void Finish(UserControl dialog, object? result) { if (!ReferenceEquals(_current, dialog) || _tcs == null || _host == null || _root == null) return; - var content = _host.Content as Control; _host.Content = null; _root.IsVisible = false; - if (content != null && ReferenceEquals(_current.Content, null)) - { - // 还原回窗口(仅当窗口内容仍是空位,避免重复移动) - _current.Content = content; - } var tcs = _tcs; _tcs = null; diff --git a/Views/PatInputWindow.axaml b/Views/PatInputWindow.axaml index d4e4810..6192753 100644 --- a/Views/PatInputWindow.axaml +++ b/Views/PatInputWindow.axaml @@ -1,12 +1,10 @@ - @@ -34,5 +32,5 @@ - + diff --git a/Views/PatInputWindow.axaml.cs b/Views/PatInputWindow.axaml.cs index 85a2be9..fbb0e25 100644 --- a/Views/PatInputWindow.axaml.cs +++ b/Views/PatInputWindow.axaml.cs @@ -6,8 +6,11 @@ namespace TraeTools.Views; /// 粘贴 GitHub PAT 的输入窗口:返回 true 表示已提交有效文本(访问 PatText 获取)。 -public partial class PatInputWindow : Window +public partial class PatInputWindow : UserControl { + /// 桌面端承载窗口的标题(Android 单视图无窗口标题栏,不使用)。 + public string DialogTitle { get; set; } = "粘贴 GitHub Token(PAT)"; + public string? PatText { get; private set; } public PatInputWindow() diff --git a/Views/PromptWindow.axaml b/Views/PromptWindow.axaml index 3be6984..9479871 100644 --- a/Views/PromptWindow.axaml +++ b/Views/PromptWindow.axaml @@ -1,8 +1,7 @@ - @@ -14,4 +13,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Views/PromptWindow.axaml.cs b/Views/PromptWindow.axaml.cs index 8307e7b..25031a0 100644 --- a/Views/PromptWindow.axaml.cs +++ b/Views/PromptWindow.axaml.cs @@ -6,15 +6,18 @@ namespace TraeTools.Views; /// /// 通用确认/提示对话框。ShowDialog<bool> 返回:true=点了确认/知道了,false=取消。 /// -public partial class PromptWindow : Window +public partial class PromptWindow : UserControl { + /// 桌面端承载窗口的标题(Android 单视图无窗口标题栏,不使用)。 + public string DialogTitle { get; set; } = "提示"; + public PromptWindow() { InitializeComponent(); } public PromptWindow(string title, string message, string confirmText = "确定", string cancelText = "取消", bool showCancel = true) : this() { - Title = title; + DialogTitle = title; MessageText.Text = message; ConfirmButton.Content = confirmText; if (showCancel) diff --git a/version.json b/version.json index d18d9dc..5414c10 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "1.4.2", + "version": "1.4.3", "channel": "stable", "buildDate": "2026-10-14" } From 52d76eb1a7d825c9addbacde1cf2dfa6c9fc2941 Mon Sep 17 00:00:00 2001 From: akexin <315600306@qq.com> Date: Wed, 14 Oct 2026 06:40:13 +0800 Subject: [PATCH 20/37] =?UTF-8?q?feat(ui):=20v1.4.4=20=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E5=B1=8F=E9=80=82=E9=85=8D=E2=80=94=E2=80=94=E7=B4=A7=E5=87=91?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=EF=BC=88=E9=A1=B6=E6=A0=8F+=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E6=A0=87=E7=AD=BE=E6=A0=8F+=E5=8D=95=E5=88=97?= =?UTF-8?q?=E5=A0=86=E5=8F=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手机(360dp 级别屏宽)上桌面布局严重溢出:190px 侧栏占去一半屏宽, 仪表盘双列卡片与页头按钮被裁掉。本版按宿主实际宽度自动切换布局: - MainView 新增紧凑模式(IsCompact,宽度<640 逻辑像素触发,由 OnPropertyChanged(Bounds) 驱动,桌面窄窗口同样生效): * 侧栏隐藏,改为顶部品牌栏 + 底部标签栏(仪表盘/用量/签到/云端/设置) * 内容区边距 28,32 → 14,12 * 仪表盘:页头按钮堆叠到标题下方;趋势图与账号概览改上下排列 * 签到/设置/用量:双列卡片上下堆叠;用量 4 张汇总卡 2x2 排布 * 所有切换用 ^.compact 后代样式实现,宽屏/桌面布局零影响 - 修复 OverlayRoot 覆盖层未跨列的 bug:此前对话框宿主只占 Grid 第一列单元格,EULA 等弹窗整体偏在侧栏上;现显式 RowSpan=3/ColumnSpan=2 - 版本 1.4.4(versionCode 14) --- TraeTools.csproj | 4 +- ViewModels/MainViewModel.cs | 7 ++ Views/CheckinView.axaml | 6 +- Views/DashboardView.axaml | 14 +-- Views/MainView.axaml | 194 ++++++++++++++++++++++++++++++++++-- Views/MainView.axaml.cs | 24 +++++ Views/SettingsView.axaml | 16 +-- Views/UsageView.axaml | 16 +-- version.json | 2 +- 9 files changed, 244 insertions(+), 39 deletions(-) diff --git a/TraeTools.csproj b/TraeTools.csproj index 6256288..4fae015 100644 --- a/TraeTools.csproj +++ b/TraeTools.csproj @@ -42,8 +42,8 @@ Exe 26 cn.akexin.traetools - 1.4.3 - 13 + 1.4.4 + 14 apk false - + - + @@ -146,7 +146,7 @@ - + diff --git a/Views/DashboardView.axaml b/Views/DashboardView.axaml index 0b0e6d5..b4fd305 100644 --- a/Views/DashboardView.axaml +++ b/Views/DashboardView.axaml @@ -45,8 +45,8 @@ - - + + @@ -72,7 +72,7 @@ - + + + + + + + + + + - + NotSupportedException),所有对话框都以 UserControl 形式借用此覆盖层呈现;桌面端不使用。 + ⚠️ 必须显式 RowSpan/ColumnSpan,否则覆盖层只占一个单元格,对话框会被挤到侧栏位置 --> + - + diff --git a/Views/MainView.axaml.cs b/Views/MainView.axaml.cs index 049ca69..2e1bb01 100644 --- a/Views/MainView.axaml.cs +++ b/Views/MainView.axaml.cs @@ -86,6 +86,8 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) TraeTools.Platform.CrashLog.Boot("MainView: attached to visual tree"); try { + // 手机屏适配:按实际宽度切换紧凑模式(顶栏 + 底部标签栏) + UpdateCompactMode(Bounds.Width); // Android 端自动签到仅 App 运行期间有效;无人值守场景请使用「云端签到」 _autoCheckinTimer.Start(); } @@ -95,6 +97,28 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) } } + /// 宿主尺寸变化时切换紧凑模式(手机 <640 逻辑像素)。 + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + base.OnPropertyChanged(e); + if (e.Property == BoundsProperty && e.NewValue is Rect r) + UpdateCompactMode(r.Width); + } + + private void UpdateCompactMode(double width) + { + try + { + if (width <= 0) return; + if (DataContext is MainViewModel vm) + vm.IsCompact = width < 640; + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("MainView.UpdateCompactMode", ex); + } + } + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { try diff --git a/Views/SettingsView.axaml b/Views/SettingsView.axaml index acda671..e28a098 100644 --- a/Views/SettingsView.axaml +++ b/Views/SettingsView.axaml @@ -35,10 +35,10 @@ @@ -240,8 +259,3 @@ - - - - - diff --git a/Views/DeviceAuthWindow.axaml b/Views/DeviceAuthWindow.axaml index f637077..37c14d9 100644 --- a/Views/DeviceAuthWindow.axaml +++ b/Views/DeviceAuthWindow.axaml @@ -2,9 +2,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="TraeTools.Views.DeviceAuthWindow" Width="500" MaxWidth="620" - Background="White" + Background="{DynamicResource CardBgBrush}" FontFamily="Microsoft YaHei UI,Segoe UI"> - + diff --git a/Views/EulaWindow.axaml b/Views/EulaWindow.axaml index a9b7bf9..264512e 100644 --- a/Views/EulaWindow.axaml +++ b/Views/EulaWindow.axaml @@ -5,10 +5,10 @@ mc:Ignorable="d" d:DesignWidth="620" d:DesignHeight="600" x:Class="TraeTools.Views.EulaWindow" Width="620" MaxHeight="620" - Background="White" + Background="{DynamicResource CardBgBrush}" FontFamily="Microsoft YaHei UI,Segoe UI"> - + diff --git a/Views/InputWindow.axaml b/Views/InputWindow.axaml index f1ebdf3..17b6ab4 100644 --- a/Views/InputWindow.axaml +++ b/Views/InputWindow.axaml @@ -5,9 +5,9 @@ mc:Ignorable="d" d:DesignWidth="480" x:Class="TraeTools.Views.InputWindow" Width="480" MaxWidth="620" - Background="White" + Background="{DynamicResource CardBgBrush}" FontFamily="Microsoft YaHei UI,Segoe UI"> - + diff --git a/Views/LoginWindow.axaml b/Views/LoginWindow.axaml index 1962abc..4886754 100644 --- a/Views/LoginWindow.axaml +++ b/Views/LoginWindow.axaml @@ -2,9 +2,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="TraeTools.Views.LoginWindow" Width="540" Height="500" MaxWidth="620" - Background="White" + Background="{DynamicResource CardBgBrush}" FontFamily="Microsoft YaHei UI,Segoe UI"> - + diff --git a/Views/MainView.axaml b/Views/MainView.axaml index 6ce5309..cf5480d 100644 --- a/Views/MainView.axaml +++ b/Views/MainView.axaml @@ -6,7 +6,7 @@ mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="720" x:Class="TraeTools.Views.MainView" x:DataType="vm:MainViewModel" - Background="{StaticResource ContentBgBrush}" + Background="{DynamicResource ContentBgBrush}" FontFamily="Inter,Microsoft YaHei UI,Segoe UI"> @@ -14,26 +14,27 @@ - + @@ -54,7 +55,7 @@ @@ -114,21 +115,24 @@ @@ -137,21 +141,24 @@ IsVisible="{Binding IsSwitchSupported}" Command="{Binding NavigateCommand}" CommandParameter="switch"> - + @@ -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 @@ - + - + - - - - - - - - - - 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + - - - - - - + + + + + + + + + + + - - + + - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - -