From 503a4d29758b1d4718c50d94f62dc30451254f15 Mon Sep 17 00:00:00 2001 From: akexin <315600306+akexin@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:53:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20=E6=96=B0=E5=A2=9E=20Android/?= =?UTF-8?q?=20=E7=9B=AE=E5=BD=95=EF=BC=8C=E4=B8=BA=20TraeTools=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20Android=20=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android/ 是独立工程(net10.0-android),Models/Services/ViewModels/Views/Styles/Assets 以 / 链接复用, 单一真源仍在仓库根目录,不存在第二份拷贝。 同时给根工程 TraeTools.csproj 增补 3 行 Remove: SDK 的 Compile(**/*.cs)、None(**/*)与 Avalonia 的 AvaloniaXaml(**/*.axaml) 默认通配符都会向下递归,若不排除 Android/,主程序会把 Android 源码一并编译, 实测报 AVLN2002(x:Class "TraeTools.App" 重复)并因 Android 命名空间无法解析而失败。 写法与既有的 保持一致。 新增 .github/workflows/android.yml,在 CI 中编译 Android 工程并产出 APK。 Windows 版的构建与发布流程不受影响:dotnet build TraeTools.csproj -c Release 仍通过, 产物 trae-tools-build 正常上传。 --- .github/workflows/android.yml | 36 +++ Android/App.axaml | 21 ++ Android/App.axaml.cs | 119 ++++++++++ Android/Platform/AndroidVault.cs | 138 ++++++++++++ Android/Platform/BrowserLauncher.cs | 101 +++++++++ Android/Platform/CrashLog.cs | 212 ++++++++++++++++++ Android/Platform/CrashReportActivity.cs | 125 +++++++++++ Android/Platform/DpapiCompat.cs | 54 +++++ Android/Platform/MainActivity.cs | 90 ++++++++ Android/Platform/PlatformLog.cs | 43 ++++ Android/Properties/AndroidManifest.xml | 23 ++ Android/README.md | 81 +++++++ .../drawable/ic_launcher_foreground.xml | 15 ++ .../mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + Android/Resources/values/colors.xml | 5 + Android/Resources/values/styles.xml | 13 ++ Android/Services/Checkin/AutoStartManager.cs | 27 +++ Android/Services/DataPaths.cs | 99 ++++++++ Android/TraeTools.Android.csproj | 172 ++++++++++++++ Android/ViewModels/UiHost.cs | 56 +++++ Android/Views/EulaWindow.axaml | 88 ++++++++ Android/Views/EulaWindow.axaml.cs | 31 +++ Android/Views/InputWindow.axaml | 29 +++ Android/Views/InputWindow.axaml.cs | 54 +++++ Android/Views/LoginWindow.axaml | 52 +++++ Android/Views/LoginWindow.axaml.cs | 78 +++++++ Android/Views/MainView.axaml | 137 +++++++++++ Android/Views/MainView.axaml.cs | 159 +++++++++++++ Android/Views/OverlayDialog.cs | 103 +++++++++ Android/Views/PatInputWindow.axaml | 37 +++ Android/Views/PatInputWindow.axaml.cs | 40 ++++ Android/Views/PromptWindow.axaml | 19 ++ Android/Views/PromptWindow.axaml.cs | 46 ++++ TraeTools.csproj | 8 + 35 files changed, 2321 insertions(+) create mode 100644 .github/workflows/android.yml create mode 100644 Android/App.axaml create mode 100644 Android/App.axaml.cs create mode 100644 Android/Platform/AndroidVault.cs create mode 100644 Android/Platform/BrowserLauncher.cs create mode 100644 Android/Platform/CrashLog.cs create mode 100644 Android/Platform/CrashReportActivity.cs create mode 100644 Android/Platform/DpapiCompat.cs create mode 100644 Android/Platform/MainActivity.cs create mode 100644 Android/Platform/PlatformLog.cs create mode 100644 Android/Properties/AndroidManifest.xml create mode 100644 Android/README.md create mode 100644 Android/Resources/drawable/ic_launcher_foreground.xml create mode 100644 Android/Resources/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 Android/Resources/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 Android/Resources/values/colors.xml create mode 100644 Android/Resources/values/styles.xml create mode 100644 Android/Services/Checkin/AutoStartManager.cs create mode 100644 Android/Services/DataPaths.cs create mode 100644 Android/TraeTools.Android.csproj create mode 100644 Android/ViewModels/UiHost.cs create mode 100644 Android/Views/EulaWindow.axaml create mode 100644 Android/Views/EulaWindow.axaml.cs create mode 100644 Android/Views/InputWindow.axaml create mode 100644 Android/Views/InputWindow.axaml.cs create mode 100644 Android/Views/LoginWindow.axaml create mode 100644 Android/Views/LoginWindow.axaml.cs create mode 100644 Android/Views/MainView.axaml create mode 100644 Android/Views/MainView.axaml.cs create mode 100644 Android/Views/OverlayDialog.cs create mode 100644 Android/Views/PatInputWindow.axaml create mode 100644 Android/Views/PatInputWindow.axaml.cs create mode 100644 Android/Views/PromptWindow.axaml create mode 100644 Android/Views/PromptWindow.axaml.cs diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..cf2674e --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,36 @@ +name: Android + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-android: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Install Android workload + run: dotnet workload install android + + - name: Build Android (Release) + run: dotnet build Android/TraeTools.Android.csproj -c Release --nologo + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: trae-tools-android-apk + path: Android/bin/Release/net10.0-android/*.apk + if-no-files-found: error diff --git a/Android/App.axaml b/Android/App.axaml new file mode 100644 index 0000000..7aa3b1e --- /dev/null +++ b/Android/App.axaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/Android/App.axaml.cs b/Android/App.axaml.cs new file mode 100644 index 0000000..3995e61 --- /dev/null +++ b/Android/App.axaml.cs @@ -0,0 +1,119 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using TraeTools.Platform; +using TraeTools.ViewModels; +using TraeTools.Views; + +namespace TraeTools; + +/// +/// Android 版应用入口。 +/// +/// 与仓库根的 App.axaml.cs 的区别只有两处: +/// 1) 没有 IClassicDesktopStyleApplicationLifetime 分支(单视图平台无 Window); +/// 2) Application 一律写全称 Avalonia.Application,与 Android.App.Application 区分。 +/// +public partial class App : Avalonia.Application +{ + public override void Initialize() + { + try + { + AvaloniaXamlLoader.Load(this); + CrashLog.Boot("App.Initialize completed"); + } + catch (Exception ex) + { + CrashLog.Write("App.Initialize", ex, true); + throw; + } + } + + public override void OnFrameworkInitializationCompleted() + { + CrashLog.Install(text => CrashReportActivity.Launch(text)); + CrashLog.Boot($"App.OnFrameworkInitializationCompleted lifetime={ApplicationLifetime?.GetType().FullName}"); + + if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) + { + // 上次运行崩溃过:本次先进错误页(可复制堆栈 / 重试),避免反复闪退 + var flag = CrashLog.ReadFlag(); + if (!string.IsNullOrEmpty(flag)) + { + CrashLog.Boot("App: previous crash detected -> startup error view"); + singleView.MainView = BuildErrorView(singleView, "上次启动崩溃了", flag!); + base.OnFrameworkInitializationCompleted(); + return; + } + + try + { + // 单视图生命周期:MainView(UserControl)作为根视图全屏展示。 + // 不能用 MainWindow(Window):单视图平台创建 Window 会抛 NotSupportedException。 + singleView.MainView = new MainView + { + DataContext = new MainViewModel(), + }; + CrashLog.Boot("App: single-view MainView created"); + } + catch (Exception ex) + { + // 主界面构建失败也不能闪退:显示可读的错误页 + CrashLog.Write("App: create MainView failed", ex, true); + singleView.MainView = BuildErrorView(singleView, "界面初始化失败", ex.ToString()); + } + } + + base.OnFrameworkInitializationCompleted(); + } + + /// 构建启动错误页:异常堆栈 + 启动日志 + 「清除并重新加载界面」。 + private static Control BuildErrorView( + ISingleViewApplicationLifetime lifetime, + string title, + string detail) + { + var body = detail + Environment.NewLine + Environment.NewLine + + "==== 启动日志(尾部)====" + Environment.NewLine + + CrashLog.ReadRecent(); + + var text = new SelectableTextBlock + { + Text = title + Environment.NewLine + Environment.NewLine + body, + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + FontFamily = new Avalonia.Media.FontFamily("Consolas,monospace"), + FontSize = 12, + Margin = new Thickness(16), + }; + + var retry = new Button + { + Content = "清除崩溃标记并重新加载界面", + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + Margin = new Thickness(16, 0, 16, 24), + Padding = new Thickness(16, 8), + }; + retry.Click += (_, _) => + { + CrashLog.ClearFlag(); + try + { + lifetime.MainView = new MainView { DataContext = new MainViewModel() }; + CrashLog.Boot("App: retry create MainView ok"); + } + catch (Exception ex) + { + CrashLog.Write("App: retry create MainView failed", ex, true); + } + }; + + var grid = new Grid { RowDefinitions = new RowDefinitions("*,Auto") }; + grid.Children.Add(new ScrollViewer { Content = text }); + Grid.SetRow(retry, 1); + grid.Children.Add(retry); + return grid; + } +} diff --git a/Android/Platform/AndroidVault.cs b/Android/Platform/AndroidVault.cs new file mode 100644 index 0000000..fc1f95b --- /dev/null +++ b/Android/Platform/AndroidVault.cs @@ -0,0 +1,138 @@ +using System.Security.Cryptography; +using System.Text; +using Android.Security.Keystore; +using Java.Security; +using Javax.Crypto; + +namespace TraeTools.Platform; + +/// +/// Android 本地密钥库(对应 Windows 侧的 DPAPI)。 +/// +/// 做法:Keystore 生成一对 RSA-2048(私钥不可导出,随应用卸载/清除数据消失), +/// 每次加密随机生成 AES-256-GCM 数据密钥,用 RSA 公钥包裹该密钥。 +/// +/// 密文格式:and-v1: + Base64(RSA 包裹的 AES 密钥) + : + Base64(nonce) +/// + : + Base64(tag + ciphertext)。前缀既是版本标记也是平台标记 —— +/// Windows 的 DPAPI 密文是裸 Base64,两边的密文天然互不通用, +/// 解密时一律先校验前缀,避免把明文误当密文解析。 +/// +internal static class AndroidVault +{ + /// 密文版本/平台标记。 + public const string Version = "and-v1"; + + /// 密文前缀。 + public const string Prefix = Version + ":"; + + private const string KeyAlias = "TraeTools.Secrets.RSA.v1"; + private const int NonceSize = 12; // AES-GCM 推荐 nonce 长度 + private const int TagSize = 16; // 128-bit 认证标签 + + /// 加密;失败返回 null(调用方保留原值,不写入半成品)。 + public static string? Protect(string plain) + { + if (string.IsNullOrEmpty(plain)) return 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")!; + // Cipher.EncryptMode 常量已废弃(CS0619,报错级),改用 CipherMode 枚举成员; + // 本文件同时 using 了 System.Security.Cryptography(含同名 CipherMode),故写全称 + wrapCipher.Init(Javax.Crypto.CipherMode.EncryptMode, pub); + var wrapped = wrapCipher.DoFinal(key); + if (wrapped == null) return null; + + 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); + + return Prefix + + Convert.ToBase64String(wrapped) + ":" + + Convert.ToBase64String(nonce) + ":" + + Convert.ToBase64String(tagAndCipher); + } + catch { return null; } + } + + /// 解密;失败(换机/清除数据/跨平台拷贝来的密文)返回 null。 + public static string? Unprotect(string enc) + { + // 必须带本平台前缀:历史上出现过「未加密的明文被当作密文送来」的情况, + // 此时应直接判失败让上层保留原值,而不是盲切前若干个字符去解析。 + if (!enc.StartsWith(Prefix, StringComparison.Ordinal)) return null; + try + { + var parts = enc[Prefix.Length..].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(Javax.Crypto.CipherMode.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(); + } +} diff --git a/Android/Platform/BrowserLauncher.cs b/Android/Platform/BrowserLauncher.cs new file mode 100644 index 0000000..08addf6 --- /dev/null +++ b/Android/Platform/BrowserLauncher.cs @@ -0,0 +1,101 @@ +using System; + +namespace TraeTools.Platform; + +/// +/// 用系统默认浏览器打开 URL(Android 实现)。 +/// +/// 失败必须可见:早先的实现把异常全部吞掉且返回 void,于是「授权 GitHub」 +/// 按钮点了没反应时,用户与日志都拿不到任何线索。现在返回 bool, +/// 并把失败原因写进平台日志(category = browser)。 +/// +/// 三级回退: +/// +/// 优先用当前 Activity 启动(前台切换最自然,无需 FLAG_ACTIVITY_NEW_TASK); +/// Activity 不可用时改用 Application 上下文 —— 此时必须带 NEW_TASK, +/// 否则抛 AndroidRuntimeException; +/// 个别定制 ROM 会拦截直接 startActivity,最后用 Intent.CreateChooser 再试一次。 +/// +/// +/// 浏览器属于「隐式 Intent 解析」,Android 11+ 的包可见性限制默认不影响 +/// startActivity,但为兼容个别 ROM 仍在清单里声明了 <queries>(见 Properties/AndroidManifest.xml)。 +/// +internal static class BrowserLauncher +{ + /// 打开 URL:成功返回 true;失败返回 false 并已写日志。 + public static bool Open(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + PlatformLog.Warn("browser", "打开浏览器失败:URL 为空"); + return false; + } + try + { + var uri = Android.Net.Uri.Parse(url); + if (uri == null) + { + PlatformLog.Warn("browser", $"打开浏览器失败:URL 无法解析({url})"); + return false; + } + + var activity = MainActivity.Current; + var context = Android.App.Application.Context; + + // ① 首选:Activity 直接启动 + if (activity != null) + { + try + { + activity.StartActivity(new Android.Content.Intent(Android.Content.Intent.ActionView, uri)); + return true; + } + catch (Exception ex) + { + PlatformLog.Warn("browser", "Activity 启动浏览器失败,回退 Application 上下文:" + ex.Message); + } + } + + if (context == null) + { + PlatformLog.Warn("browser", "打开浏览器失败:Android 上下文不可用(Application.Context 为 null)"); + return false; + } + + // ② 回退:Application 上下文(必须 NEW_TASK) + try + { + var intent = new Android.Content.Intent(Android.Content.Intent.ActionView, uri); + intent.AddFlags(Android.Content.ActivityFlags.NewTask); + context.StartActivity(intent); + return true; + } + catch (Exception ex) + { + PlatformLog.Warn("browser", "Application 启动浏览器失败,改用选择器:" + ex.Message); + } + + // ③ 再回退:让系统弹「用哪个应用打开」 + try + { + var view = new Android.Content.Intent(Android.Content.Intent.ActionView, uri); + view.AddFlags(Android.Content.ActivityFlags.NewTask); + var chooser = Android.Content.Intent.CreateChooser(view, "选择浏览器打开")!; + chooser.AddFlags(Android.Content.ActivityFlags.NewTask); + context.StartActivity(chooser); + return true; + } + catch (Exception ex) + { + PlatformLog.Warn("browser", + $"打开浏览器失败(三种方式均未成功):{ex.GetType().Name}: {ex.Message}(URL={url})"); + return false; + } + } + catch (Exception ex) + { + PlatformLog.Warn("browser", $"打开浏览器失败:{ex.GetType().Name}: {ex.Message}(URL={url})"); + return false; + } + } +} diff --git a/Android/Platform/CrashLog.cs b/Android/Platform/CrashLog.cs new file mode 100644 index 0000000..646d3ca --- /dev/null +++ b/Android/Platform/CrashLog.cs @@ -0,0 +1,212 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Android.Runtime; +using Android.Util; + +namespace TraeTools.Platform; + +/// +/// 全局崩溃 / 启动轨迹日志(Android 实现)。 +/// +/// 写入外部应用目录 +/// /storage/emulated/0/Android/data/<包名>/files/crash/(无需 root,手机文件管理器即可查看), +/// 并同步输出 logcat(tag: TraeTools)。 +/// +/// 闪退排查:把 log_日期.txt 发回即可。另提供崩溃标记(crash_flag): +/// 上一次运行若崩溃,本次启动直接进入错误页,保证用户至少能看到堆栈,而不是反复闪退。 +/// +public static class CrashLog +{ + private static readonly object LockObj = new(); + private static string? _dir; + private static bool _installed; + + /// 崩溃标记文件路径(存在即代表上次运行崩溃过)。 + public static string? FlagPath + { + get + { + try { return Path.Combine(TraeTools.Services.DataPaths.Root, "crash_flag"); } + catch { return null; } + } + } + + /// 日志文件所在目录(供界面展示)。 + public static string? LogDirectory => EnsureDir(); + + /// + /// 注册全局异常捕获。fatalHandler 会额外拉起原生崩溃报告页。 + /// 可重复调用(幂等)。 + /// + public static void Install(Action? fatalHandler = null) + { + if (_installed) return; + _installed = true; + + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + var ex = e.ExceptionObject as Exception; + Write("AppDomain.UnhandledException", ex, e.IsTerminating); + TryFatal(fatalHandler, ex); + }; + + TaskScheduler.UnobservedTaskException += (_, e) => + { + Write("TaskScheduler.UnobservedTaskException", e.Exception); + e.SetObserved(); + }; + + AndroidEnvironment.UnhandledExceptionRaiser += (_, e) => + { + Write("AndroidEnvironment.UnhandledExceptionRaiser", e.Exception); + // 反射设置 Handled=true:阻止 Android 立即杀掉进程,改为弹出可读的崩溃报告页。 + // 用反射是为了兼容不同 .NET Android 版本的事件参数差异(失败也不影响编译/运行)。 + try + { + e.GetType().GetProperty("Handled")?.SetValue(e, true); + } + catch + { + } + TryFatal(fatalHandler, e.Exception); + }; + } + + private static void TryFatal(Action? fatalHandler, Exception? ex) + { + if (fatalHandler == null) return; + try { fatalHandler(ex?.ToString() ?? "(未知异常)"); } + catch { } + } + + /// 记录启动轨迹(无异常也记,用于定位闪退卡在哪个环节)。 + public static void Boot(string message) + { + try + { + Log.Info("TraeTools", message); + Append($"[BOOT {DateTime.Now:HH:mm:ss.fff}] {message}"); + } + catch + { + // 日志失败不影响主流程 + } + } + + /// 记录异常。 + public static void Write(string source, Exception? ex, bool terminating = false) + { + try + { + var sb = new StringBuilder(); + sb.AppendLine($"===== {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{source}] terminating={terminating} ====="); + sb.AppendLine(ex?.ToString() ?? "(non-exception object)"); + sb.AppendLine(); + Log.Error("TraeTools", ex?.ToString() ?? source); + Append(sb.ToString()); + MarkCrashed(ex); + } + catch + { + // 忽略 + } + } + + /// 写崩溃标记(内容即异常摘要),供下次启动进入错误页。 + public static void MarkCrashed(Exception? ex) + { + try + { + var flag = FlagPath; + if (string.IsNullOrEmpty(flag)) return; + var root = Path.GetDirectoryName(flag); + if (!string.IsNullOrEmpty(root)) Directory.CreateDirectory(root); + File.WriteAllText(flag, + $"{DateTime.Now:yyyy-MM-dd HH:mm:ss}{Environment.NewLine}{ex?.ToString() ?? "(unknown)"}", + Encoding.UTF8); + } + catch + { + // 忽略 + } + } + + /// 读取崩溃标记内容(无则返回 null)。 + public static string? ReadFlag() + { + try + { + var flag = FlagPath; + if (string.IsNullOrEmpty(flag) || !File.Exists(flag)) return null; + return File.ReadAllText(flag, Encoding.UTF8); + } + catch { return null; } + } + + /// 清除崩溃标记(用户确认后恢复正常运行)。 + public static void ClearFlag() + { + try + { + var flag = FlagPath; + if (!string.IsNullOrEmpty(flag) && File.Exists(flag)) File.Delete(flag); + } + catch + { + // 忽略 + } + } + + /// 读取最近的日志文本(截断到尾部 maxChars 个字符),供错误页展示。 + public static string ReadRecent(int maxChars = 4000) + { + try + { + var dir = EnsureDir(); + if (dir == null) return "(日志目录不可用)"; + var file = Path.Combine(dir, $"log_{DateTime.Now:yyyyMMdd}.txt"); + if (!File.Exists(file)) return "(今日无日志)"; + var text = File.ReadAllText(file, Encoding.UTF8); + if (text.Length > maxChars) text = text[^maxChars..]; + return text; + } + catch (Exception ex) + { + return "(读取日志失败) " + ex.Message; + } + } + + private static void Append(string text) + { + lock (LockObj) + { + var dir = EnsureDir(); + if (dir == null) return; + File.AppendAllText(Path.Combine(dir, $"log_{DateTime.Now:yyyyMMdd}.txt"), + text + Environment.NewLine, Encoding.UTF8); + } + } + + private static string? EnsureDir() + { + if (_dir != null) return _dir; + try + { + // 外部应用专属目录:无需 root,文件管理器 / USB 直接可见 + var ext = Android.App.Application.Context.GetExternalFilesDir(null); + var baseDir = ext?.AbsolutePath; + // 外部存储不可用(未挂载/受限)时退回应用内部目录,日志同样可导出 + _dir = string.IsNullOrEmpty(baseDir) + ? Path.Combine(TraeTools.Services.DataPaths.Root, "logs", "crash") + : Path.Combine(baseDir, "crash"); + Directory.CreateDirectory(_dir); + return _dir; + } + catch + { + return null; + } + } +} diff --git a/Android/Platform/CrashReportActivity.cs b/Android/Platform/CrashReportActivity.cs new file mode 100644 index 0000000..1f9af1b --- /dev/null +++ b/Android/Platform/CrashReportActivity.cs @@ -0,0 +1,125 @@ +using System; +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.OS; + +namespace TraeTools.Platform; + +/// +/// 原生崩溃报告页。 +/// +/// 当 Avalonia 尚未启动完成、或已崩溃时,用 Android 原生 UI 展示异常堆栈, +/// 让用户看到的是「可读的错误页」而不是「一闪而过的闪退」:可复制、可重试重启。 +/// 使用 DeviceDefault 主题,避免依赖 AppCompat(保证任何阶段都能起来)。 +/// +[Activity( + Label = "TraeTools 崩溃报告", + Theme = "@android:style/Theme.DeviceDefault.Light.NoActionBar", + ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize)] +public class CrashReportActivity : Activity +{ + /// 传递崩溃文本的 Intent extra 键。 + public const string ExtraText = "crash_text"; + + /// 从任意上下文(含 Application)拉起崩溃报告页。 + public static void Launch(string text) + { + try + { + var ctx = global::Android.App.Application.Context; + var intent = new global::Android.Content.Intent(ctx, typeof(CrashReportActivity)); + intent.PutExtra(ExtraText, text); + intent.AddFlags(global::Android.Content.ActivityFlags.NewTask + | global::Android.Content.ActivityFlags.ClearTop + | global::Android.Content.ActivityFlags.ClearTask); + ctx.StartActivity(intent); + } + catch + { + // 兜底失败也不允许再次抛出(已进入崩溃流程) + } + } + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + var text = Intent?.GetStringExtra(ExtraText) ?? "(无异常内容)"; + + var scroll = new global::Android.Widget.ScrollView(this); + var root = new global::Android.Widget.LinearLayout(this) + { + Orientation = global::Android.Widget.Orientation.Vertical + }; + root.SetPadding(32, 32, 32, 32); + + var title = new global::Android.Widget.TextView(this) { Text = "TraeTools 启动失败" }; + title.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 20f); + root.AddView(title); + + var hint = new global::Android.Widget.TextView(this) + { + Text = "下面是崩溃详情,请截图或复制后反馈给开发者。" + }; + hint.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 13f); + hint.SetPadding(0, 8, 0, 12); + root.AddView(hint); + + var copy = new global::Android.Widget.Button(this) { Text = "复制错误信息" }; + copy.Click += (_, _) => + { + try + { + var cm = (global::Android.Content.ClipboardManager?) + GetSystemService(global::Android.Content.Context.ClipboardService); + if (cm != null) + { + var clip = global::Android.Content.ClipData.NewPlainText("TraeTools-crash", text); + // 各版本 .NET Android 的 API 形态不一致(PrimaryClip 属性 / SetPrimaryClip 方法), + // 用反射兼容,避免编译期绑定失败。 + var t = cm.GetType(); + var p = t.GetProperty("PrimaryClip"); + if (p != null && p.CanWrite) + { + p.SetValue(cm, clip); + } + else + { + var m = t.GetMethod("SetPrimaryClip", new[] { typeof(global::Android.Content.ClipData) }); + m?.Invoke(cm, new object[] { clip }); + } + } + global::Android.Widget.Toast.MakeText(this, "已复制", + global::Android.Widget.ToastLength.Short)?.Show(); + } + catch + { + } + }; + root.AddView(copy); + + var restart = new global::Android.Widget.Button(this) { Text = "重新启动 TraeTools" }; + restart.Click += (_, _) => + { + try + { + StartActivity(new global::Android.Content.Intent(this, typeof(MainActivity))); + Finish(); + } + catch + { + } + }; + root.AddView(restart); + + var detail = new global::Android.Widget.TextView(this) { Text = text }; + detail.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 10f); + detail.SetTextIsSelectable(true); + detail.SetPadding(0, 16, 0, 0); + root.AddView(detail); + + scroll.AddView(root); + SetContentView(scroll); + } +} diff --git a/Android/Platform/DpapiCompat.cs b/Android/Platform/DpapiCompat.cs new file mode 100644 index 0000000..bb4369d --- /dev/null +++ b/Android/Platform/DpapiCompat.cs @@ -0,0 +1,54 @@ +using System.Text; + +// ⚠️ 在 System.Security.Cryptography 命名空间下定义 BCL 同名类型是刻意的,理由见下方注释。 +namespace System.Security.Cryptography; + +/// 与 DPAPI 同名的作用域枚举(Android 上只有应用本地密钥库,语义上等同 CurrentUser)。 +public enum DataProtectionScope +{ + CurrentUser, + LocalMachine, +} + +/// +/// DPAPI 兼容层(仅 Android 编译)。 +/// +/// 背景:共享的 Services/Checkin/AppConfig.cs 用 +/// ProtectedData.Protect/Unprotect 加密 Token / Session / DeviceId 等敏感字段; +/// 而 System.Security.Cryptography.ProtectedData 是 Windows 专属包, +/// net10.0-android 上不存在该类型,直接编译会失败。 +/// +/// 本工程的前提是不改动仓库根目录下的任何文件,因此这里提供一份 +/// 同名同签名的兼容层,把 DPAPI 的语义映射到 +/// (Keystore + AES-256-GCM), +/// 于是上游的业务代码一行不改即可在手机上正常工作。 +/// +/// 两处语义差异,均已被上游代码的用法包容: +/// +/// 密文格式不同 —— AppConfig 只把结果当不透明字符串存取,跨平台拷贝时解密失败会 +/// 走它自己的兜底(字段置 null → 提示重新登录),不会把密文当明文使用; +/// 失败时抛 —— 与 DPAPI 行为一致, +/// AppConfig 的 DecryptSecret 本来就 catch 全部异常。 +/// +/// +/// 注:若某个 .NET Android 版本将来自带了同名类型,编译器会以本文件为准并给出 +/// CS0436 警告(构建时已抑制),行为仍然是我们的 Keystore 实现 —— 正是期望的结果。 +/// +public static class ProtectedData +{ + public static byte[] Protect(byte[] userData, byte[]? optionalEntropy, DataProtectionScope scope) + { + ArgumentNullException.ThrowIfNull(userData); + var encrypted = TraeTools.Platform.AndroidVault.Protect(Encoding.UTF8.GetString(userData)) + ?? throw new CryptographicException("Android Keystore 加密失败"); + return Encoding.UTF8.GetBytes(encrypted); + } + + public static byte[] Unprotect(byte[] encryptedData, byte[]? optionalEntropy, DataProtectionScope scope) + { + ArgumentNullException.ThrowIfNull(encryptedData); + var plain = TraeTools.Platform.AndroidVault.Unprotect(Encoding.UTF8.GetString(encryptedData)) + ?? throw new CryptographicException("Android Keystore 解密失败(换机 / 清除数据 / 跨平台拷贝)"); + return Encoding.UTF8.GetBytes(plain); + } +} diff --git a/Android/Platform/MainActivity.cs b/Android/Platform/MainActivity.cs new file mode 100644 index 0000000..4b194e4 --- /dev/null +++ b/Android/Platform/MainActivity.cs @@ -0,0 +1,90 @@ +using System; +using Android.App; +using Android.Content.PM; +using Android.Runtime; +using Avalonia; +using Avalonia.Android; +using TraeTools.Platform; + +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 +{ + /// + /// 当前 Activity 引用。应用只有这一个 Activity,用静态引用比从 Avalonia 生命周期反查更直接可靠 + /// (例如用系统浏览器打开授权页需要 Activity 上下文)。 + /// + public static MainActivity? Current { get; private set; } + + protected override void OnCreate(Bundle? savedInstanceState) + { + try + { + base.OnCreate(savedInstanceState); + Current = this; + CrashLog.Boot("MainActivity.OnCreate completed"); + } + catch (Exception ex) + { + // Activity 创建即失败时,用原生页面把异常显示出来(而不是静默闪退) + CrashLog.Write("MainActivity.OnCreate", ex, true); + CrashReportActivity.Launch(ex.ToString()); + } + } + + protected override void OnDestroy() + { + // 避免持有已销毁的 Activity 造成内存泄漏 + if (ReferenceEquals(Current, this)) Current = null; + base.OnDestroy(); + } +} + +/// Android Application:提供 Avalonia AppBuilder(Inter 字体 + 渲染后端选择)。 +[Application] +public class TraeAndroidApplication : AvaloniaAndroidApplication +{ + protected TraeAndroidApplication(nint javaReference, JniHandleOwnership transfer) + : base(javaReference, transfer) + { + // 进程内最早的可执行点(早于 Avalonia 初始化):先装好全局异常捕获, + // 之后任何阶段崩溃都会写日志并拉起原生崩溃报告页,而不再是「一闪而过」。 + try + { + CrashLog.Install(CrashReportActivity.Launch); + CrashLog.Boot("Application ctor: crash handler installed"); + } + catch + { + // 安装失败不能影响启动 + } + } + + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) + { + var b = base.CustomizeAppBuilder(builder); + try + { + // Avalonia 12 的 Android 渲染后端按 RenderingMode 顺序尝试(默认 [Egl, Software])。 + // 问题:EglPlatformGraphics.TryCreate() 在部分机型上不是返回 null 而是直接抛异常, + // 此时 Software 兜底根本不会被执行,平台层继续抛 → 进程直接死。 + // 因此这里显式只用软件渲染:本应用是轻量 IO 型工具,先保证能起来。 + b = b.With(new AndroidPlatformOptions + { + RenderingMode = new[] { AndroidRenderingMode.Software } + }); + CrashLog.Boot("CustomizeAppBuilder: RenderingMode=Software"); + } + catch (Exception ex) + { + CrashLog.Write("CustomizeAppBuilder", ex); + } + return b.WithInterFont(); + } +} diff --git a/Android/Platform/PlatformLog.cs b/Android/Platform/PlatformLog.cs new file mode 100644 index 0000000..6fde30d --- /dev/null +++ b/Android/Platform/PlatformLog.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; + +namespace TraeTools.Platform; + +/// +/// 平台层的最小日志出口。 +/// +/// 页面级日志走 ViewModels.AccountHelpers.AppLog(按业务分类归档), +/// 但 Platform/ 下的代码属于更底层的设施(加解密、浏览器跳转、崩溃捕获), +/// 不应反向依赖 ViewModels,因此在这里提供一个同格式、同目录的写入点: +/// 文件名与格式与页面日志保持一致({category}_log_yyyyMM.txt), +/// 打开日志目录时能看到完整的一批记录。 +/// +internal static class PlatformLog +{ + private static readonly object Lock = new(); + + public static void Info(string category, string message) => Write(category, "INFO", message); + + public static void Warn(string category, string message) => Write(category, "WARN", message); + + public static void Error(string category, string message) => Write(category, "ERROR", message); + + private static void Write(string category, string level, string message) + { + try + { + lock (Lock) + { + var file = Path.Combine( + TraeTools.Services.DataPaths.LogsDir, + $"{category}_log_{DateTime.Now:yyyyMM}.txt"); + File.AppendAllText(file, + $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{level}] {message}{Environment.NewLine}"); + } + } + catch + { + // 日志写入失败绝不能影响主流程(尤其是崩溃与加解密路径) + } + } +} diff --git a/Android/Properties/AndroidManifest.xml b/Android/Properties/AndroidManifest.xml new file mode 100644 index 0000000..7aa91d4 --- /dev/null +++ b/Android/Properties/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/Android/README.md b/Android/README.md new file mode 100644 index 0000000..ba28ebe --- /dev/null +++ b/Android/README.md @@ -0,0 +1,81 @@ +# TraeTools for Android + +给 TraeTools 加上 **Android 端**。本目录是一个独立的 `net10.0-android` 工程, +Windows 版的构建、发布流程与产物**不受影响**。 + +根目录只有 `TraeTools.csproj` 增加了 3 行 `Remove`(把本目录排除出主程序的默认通配符), +原因见下方「与根工程的关系」。除此之外,仓库其余文件一行未动。 + +## 包含内容 + +| 路径 | 说明 | +|---|---| +| `TraeTools.Android.csproj` | 独立的 `net10.0-android` 工程。共享代码(`Models/` `Services/` `ViewModels/` `Views/` 的六个页面、`Styles/` `Assets/`)以链接方式复用,单一真源仍在仓库根目录 | +| `App.axaml(.cs)` | Android 应用入口(单视图生命周期) | +| `Platform/` | `AndroidVault`(Keystore + AES-256-GCM)、`DpapiCompat`(DPAPI 兼容层)、`BrowserLauncher`、`CrashLog`、`CrashReportActivity`、`MainActivity`、`PlatformLog` | +| `Views/` | `MainView`(单视图外壳:顶栏 + 横向标签导航 + 覆盖层对话框宿主)、`OverlayDialog`、5 个对话框(`PromptWindow` / `InputWindow` / `PatInputWindow` / `LoginWindow` / `EulaWindow`) | +| `Services/` | `DataPaths`、`AutoStartManager` 的 Android 实现 | +| `ViewModels/UiHost.cs` | 把宿主解析结果放宽为 `TopLevel`,使上游 ViewModel 无需改动即可正常弹窗与访问剪贴板 | +| `Properties/` `Resources/` | 应用清单(INTERNET + 浏览器查询意图)、自适应图标、主题与配色 | + +## 编译 + +需要 .NET 10 SDK,并安装 Android 工作负载: + +```bash +dotnet workload install android +dotnet build Android/TraeTools.Android.csproj -c Release +``` + +产物:`Android/bin/Release/net10.0-android/*.apk`(最低 Android 8.0 / API 26)。 +包名 `com.traetools.android`,`versionName` 取自根目录 `version.json`。 +Release 默认使用调试密钥签名,正式分发请自行配置 keystore。 + +CI 见仓库根的 `.github/workflows/android.yml`(编译并上传 APK)。 + +## 与根工程的关系 + +本工程位于 Windows 主程序的工程目录之内,而 MSBuild 与 Avalonia 的默认通配符会向下递归, +且并不认「子目录里有自己的 csproj」: + +| 来源 | 默认通配符 | +|---|---| +| .NET SDK | `Compile Include="**/*.cs"`、`None Include="**/*"` | +| Avalonia | `AvaloniaXaml Include="**\*.axaml"`(另有 `**\*.paml`) | + +若不排除,主程序会把 Android 源码一并编译,实测报 +`AVLN2002: Duplicate x:Class directive, TraeTools.App is already used in /Android/App.axaml`, +并且 `Android/Platform/*.cs` 依赖的 `Android.App` / `Avalonia.Android` 在主程序里无法解析。 + +因此 `TraeTools.csproj` 中增加了: + +```xml + + + + + +``` + +写法与仓库里既有的 `` 一致。 + +## 平台差异 + +| 能力 | Windows | Android | +|---|---|---| +| 签到 / 自动签到 / 云端签到 / 用量统计 / 仪表盘 | ✅ | ✅ | +| 账号切换(vault 建档 / 冷切换) | ✅ 直接操作本地客户端 | ⚠️ 手机端无本地 Trae 客户端,页面仅作展示 | +| 登录方式 | ✅ 内嵌浏览器(WebView2 宿主)自动取凭证 | ✅ 手动粘贴 Cloud-IDE-Token | +| 开机自启 | ✅ 注册表 `Run` | ❌ 无等价机制,已降级为无操作 | +| 凭证加密 | DPAPI(当前用户) | Android Keystore(RSA-2048 包裹 AES-256-GCM 数据密钥) | +| 系统托盘 / 通知栏 | ✅ 托盘图标 + 原生菜单 | ❌ 无对应概念(自动签到在进程存活期间执行) | + +## 已知限制 + +- **应用内自动更新不可用**:上游 `SettingsViewModel` 的更新流程会下载 zip 并替换 exe, + 手机上无法执行。建议直接到 Releases 下载新 APK 覆盖安装。 +- **部分「打开网页」入口点了没反应**:上游 `CloudViewModel` / `SettingsViewModel` 里 + 打开浏览器走的是 `Process.Start(...)`,该调用在 Android 上不可用(失败已被 try/catch 捕获, + 日志里会留痕)。设备码授权流程仍可用 —— 授权码会自动复制到剪贴板, + 按日志提示手动访问授权页即可。要修好这两处需要给上游 VM 加一个平台跳转抽象(约 7 处调用点)。 +- 仅编译过 `net10.0-android`;未在真机长时间运行验证。 diff --git a/Android/Resources/drawable/ic_launcher_foreground.xml b/Android/Resources/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..705ae07 --- /dev/null +++ b/Android/Resources/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/Android/Resources/mipmap-anydpi-v26/ic_launcher.xml b/Android/Resources/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/Android/Resources/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/Resources/mipmap-anydpi-v26/ic_launcher_round.xml b/Android/Resources/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/Android/Resources/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/Resources/values/colors.xml b/Android/Resources/values/colors.xml new file mode 100644 index 0000000..f2637dd --- /dev/null +++ b/Android/Resources/values/colors.xml @@ -0,0 +1,5 @@ + + + #0F172A + #0F172A + diff --git a/Android/Resources/values/styles.xml b/Android/Resources/values/styles.xml new file mode 100644 index 0000000..79e5fc6 --- /dev/null +++ b/Android/Resources/values/styles.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/Android/Services/Checkin/AutoStartManager.cs b/Android/Services/Checkin/AutoStartManager.cs new file mode 100644 index 0000000..225ec1d --- /dev/null +++ b/Android/Services/Checkin/AutoStartManager.cs @@ -0,0 +1,27 @@ +namespace TraeCheckin; + +/// +/// 开机自启动(Android 实现)。 +/// +/// Windows 版通过注册表 HKCU\...\CurrentVersion\Run 注册 exe 路径; +/// Android 在应用层面没有等价的、无需额外权限的通用机制: +/// +/// BOOT_COMPLETED 广播 + 前台服务需要 RECEIVE_BOOT_COMPLETED 权限, +/// 且 Android 12+ 起后台启动前台服务受限,Play 政策也不允许「静默常驻」; +/// 本应用真正的「无人值守签到」场景由「云端签到」承担 +/// (GitHub Actions 每天定时跑 checkin.py,不依赖手机开机)。 +/// +/// 因此这里保留与原实现完全一致的 API,但固定返回「未启用 / 设置无效」, +/// 让设置页能如实展示「本平台不支持」,而不是静默假装成功。 +/// +internal static class AutoStartManager +{ + /// 是否已注册开机自启动 —— Android 恒为 false。 + public static bool IsEnabled() => false; + + /// 开启或关闭开机自启动 —— Android 为空操作(无注册表可写)。 + public static void SetEnabled(bool enabled) + { + // 有意留空:调用方(设置页)会依据 IsEnabled() 的返回值把开关复位并给出提示。 + } +} diff --git a/Android/Services/DataPaths.cs b/Android/Services/DataPaths.cs new file mode 100644 index 0000000..a76cb3a --- /dev/null +++ b/Android/Services/DataPaths.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; + +namespace TraeTools.Services; + +/// +/// 统一数据根目录(Android 实现)。 +/// +/// Windows 版落在 %APPDATA%\TraeTools;Android 没有「用户漫游目录」的概念, +/// 应用私有目录就是 Context.FilesDir(/data/data/<包名>/files, +/// 非 root 不可读、随卸载清除),因此根目录取它的 TraeTools 子目录。 +/// +/// 目录布局与桌面端保持一致(config.json / data / logs / switch / vault), +/// 这样 Services/Checkin/*、Services/Switch/* 等共享代码无需任何分支。 +/// +/// Migrate() 在本平台是空操作:TraeCheckin / TraeSwitch 的历史目录只存在于 Windows。 +/// +public static class DataPaths +{ + /// 根目录:应用私有 files 目录下的 TraeTools 子目录。 + public static string Root + { + get + { + try + { + var files = Android.App.Application.Context?.FilesDir?.AbsolutePath; + if (!string.IsNullOrEmpty(files)) + return Path.Combine(files!, "TraeTools"); + } + catch + { + // Context 尚未就绪时走下面的兜底路径 + } + // 兜底:极早期调用(Application.Context 为 null)时退化到 HOME 目录, + // 保证 Root 永远返回可用字符串而不是抛异常。 + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Personal), "TraeTools"); + } + } + + /// 签到配置 config.json。 + public static string ConfigPath => Path.Combine(Root, "config.json"); + + /// 数据目录:存放 checkin.db、history_*.txt、credits_total_*.txt、usage_*.jsonl。 + public static string DataDir + { + get + { + var dir = Path.Combine(Root, "data"); + Directory.CreateDirectory(dir); + return dir; + } + } + + /// 日志目录:存放各模块调试日志(checkin_log / account_log / cloud_log / switch_log / usage_log)。 + public static string LogsDir + { + get + { + var dir = Path.Combine(Root, "logs"); + Directory.CreateDirectory(dir); + return dir; + } + } + + /// 账号切换配置(settings.json)。 + public static string SwitchDir => Path.Combine(Root, "switch"); + + /// 登录态备份 vault。手机端不做客户端切换,该目录仅用于放置共享代码。 + public static string VaultDir => Path.Combine(Root, "vault"); + + /// 确保所有子目录存在(启动时调用一次)。 + public static void EnsureDirectories() + { + Directory.CreateDirectory(Root); + Directory.CreateDirectory(DataDir); + Directory.CreateDirectory(LogsDir); + Directory.CreateDirectory(SwitchDir); + Directory.CreateDirectory(VaultDir); + } + + /// + /// Windows 版用于把 TraeCheckin / TraeSwitch 历史目录迁移到统一根; + /// Android 是全新平台、没有历史目录可迁移,因此只保证目录存在。 + /// 保留同名方法是为了让共享的调用方(如各 ViewModel 的初始化路径)不改一行代码。 + /// + public static void Migrate() + { + try + { + EnsureDirectories(); + } + catch + { + // 目录创建失败不阻塞启动:后续写入会各自失败并提示 + } + } +} diff --git a/Android/TraeTools.Android.csproj b/Android/TraeTools.Android.csproj new file mode 100644 index 0000000..5655c47 --- /dev/null +++ b/Android/TraeTools.Android.csproj @@ -0,0 +1,172 @@ + + + + + + net10.0-android + Exe + + TraeTools + TraeTools + enable + enable + true + true + CA1416;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 + + + + + + 26 + + com.traetools.android + + $(_VJsonVer) + 1.0.0 + + 1 + apk + + + android-arm64;android-arm + + + false + false + None + false + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/ViewModels/UiHost.cs b/Android/ViewModels/UiHost.cs new file mode 100644 index 0000000..669117e --- /dev/null +++ b/Android/ViewModels/UiHost.cs @@ -0,0 +1,56 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; + +namespace TraeTools.ViewModels; + +/// +/// 解析当前可用于承载对话框的宿主,供页面 VM 弹模态与访问剪贴板 / 文件选择器。 +/// +/// 关键设计(整个 Android 移植能「零改动共享 VM」的原因): +/// 上游桌面版的 MainWindow 属性声明为 Window?,各 VM 的用法只有四种 —— +/// +/// dlg.ShowDialog<T>(owner):把 owner 透传给对话框; +/// owner != null ? … : …:判空守卫; +/// top?.Clipboard:复制 Token / 授权码; +/// win.StorageProvider:文件选择器。 +/// +/// 这四者全部是 的成员,而 Window 只是 TopLevel 的子类。 +/// 上游 VM 一律用 var 接收该属性,因此这里把返回类型放宽成 TopLevel? 后: +/// +/// 上游 VM 一行都不用改; +/// Android 单视图平台也能拿到非 null 的宿主(单视图的根视图反查 TopLevel), +/// owner != null 的守卫照样通过,弹窗与剪贴板全部可用。 +/// +/// 若这里坚持返回 Window?,Android 上恒为 null, +/// 上游那些 owner != null ? await … : false 的确认框会被整体跳过(功能性缺失)。 +/// +internal static class UiHost +{ + /// + /// 对话框宿主 / 剪贴板来源。桌面端是主窗口,Android 是承载单视图的根 TopLevel; + /// 尚未挂载到视觉树时返回 null(调用方已有判空守卫)。 + /// + public static TopLevel? MainWindow => Resolve(); + + private static TopLevel? Resolve() + { + try + { + var lifetime = Avalonia.Application.Current?.ApplicationLifetime; + + // 桌面端(本工程不编译该分支,保留以便共享代码语义完整) + if (lifetime is IClassicDesktopStyleApplicationLifetime desktop) + return desktop.MainWindow; + + // Android:单视图生命周期,从根视图反查 TopLevel + if (lifetime is ISingleViewApplicationLifetime singleView && singleView.MainView is Control root) + return TopLevel.GetTopLevel(root); + } + catch + { + // 视觉树尚未就绪 / 生命周期切换中:返回 null,由调用方的守卫兜底 + } + return null; + } +} diff --git a/Android/Views/EulaWindow.axaml b/Android/Views/EulaWindow.axaml new file mode 100644 index 0000000..8673c48 --- /dev/null +++ b/Android/Views/EulaWindow.axaml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + +TRAETOOLS 最终用户许可协议(EULA) + +欢迎使用本软件。在使用本软件前,请仔细阅读以下条款。 +点击「同意并继续」即表示您接受本协议全部内容;如不同意,请立即关闭并删除本软件。 + +1. 软件性质 + 本软件为作者个人技术学习成果,以开源形式(GPL-3.0)发布,按"现状"提供, + 不包含任何形式的明示或默示担保。本软件由「TRAE 每日签到助手」与 + 「TRAE 多账号冷切换助手」整合而来,本协议同时适用于两项功能。 + +2. 账号归属限制(使用前提) + 您只能将本软件用于管理您本人合法拥有并有权使用的 Trae 账号。 + 签到与冷切换均仅限本人账号场景,您不得使用本软件从事批量注册、账号交易、 + 积分倒卖、共享账号租赁、工作室多开、破坏平台正常运营或任何违反 + 中华人民共和国法律法规及 Trae 平台服务条款的行为。 + +3. 数据安全责任 + 本软件会在您的本地设备上存储登录凭证(Token / 会话 Cookie)以及 + 账号登录态备份(vault 目录,等同账号凭证)。您有责任确保这些文件 + 不被上传至公共网络、云盘或分享给任何第三方。 + 因您未妥善保管本地数据导致的账号被盗、信息泄露或其他损失,作者不承担责任。 + +4. 切换与登录态说明 + 账号冷切换依赖 vault 中保存的登录态字节。切换仅在本机完成, + 不涉及对 Trae 客户端程序代码的任何修改、注入或破解。 + 请您务必保管好 vault 目录,不要将其置于公共云盘或分享。 + +5. 平台关系声明 + 本软件与 Trae 及其运营方无任何关联。 + 如官方推出同类功能或要求停止使用,您应立即停用本软件。 + +6. 禁止逆向与滥用 + 您不得对本软件进行反编译、破解或二次开发后用于违反平台规则的目的。 + 您不得移除、修改或绕过本软件中的任何使用限制或安全提示。 + +7. 责任免除 + 在法律允许的最大范围内,作者不对因使用或无法使用本软件而产生的任何 + 直接、间接、附带或惩罚性损害承担责任,包括但不限于账号限制、积分调整、 + 数据丢失、登录态失效等。 + +8. 协议更新 + 作者保留随时修改本协议的权利,修改后的协议将在软件更新或仓库中公布。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/Views/MainView.axaml.cs b/Android/Views/MainView.axaml.cs new file mode 100644 index 0000000..8ffa194 --- /dev/null +++ b/Android/Views/MainView.axaml.cs @@ -0,0 +1,159 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; +using TraeTools.Platform; +using TraeTools.ViewModels; + +namespace TraeTools.Views; + +/// +/// Android 主视图(单视图根 UserControl)。 +/// +/// 承担三件上游由 MainWindow 负责的事: +/// 1) 把覆盖层挂给 (对话框宿主); +/// 2) 每 30 秒检查一次自动签到; +/// 3) 首次启动弹出 EULA。 +/// 桌面端那些能力(托盘图标、最小化到托盘、任务栏图标、系统通知)在手机上无对应概念,故不移植。 +/// +public partial class MainView : UserControl +{ + /// 自动签到定时器:每 30 秒检查一次,到点且当天未签时自动签到。 + private readonly DispatcherTimer _autoCheckinTimer; + + /// 计时器重入守卫:上一轮任务未结束前跳过本轮 Tick。 + private bool _tickBusy; + private bool _started; + private bool _eulaChecked; + + public MainView() + { + InitializeComponent(); + + // 覆盖层宿主:所有对话框以 UserControl 形式挂到这里 + OverlayDialog.Attach(OverlayRoot, OverlayContent); + 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) + { + // 与桌面端 MainWindow 的定时器保持一致的两步 + await vm.AutoCheckinIfDue(); + await vm.RefreshSnapshotIfNeeded(); + } + } + catch (Exception ex) + { + // 定时任务异常不允许带崩 UI(未捕获异常会直接闪退) + CrashLog.Write("AutoCheckin timer tick", ex); + } + finally + { + _tickBusy = false; + } + }; + + // EULA 需要视觉树就绪后才能取到 TopLevel,故放到 Loaded + Loaded += OnLoaded; + } + + private void OnLoaded(object? sender, RoutedEventArgs e) + { + if (_eulaChecked) return; + _eulaChecked = true; + CrashLog.Boot("MainView: loaded"); + try + { + _ = EulaCheckAsync(); + } + catch (Exception ex) + { + CrashLog.Write("MainView.OnLoaded", ex); + } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (_started) return; + _started = true; + CrashLog.Boot("MainView: attached to visual tree"); + try + { + // 自动签到仅在 App 运行期间有效;长期无人值守请使用「云端签到」 + _autoCheckinTimer.Start(); + } + catch (Exception ex) + { + CrashLog.Write("MainView.OnAttachedToVisualTree", ex); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + try { _autoCheckinTimer.Stop(); } + catch { /* 停止定时器失败不影响退出 */ } + base.OnDetachedFromVisualTree(e); + } + + /// + /// 首次启动弹出免责声明协议(EULA)。同意标记存放于数据根目录 eula_accepted, + /// 与 SettingsViewModel 里「重置协议同意状态」用的是同一个路径,两边保持一致。 + /// + private async Task EulaCheckAsync() + { + try + { + var flag = Path.Combine(TraeTools.Services.DataPaths.Root, "eula_accepted"); + if (File.Exists(flag)) return; + + CrashLog.Boot("EULA: showing agreement"); + var eula = new EulaWindow(); + bool agreed; + try + { + agreed = await eula.ShowDialog(TopLevel.GetTopLevel(this)); + } + catch (Exception ex) + { + // 兜底:对话框呈现失败则记录并视为同意,保证应用可用(日志留痕) + CrashLog.Write("EULA dialog failed", ex); + agreed = true; + } + CrashLog.Boot($"EULA: agreed={agreed}"); + + if (agreed) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(flag)!); + File.WriteAllText(flag, DateTime.UtcNow.ToString("o")); + } + catch + { + // 标记写失败下次仍会弹,可接受 + } + } + else + { + // 不同意:与桌面端「直接退出」对齐 —— 手机端关闭唯一 Activity 即回到桌面。 + // 不做 Process.Kill:那会让系统记录成异常退出。 + CrashLog.Boot("EULA: declined -> finish activity"); + MainActivity.Current?.Finish(); + } + } + catch (Exception ex) + { + CrashLog.Write("MainView.EulaCheckAsync", ex); + } + } +} diff --git a/Android/Views/OverlayDialog.cs b/Android/Views/OverlayDialog.cs new file mode 100644 index 0000000..0fb5381 --- /dev/null +++ b/Android/Views/OverlayDialog.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; + +namespace TraeTools.Views; + +/// +/// 覆盖层模态对话框宿主(Android)。 +/// +/// 为什么必须有这一层:Avalonia 的 Android 后端是单视图平台, +/// new Window() 会在 Avalonia.Android.WindowingPlatformStub.CreateWindow() +/// 抛 NotSupportedException,所以整个移植里所有对话框内容都写成 , +/// 由本类把它们挂到主视图的全屏覆盖层上呈现,并用 +/// 模拟「模态 + 返回值」的语义。 +/// +/// 与上游 VM 的衔接:上游 ViewModel 调用的是 dlg.ShowDialog<bool>(owner) +/// (Window 的实例方法)。Android\Views\ 下的对话框类因此都额外提供了一个 +/// 同名的 ShowDialog<TResult>(TopLevel? owner) 方法,内部转调本类 —— +/// 于是共享 ViewModel 一行都不用改。 +/// +public static class OverlayDialog +{ + private static Panel? _root; + private static ContentPresenter? _host; + private static UserControl? _current; + private static TaskCompletionSource? _tcs; + + /// 由主视图在构造时挂接覆盖层容器。 + public static void Attach(Panel root, ContentPresenter host) + { + _root = root; + _host = host; + } + + /// + /// 显示对话框并等待其关闭,返回值由 传入。 + /// 只是为了与 Window.ShowDialog(owner) 的调用形态对齐 —— + /// 覆盖层是全屏的,不需要 owner,允许为 null。 + /// + public static async Task ShowAsync(UserControl dialog, TopLevel? owner = null) + { + try + { + var result = await ShowCore(dialog).Task; + return result is T typed ? typed : default!; + } + catch (Exception ex) + { + // 呈现失败时降级返回默认值:绝不让对话框问题带崩应用(Android 上即闪退), + // 调用方拿到 false/null 会按「用户取消」处理。 + Platform.CrashLog.Write("OverlayDialog.ShowAsync", ex); + return default!; + } + } + + /// 对话框按钮的统一关闭入口(各对话框的 Click 处理里调用)。 + public static void CloseDialog(UserControl dialog, object? result) => Finish(dialog, result); + + private static TaskCompletionSource ShowCore(UserControl dialog) + { + if (_root == null || _host == null) + throw new InvalidOperationException("OverlayDialog 尚未 Attach(主视图未就绪)"); + if (_tcs != null) + throw new InvalidOperationException("已有对话框在显示中,不允许叠加"); + + // 手机屏宽只有桌面端的 1/3:清掉为桌面端写死的尺寸约束, + // 让对话框横向铺满覆盖层、纵向由各自的 VerticalAlignment 决定 + //(确认框 Center 只占内容高度;EULA 这类长内容用 Stretch 吃满可用高度并在内部滚动)。 + try + { + dialog.Width = double.NaN; + dialog.MinWidth = 0; + dialog.MinHeight = 0; + dialog.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch; + } + catch + { + // 尺寸调整失败不影响显示 + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _tcs = tcs; + _current = dialog; + _host.Content = dialog; + _root.IsVisible = true; + return tcs; + } + + private static void Finish(UserControl dialog, object? result) + { + if (!ReferenceEquals(_current, dialog) || _tcs == null || _host == null || _root == null) + return; + + _host.Content = null; + _root.IsVisible = false; + + var tcs = _tcs; + _tcs = null; + _current = null; + tcs.TrySetResult(result); + } +} diff --git a/Android/Views/PatInputWindow.axaml b/Android/Views/PatInputWindow.axaml new file mode 100644 index 0000000..6473851 --- /dev/null +++ b/Android/Views/PatInputWindow.axaml @@ -0,0 +1,37 @@ + + + + + + + + + +