From 2aca7866b30afb7147edcbc95b4a158a6f9e83bc Mon Sep 17 00:00:00 2001
From: akexin <315600306+akexin@users.noreply.github.com>
Date: Wed, 14 Oct 2026 22:27:58 +0800
Subject: [PATCH] =?UTF-8?q?feat(android):=20=E6=96=B0=E5=A2=9E=20net10.0-a?=
=?UTF-8?q?ndroid=20=E7=9B=AE=E6=A0=87=EF=BC=8C=E4=B8=80=E5=A5=97=E4=BB=A3?=
=?UTF-8?q?=E7=A0=81=E5=90=8C=E6=97=B6=E4=BA=A7=E5=87=BA=20Windows=20?=
=?UTF-8?q?=E4=B8=8E=20Android=20=E5=AE=A2=E6=88=B7=E7=AB=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
把 Windows 专属实现下沉到 Platform/ 与 TFM 条件编译,业务层零分叉:
- TraeTools.csproj 改为双目标 net10.0-windows;net10.0-android。Windows 侧的
AOT / 裁剪 / 单文件 / RuntimeIdentifier 全部保留在 windows 条件组内,现有
Windows 发布流程零改动(release.yml 仅追加 -f net10.0-windows 消除多目标歧义)
- 新增 Platform/:Secrets(DPAPI ↔ Keystore+AES-GCM 双实现)、DataPaths、
CrashLog、CrashReportActivity、BrowserLauncher、PlatformLog、MainActivity
- 弹窗一律由 Window 改为 UserControl:Android 走 ISingleViewApplicationLifetime
时 new Window() 会抛 NotSupportedException;新增 OverlayDialog 双模宿主
(桌面端包 DialogHostWindow,Android 端挂全屏覆盖层并用 TCS 模拟模态)
- 新增 MainView 单视图外壳(品牌头 + 胶囊导航 + 全屏弹窗层);MainWindow 精简为
桌面壳,仅保留托盘、原生菜单与最小化到托盘
- Android 侧降级:开机自启→无操作;内嵌浏览器登录→手动粘贴 Token;
应用内更新→引导至 Release 页下载 APK 覆盖安装
- android TFM 显式关闭 AOT / 裁剪 / 链接器:.NET for Android 在 Release 下默认
开启 LLVM AOT,会因 SQLitePCLRaw 等反射路径抛 MissingRuntimeArtifactException
- version.json 的读取从 Target 上移到求值期 PropertyGroup,否则 AndroidManifest
的 versionName 会停在静态兜底值,与 App 内「关于」显示的版本互相矛盾
- ci.yml 拆分为 build-windows / build-android 两个作业,并新增 workflow_dispatch
- README 补充 Android 章节(功能差异表 / 构建方式 / 实现要点)
Windows Debug/Release 编译 0 警告 0 错误。
---
.github/workflows/ci.yml | 40 ++-
.github/workflows/release.yml | 6 +-
App.axaml.cs | 134 +++++++++-
Platform/BrowserLauncher.cs | 113 +++++++++
Platform/CrashLog.cs | 235 ++++++++++++++++++
Platform/CrashReportActivity.cs | 127 ++++++++++
Platform/MainActivity.cs | 94 +++++++
Platform/PlatformLog.cs | 43 ++++
Platform/Secrets.Android.cs | 125 ++++++++++
Platform/Secrets.Windows.cs | 34 +++
Platform/Secrets.cs | 53 ++++
Program.cs | 8 +-
Properties/AndroidManifest.xml | 23 ++
README.md | 54 +++-
Resources/drawable/ic_launcher_foreground.xml | 15 ++
Resources/mipmap-anydpi-v26/ic_launcher.xml | 5 +
.../mipmap-anydpi-v26/ic_launcher_round.xml | 5 +
Resources/values/colors.xml | 5 +
Resources/values/styles.xml | 13 +
Services/Checkin/AppConfig.cs | 60 ++---
Services/Checkin/AutoStartManager.cs | 20 +-
Services/DataPaths.cs | 31 ++-
TraeTools.csproj | 117 ++++++---
ViewModels/CheckinViewModel.cs | 4 +-
ViewModels/CloudViewModel.cs | 8 +-
ViewModels/DashboardViewModel.cs | 5 +-
ViewModels/SettingsViewModel.cs | 36 ++-
ViewModels/SwitchViewModel.cs | 4 +-
ViewModels/UiHost.cs | 61 ++++-
Views/DeviceAuthWindow.axaml | 8 +-
Views/DeviceAuthWindow.axaml.cs | 17 +-
Views/DialogHostWindow.axaml | 16 ++
Views/DialogHostWindow.axaml.cs | 32 +++
Views/EulaWindow.axaml | 7 +-
Views/EulaWindow.axaml.cs | 9 +-
Views/IDialogView.cs | 15 ++
Views/InputWindow.axaml | 8 +-
Views/InputWindow.axaml.cs | 12 +-
Views/LoginWindow.axaml | 9 +-
Views/LoginWindow.axaml.cs | 41 +--
Views/MainView.axaml | 132 ++++++++++
Views/MainView.axaml.cs | 160 ++++++++++++
Views/MainWindow.axaml | 82 +-----
Views/MainWindow.axaml.cs | 83 ++-----
Views/OverlayDialog.cs | 146 +++++++++++
Views/PatInputWindow.axaml | 9 +-
Views/PatInputWindow.axaml.cs | 14 +-
Views/PromptWindow.axaml | 8 +-
Views/PromptWindow.axaml.cs | 11 +-
49 files changed, 1970 insertions(+), 327 deletions(-)
create mode 100644 Platform/BrowserLauncher.cs
create mode 100644 Platform/CrashLog.cs
create mode 100644 Platform/CrashReportActivity.cs
create mode 100644 Platform/MainActivity.cs
create mode 100644 Platform/PlatformLog.cs
create mode 100644 Platform/Secrets.Android.cs
create mode 100644 Platform/Secrets.Windows.cs
create mode 100644 Platform/Secrets.cs
create mode 100644 Properties/AndroidManifest.xml
create mode 100644 Resources/drawable/ic_launcher_foreground.xml
create mode 100644 Resources/mipmap-anydpi-v26/ic_launcher.xml
create mode 100644 Resources/mipmap-anydpi-v26/ic_launcher_round.xml
create mode 100644 Resources/values/colors.xml
create mode 100644 Resources/values/styles.xml
create mode 100644 Views/DialogHostWindow.axaml
create mode 100644 Views/DialogHostWindow.axaml.cs
create mode 100644 Views/IDialogView.cs
create mode 100644 Views/MainView.axaml
create mode 100644 Views/MainView.axaml.cs
create mode 100644 Views/OverlayDialog.cs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ee536d6..aeaad55 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,12 +5,13 @@ on:
branches: [main]
pull_request:
branches: [main]
+ workflow_dispatch:
permissions:
contents: read
jobs:
- build:
+ build-windows:
runs-on: windows-latest
steps:
- name: Checkout
@@ -22,12 +23,41 @@ jobs:
with:
dotnet-version: "10.0.x"
- - name: Build (Release)
- run: dotnet build TraeTools.csproj -c Release --nologo
+ # 主程序现在是双目标(net10.0-windows;net10.0-android),本作业只负责桌面端:
+ # 用 -f 限定框架,否则会连带构建 android TFM 而本作业未安装 Android workload
+ - name: Build Windows (Release)
+ run: dotnet build TraeTools.csproj -c Release -f net10.0-windows --nologo
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
- name: trae-tools-build
+ name: trae-tools-build-windows
path: bin/Release/net10.0-windows/
- if-no-files-found: error
\ No newline at end of file
+ if-no-files-found: error
+
+ build-android:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 10
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ # Android workload:含 Microsoft.Android SDK / OpenJDK 等构建依赖(首次安装约 5-10 分钟)
+ - name: Install Android workload
+ run: dotnet workload install android
+
+ # EnableWindowsTargeting:双目标项目还原时会一并还原 net10.0-windows,
+ # ubuntu 上必须允许下载 Windows 目标包,否则报 NETSDK1100
+ - name: Build Android (Release)
+ run: dotnet build TraeTools.csproj -c Release -f net10.0-android -p:EnableWindowsTargeting=true --nologo
+
+ - name: Upload APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: trae-tools-android-apk
+ path: bin/Release/net10.0-android/*.apk
+ if-no-files-found: error
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index eae2c43..e684645 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,14 +30,16 @@ jobs:
# libsodium 必须与 exe 同目录,只替换 exe 会得到"缺 DLL 打不开"的残废程序。
# 应用内更新器(UpdaterService)现在下载 `TraeTools-win-x64.zip` → 校验 SHA-256 → 解包 →
# 整套文件原子替换 → 重启;zip 压缩也抵消了 AOT 原生库带来的体积增长。
+ # ⚠️ 主程序是双目标(net10.0-windows;net10.0-android),必须用 -f 限定框架:
+ # 否则 -r win-x64 会渗入 android TFM 的还原,报 NU1102(找不到 Mono 的 win-x64 运行时包)。
- name: Publish main (NativeAOT directory form)
- run: dotnet publish TraeTools.csproj -c Release -r win-x64 --self-contained true -p:EnableAot=true -o publish_aot
+ run: dotnet publish TraeTools.csproj -c Release -f net10.0-windows -r win-x64 --self-contained true -p:EnableAot=true -o publish_aot
# 兼容引导包:历史客户端内的旧更新器只会下载名为 `TraeTools.exe` 的资产并替换自身。
# 提供单文件自包含版(csproj 已开 PublishTrimmed=full,约 23MB),老客户端可一次性升到新版,
# 之后其内置的新更新器会改走上面的 zip 整包。
- name: Publish main (single-file bootstrap, legacy updater compatible)
- run: dotnet publish TraeTools.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -o publish
+ run: dotnet publish TraeTools.csproj -c Release -f net10.0-windows -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -o publish
# P2-1:登录宿主(WinForms + WebView2)是独立进程,不参与主程序的 NativeAOT,
# 因此单独发布并随压缩包/Release 一起分发;缺失时主程序自动回退到"手动粘贴 Token"。
diff --git a/App.axaml.cs b/App.axaml.cs
index eb816f8..801e4eb 100644
--- a/App.axaml.cs
+++ b/App.axaml.cs
@@ -7,26 +7,152 @@
namespace TraeTools;
-public partial class App : Application
+///
+/// 应用入口(双平台)。
+/// 注意:.NET Android SDK 会生成含 Android.App 的全局 using,
+/// 本文件中的 Application 一律写全称 Avalonia.Application,否则与之歧义(CS0104)。
+///
+public partial class App : Avalonia.Application
{
- /// 系统托盘图标全局引用(TrayIcon 无源头引用会被 GC 回收,故存于此)。
+ /// 系统托盘图标全局引用(TrayIcon 无源头引用会被 GC 回收,故存于此;仅桌面端使用)。
public static TrayIcon? TrayRef;
+ ///
+ /// 「需要真正退出」的退出入口。桌面端由 MainWindow 挂上(关闭窗口默认是「最小化到托盘」,
+ /// 该行为会拦住退出),Android 上没有窗口, 会退回
+ /// ApplicationLifetime 的默认退出方式。
+ ///
+ public static System.Action? ExitRequested;
+
+ /// 请求退出应用(不同意 EULA 等场景使用)。
+ public static void RequestExit()
+ {
+ try
+ {
+ if (ExitRequested != null)
+ {
+ ExitRequested();
+ return;
+ }
+ if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ desktop.Shutdown();
+ }
+ catch
+ {
+ // 退出失败不阻塞其他逻辑
+ }
+ }
+
public override void Initialize()
{
- AvaloniaXamlLoader.Load(this);
+ try
+ {
+ AvaloniaXamlLoader.Load(this);
+ Platform.CrashLog.Boot("App.Initialize completed");
+ }
+ catch (System.Exception ex)
+ {
+ Platform.CrashLog.Write("App.Initialize", ex, true);
+ throw;
+ }
}
public override void OnFrameworkInitializationCompleted()
{
+ Platform.CrashLog.Install();
+ Platform.CrashLog.Boot(
+ $"App.OnFrameworkInitializationCompleted lifetime={ApplicationLifetime?.GetType().FullName}");
+
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainViewModel(),
};
+ Platform.CrashLog.Boot("App: desktop MainWindow created");
}
+#if ANDROID
+ else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
+ {
+ // 上次运行崩溃过:本次先进错误页(可复制堆栈 / 重试),避免反复闪退
+ var flag = Platform.CrashLog.ReadFlag();
+ if (!string.IsNullOrEmpty(flag))
+ {
+ Platform.CrashLog.Boot("App: previous crash detected -> startup error view");
+ singleView.MainView = BuildErrorView(singleView, "上次启动崩溃了", flag);
+ base.OnFrameworkInitializationCompleted();
+ return;
+ }
+
+ try
+ {
+ // Android:单视图生命周期,MainView(UserControl)作为根视图全屏展示。
+ // 不能用 MainWindow(Window):单视图平台创建 Window 会抛 NotSupportedException。
+ singleView.MainView = new MainView
+ {
+ DataContext = new MainViewModel(),
+ };
+ Platform.CrashLog.Boot("App: single-view MainView created");
+ }
+ catch (System.Exception ex)
+ {
+ // 主界面构建失败也不能闪退:显示可读的错误页
+ Platform.CrashLog.Write("App: create MainView failed", ex, true);
+ singleView.MainView = BuildErrorView(singleView, "界面初始化失败", ex.ToString());
+ }
+ }
+#endif
base.OnFrameworkInitializationCompleted();
}
-}
\ No newline at end of file
+
+#if ANDROID
+ /// 构建启动错误页:异常堆栈 + 启动日志 + 「清除并重新加载界面」。
+ private static Control BuildErrorView(
+ ISingleViewApplicationLifetime lifetime,
+ string title,
+ string detail)
+ {
+ var body = detail + System.Environment.NewLine + System.Environment.NewLine
+ + "==== 启动日志(尾部)====" + System.Environment.NewLine
+ + Platform.CrashLog.ReadRecent();
+
+ var text = new SelectableTextBlock
+ {
+ Text = title + System.Environment.NewLine + System.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 += (_, _) =>
+ {
+ Platform.CrashLog.ClearFlag();
+ try
+ {
+ lifetime.MainView = new MainView { DataContext = new MainViewModel() };
+ Platform.CrashLog.Boot("App: retry create MainView ok");
+ }
+ catch (System.Exception ex)
+ {
+ Platform.CrashLog.Write("App: retry create MainView failed", ex, true);
+ }
+ };
+
+ var grid = new Grid { RowDefinitions = new RowDefinitions("*,Auto") };
+ var scroller = new ScrollViewer { Content = text };
+ grid.Children.Add(scroller);
+ Grid.SetRow(retry, 1);
+ grid.Children.Add(retry);
+ return grid;
+ }
+#endif
+}
diff --git a/Platform/BrowserLauncher.cs b/Platform/BrowserLauncher.cs
new file mode 100644
index 0000000..50ae2d8
--- /dev/null
+++ b/Platform/BrowserLauncher.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Diagnostics;
+
+namespace TraeTools.Platform;
+
+///
+/// 用系统默认浏览器打开 URL(跨平台封装)。
+///
+/// 失败必须可见:早期实现把异常全部吞掉且返回 void,于是 Android 上
+/// 「授权 GitHub」按钮点了没反应时,用户与日志都拿不到任何线索。
+/// 现在返回 bool,并把失败原因写进平台日志(category = browser)。
+///
+/// Android 侧要点:
+///
+/// - 优先用 Activity 启动(MainActivity.Current),Activity 不可用时回退 Application 上下文
+/// —— 后者必须带 FLAG_ACTIVITY_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
+ {
+#if ANDROID
+ return OpenOnAndroid(url);
+#else
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ return true;
+#endif
+ }
+ catch (Exception ex)
+ {
+ PlatformLog.Warn("browser", $"打开浏览器失败:{ex.GetType().Name}: {ex.Message}(URL={url})");
+ return false;
+ }
+ }
+
+#if ANDROID
+ private static bool OpenOnAndroid(string url)
+ {
+ var uri = Android.Net.Uri.Parse(url);
+ if (uri == null)
+ {
+ PlatformLog.Warn("browser", $"打开浏览器失败:URL 无法解析({url})");
+ return false;
+ }
+
+ var context = Android.App.Application.Context;
+ var activity = TraeTools.MainActivity.Current;
+
+ // ① 首选:Activity 直接启动(无需 NEW_TASK,前台切换最自然)
+ if (activity != null)
+ {
+ try
+ {
+ var intent = new Android.Content.Intent(Android.Content.Intent.ActionView, uri);
+ activity.StartActivity(intent);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ PlatformLog.Warn("browser", "Activity 启动浏览器失败,回退 Application 上下文:" + ex.Message);
+ }
+ }
+
+ // ② 回退:Application 上下文(必须 NEW_TASK)
+ if (context != null)
+ {
+ 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);
+ }
+
+ // ③ 再回退:让系统弹「用哪个应用打开」,规避个别 ROM 对直接 startActivity 的拦截
+ 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;
+ }
+ }
+
+ PlatformLog.Warn("browser", "打开浏览器失败:Android 上下文不可用(Application.Context 为 null)");
+ return false;
+ }
+#endif
+}
diff --git a/Platform/CrashLog.cs b/Platform/CrashLog.cs
new file mode 100644
index 0000000..08f36c8
--- /dev/null
+++ b/Platform/CrashLog.cs
@@ -0,0 +1,235 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace TraeTools.Platform;
+
+///
+/// 全局崩溃 / 启动轨迹日志。
+///
+/// Android:写入外部应用目录
+/// /storage/emulated/0/Android/data/<包名>/files/crash/(无需 root,手机文件管理器即可查看),
+/// 并同步输出 logcat(tag: TraeTools)。
+/// Windows:写入 %APPDATA%\TraeTools\logs\crash\。
+///
+/// 闪退排查:把 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 在 Android 上会额外拉起原生崩溃报告页。
+ /// 可重复调用(幂等)。
+ ///
+ 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();
+ };
+
+#if ANDROID
+ Android.Runtime.AndroidEnvironment.UnhandledExceptionRaiser += (_, e) =>
+ {
+ Write("AndroidEnvironment.UnhandledExceptionRaiser", e.Exception);
+ // 反射设置 Handled=true:阻止 Android 立即杀掉进程,改为弹出可读的崩溃报告页。
+ // 用反射是为了兼容不同 .NET Android 版本的事件参数差异(失败也不影响编译/运行)。
+ try
+ {
+ var prop = e.GetType().GetProperty("Handled");
+ prop?.SetValue(e, true);
+ }
+ catch
+ {
+ }
+ TryFatal(fatalHandler, e.Exception);
+ };
+#endif
+ }
+
+ private static void TryFatal(Action? fatalHandler, Exception? ex)
+ {
+ if (fatalHandler == null) return;
+ try
+ {
+ fatalHandler(ex?.ToString() ?? "(未知异常)");
+ }
+ catch
+ {
+ }
+ }
+
+ /// 记录启动轨迹(无异常也记,用于定位闪退卡在哪个环节)。
+ public static void Boot(string message)
+ {
+ try
+ {
+#if ANDROID
+ global::Android.Util.Log.Info("TraeTools", message);
+#endif
+ 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();
+#if ANDROID
+ global::Android.Util.Log.Error("TraeTools", ex?.ToString() ?? source);
+#endif
+ 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.Substring(text.Length - 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
+ {
+#if ANDROID
+ // 外部应用专属目录:无需 root,文件管理器 / USB 直接可见
+ var ext = Android.App.Application.Context.GetExternalFilesDir(null);
+ var baseDir = ext?.AbsolutePath;
+ if (string.IsNullOrEmpty(baseDir)) return null;
+ _dir = Path.Combine(baseDir, "crash");
+#else
+ _dir = Path.Combine(TraeTools.Services.DataPaths.Root, "logs", "crash");
+#endif
+ Directory.CreateDirectory(_dir);
+ return _dir;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/Platform/CrashReportActivity.cs b/Platform/CrashReportActivity.cs
new file mode 100644
index 0000000..200c1a3
--- /dev/null
+++ b/Platform/CrashReportActivity.cs
@@ -0,0 +1,127 @@
+#if ANDROID
+using System;
+using Android.App;
+using Android.Content;
+using Android.Content.PM;
+using Android.OS;
+using Android.Runtime;
+
+namespace TraeTools.Platform;
+
+///
+/// 原生崩溃报告页(Android)。
+/// 当 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);
+ }
+}
+#endif
diff --git a/Platform/MainActivity.cs b/Platform/MainActivity.cs
new file mode 100644
index 0000000..ba68981
--- /dev/null
+++ b/Platform/MainActivity.cs
@@ -0,0 +1,94 @@
+#if ANDROID
+using Android.App;
+using Android.Content.PM;
+using Android.Runtime;
+using Avalonia;
+using Avalonia.Android;
+
+// Android 13(API 33)起发送通知需要 POST_NOTIFICATIONS 权限;本项目暂未使用通知栏,
+// 权限声明位置见 Properties/AndroidManifest.xml(INTERNET 同一处)。
+
+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(Android.OS.Bundle? savedInstanceState)
+ {
+ try
+ {
+ base.OnCreate(savedInstanceState);
+ Current = this;
+ TraeTools.Platform.CrashLog.Boot("MainActivity.OnCreate completed");
+ }
+ catch (System.Exception ex)
+ {
+ // Activity 创建即失败时,用原生页面把异常显示出来(而不是静默闪退)
+ TraeTools.Platform.CrashLog.Write("MainActivity.OnCreate", ex, true);
+ TraeTools.Platform.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
+ {
+ TraeTools.Platform.CrashLog.Install(text =>
+ TraeTools.Platform.CrashReportActivity.Launch(text));
+ TraeTools.Platform.CrashLog.Boot("Application ctor: crash handler installed");
+ }
+ catch
+ {
+ // 安装失败不能影响启动
+ }
+ }
+
+ protected override Avalonia.AppBuilder CustomizeAppBuilder(Avalonia.AppBuilder builder)
+ {
+ var b = base.CustomizeAppBuilder(builder);
+ try
+ {
+ // Avalonia 12 的 Android 渲染后端按 RenderingMode 顺序尝试(默认 [Egl, Software])。
+ // 问题:EglPlatformGraphics.TryCreate() 在部分机型上不是返回 null 而是直接抛异常,
+ // 此时 Software 兜底根本不会被执行,平台层继续抛 → 进程直接死。
+ // 因此这里显式只用软件渲染:本应用是轻量 IO 型工具,先保证能起来。
+ b = b.With(new Avalonia.AndroidPlatformOptions
+ {
+ RenderingMode = new[] { Avalonia.AndroidRenderingMode.Software }
+ });
+ TraeTools.Platform.CrashLog.Boot("CustomizeAppBuilder: RenderingMode=Software");
+ }
+ catch (System.Exception ex)
+ {
+ TraeTools.Platform.CrashLog.Write("CustomizeAppBuilder", ex);
+ }
+ return b.WithInterFont();
+ }
+}
+#endif
diff --git a/Platform/PlatformLog.cs b/Platform/PlatformLog.cs
new file mode 100644
index 0000000..6fde30d
--- /dev/null
+++ b/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/Platform/Secrets.Android.cs b/Platform/Secrets.Android.cs
new file mode 100644
index 0000000..83c0cdc
--- /dev/null
+++ b/Platform/Secrets.Android.cs
@@ -0,0 +1,125 @@
+#if ANDROID
+using System.Security.Cryptography;
+using System.Text;
+using Android.Security.Keystore;
+using Java.Security;
+using Javax.Crypto;
+
+namespace TraeTools.Platform;
+
+///
+/// Android 实现:Keystore 生成 RSA-2048 密钥对(私钥不可导出),
+/// 每次加密生成随机 AES-256-GCM 密钥加密数据,再用 RSA 公钥包裹该 AES 密钥。
+/// 密文格式:"and-v1:" + Base64(RSA 包裹的 AES 密钥) + ":" + Base64(nonce) + ":" + Base64(tag+ciphertext)。
+/// 密钥随应用卸载/清除数据一同消失,因此密文不会在本机之外泄露。
+///
+internal static partial class Secrets
+{
+ private const string KeyAlias = "TraeTools.Secrets.RSA.v1";
+ private const int NonceSize = 12; // AES-GCM 推荐 nonce 长度
+ private const int TagSize = 16; // 128-bit 认证标签
+
+ private static partial string? ProtectCore(string plain)
+ {
+ try
+ {
+ var plainBytes = Encoding.UTF8.GetBytes(plain);
+
+ // 随机 AES-256 会话密钥 + GCM 加密
+ var key = RandomNumberGenerator.GetBytes(32);
+ var nonce = RandomNumberGenerator.GetBytes(NonceSize);
+ var cipher = new byte[plainBytes.Length];
+ var tag = new byte[TagSize];
+ using (var aes = new AesGcm(key, TagSize))
+ aes.Encrypt(nonce, plainBytes, cipher, tag);
+
+ // RSA 公钥包裹 AES 密钥
+ var pub = GetOrCreatePublicKey();
+ if (pub == null) return null;
+ var wrapCipher = Cipher.GetInstance("RSA/ECB/PKCS1Padding")!;
+ // 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 payload = Convert.ToBase64String(wrapped) + ":" + Convert.ToBase64String(nonce);
+ var tagAndCipher = new byte[tag.Length + cipher.Length];
+ Buffer.BlockCopy(tag, 0, tagAndCipher, 0, tag.Length);
+ Buffer.BlockCopy(cipher, 0, tagAndCipher, tag.Length, cipher.Length);
+ payload += ":" + Convert.ToBase64String(tagAndCipher);
+ return AndroidPrefix + payload;
+ }
+ catch { return null; }
+ }
+
+ private static partial string? UnprotectCore(string enc)
+ {
+ // 必须带本平台前缀:历史上出现过「未加密的明文被当作密文送来」的情况,
+ // 此时应直接判失败让上层保留原值,而不是盲切前 7 个字符去解析。
+ if (!enc.StartsWith(AndroidPrefix, StringComparison.Ordinal)) return null;
+ try
+ {
+ var body = enc[AndroidPrefix.Length..];
+ var parts = body.Split(':', 3);
+ if (parts.Length != 3) return null;
+
+ var wrapped = Convert.FromBase64String(parts[0]);
+ var nonce = Convert.FromBase64String(parts[1]);
+ var tagAndCipher = Convert.FromBase64String(parts[2]);
+ if (tagAndCipher.Length < TagSize) return null;
+
+ // RSA 私钥解出 AES 密钥
+ var priv = GetPrivateKey();
+ if (priv == null) return null;
+ var unwrapCipher = Cipher.GetInstance("RSA/ECB/PKCS1Padding")!;
+ unwrapCipher.Init(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();
+ }
+}
+#endif
diff --git a/Platform/Secrets.Windows.cs b/Platform/Secrets.Windows.cs
new file mode 100644
index 0000000..b3b0773
--- /dev/null
+++ b/Platform/Secrets.Windows.cs
@@ -0,0 +1,34 @@
+#if !ANDROID
+using System.Security.Cryptography;
+using System.Text;
+
+namespace TraeTools.Platform;
+
+/// Windows 实现:DPAPI(当前用户作用域)加密敏感字段,密文为纯 Base64。
+internal static partial class Secrets
+{
+ private static partial string? ProtectCore(string plain)
+ {
+ try
+ {
+ var bytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(plain), null, DataProtectionScope.CurrentUser);
+ return Convert.ToBase64String(bytes);
+ }
+ catch { return null; }
+ }
+
+ private static partial string? UnprotectCore(string enc)
+ {
+ // 跨平台密文互不通用:DPAPI 不可能解开 Android Keystore 产生的密文。
+ // 明确判失败(而不是把密文当明文用),让上层走「重新登录」流程。
+ // 注意这条判断只在 Windows 实现里成立 —— Android 密文本身就是这个前缀。
+ if (enc.StartsWith(AndroidPrefix, StringComparison.Ordinal)) return null;
+ try
+ {
+ var bytes = ProtectedData.Unprotect(Convert.FromBase64String(enc), null, DataProtectionScope.CurrentUser);
+ return Encoding.UTF8.GetString(bytes);
+ }
+ catch { return null; }
+ }
+}
+#endif
diff --git a/Platform/Secrets.cs b/Platform/Secrets.cs
new file mode 100644
index 0000000..39fb115
--- /dev/null
+++ b/Platform/Secrets.cs
@@ -0,0 +1,53 @@
+namespace TraeTools.Platform;
+
+///
+/// 敏感字段加密抽象(跨平台 partial)。
+/// Windows:DPAPI(当前用户作用域),密文为纯 Base64(历史格式,无前缀)。
+/// Android:Keystore(RSA 包裹 AES-256-GCM 密钥),密文带 前缀。
+/// Protect 返回 null 表示加密失败(调用方按各自策略兜底)。
+///
+internal static partial class Secrets
+{
+ /// Windows 侧落盘标记(沿用历史值,保证老配置可直接解密读回)。
+ public const string WindowsVaultVersion = "dpapi-v1";
+
+ /// Android 侧落盘标记:与 Windows 的 DPAPI 密文互不通用,用版本号区分来源。
+ public const string AndroidVaultVersion = "and-v1";
+
+ /// Android 密文前缀。
+ public const string AndroidPrefix = AndroidVaultVersion + ":";
+
+ /// 当前平台写入配置时使用的密文版本标记。
+ public static string CurrentVaultVersion =>
+ OperatingSystem.IsAndroid() ? AndroidVaultVersion : WindowsVaultVersion;
+
+ ///
+ /// 该版本标记是否为本程序认识的密文格式。
+ /// 两个平台都要认识:配置目录可能被跨平台拷贝(手机 → 电脑),
+ /// 此时解密必然失败,交由各字段的兜底策略处理(Token 失效 → 重新登录),
+ /// 而不是把密文当明文继续用。
+ ///
+ public static bool IsKnownVaultVersion(string? version) =>
+ version == WindowsVaultVersion || version == AndroidVaultVersion;
+
+ /// 加密敏感字段;null/空串原样返回(无需加密)。
+ public static string? Protect(string? plain)
+ {
+ if (string.IsNullOrEmpty(plain)) return plain;
+ return ProtectCore(plain);
+ }
+
+ /// 解密敏感字段;null/空串原样返回。
+ public static string? Unprotect(string? enc)
+ {
+ if (string.IsNullOrEmpty(enc)) return enc;
+ // ⚠️ 「跨平台密文互不通用」这条规则必须写在具体平台的实现里,不能写在这层共享代码里:
+ // Android 自己的密文就是 "and-v1:" 前缀,若在此处按前缀拦截,Android 将永远解不开
+ // 自己加密的数据(症状:DeviceId 退化成密文被当作设备号发给服务端 → 风控拦截)。
+ // 现在的分工是:这里只负责分发,平台差异由各自的 UnprotectCore 处理。
+ return UnprotectCore(enc);
+ }
+
+ private static partial string? ProtectCore(string plain);
+ private static partial string? UnprotectCore(string enc);
+}
diff --git a/Program.cs b/Program.cs
index d85cee5..58ac133 100644
--- a/Program.cs
+++ b/Program.cs
@@ -12,6 +12,11 @@ sealed class Program
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
+ //
+ // ⚠️ 整段桌面入口仅在非 Android 目标编译:Android 由 Android.App.Application 入口
+ // (见 Platform/MainActivity.cs 的 TraeAndroidApplication)驱动 Avalonia 单视图生命周期,
+ // 不使用 Main() 与 StartWithClassicDesktopLifetime(后者由 Avalonia.Desktop 提供,Android 未引用该包)。
+#if !ANDROID
[STAThread]
public static void Main(string[] args)
{
@@ -21,7 +26,7 @@ public static void Main(string[] args)
// 已有实例在运行,直接退出本实例(TraeTools 为单实例工具)
return;
}
- // 旧版 TraeCheckin / TraeSwitch 数据迁移到统一根 %APPDATA%\TraeTools(幂等,保留旧目录)
+ // 旧版 TraeCheckin / TraeSwitch 数据迁移到统一根(幂等,保留旧目录;Android 为空操作)
TraeTools.Services.DataPaths.Migrate();
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
@@ -35,4 +40,5 @@ public static AppBuilder BuildAvaloniaApp()
#endif
.WithInterFont()
.LogToTrace();
+#endif
}
diff --git a/Properties/AndroidManifest.xml b/Properties/AndroidManifest.xml
new file mode 100644
index 0000000..7aa91d4
--- /dev/null
+++ b/Properties/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 2a2bf4f..43c8892 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
> Trae 一体化管理工具:多账号每日签到 · 云端自动签到 · 账号快速切换 · 用量统计
-TraeTools 是对 **TRAE-Checkin** 的重构版,并集成了 **Trea-Switch** 的账号切换能力,用 Avalonia 重写为现代化桌面应用(.NET 9)。一个工具覆盖 Trae 账号的「登录 → 签到 → 切换 → 用量查看 → 云端托管」全流程。
+TraeTools 是对 **TRAE-Checkin** 的重构版,并集成了 **Trea-Switch** 的账号切换能力,用 Avalonia 重写为现代化客户端(.NET 10)。**一套代码同时产出 Windows 桌面版与 Android 手机版**,覆盖 Trae 账号的「登录 → 签到 → 切换 → 用量查看 → 云端托管」全流程。
### 气死我了,怎么老是给我爆9074
@@ -29,22 +29,60 @@ TraeTools 是对 **TRAE-Checkin** 的重构版,并集成了 **Trea-Switch**
## 快速开始
-> **macOS 用户看这里**:macOS 原生版 TraeBar(菜单栏常驻多账号自动签到,Swift/AppKit 实现)在本仓库的 [`macos`](https://github.com/star620/TraeTools/tree/macos) 分支独立维护与发布,版本 tag 为 `macos-v*`,发布产物见 [Releases](https://github.com/star620/TraeTools/releases)。本分支(main)仅维护 Windows 版。
+> **macOS 用户看这里**:macOS 原生版 TraeBar(菜单栏常驻多账号自动签到,Swift/AppKit 实现)在本仓库的 [`macos`](https://github.com/star620/TraeTools/tree/macos) 分支独立维护与发布,版本 tag 为 `macos-v*`,发布产物见 [Releases](https://github.com/star620/TraeTools/releases)。本分支(main)维护 **Windows 版与 Android 版**。
### 方式一:直接使用
-在 [Releases](https://github.com/star620/TraeTools/releases) 下载最新版运行(Windows,需 .NET 9 桌面运行时)。
+在 [Releases](https://github.com/star620/TraeTools/releases) 下载最新版运行(Windows 需 .NET 10 桌面运行时;Android 直接安装 APK,需 Android 8.0+)。
### 方式二:源码编译
-需要 **.NET 9 SDK**:
+需要 **.NET 10 SDK**:
```bash
git clone https://github.com/star620/TraeTools.git
cd TraeTools
-dotnet build -c Release
+
+# Windows 桌面版
+dotnet build TraeTools.csproj -c Release -f net10.0-windows
+
+# Android 手机版(需先安装 Android 工作负载)
+dotnet workload install android
+dotnet build TraeTools.csproj -c Release -f net10.0-android
```
-产物位于 `bin/Release/net9.0-windows/TraeTools.exe`。
+产物分别位于 `bin/Release/net10.0-windows/`(`TraeTools.exe`)与 `bin/Release/net10.0-android/`(`*.apk`)。
+
+> 项目是双目标(`net10.0-windows;net10.0-android`),**编译时必须用 `-f` 限定框架**,否则会同时构建两端。在非 Windows 系统上构建 android 目标时,`EnableWindowsTargeting` 已在 csproj 中开启,无需额外参数。
+
+## Android 版
+
+Android 与 Windows **共用同一套** `ViewModels/` `Services/` `Views/`(Avalonia 单视图模式),平台差异统一收敛在 `Platform/` 目录与 TFM 条件编译中,不存在第二套业务代码。
+
+### 功能差异
+
+| 能力 | Windows | Android |
+|---|---|---|
+| 多账号每日签到 / 自动签到 | ✅ | ✅(进程存活期间到点执行) |
+| 云端签到部署(GitHub Actions) | ✅ | ✅ |
+| 用量统计 / 仪表盘 / 账号资料 | ✅ | ✅ |
+| 账号切换(vault 建档 / 冷切换) | ✅ 直接操作本地客户端 | ⚠️ 手机端无本地 Trae 客户端,页面仅作展示 |
+| 登录方式 | ✅ 内嵌浏览器(WebView2)自动取凭证 | ✅ 手动粘贴 Token / 会话 Cookie |
+| 开机自启 | ✅ 注册表 `Run` 项 | ❌ 系统无等价机制,已降级为无操作 |
+| 应用内自动更新 | ✅ 下载 zip 整包并整套替换 | ⚠️ 引导至 Release 页下载 APK 覆盖安装 |
+| 凭证加密 | DPAPI(当前用户) | Android Keystore(RSA-2048 包裹 AES-256-GCM 数据密钥) |
+
+### 实现要点
+
+- **多目标而非分叉**:`TraeTools.csproj` 改为 `net10.0-windows;net10.0-android`。Windows 侧的 AOT / 裁剪 / 单文件 / `RuntimeIdentifier` 全部保留在 `net10.0-windows` 条件组内,**现有 Windows 发布流程零改动**(`release.yml` 仅追加 `-f net10.0-windows` 以消除多目标歧义)。
+- **对话框全部改为 `UserControl`**:Android 走 `ISingleViewApplicationLifetime`,此时 `new Window()` 会抛 `NotSupportedException`。新增 `Views/OverlayDialog.cs` 作为双模宿主——桌面端包一层 `DialogHostWindow`,Android 端挂到 `MainView` 的全屏覆盖层并用 `TaskCompletionSource` 模拟模态;所有弹窗调用点由 `ShowDialog` 改为 `OverlayDialog.ShowAsync`。
+- **Android 目标显式关闭 AOT / 裁剪 / 链接器**:.NET for Android 在 Release 下默认启用 LLVM AOT,会因 `SQLitePCLRaw` 等反射路径抛 `MissingRuntimeArtifactException`(表现为「启动即闪退」)。本应用是网络 IO 型工具,稳定性优先。
+- **版本号单一真源**:`version.json` 的读取从 `Target` 上移到**求值期** `PropertyGroup`,否则 `AndroidManifest` 的 `versionName` 会停在静态兜底值,与 App 内「关于」显示的版本互相矛盾。
+- **崩溃可见**:`Platform/CrashLog.cs` 安装全局异常钩子(含 `AndroidEnvironment.UnhandledExceptionRaiser`),异常落盘并在下次启动时弹出原生错误页 `CrashReportActivity`,避免手机端「闪退且无日志」。
+
+### 构建产物与 CI
+
+- APK 产物位于 `bin/Release/net10.0-android/`;Release 默认使用调试密钥签名,正式分发请自行配置 keystore(`AndroidKeyStore` / `AndroidSigningKeyStore`)。
+- `.github/workflows/ci.yml` 已拆为 `build-windows` 与 `build-android` 两个作业,后者在 Ubuntu 上安装 Android 工作负载编译 APK 并上传为构建产物。
## 使用指南
@@ -83,8 +121,8 @@ dotnet build -c Release
## 开发者
-- 技术栈:Avalonia 12 / .NET 9 / CommunityToolkit.Mvvm / Sodium.Core / WebView2
-- 目录:`ViewModels/` 页面逻辑、`Services/` 网络与领域服务、`Views/` 界面、`Models/` 数据模型
+- 技术栈:Avalonia 12 / .NET 10 / CommunityToolkit.Mvvm / Sodium.Core / WebView2(仅 Windows 登录宿主)
+- 目录:`ViewModels/` 页面逻辑、`Services/` 网络与领域服务、`Views/` 界面、`Models/` 数据模型、`Platform/` 平台差异实现(加密 / 路径 / 日志 / 崩溃 / 浏览器 / 对话框宿主)
- 行为默认不对外请求官方非公开接口之外的能力;用量/积分接口为逆向网页接口,字段随官方可能变化,代码均做容错解析
## 免责声明
diff --git a/Resources/drawable/ic_launcher_foreground.xml b/Resources/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..705ae07
--- /dev/null
+++ b/Resources/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/Resources/mipmap-anydpi-v26/ic_launcher.xml b/Resources/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..a8a8fa5
--- /dev/null
+++ b/Resources/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Resources/mipmap-anydpi-v26/ic_launcher_round.xml b/Resources/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..a8a8fa5
--- /dev/null
+++ b/Resources/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Resources/values/colors.xml b/Resources/values/colors.xml
new file mode 100644
index 0000000..f2637dd
--- /dev/null
+++ b/Resources/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #0F172A
+ #0F172A
+
diff --git a/Resources/values/styles.xml b/Resources/values/styles.xml
new file mode 100644
index 0000000..79e5fc6
--- /dev/null
+++ b/Resources/values/styles.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/Services/Checkin/AppConfig.cs b/Services/Checkin/AppConfig.cs
index c236019..e2819c4 100644
--- a/Services/Checkin/AppConfig.cs
+++ b/Services/Checkin/AppConfig.cs
@@ -1,5 +1,3 @@
-using System.Security.Cryptography;
-using System.Text;
using System.Text.Json;
using TraeTools.Services.Json;
@@ -7,13 +5,15 @@ namespace TraeCheckin;
///
/// 本地配置:存储 token、设备号、自动签到设置。
-/// 保存到 %APPDATA%\TraeTools\config.json。
-/// 敏感字段(Token/Session/GitHubToken/DeviceId)以当前 Windows 用户 DPAPI 加密落盘,
-/// 加载时解密回内存;旧版明文在首次加载时自动迁移加密。
+/// 保存到配置根目录下的 config.json(Windows: %APPDATA%\TraeTools;Android: 应用私有目录)。
+/// 敏感字段(Token/Session/GitHubToken/DeviceId)以本平台的原生密钥加密落盘
+/// (Windows=DPAPI,Android=Keystore,见 TraeTools.Platform.Secrets),加载时解密回内存;
+/// 旧版明文在首次加载时自动迁移加密。
///
public class AppConfig
{
- /// 敏感字段加密版本标记:"dpapi-v1"=已用 DPAPI 加密;null=旧版明文(等待迁移)。
+ /// 敏感字段加密版本标记:"dpapi-v1"=Windows DPAPI、"and-v1"=Android Keystore;
+ /// null=旧版明文(等待迁移)。两平台标记都会被识别,跨平台拷贝时解密失败由各字段兜底处理。
public string? SecretsVaultVersion { get; set; }
public string? Token { get; set; }
/// X-Cloudide-Session 会话 Cookie 值(约 14 天有效),用于 token 失效时静默换新。
@@ -62,10 +62,10 @@ public static AppConfig Load()
var cfg = AppJsonIndented.Deserialize(json);
if (cfg != null)
{
- // DPAPI 加密迁移:已是 dpapi-v1 则解密回内存使用;旧明文标记版本,稍后统一加密落盘
- var needSeal = cfg.SecretsVaultVersion != SecretVaultVersionV1;
+ // 加密迁移:已是本平台的密文版本则解密回内存使用;旧明文标记版本,稍后统一加密落盘
+ var needSeal = cfg.SecretsVaultVersion != TraeTools.Platform.Secrets.CurrentVaultVersion;
UnsealSecrets(cfg);
- cfg.SecretsVaultVersion = SecretVaultVersionV1;
+ cfg.SecretsVaultVersion = TraeTools.Platform.Secrets.CurrentVaultVersion;
// 多账号迁移:旧单账号转成 Accounts[0]
if (TryMigrateLegacy(cfg))
@@ -171,22 +171,21 @@ public void Save()
catch { /* 保存失败(含 DPAPI 异常)时保留磁盘旧文件,下次启动仍可读取 */ }
}
- /// 敏感字段加密版本号。
- private const string SecretVaultVersionV1 = "dpapi-v1";
-
- /// 生成一份敏感字段(Token/Session/GitHubToken/DeviceId)已 DPAPI 加密的副本。
+ /// 生成一份敏感字段(Token/Session/GitHubToken/DeviceId)已平台加密的副本。
private AppConfig CloneForStorage()
{
return new AppConfig
{
- SecretsVaultVersion = SecretVaultVersionV1,
+ SecretsVaultVersion = TraeTools.Platform.Secrets.CurrentVaultVersion,
Token = EncryptSecret(Token),
Session = EncryptSecret(Session),
TokenUpdatedAt = TokenUpdatedAt,
GitHubToken = EncryptSecret(GitHubToken),
GitHubLogin = GitHubLogin,
FeishuWebhook = FeishuWebhook,
- DeviceId = EncryptSecret(DeviceId),
+ // DeviceId 非空类型:加密失败(极端平台异常)时退回明文,避免设备号被静默清掉
+ // 后在下次启动被重新生成,导致风控把新设备号当陌生设备
+ DeviceId = EncryptSecret(DeviceId) ?? DeviceId,
AutoCheckinEnabled = AutoCheckinEnabled,
AutoCheckinTime = AutoCheckinTime,
CheckinIntervalSeconds = CheckinIntervalSeconds,
@@ -206,7 +205,7 @@ private AppConfig CloneForStorage()
Name = a.Name,
Token = EncryptSecret(a.Token),
Session = EncryptSecret(a.Session),
- DeviceId = EncryptSecret(a.DeviceId),
+ DeviceId = EncryptSecret(a.DeviceId) ?? a.DeviceId,
AccountUid = a.AccountUid,
TokenUpdatedAt = a.TokenUpdatedAt,
LastCheckinDate = a.LastCheckinDate,
@@ -221,10 +220,12 @@ private AppConfig CloneForStorage()
};
}
- /// 已加密配置加载后:解密敏感字段回内存(仅 dpapi-v1 生效;旧明文保持原样等迁移)。
+ /// 已加密配置加载后:解密敏感字段回内存(仅已加密版本生效;旧明文保持原样等迁移)。
private static void UnsealSecrets(AppConfig cfg)
{
- if (cfg.SecretsVaultVersion != SecretVaultVersionV1) return;
+ // 两个平台的版本标记都认:配置文件可能被跨平台拷贝,此时解密必然失败,
+ // 交由各字段的兜底策略处理(Token → null → 重新登录),而不是把密文当明文继续用。
+ if (!TraeTools.Platform.Secrets.IsKnownVaultVersion(cfg.SecretsVaultVersion)) return;
cfg.Token = DecryptSecret(cfg.Token);
cfg.Session = DecryptSecret(cfg.Session);
cfg.GitHubToken = DecryptSecret(cfg.GitHubToken);
@@ -239,23 +240,10 @@ private static void UnsealSecrets(AppConfig cfg)
}
}
- /// DPAPI 加密(当前 Windows 用户作用域)。保护失败抛异常,由调用方兜底(保留旧文件)。
- private static string? EncryptSecret(string? plain)
- {
- if (string.IsNullOrEmpty(plain)) return plain;
- var bytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(plain), null, DataProtectionScope.CurrentUser);
- return Convert.ToBase64String(bytes);
- }
+ /// 平台加密(Windows=DPAPI / Android=Keystore,见 TraeTools.Platform.Secrets)。
+ /// 加密失败返回 null,由调用方兜底(保留旧文件)。
+ private static string? EncryptSecret(string? plain) => TraeTools.Platform.Secrets.Protect(plain);
- /// DPAPI 解密;失败(如换 Windows 用户/重装系统)返回 null,触发重新登录。
- private static string? DecryptSecret(string? enc)
- {
- if (string.IsNullOrEmpty(enc)) return enc;
- try
- {
- var bytes = ProtectedData.Unprotect(Convert.FromBase64String(enc), null, DataProtectionScope.CurrentUser);
- return Encoding.UTF8.GetString(bytes);
- }
- catch { return null; }
- }
+ /// 平台解密;失败(换 Windows 用户/重装系统/跨平台拷贝配置)返回 null,触发重新登录。
+ private static string? DecryptSecret(string? enc) => TraeTools.Platform.Secrets.Unprotect(enc);
}
\ No newline at end of file
diff --git a/Services/Checkin/AutoStartManager.cs b/Services/Checkin/AutoStartManager.cs
index 3856694..1bbe2ee 100644
--- a/Services/Checkin/AutoStartManager.cs
+++ b/Services/Checkin/AutoStartManager.cs
@@ -1,9 +1,10 @@
-using Microsoft.Win32;
+#if !ANDROID
+using Microsoft.Win32;
namespace TraeCheckin;
///
-/// 开机自启动:通过注册表 HKCU\Software\Microsoft\Windows\CurrentVersion\Run
+/// 开机自启动(Windows):通过注册表 HKCU\Software\Microsoft\Windows\CurrentVersion\Run
/// 写入当前程序 exe 的完整路径(带引号)。
///
internal static class AutoStartManager
@@ -44,3 +45,18 @@ public static void SetEnabled(bool enabled)
catch { /* 静默失败 */ }
}
}
+#else
+namespace TraeCheckin;
+
+///
+/// Android 空实现:开机自启依赖 Windows 注册表(HKCU\...\Run),Android 上对应的能力是
+/// WorkManager / AlarmManager 常驻任务,需要前台服务权限,本工具暂不申请。
+/// 移动端自动签到仅在 App 运行期间有效;长期无人值守请使用「云端签到」(GitHub Actions)。
+///
+internal static class AutoStartManager
+{
+ public static bool IsEnabled() => false;
+
+ public static void SetEnabled(bool enabled) { /* Android 无注册表,空操作 */ }
+}
+#endif
diff --git a/Services/DataPaths.cs b/Services/DataPaths.cs
index 9a8b525..5060234 100644
--- a/Services/DataPaths.cs
+++ b/Services/DataPaths.cs
@@ -3,15 +3,31 @@
namespace TraeTools.Services;
///
-/// 统一数据根目录(%APPDATA%\TraeTools),收敛历史遗留的 TraeCheckin / TraeSwitch 三个目录。
-/// WebView 缓存与抓包输出保持在 %LOCALAPPDATA%\TraeTools(缓存不进 APPDATA)。
+/// 统一数据根目录(Windows:%APPDATA%\TraeTools;Android:应用私有目录 files/TraeTools),
+/// 收敛历史遗留的 TraeCheckin / TraeSwitch 三个目录。
+/// WebView 缓存与抓包输出保持在 %LOCALAPPDATA%\TraeTools(缓存不进 APPDATA;Android 无此概念)。
/// 启动时调用 Migrate() 把旧目录数据自动搬入新位置(幂等、失败不阻塞)。
///
public static class DataPaths
{
- /// 根目录:%APPDATA%\TraeTools
- public static string Root =>
- Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TraeTools");
+ /// 根目录:Windows=%APPDATA%\TraeTools;Android=files/TraeTools(应用私有,其他 App 不可读)
+ public static string Root
+ {
+ get
+ {
+#if ANDROID
+ // Android 上不能用 SpecialFolder.ApplicationData:该枚举值在 .NET Android 下
+ // 并没有映射到应用私有目录,取值不可靠。统一用 Context.FilesDir。
+ var ctx = Android.App.Application.Context;
+ var files = ctx?.FilesDir?.AbsolutePath;
+ if (string.IsNullOrEmpty(files))
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "TraeTools");
+ return Path.Combine(files, "TraeTools");
+#else
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TraeTools");
+#endif
+ }
+ }
/// 签到配置 config.json。
public static string ConfigPath => Path.Combine(Root, "config.json");
@@ -79,6 +95,10 @@ public static void Migrate()
try
{
EnsureDirectories();
+#if ANDROID
+ // Android 是全新平台,不存在历史 TraeCheckin / TraeSwitch 目录,无需迁移
+ return;
+#else
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
@@ -133,6 +153,7 @@ public static void Migrate()
Directory.Move(sub, dest);
}
}
+#endif
}
catch { /* 迁移失败不阻塞启动 */ }
}
diff --git a/TraeTools.csproj b/TraeTools.csproj
index c8bcf53..3bbcaa7 100644
--- a/TraeTools.csproj
+++ b/TraeTools.csproj
@@ -1,24 +1,49 @@
WinExe
-
- net10.0-windows
-
+
+ net10.0-windows;net10.0-android
+
+ true
+
enable
enable
- app.manifest
- Assets\avalonia-logo.ico
true
TraeTools
TraeTools
-
- 1.0.0
- 1.0.0
- CA1416;MSB3277
+ CA1416;MSB3277;XA0121;CS0436
+
+
+
+
+ <_VJsonRaw Condition="Exists('$(MSBuildProjectDirectory)\version.json')">$([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\version.json'))
+ <_VJsonVer>$([System.Text.RegularExpressions.Regex]::Match('$(_VJsonRaw)', '"version"\s*:\s*"([^"]+)"').Groups[1].Value)
+
+ $(_VJsonVer)
+ 1.0.0
+ $(_VJsonVer)
+ 1.0.0
+
+
+
+
+ app.manifest
+ Assets\avalonia-logo.ico
+
+ win-x64
true
true
@@ -26,18 +51,52 @@
true
+ XAML 全部走编译绑定、ViewLocator 改为显式映射 —— PublishTrimmed/PublishAot 分析 0 条 IL2026/IL3050。
+ ⚠️ 仅 Windows:Android 目标必须关闭裁剪(见下方 android 组),否则启动即闪退。 -->
true
full
+ 启用方式:dotnet publish TraeTools.csproj -c Release -f net10.0-windows -p:EnableAot=true -o publish_aot -->
false
true
-
+
+
+
+ Exe
+
+ 26
+
+ com.traetools.android
+
+ $(_VJsonVer)
+ 1.0.0
+
+ 1
+ apk
+
+ false
+ false
+ None
+ false
+
+ false
+ true
+
+
+
+
+
+
+
@@ -45,6 +104,8 @@
+
+
@@ -54,21 +115,17 @@
PreserveNewest
-
-
-
- <_VJsonContent>$([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\version.json'))
- <_VJsonVer>$([System.Text.RegularExpressions.Regex]::Match('$(_VJsonContent)', '"version"\s*:\s*"([^"]+)"').Groups[1].Value)
-
-
- $(_VJsonVer)
- $(_VJsonVer)
-
-
+
+
+
-
+
+
+
@@ -77,11 +134,13 @@
+
+
-
-
+
+
-
-
diff --git a/ViewModels/CheckinViewModel.cs b/ViewModels/CheckinViewModel.cs
index 739827b..2b2b221 100644
--- a/ViewModels/CheckinViewModel.cs
+++ b/ViewModels/CheckinViewModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
@@ -606,7 +606,7 @@ private async Task PromptLoginAsync(TraeCheckin.TraeAccount? acc)
var owner = UiHost.MainWindow;
if (owner == null) return false;
var dlg = new LoginWindow(acc);
- return await dlg.ShowDialog(owner);
+ return await OverlayDialog.ShowAsync(dlg, owner);
}
catch
{
diff --git a/ViewModels/CloudViewModel.cs b/ViewModels/CloudViewModel.cs
index c8e09f8..3063670 100644
--- a/ViewModels/CloudViewModel.cs
+++ b/ViewModels/CloudViewModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using Avalonia.Input.Platform;
@@ -348,7 +348,7 @@ private async Task DeployAsync()
"通常需要删除旧 fork 后重建。\n\n是否删除旧 fork 并重新部署?\n" +
"(选择「保留」则继续使用现有仓库,不会重复创建)",
"删除并重建", "保留现有");
- bool rebuild = ownerW != null ? await ask.ShowDialog(ownerW) : true;
+ bool rebuild = ownerW != null ? await OverlayDialog.ShowAsync(ask, ownerW) : true;
if (rebuild)
{
AppendLog("正在删除旧 fork 仓库…");
@@ -435,7 +435,7 @@ async Task WriteSecretsAsync()
"通常是旧 fork 早于源仓库添加 checkin.yml、或 fork 状态异常导致。\n\n" +
"是否自动删除旧 fork 并重建?(重建后会自动重新写入凭证)",
"删除并重建", "保留现状");
- bool rebuild = ownerW != null ? await ask.ShowDialog(ownerW) : false;
+ bool rebuild = ownerW != null ? await OverlayDialog.ShowAsync(ask, ownerW) : false;
if (!rebuild)
{
AppendLog("已跳过重建:可手动删除 fork 后重试,或到源仓库确认 checkin.yml 是否存在。");
@@ -556,7 +556,7 @@ private async Task UsePat()
var owner = UiHost.MainWindow;
if (owner == null) { AppendLog("无法打开输入窗口(主窗口未就绪)"); return; }
var dlg = new Views.PatInputWindow();
- bool ok = await dlg.ShowDialog(owner);
+ bool ok = await OverlayDialog.ShowAsync(dlg, owner);
if (!ok || string.IsNullOrWhiteSpace(dlg.PatText)) { AppendLog("已取消 PAT 授权"); return; }
AppendLog("正在校验 PAT…");
diff --git a/ViewModels/DashboardViewModel.cs b/ViewModels/DashboardViewModel.cs
index 5f12981..19c0b2a 100644
--- a/ViewModels/DashboardViewModel.cs
+++ b/ViewModels/DashboardViewModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
@@ -10,6 +10,7 @@
using TraeCheckin;
using TraeTools.Models;
using TraeTools.Services.Avatar;
+using TraeTools.Views;
namespace TraeTools.ViewModels;
@@ -634,7 +635,7 @@ private async Task Relogin()
?? cfg?.Accounts.FirstOrDefault();
if (cfg == null || owner == null || acc == null) return;
var dlg = new Views.LoginWindow(acc);
- bool ok = await dlg.ShowDialog(owner);
+ bool ok = await OverlayDialog.ShowAsync(dlg, owner);
if (ok)
{
try { cfg.Save(); } catch { /* 忽略保存失败 */ }
diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs
index 5beb968..f1182ba 100644
--- a/ViewModels/SettingsViewModel.cs
+++ b/ViewModels/SettingsViewModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using Avalonia.Input.Platform;
@@ -310,7 +310,7 @@ private async Task RenewAllDeviceIds()
"用于多账号被风控(-9074 参与用户太多)时批量规避。\n" +
"更换后云端部署需重新「云端部署」同步新的 TRAE_DEVICE_ID*。\n\n是否继续?",
"全部更换", "取消");
- bool ok = owner != null ? await ask.ShowDialog(owner) : false;
+ bool ok = owner != null ? await OverlayDialog.ShowAsync(ask, owner) : false;
if (!ok)
{
PushStatus = "已取消";
@@ -481,7 +481,7 @@ private async Task AddAccount()
cfg.Save();
var dlg = new Views.LoginWindow(acc);
- bool ok = await dlg.ShowDialog(owner);
+ bool ok = await OverlayDialog.ShowAsync(dlg, owner);
if (ok)
{
// 同一手机号判重(JWT data.id 跨会话恒定):重复则拒绝保存本次登录
@@ -617,7 +617,7 @@ private async Task ResetAgreement()
"重置用户协议",
"将清除「用户协议与免责声明」的同意记录并退出软件。\n下次启动时会要求你重新阅读并同意。\n\n确定重置并退出?",
"重置并退出", "取消");
- bool ok = owner != null ? await ask.ShowDialog(owner) : false;
+ bool ok = owner != null ? await OverlayDialog.ShowAsync(ask, owner) : false;
if (!ok)
{
PushStatus = "已取消重置";
@@ -673,7 +673,7 @@ private async Task ResetSoftware()
" · 所有个性化设置\n\n" +
"重置后软件将退出,重新启动后回到初始状态。\n\n确定要继续吗?",
"继续", "取消");
- bool go1 = owner != null ? await ask1.ShowDialog(owner) : false;
+ bool go1 = owner != null ? await OverlayDialog.ShowAsync(ask1, owner) : false;
if (!go1) { PushStatus = "已取消重置"; return; }
// ---- 第二次确认:最终警告 ----
@@ -681,7 +681,7 @@ private async Task ResetSoftware()
"最终确认",
"最后警告:此操作不可撤销!\n\n所有账号数据、签到记录、设置将被永久删除。\n\n确认执行重置?",
"确认重置", "取消");
- bool go2 = owner != null ? await ask2.ShowDialog(owner) : false;
+ bool go2 = owner != null ? await OverlayDialog.ShowAsync(ask2, owner) : false;
if (!go2) { PushStatus = "已取消重置"; return; }
// ---- 执行清理 ----
@@ -794,12 +794,33 @@ private async Task CheckUpdate()
return;
}
+#if ANDROID
+ // Android 无法像桌面端那样替换正在运行的 exe(Zip 整包 + 后台脚本),
+ // 改为引导用户到 Release 页下载 APK 覆盖安装。
+ var goDownload = await OverlayDialog.ShowAsync(
+ new PromptWindow("发现新版本",
+ $"发现新版本 {rel.Tag}(当前 {AppVersion},约 {Math.Max(1, (long)(rel.Size / 1048576.0))} MB)。\n\n"
+ + "手机端不支持应用内自动安装,是否现在打开下载页?",
+ "打开下载页", "取消"),
+ UiHost.MainWindow);
+ if (goDownload)
+ {
+ TraeTools.Platform.BrowserLauncher.Open(
+ $"https://github.com/{UpdaterService.Repo}/releases/tag/{rel.Tag}");
+ PushStatus = "已打开下载页,请下载 APK 后覆盖安装";
+ }
+ else
+ {
+ PushStatus = "已取消更新";
+ }
+ return;
+#else
PushStatus = $"发现新版本 {rel.Tag}";
var owner = UiHost.MainWindow;
var ask = new PromptWindow("发现新版本",
$"发现新版本 {rel.Tag}\n(当前 {AppVersion},约 {Math.Max(1, (long)(rel.Size / 1048576.0))} MB)\n\n是否下载并自动安装?",
"下载并安装", "取消");
- bool ok = owner != null ? await ask.ShowDialog(owner) : false;
+ bool ok = owner != null ? await OverlayDialog.ShowAsync(ask, owner) : false;
if (!ok)
{
PushStatus = "已取消更新";
@@ -857,6 +878,7 @@ private async Task CheckUpdate()
{
PushStatus = "更新失败:" + ex.Message;
}
+#endif
}
catch (Exception ex)
{
diff --git a/ViewModels/SwitchViewModel.cs b/ViewModels/SwitchViewModel.cs
index 443d745..8ad766d 100644
--- a/ViewModels/SwitchViewModel.cs
+++ b/ViewModels/SwitchViewModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
@@ -496,7 +496,7 @@ private async Task CreateProfile()
"建档新账号",
"账号列表为空。\n请为当前登录的 Trae 客户输入一个账号名称(用于区分多个账号的登录态备份,如「主号 / 小号」)。已删除的账号如需恢复,用原来的名称即可。",
"如:主号 / 小号");
- string? name = owner != null ? await dlg.ShowDialog(owner) : null;
+ string? name = owner != null ? await OverlayDialog.ShowAsync(dlg, owner) : null;
if (string.IsNullOrWhiteSpace(name))
{
AppendLog("已取消建档(未输入账号名)");
diff --git a/ViewModels/UiHost.cs b/ViewModels/UiHost.cs
index 64d4654..d0a610b 100644
--- a/ViewModels/UiHost.cs
+++ b/ViewModels/UiHost.cs
@@ -4,9 +4,64 @@
namespace TraeTools.ViewModels;
-/// 解析当前主窗口,供页面 VM 弹模态窗口(ShowDialog 需要 owner)。
+///
+/// 解析当前主窗口 / 根视图 / 剪贴板,供页面 VM 弹模态对话框与复制文本。
+/// 桌面端取自主窗口;Android 是单视图平台,需要从根视图反查 TopLevel 才能拿到剪贴板。
+///
internal static class UiHost
{
+ /// 桌面端主窗口(Android 上为 null:单视图平台没有 Window)。
public static Window? MainWindow
- => (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
-}
\ No newline at end of file
+ {
+ get
+ {
+ // Android 上 Application 需写全称:.NET Android SDK 的隐式全局 using 含 Android.App,
+ // 裸写 Application 会与 Android.App.Application 歧义(CS0104)
+ var lifetime = Avalonia.Application.Current?.ApplicationLifetime;
+ if (lifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ return desktop.MainWindow;
+ return null;
+ }
+ }
+
+ ///
+ /// 当前根 TopLevel(桌面端为主窗口,Android 为承载单视图的根)。
+ /// 文件选择器(IStorageProvider)等能力必须经它获取。
+ /// 刻意不叫 TopLevel,避免属性名与类型名同名带来的解析歧义。
+ ///
+ public static TopLevel? Root
+ {
+ get
+ {
+ try
+ {
+ var lifetime = Avalonia.Application.Current?.ApplicationLifetime;
+ if (lifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ return desktop.MainWindow;
+#if ANDROID
+ if (lifetime is ISingleViewApplicationLifetime singleView && singleView.MainView is Control root)
+ return TopLevel.GetTopLevel(root);
+#endif
+ }
+ catch { /* 获取失败时返回 null,调用方自行回退 */ }
+ return null;
+ }
+ }
+
+ ///
+ /// 剪贴板:桌面端取自主窗口;单视图平台(Android)没有 Window,
+ /// 需从根视图反查 TopLevel —— 这是「复制 Token / 复制日志」在手机端可用的前提。
+ ///
+ public static Avalonia.Input.Platform.IClipboard? Clipboard
+ {
+ get
+ {
+ try
+ {
+ return Root?.Clipboard;
+ }
+ catch { /* 剪贴板不可用时返回 null,调用方给出提示 */ }
+ return null;
+ }
+ }
+}
diff --git a/Views/DeviceAuthWindow.axaml b/Views/DeviceAuthWindow.axaml
index e7eac0e..479166a 100644
--- a/Views/DeviceAuthWindow.axaml
+++ b/Views/DeviceAuthWindow.axaml
@@ -1,8 +1,6 @@
-
@@ -30,4 +28,4 @@
-
\ No newline at end of file
+
diff --git a/Views/DeviceAuthWindow.axaml.cs b/Views/DeviceAuthWindow.axaml.cs
index b02215d..23f38e1 100644
--- a/Views/DeviceAuthWindow.axaml.cs
+++ b/Views/DeviceAuthWindow.axaml.cs
@@ -1,5 +1,4 @@
-using System.Diagnostics;
-using Avalonia.Controls;
+using Avalonia.Controls;
using Avalonia.Interactivity;
using TraeCheckin;
@@ -9,8 +8,11 @@ namespace TraeTools.Views;
/// GitHub 设备码授权窗口。申请设备码→展示授权码→打开授权页→轮询授权结果,
/// ShowDialog<bool> 返回是否授权成功;成功后通过 取回 token。
///
-public partial class DeviceAuthWindow : Window
+public partial class DeviceAuthWindow : UserControl, IDialogView
{
+ /// 对话框标题(桌面端由 DialogHostWindow 读取,作为宿主窗口标题)。
+ public string DialogTitle => "GitHub 授权";
+
private readonly GitHubApiClient _api = null!;
private GitHubDeviceCode? _code;
private bool _polling;
@@ -57,8 +59,9 @@ private async Task InitAsync()
private void OpenPage(GitHubDeviceCode code)
{
- try { Process.Start(new ProcessStartInfo(code.VerificationUri) { UseShellExecute = true }); }
- catch (Exception ex) { StatusText.Text = "自动打开浏览器失败,请手动访问:" + code.VerificationUri + "(" + ex.Message + ")"; }
+ // 跨平台打开:Android 走 Intent(Activity → Application → 选择器 三级回退),失败会写日志
+ if (!TraeTools.Platform.BrowserLauncher.Open(code.VerificationUri))
+ StatusText.Text = "自动打开浏览器失败,请手动访问:" + code.VerificationUri;
}
private void OnOpenPageClick(object? sender, RoutedEventArgs e)
@@ -95,7 +98,7 @@ private async Task PollLoop()
AccessToken = token;
_done = true;
StatusText.Text = "授权成功 ✓";
- Close(true);
+ OverlayDialog.CloseDialog(this, true);
return;
}
if (state == DeviceAuthState.Failed)
@@ -115,7 +118,7 @@ private async Task PollLoop()
private void OnCancelClick(object? sender, RoutedEventArgs e)
{
_done = true;
- Close(false);
+ OverlayDialog.CloseDialog(this, false);
}
}
diff --git a/Views/DialogHostWindow.axaml b/Views/DialogHostWindow.axaml
new file mode 100644
index 0000000..d6a38a1
--- /dev/null
+++ b/Views/DialogHostWindow.axaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/Views/DialogHostWindow.axaml.cs b/Views/DialogHostWindow.axaml.cs
new file mode 100644
index 0000000..71edf3f
--- /dev/null
+++ b/Views/DialogHostWindow.axaml.cs
@@ -0,0 +1,32 @@
+using Avalonia.Controls;
+
+namespace TraeTools.Views;
+
+///
+/// 桌面端对话框承载窗口。
+/// 由于 Android 单视图平台不能创建 Window(WindowingPlatformStub.CreateWindow 抛
+/// NotSupportedException),所有对话框内容都实现为 UserControl;
+/// 桌面端仍需要一个真正的窗口来 ShowDialog,故由本窗口包装后显示。
+/// ⚠️ 仅在桌面端(!OverlayDialog.IsOverlayMode)实例化。
+///
+public partial class DialogHostWindow : Window
+{
+ public DialogHostWindow()
+ {
+ InitializeComponent();
+ }
+
+ /// 把对话框 UserControl 装入本窗口,并沿用其标题作为窗口标题。
+ public void AttachDialog(UserControl dialog, string? title)
+ {
+ DialogHost.Content = dialog;
+ if (!string.IsNullOrWhiteSpace(title))
+ Title = title;
+ }
+
+ /// 卸载内容,避免窗口关闭后仍持有对话框引用。
+ public void DetachDialog()
+ {
+ DialogHost.Content = null;
+ }
+}
diff --git a/Views/EulaWindow.axaml b/Views/EulaWindow.axaml
index 3d1fa66..3fb148c 100644
--- a/Views/EulaWindow.axaml
+++ b/Views/EulaWindow.axaml
@@ -1,10 +1,9 @@
-
-
-
-
+
diff --git a/Views/EulaWindow.axaml.cs b/Views/EulaWindow.axaml.cs
index 4ec07e3..d157fa8 100644
--- a/Views/EulaWindow.axaml.cs
+++ b/Views/EulaWindow.axaml.cs
@@ -7,8 +7,11 @@ namespace TraeTools.Views;
/// 首次启动的最终用户许可协议弹窗(Avalonia 版)。
/// ShowDialog<bool> 返回值:true=同意并继续,false=不同意并退出。
///
-public partial class EulaWindow : Window
+public partial class EulaWindow : UserControl, IDialogView
{
+ /// 对话框标题(桌面端由 DialogHostWindow 读取,作为宿主窗口标题)。
+ public string DialogTitle => "TRAETOOLS 最终用户许可协议";
+
public EulaWindow()
{
InitializeComponent();
@@ -20,8 +23,8 @@ public EulaWindow()
}
private void OnAgreeClick(object? sender, RoutedEventArgs e)
- => Close(true);
+ => OverlayDialog.CloseDialog(this, true);
private void OnDeclineClick(object? sender, RoutedEventArgs e)
- => Close(false);
+ => OverlayDialog.CloseDialog(this, false);
}
diff --git a/Views/IDialogView.cs b/Views/IDialogView.cs
new file mode 100644
index 0000000..85d1164
--- /dev/null
+++ b/Views/IDialogView.cs
@@ -0,0 +1,15 @@
+namespace TraeTools.Views;
+
+///
+/// 对话框视图(UserControl)的标题契约。
+///
+/// 桌面端由 承载对话框时需要一个窗口标题。
+/// 这里用接口而不是反射(GetType().GetProperty("DialogTitle"))读取:
+/// 本项目开启了 full 裁剪与 NativeAOT 分析,反射读属性会产生 IL2075 警告,
+/// 且裁剪后属性可能被移除导致标题静默丢失;接口调用在编译期确定,两者都不会发生。
+///
+public interface IDialogView
+{
+ /// 对话框标题;为空时宿主窗口沿用默认标题。
+ string? DialogTitle { get; }
+}
diff --git a/Views/InputWindow.axaml b/Views/InputWindow.axaml
index a457bf3..6de2cd0 100644
--- a/Views/InputWindow.axaml
+++ b/Views/InputWindow.axaml
@@ -1,12 +1,10 @@
-
@@ -26,4 +24,4 @@
-
\ No newline at end of file
+
diff --git a/Views/InputWindow.axaml.cs b/Views/InputWindow.axaml.cs
index fe15d95..cd1655a 100644
--- a/Views/InputWindow.axaml.cs
+++ b/Views/InputWindow.axaml.cs
@@ -1,4 +1,4 @@
-using Avalonia.Controls;
+using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
@@ -8,8 +8,11 @@ namespace TraeTools.Views;
/// 通用单行文本输入窗口。ShowDialog<string> 返回:true 路径关窗时返回输入文本(trim 后),
/// 取消返回 null。
///
-public partial class InputWindow : Window
+public partial class InputWindow : UserControl, IDialogView
{
+ /// 对话框标题(桌面端由 DialogHostWindow 读取,作为宿主窗口标题)。
+ public string DialogTitle { get; private set; } = "输入";
+
public string? ResultText { get; private set; }
public InputWindow()
@@ -24,6 +27,7 @@ public InputWindow(string title, string message, string placeholder)
: this()
{
TitleText.Text = title;
+ DialogTitle = title;
MessageText.Text = message;
InputBox.PlaceholderText = placeholder;
}
@@ -37,11 +41,11 @@ private void OnOkClick(object? sender, RoutedEventArgs e)
return;
}
ResultText = value;
- Close(ResultText);
+ OverlayDialog.CloseDialog(this, ResultText);
}
private void OnCancelClick(object? sender, RoutedEventArgs e)
- => Close(null);
+ => OverlayDialog.CloseDialog(this, null);
private void OnInputKeyDown(object? sender, KeyEventArgs e)
{
diff --git a/Views/LoginWindow.axaml b/Views/LoginWindow.axaml
index e582e4e..074bb8f 100644
--- a/Views/LoginWindow.axaml
+++ b/Views/LoginWindow.axaml
@@ -1,8 +1,6 @@
-
@@ -49,5 +47,4 @@
-
-
+
diff --git a/Views/LoginWindow.axaml.cs b/Views/LoginWindow.axaml.cs
index e676957..29ae8bf 100644
--- a/Views/LoginWindow.axaml.cs
+++ b/Views/LoginWindow.axaml.cs
@@ -1,5 +1,4 @@
-using System;
-using System.Diagnostics;
+using System;
using System.IO;
using Avalonia.Controls;
using Avalonia.Interactivity;
@@ -10,11 +9,15 @@
namespace TraeTools.Views;
///
-/// 登录窗口:优先使用内嵌 WebView2 自动登录(自动读取 token + session),
-/// 也保留手动粘贴 Cloud-IDE-Token 的备选方案。
+/// 登录对话框(UserControl,双平台)。
+/// 桌面端:优先用独立宿主进程里的内嵌 WebView2 自动登录(自动读取 token + session),
+/// 找不到宿主或 Android 端时,走手动粘贴 Cloud-IDE-Token / Session 的通道。
///
-public partial class LoginWindow : Window
+public partial class LoginWindow : UserControl, IDialogView
{
+ /// 对话框标题(桌面端由 DialogHostWindow 读取作为窗口标题)。
+ public string DialogTitle => "登录 TRAE 账号";
+
private readonly TraeAccount? _account;
private bool _busy;
@@ -29,15 +32,16 @@ public LoginWindow(TraeAccount? account)
_account = account;
}
- /// 内嵌 WebView2 自动登录:独立 STA 线程宿主,自动读取 Cloud-IDE-Token 与 X-Cloudide-Session。
+ /// 内嵌 WebView2 自动登录:由独立宿主进程完成,自动读取 Cloud-IDE-Token 与 X-Cloudide-Session。
private async void OnAutoLoginClick(object? sender, RoutedEventArgs e)
{
+#if ANDROID
+ // Android 上不存在 WebView2 宿主进程:直接引导用户走手动方案(下方「手动粘贴」)。
+ AutoStatus.Text = "手机端请使用下方「手动粘贴」:在电脑浏览器登录 www.trae.cn 后,"
+ + "从开发者工具复制 Cloud-IDE-Token / X-Cloudide-Session 粘贴到下面。";
+ await System.Threading.Tasks.Task.CompletedTask;
+#else
if (_busy || _account == null) return;
- if (string.IsNullOrEmpty(_account.Token))
- {
- // 首次登录:为账号准备独立的 WebView2 用户数据目录(账号间登录态隔离)
- _ = _account;
- }
// P2-1:内嵌浏览器已拆到独立宿主进程(TraeTools.LoginHost.exe),主程序不含 WinForms/WebView2。
// 找不到宿主(未随程序分发)时明确提示改用下方手动粘贴方案。
@@ -70,7 +74,7 @@ private async void OnAutoLoginClick(object? sender, RoutedEventArgs e)
var uid = TokenUtils.ParseAccountUid(result.Token);
if (!string.IsNullOrEmpty(uid)) _account.AccountUid = uid;
MainViewModel.AppConfig?.Save();
- Close(true);
+ OverlayDialog.CloseDialog(this, true);
}
catch (Exception ex)
{
@@ -80,13 +84,11 @@ private async void OnAutoLoginClick(object? sender, RoutedEventArgs e)
{
_busy = false;
}
+#endif
}
private void OnOpenBrowserClick(object? sender, RoutedEventArgs e)
- {
- try { Process.Start(new ProcessStartInfo("https://www.trae.cn") { UseShellExecute = true }); }
- catch { /* 打开失败忽略 */ }
- }
+ => TraeTools.Platform.BrowserLauncher.Open("https://www.trae.cn");
private void OnSaveClick(object? sender, RoutedEventArgs e)
{
@@ -112,8 +114,9 @@ private void OnSaveClick(object? sender, RoutedEventArgs e)
var uid = TokenUtils.ParseAccountUid(pasted);
if (!string.IsNullOrEmpty(uid)) _account.AccountUid = uid;
MainViewModel.AppConfig?.Save();
- Close(true);
+ OverlayDialog.CloseDialog(this, true);
}
- private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
-}
\ No newline at end of file
+ private void OnCancelClick(object? sender, RoutedEventArgs e)
+ => OverlayDialog.CloseDialog(this, false);
+}
diff --git a/Views/MainView.axaml b/Views/MainView.axaml
new file mode 100644
index 0000000..7f71de7
--- /dev/null
+++ b/Views/MainView.axaml
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/MainView.axaml.cs b/Views/MainView.axaml.cs
new file mode 100644
index 0000000..e401b8c
--- /dev/null
+++ b/Views/MainView.axaml.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using TraeTools.ViewModels;
+
+namespace TraeTools.Views;
+
+///
+/// 双平台主视图(UserControl):桌面端由 承载,Android 直接作为单视图根。
+/// 承载两平台共享逻辑:覆盖层对话框宿主挂接、自动签到定时器、首次启动 EULA。
+///
+public partial class MainView : UserControl
+{
+ /// 自动签到定时器(双平台):每 30 秒检查一次,到点且当天未签时自动签到。
+ private readonly DispatcherTimer _autoCheckinTimer;
+ private bool _tickBusy;
+ private bool _started;
+ private bool _eulaChecked;
+
+ public MainView()
+ {
+ InitializeComponent();
+
+ // 覆盖层宿主:Android 用于承载对话框;桌面端挂上也不使用(IsOverlayMode=false)
+ OverlayDialog.Attach(OverlayRoot, OverlayContent);
+ Platform.CrashLog.Boot("MainView: ctor, overlay attached");
+
+ _autoCheckinTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
+ // 防重入:上一轮未结束则跳过本轮,避免长任务在常驻托盘场景下叠加
+ _autoCheckinTimer.Tick += async (_, _) =>
+ {
+ if (_tickBusy) return;
+ _tickBusy = true;
+ try
+ {
+ if (DataContext is MainViewModel vm)
+ {
+ await vm.AutoCheckinIfDue();
+#if !ANDROID
+ await vm.RefreshSnapshotIfNeeded();
+#endif
+ }
+ }
+ catch (Exception ex)
+ {
+ // 定时任务异常不允许带崩 UI(Android 上未捕获异常会直接闪退)
+ Platform.CrashLog.Write("AutoCheckin timer tick", ex);
+ }
+ finally
+ {
+ _tickBusy = false;
+ }
+ };
+
+ // EULA 需要 owner 窗口居中(桌面端),故放到 Loaded:此时视觉树与宿主窗口均已就绪
+ //(构造期 desktop.MainWindow 尚未赋值,取 owner 会失败导致协议弹不出来)
+ Loaded += OnLoaded;
+ }
+
+ private void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ if (_eulaChecked) return;
+ _eulaChecked = true;
+ Platform.CrashLog.Boot("MainView: loaded");
+ try
+ {
+ _ = EulaCheckAsync();
+ }
+ catch (Exception ex)
+ {
+ Platform.CrashLog.Write("MainView.OnLoaded", ex);
+ }
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ if (_started) return;
+ _started = true;
+ Platform.CrashLog.Boot("MainView: attached to visual tree");
+ try
+ {
+ // 自动签到仅在 App 运行期间有效;Android 长期无人值守请使用「云端签到」
+ _autoCheckinTimer.Start();
+ }
+ catch (Exception ex)
+ {
+ Platform.CrashLog.Write("MainView.OnAttachedToVisualTree", ex);
+ }
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ try
+ {
+ _autoCheckinTimer.Stop();
+ }
+ catch
+ {
+ // 停止定时器失败不影响退出
+ }
+ base.OnDetachedFromVisualTree(e);
+ }
+
+ ///
+ /// 首次启动弹出免责声明协议(EULA)。同意标记独立存放于数据根目录 eula_accepted,
+ /// 与 TraeCheckin 的已同意配置互相独立,保证本程序首次启动必定展示协议。
+ ///
+ private async Task EulaCheckAsync()
+ {
+ try
+ {
+ var flag = System.IO.Path.Combine(TraeTools.Services.DataPaths.Root, "eula_accepted");
+ if (System.IO.File.Exists(flag)) return;
+
+ Platform.CrashLog.Boot("EULA: showing agreement");
+ var eula = new EulaWindow();
+ bool agreed;
+ try
+ {
+ // owner:桌面端用宿主窗口居中;Android 上为 null(走覆盖层,忽略 owner)
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ agreed = await OverlayDialog.ShowAsync(eula, owner);
+ }
+ catch (Exception ex)
+ {
+ // 兜底:对话框呈现失败则记录并视为同意,保证应用可用(日志留痕)
+ Platform.CrashLog.Write("EULA dialog failed", ex);
+ agreed = true;
+ }
+ Platform.CrashLog.Boot($"EULA: agreed={agreed}");
+
+ if (agreed)
+ {
+ try
+ {
+ System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(flag)!);
+ System.IO.File.WriteAllText(flag, DateTime.UtcNow.ToString("o"));
+ }
+ catch
+ {
+ // 标记写失败下次仍会弹,可接受
+ }
+ }
+ else
+ {
+ // 不同意:桌面端真正退出(绕过「最小化到托盘」,由 App.ExitRequested 挂接);
+ // Android 单视图无法关闭界面,保持界面不动即可
+ App.RequestExit();
+ }
+ }
+ catch (Exception ex)
+ {
+ Platform.CrashLog.Write("MainView.EulaCheckAsync", ex);
+ }
+ }
+}
diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml
index 1ce8050..8559f95 100644
--- a/Views/MainWindow.axaml
+++ b/Views/MainWindow.axaml
@@ -1,6 +1,7 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs
index 23c3450..5474a39 100644
--- a/Views/MainWindow.axaml.cs
+++ b/Views/MainWindow.axaml.cs
@@ -2,19 +2,20 @@
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using Avalonia.Platform;
-using Avalonia.Threading;
using TraeTools.ViewModels;
namespace TraeTools.Views;
+///
+/// 桌面端主窗口(仅 Windows / Linux / macOS 桌面后端)。
+/// 界面本体在 (UserControl),本窗口只保留桌面专有行为:
+/// 系统托盘图标、关闭即最小化到托盘、桌面通知。
+///
public partial class MainWindow : Window
{
- private readonly DispatcherTimer _autoCheckinTimer;
private TrayIcon? _trayIcon;
- /// true=用户从托盘「退出」真正退出;false=关闭窗口仅最小化到托盘。
+ /// true=用户从托盘「退出」真正退出;false=关闭窗口仅最小化到托盘。
private bool _realExit;
- /// 计时器重入守卫:上一轮任务(可能数十秒)未结束前跳过本轮 Tick(#33)。
- private bool _tickBusy;
private readonly WindowNotificationManager _notify;
public MainWindow()
@@ -30,49 +31,33 @@ public MainWindow()
}
catch { /* 图标加载失败不影响主程序 */ }
- // 自动签到定时器:每 30 秒检查一次,到点且当天未签时自动签到
- _autoCheckinTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
- // 防重入:上一轮未结束则跳过本轮,避免长任务在常驻托盘场景下叠加(#33)
- _autoCheckinTimer.Tick += async (_, _) =>
- {
- if (_tickBusy) return;
- _tickBusy = true;
- try
- {
- if (DataContext is MainViewModel vm)
- {
- await vm.AutoCheckinIfDue();
- await vm.RefreshSnapshotIfNeeded();
- }
- }
- finally { _tickBusy = false; }
- };
-
BuildTray();
Closing += OnWindowClosing;
- }
- protected override void OnOpened(EventArgs e)
- {
- base.OnOpened(e);
- OnOpenedEulaCheck();
- _autoCheckinTimer.Start();
+ // 「不同意协议 / 需要真正退出」的退出入口:桌面端要绕过「关闭即最小化到托盘」,
+ // 由本窗口接管(Android 上没有窗口,MainView 会走 ApplicationLifetime 兜底)。
+ App.ExitRequested = () =>
+ {
+ _realExit = true;
+ Close();
+ };
}
protected override void OnClosed(EventArgs e)
{
- _autoCheckinTimer.Stop();
+ App.ExitRequested = null;
_trayIcon?.Dispose();
base.OnClosed(e);
}
- /// 关闭窗口默认最小化到托盘(与 TraeCheckin 一致);仅托盘「退出」真正退出。
+ /// 关闭窗口默认最小化到托盘;仅托盘「退出」真正退出。
private void OnWindowClosing(object? sender, WindowClosingEventArgs e)
{
if (_realExit) return;
e.Cancel = true;
Hide();
- _notify.Show(new Notification("TraeTools", "已最小化到托盘,后台继续自动签到", NotificationType.Information, TimeSpan.FromSeconds(2)));
+ _notify.Show(new Notification("TraeTools", "已最小化到托盘,后台继续自动签到",
+ NotificationType.Information, TimeSpan.FromSeconds(2)));
}
private void BuildTray()
@@ -119,7 +104,7 @@ private void ShowAndActivate()
Activate();
}
- private async Task TrayCheckinAsync()
+ private async System.Threading.Tasks.Task TrayCheckinAsync()
{
if (DataContext is MainViewModel vm)
{
@@ -130,36 +115,4 @@ private async Task TrayCheckinAsync()
ok ? NotificationType.Success : NotificationType.Error));
}
}
-
- ///
- /// 首次启动弹出免责声明协议(EULA)。同意标记独立存放于 %APPDATA%\TraeTools\eula_accepted,
- /// 与 TraeCheckin 的已同意配置互相独立,保证本程序首次启动必定展示协议;
- /// 不同意则直接退出。
- ///
- private async void OnOpenedEulaCheck()
- {
- var flag = System.IO.Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "TraeTools", "eula_accepted");
- if (System.IO.File.Exists(flag)) return;
-
- var eula = new EulaWindow();
- bool agreed = await eula.ShowDialog(this);
- if (agreed)
- {
- try
- {
- System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(flag)!);
- System.IO.File.WriteAllText(flag, DateTime.UtcNow.ToString("o"));
- }
- catch { /* 标记写失败下次仍会弹,可接受 */ }
- }
- else
- {
- _realExit = true;
- Close();
- }
- }
}
-
-
diff --git a/Views/OverlayDialog.cs b/Views/OverlayDialog.cs
new file mode 100644
index 0000000..5ca5c6c
--- /dev/null
+++ b/Views/OverlayDialog.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Controls.Presenters;
+
+namespace TraeTools.Views;
+
+///
+/// 双平台模态对话框宿主。
+///
+/// ⚠️ 关键背景:Android 是 Avalonia 单视图平台,new Window() 会在
+/// Avalonia.Android.WindowingPlatformStub.CreateWindow() 抛 NotSupportedException,
+/// 因此**所有对话框内容都必须是 UserControl**:
+/// - Android:把 UserControl 直接挂到主视图的覆盖层(ZIndex 999)呈现,
+/// 用 TaskCompletionSource 模拟模态返回;
+/// - 桌面端:由 (真正的 Window)包装后 ShowDialog。
+///
+public static class OverlayDialog
+{
+ private static Panel? _root;
+ private static ContentPresenter? _host;
+ private static UserControl? _current;
+ private static TaskCompletionSource