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/EulaWindow.axaml.cs b/Android/Views/EulaWindow.axaml.cs
new file mode 100644
index 0000000..661a5fe
--- /dev/null
+++ b/Android/Views/EulaWindow.axaml.cs
@@ -0,0 +1,31 @@
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace TraeTools.Views;
+
+///
+/// 首次启动的最终用户许可协议对话框。
+/// ShowDialog<bool> 返回值:true=同意并继续,false=不同意并退出。
+///
+public partial class EulaWindow : UserControl
+{
+ public EulaWindow()
+ {
+ InitializeComponent();
+ // 勾选同意后才允许点「同意并继续」
+ AgreeCheck.IsCheckedChanged += (_, _) =>
+ {
+ AgreeButton.IsEnabled = AgreeCheck.IsChecked == true;
+ };
+ }
+
+ /// 与 Window.ShowDialog<TResult>(Window owner) 对齐的入口。
+ public Task ShowDialog(TopLevel? owner) => OverlayDialog.ShowAsync(this, owner);
+
+ private void OnAgreeClick(object? sender, RoutedEventArgs e)
+ => OverlayDialog.CloseDialog(this, true);
+
+ private void OnDeclineClick(object? sender, RoutedEventArgs e)
+ => OverlayDialog.CloseDialog(this, false);
+}
diff --git a/Android/Views/InputWindow.axaml b/Android/Views/InputWindow.axaml
new file mode 100644
index 0000000..f1525f0
--- /dev/null
+++ b/Android/Views/InputWindow.axaml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Android/Views/InputWindow.axaml.cs b/Android/Views/InputWindow.axaml.cs
new file mode 100644
index 0000000..c2dfbf6
--- /dev/null
+++ b/Android/Views/InputWindow.axaml.cs
@@ -0,0 +1,54 @@
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace TraeTools.Views;
+
+///
+/// 通用单行文本输入对话框。
+/// ShowDialog<string> 返回:确认时返回输入文本(trim 后),取消返回 null。
+///
+public partial class InputWindow : UserControl
+{
+ public string? ResultText { get; private set; }
+
+ public InputWindow()
+ {
+ InitializeComponent();
+ }
+
+ /// 标题。
+ /// 提示文案。
+ /// 输入框占位符。
+ public InputWindow(string title, string message, string placeholder)
+ : this()
+ {
+ TitleText.Text = title;
+ MessageText.Text = message;
+ InputBox.PlaceholderText = placeholder;
+ }
+
+ /// 与 Window.ShowDialog<TResult>(Window owner) 对齐的入口。
+ public Task ShowDialog(TopLevel? owner) => OverlayDialog.ShowAsync(this, owner);
+
+ private void OnOkClick(object? sender, RoutedEventArgs e)
+ {
+ var value = InputBox.Text?.Trim();
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ InputBox.Focus();
+ return;
+ }
+ ResultText = value;
+ OverlayDialog.CloseDialog(this, ResultText);
+ }
+
+ private void OnCancelClick(object? sender, RoutedEventArgs e)
+ => OverlayDialog.CloseDialog(this, null);
+
+ private void OnInputKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter) OnOkClick(sender, e);
+ }
+}
diff --git a/Android/Views/LoginWindow.axaml b/Android/Views/LoginWindow.axaml
new file mode 100644
index 0000000..b22dddc
--- /dev/null
+++ b/Android/Views/LoginWindow.axaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+手机端无法内嵌浏览器自动截取凭证,请按下述步骤手动获取 Cloud-IDE-Token。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Android/Views/LoginWindow.axaml.cs b/Android/Views/LoginWindow.axaml.cs
new file mode 100644
index 0000000..e0982cd
--- /dev/null
+++ b/Android/Views/LoginWindow.axaml.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using TraeCheckin;
+using TraeTools.ViewModels;
+
+namespace TraeTools.Views;
+
+///
+/// 登录对话框(Android)。
+///
+/// 桌面端优先走独立宿主进程里的内嵌 WebView2 自动读取 token + session;
+/// Android 没有 WebView2 宿主进程(Services/Login/** 也不在本工程编译范围内),
+/// 因此只保留「手动粘贴 Cloud-IDE-Token」这一条通道 ——
+/// 校验、写入账号、持久化的逻辑与桌面端逐字一致。
+///
+public partial class LoginWindow : UserControl
+{
+ private readonly TraeAccount? _account;
+
+ public LoginWindow()
+ {
+ InitializeComponent();
+ }
+
+ public LoginWindow(TraeAccount? account)
+ : this()
+ {
+ _account = account;
+ }
+
+ /// 与 Window.ShowDialog<TResult>(Window owner) 对齐的入口。
+ public Task ShowDialog(TopLevel? owner) => OverlayDialog.ShowAsync(this, owner);
+
+ /// 手机端获取 Token 的引导(桌面端此处是内嵌浏览器自动登录)。
+ private void OnAutoLoginClick(object? sender, RoutedEventArgs e)
+ {
+ AutoStatus.Text =
+ "1)在电脑浏览器打开 www.trae.cn 并登录;\n" +
+ "2)按 F12 打开开发者工具 → Application → Local Storage → 找到 Cloud-IDE-Token;\n" +
+ "3)复制它的值(一串以 eyJ 开头的 JWT,很长);\n" +
+ "4)回到本页粘贴到下方输入框并点「保存」。";
+ }
+
+ private void OnOpenBrowserClick(object? sender, RoutedEventArgs e)
+ => TraeTools.Platform.BrowserLauncher.Open("https://www.trae.cn");
+
+ private void OnSaveClick(object? sender, RoutedEventArgs e)
+ {
+ var pasted = TokenBox.Text?.Trim();
+ if (string.IsNullOrWhiteSpace(pasted))
+ {
+ StatusText.Text = "请先粘贴 Cloud-IDE-Token";
+ return;
+ }
+ if (_account == null)
+ {
+ StatusText.Text = "无当前账号,无法保存";
+ return;
+ }
+ var initial = _account.Token;
+ if (!TokenUtils.ShouldAcceptNewToken(initial, pasted))
+ {
+ StatusText.Text = "Token 无效(长度不足或与当前账号相同),请重新复制";
+ return;
+ }
+ _account.Token = pasted;
+ _account.TokenUpdatedAt = DateTime.Now;
+ var uid = TokenUtils.ParseAccountUid(pasted);
+ if (!string.IsNullOrEmpty(uid)) _account.AccountUid = uid;
+ MainViewModel.AppConfig?.Save();
+ OverlayDialog.CloseDialog(this, true);
+ }
+
+ private void OnCancelClick(object? sender, RoutedEventArgs e)
+ => OverlayDialog.CloseDialog(this, false);
+}
diff --git a/Android/Views/MainView.axaml b/Android/Views/MainView.axaml
new file mode 100644
index 0000000..0ef5cbb
--- /dev/null
+++ b/Android/Views/MainView.axaml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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