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? _tcs; + + /// 当前是否处于覆盖层对话框模式(仅 Android)。 + public static bool IsOverlayMode => OperatingSystem.IsAndroid(); + + /// 主视图启动时挂接覆盖层(桌面端挂上也不使用)。 + public static void Attach(Panel root, ContentPresenter host) + { + _root = root; + _host = host; + } + + /// + /// 双平台对话框入口:桌面端用 DialogHostWindow 包装后原生 ShowDialog, + /// Android 走覆盖层。返回对话框通过 关闭时携带的结果。 + /// + public static async Task ShowAsync(UserControl dialog, Window? owner) + { + if (!IsOverlayMode) + { + try + { + var host = new DialogHostWindow(); + host.AttachDialog(dialog, (dialog as IDialogView)?.DialogTitle); + // ShowDialog 需要 owner;缺省时退回桌面端主窗口 + var target = owner ?? (Avalonia.Application.Current?.ApplicationLifetime + as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + if (target == null) + { + Platform.CrashLog.Write("OverlayDialog.ShowAsync", + new InvalidOperationException("桌面端无可用 owner 窗口,无法显示对话框")); + return default!; + } + var result = await host.ShowDialog(target); + host.DetachDialog(); + return result is T typed ? typed : default!; + } + catch (Exception ex) + { + Platform.CrashLog.Write("OverlayDialog.ShowAsync(desktop)", ex); + return default!; + } + } + + try + { + var tcs = ShowCore(dialog); + var result = await tcs.Task; + return result is T typedValue ? typedValue : default!; + } + catch (Exception ex) + { + // 覆盖层呈现失败时降级返回默认值:绝不让对话框问题带崩应用(Android 上即闪退) + Platform.CrashLog.Write("OverlayDialog.ShowAsync(overlay)", ex); + return default!; + } + } + + /// 对话框按钮统一关闭入口:Android 走覆盖层还原,桌面走宿主 Window.Close(result)。 + public static void CloseDialog(UserControl dialog, object? result) + { + if (IsOverlayMode && ReferenceEquals(_current, dialog)) + { + Finish(dialog, result); + return; + } + + try + { + var host = TopLevel.GetTopLevel(dialog) as Window; + if (host != null) + { + host.Close(result); + return; + } + } + catch (Exception ex) + { + Platform.CrashLog.Write("OverlayDialog.CloseDialog", ex); + } + } + + private static TaskCompletionSource ShowCore(UserControl dialog) + { + if (_root == null || _host == null) + throw new InvalidOperationException("OverlayDialog 尚未 Attach(主视图未就绪)"); + if (_tcs != null) + throw new InvalidOperationException("已有对话框在显示中,不允许叠加"); + + // 手机屏幕较窄:清掉 XAML 中为桌面端写死的 Width,改为填满覆盖层卡片 + try + { + dialog.Width = double.NaN; + dialog.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch; + } + catch + { + // 尺寸调整失败不影响显示 + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _tcs = tcs; + _current = dialog; + _host.Content = dialog; + _root.IsVisible = true; + return tcs; + } + + private static void Finish(UserControl dialog, object? result) + { + if (!ReferenceEquals(_current, dialog) || _tcs == null || _host == null || _root == null) + return; + + _host.Content = null; + _root.IsVisible = false; + + var tcs = _tcs; + _tcs = null; + _current = null; + tcs.TrySetResult(result); + } +} diff --git a/Views/PatInputWindow.axaml b/Views/PatInputWindow.axaml index c7be87c..e2ebc61 100644 --- a/Views/PatInputWindow.axaml +++ b/Views/PatInputWindow.axaml @@ -1,12 +1,10 @@ - @@ -34,5 +32,4 @@ - - + diff --git a/Views/PatInputWindow.axaml.cs b/Views/PatInputWindow.axaml.cs index c677ec6..913b4fc 100644 --- a/Views/PatInputWindow.axaml.cs +++ b/Views/PatInputWindow.axaml.cs @@ -1,13 +1,15 @@ using System; -using System.Diagnostics; using Avalonia.Controls; using Avalonia.Interactivity; namespace TraeTools.Views; /// 粘贴 GitHub PAT 的输入窗口:返回 true 表示已提交有效文本(访问 PatText 获取)。 -public partial class PatInputWindow : Window +public partial class PatInputWindow : UserControl, IDialogView { + /// 对话框标题(桌面端由 DialogHostWindow 读取,作为宿主窗口标题)。 + public string DialogTitle => "粘贴 GitHub Token(PAT)"; + public string? PatText { get; private set; } public PatInputWindow() @@ -17,8 +19,8 @@ public PatInputWindow() private void OnOpenGithubClick(object? sender, RoutedEventArgs e) { - try { Process.Start(new ProcessStartInfo("https://github.com/settings/personal-access-tokens/new") { UseShellExecute = true }); } - catch { /* 打开失败忽略 */ } + // 走跨平台封装:Android 用 Intent 拉起系统浏览器,失败会写日志(category=browser) + TraeTools.Platform.BrowserLauncher.Open("https://github.com/settings/personal-access-tokens/new"); } private void OnOkClick(object? sender, RoutedEventArgs e) @@ -31,8 +33,8 @@ private void OnOkClick(object? sender, RoutedEventArgs e) return; } PatText = value; - Close(true); + OverlayDialog.CloseDialog(this, true); } - private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false); + private void OnCancelClick(object? sender, RoutedEventArgs e) => OverlayDialog.CloseDialog(this, false); } diff --git a/Views/PromptWindow.axaml b/Views/PromptWindow.axaml index 3be6984..a326da7 100644 --- a/Views/PromptWindow.axaml +++ b/Views/PromptWindow.axaml @@ -1,8 +1,6 @@ - @@ -14,4 +12,4 @@ - \ No newline at end of file + diff --git a/Views/PromptWindow.axaml.cs b/Views/PromptWindow.axaml.cs index 018702f..469bd83 100644 --- a/Views/PromptWindow.axaml.cs +++ b/Views/PromptWindow.axaml.cs @@ -6,15 +6,18 @@ namespace TraeTools.Views; /// /// 通用确认/提示对话框。ShowDialog<bool> 返回:true=点了确认/知道了,false=取消。 /// -public partial class PromptWindow : Window +public partial class PromptWindow : UserControl, IDialogView { + /// 对话框标题(桌面端由 DialogHostWindow 读取,作为宿主窗口标题)。 + public string DialogTitle { get; private set; } = "提示"; + public PromptWindow() { InitializeComponent(); } public PromptWindow(string title, string message, string confirmText = "确定", string cancelText = "取消", bool showCancel = true) : this() { - Title = title; + DialogTitle = title; MessageText.Text = message; ConfirmButton.Content = confirmText; if (showCancel) @@ -28,8 +31,8 @@ public PromptWindow(string title, string message, string confirmText = "确定", } } - private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true); + private void OnConfirmClick(object? sender, RoutedEventArgs e) => OverlayDialog.CloseDialog(this, true); - private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false); + private void OnCancelClick(object? sender, RoutedEventArgs e) => OverlayDialog.CloseDialog(this, false); }