diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml deleted file mode 100644 index cf2674e..0000000 --- a/.github/workflows/android.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Android - -on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -jobs: - build-android: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET 10 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "10.0.x" - - - name: Install Android workload - run: dotnet workload install android - - - name: Build Android (Release) - run: dotnet build Android/TraeTools.Android.csproj -c Release --nologo - - - name: Upload APK - uses: actions/upload-artifact@v4 - with: - name: trae-tools-android-apk - path: Android/bin/Release/net10.0-android/*.apk - if-no-files-found: error diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000..05dfad9 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,122 @@ +name: Bump Version + +# 手动触发的版本号自增工作流。 +# +# 设计要点: +# 1. version.json 是**唯一版本源**(csproj 的 Version/InformationalVersion/ApplicationDisplayVersion +# 由 ReadVersionFromJson 目标自动读取并覆盖),因此只需改 version.json; +# 但 Android 的 ApplicationVersion(versionCode)必须**单调递增**才能覆盖安装,需一并自增。 +# 2. 使用 GITHUB_TOKEN 推送的提交**不会**触发其它工作流(GitHub 官方限制), +# 因此这里在推送后显式派发 ci.yml 再验证一次(workflow_dispatch 不受该限制影响)。 + +on: + workflow_dispatch: + inputs: + version: + description: "目标版本号,如 2.0.0(填写后忽略自增类型)" + required: false + type: string + bump: + description: "自增类型(version 留空时生效)" + required: false + default: minor + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + actions: write + +concurrency: + group: bump-version + cancel-in-progress: false + +jobs: + bump: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.calc.outputs.version }} + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Calculate and apply new version + id: calc + env: + TARGET_VERSION: ${{ inputs.version }} + BUMP_TYPE: ${{ inputs.bump }} + run: | + set -euo pipefail + python3 - <<'PY' + import datetime, json, os, re + + path = "version.json" + with open(path, encoding="utf-8") as f: + data = json.load(f) + + old = str(data.get("version", "0.0.0")).strip() + target = (os.environ.get("TARGET_VERSION") or "").strip() + + if not target: + kind = (os.environ.get("BUMP_TYPE") or "minor").strip() + nums = [int(x) for x in re.split(r"[.\-+]", old) if x.isdigit()] + while len(nums) < 3: + nums.append(0) + major, minor, patch = nums[0], nums[1], nums[2] + if kind == "major": + major, minor, patch = major + 1, 0, 0 + elif kind == "patch": + patch += 1 + else: + minor, patch = minor + 1, 0 + target = f"{major}.{minor}.{patch}" + + if not re.fullmatch(r"\d+\.\d+\.\d+", target): + raise SystemExit(f"非法版本号:{target}(应形如 1.2.3)") + + data["version"] = target + data["buildDate"] = datetime.date.today().isoformat() + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + + csproj_path = "TraeTools.csproj" + with open(csproj_path, encoding="utf-8") as f: + csproj = f.read() + m = re.search(r"(\d+)", csproj) + if not m: + raise SystemExit("TraeTools.csproj 中未找到 ") + new_code = int(m.group(1)) + 1 + csproj = csproj[: m.start(1)] + str(new_code) + csproj[m.end(1) :] + with open(csproj_path, "w", encoding="utf-8") as f: + f.write(csproj) + + print(f"version.json: {old} -> {target}") + print(f"Android versionCode: {m.group(1)} -> {new_code}") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"version={target}\n") + PY + + - name: Commit and push to main + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add version.json TraeTools.csproj + if git diff --cached --quiet; then + echo "无版本变化,跳过提交" + exit 0 + fi + git commit -m "chore(release): bump version to v${{ steps.calc.outputs.version }}" + git push origin HEAD:main + + - name: Dispatch CI to validate the bumped commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh workflow run ci.yml --ref main diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee536d6..ff7e56e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: push: branches: [main] + workflow_dispatch: pull_request: branches: [main] @@ -10,24 +11,43 @@ permissions: contents: read jobs: - build: + build-windows: runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v4 - # P2-2:主程序已升到 net10.0-windows(NativeAOT 前置) - - name: Setup .NET 10 + - name: Setup .NET 9 uses: actions/setup-dotnet@v4 with: dotnet-version: "10.0.x" - - name: Build (Release) - run: dotnet build TraeTools.csproj -c Release --nologo + - name: Build Windows (Release) + run: dotnet build TraeTools.csproj -c Release -f 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 9 + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eae2c43..5f1d5be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,40 +19,23 @@ jobs: fetch-depth: 0 # 拉取完整历史与所有 tag,供生成"上一版本→本版本"更新差异 fetch-tags: true - # P2-2:主程序已升到 net10.0-windows - - name: Setup .NET 10 + - name: Setup .NET 9 uses: actions/setup-dotnet@v4 with: dotnet-version: "10.0.x" - # P2-4:更新分发包改为「NativeAOT 目录版整包 zip」。 - # NativeAOT 产物不是单文件:libSkiaSharp / av_libglesv2 / e_sqlite3 / libHarfBuzzSharp / - # libsodium 必须与 exe 同目录,只替换 exe 会得到"缺 DLL 打不开"的残废程序。 - # 应用内更新器(UpdaterService)现在下载 `TraeTools-win-x64.zip` → 校验 SHA-256 → 解包 → - # 整套文件原子替换 → 重启;zip 压缩也抵消了 AOT 原生库带来的体积增长。 - - 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 - - # 兼容引导包:历史客户端内的旧更新器只会下载名为 `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 - - # P2-1:登录宿主(WinForms + WebView2)是独立进程,不参与主程序的 NativeAOT, - # 因此单独发布并随压缩包/Release 一起分发;缺失时主程序自动回退到"手动粘贴 Token"。 - - name: Publish login host (separate process, not AOT) - run: dotnet publish TraeTools.LoginHost/TraeTools.LoginHost.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish_aot - - - name: Zip distribution (NativeAOT package, no PDBs) - # 必须用 pwsh:Windows PowerShell 5.1 的 Compress-Archive 会把条目名里的目录分隔符写成 - # 反斜杠(实测 `a\b\z.pdb`),依赖"zip 内路径用 /"的解包器/校验器可能踩坑。 - shell: pwsh + # 发布单文件自包含 exe:应用内更新器(UpdaterService)会下载名为 TraeTools.exe 的资产, + # 直接覆盖本地 exe 后重启,因此必须产出可独立运行的单文件程序。 + # 注意:RuntimeIdentifier(win-x64) 定义在 csproj 的 windows 条件组内, + # 不能在此用 -r 传入(全局属性会污染 android TFM 的还原,报 NU1102)。 + - name: Publish (self-contained single-file win-x64) + run: dotnet publish TraeTools.csproj -c Release -f net10.0-windows --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish + + - name: Zip distribution + shell: powershell run: | - Copy-Item publish_aot\TraeTools.LoginHost.exe publish\ -Force - Get-ChildItem -Path publish_aot -Recurse -Include *.pdb | Remove-Item -Force - Compress-Archive -Path publish_aot\* -DestinationPath TraeTools-win-x64.zip -Force - Get-ChildItem -Path publish, publish_aot, TraeTools-win-x64.zip | Select-Object Name, Length + Compress-Archive -Path publish\* -DestinationPath TraeTools-win-x64.zip -Force + Get-ChildItem -Path publish, TraeTools-win-x64.zip | Select-Object Name, Length # 自动生成更新摘要:取「版本号不大于当前版本」的最近一个 v* tag, # 汇总其到本版本之间的全部提交(跳过 merge 提交)。 @@ -80,9 +63,9 @@ jobs: { echo "TraeTools ${GITHUB_REF_NAME}" echo "" - echo "- Windows x64:\`TraeTools-win-x64.zip\`(NativeAOT 整包,解压运行,无需安装 .NET 运行时)" - echo "- 应用内「检查更新」会下载本 Release 的 \`TraeTools-win-x64.zip\`,校验后自动替换整套文件并重启" - echo "- \`TraeTools.exe\` 为单文件引导包,仅用于老版本客户端首次升级" + echo "- 单文件自包含 Windows x64 程序(无需额外安装 .NET 运行时)" + echo "- Android 手机版 APK(Android 8.0+,见 TraeTools-Android.apk)" + echo "- 应用内「检查更新」为**非强制**:仅提示,经用户确认后才下载安装(Windows 端后台替换,Android 端跳转下载页手动安装),可随时取消" echo "" if [ -n "${PREV}" ]; then echo "## 更新差异(${PREV} → ${GITHUB_REF_NAME})" @@ -103,6 +86,178 @@ jobs: body_path: release_notes.md files: | publish/TraeTools.exe - publish/TraeTools.LoginHost.exe TraeTools-win-x64.zip - generate_release_notes: false \ No newline at end of file + generate_release_notes: false + + publish-android: + runs-on: ubuntu-latest + needs: publish # 等 Windows job 创建好 Release 后再追加 APK 资产 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Install Android workload + run: dotnet workload install android + + # Android APK:先由 .NET 打包器签名(它会按自己的策略完成 zipalign), + # 再**重签**一次以强制三种签名方案齐全。 + # + # 为什么不自己 zipalign + 签名:.NET 打包出的包已经过它的对齐流程, + # 而本机 build-tools 的 zipalign 不支持 -P 16(16KB 页对齐,Android 15+ 对含 + # 原生库的包有要求),自行 zipalign 会把 16KB 对齐**降级**成 4KB。 + # 只做重签则完全不触碰 zip 结构,对齐策略保持不变。 + # + # EnableWindowsTargeting:多目标项目还原需一并还原 net10.0-windows,ubuntu 上必须显式开启 + - name: Publish (android apk, signed by dotnet) + run: | + dotnet publish TraeTools.csproj -c Release -f net10.0-android \ + -p:EnableWindowsTargeting=true \ + -p:AndroidPackageFormat=apk \ + -p:AndroidKeyStore=true \ + -p:AndroidSigningKeyStore="$PWD/signing/traetools.keystore" \ + -p:AndroidSigningKeyAlias=traetools \ + -p:AndroidSigningKeyPass=traetools \ + -p:AndroidSigningStorePass=traetools \ + -o publish-android + echo "== publish-android 产物 ==" + find publish-android -name "*.apk" -printf "%s\t%p\n" | sort -rn || true + + - name: Re-sign APK (force v1 + v2 + v3) + run: | + set -euo pipefail + SRC=$(find publish-android -iname "*-Signed.apk" | head -1) + [ -n "$SRC" ] || SRC=$(find publish-android -name "*.apk" | head -1) + [ -n "$SRC" ] || { echo "::error::未找到 APK"; exit 1; } + echo "input apk: $SRC" + + BT=$(ls -d "${ANDROID_HOME:-/usr/local/lib/android/sdk}"/build-tools/* 2>/dev/null | sort -V | tail -1) + AS="$BT/apksigner" + [ -x "$AS" ] || { echo "::error::apksigner not found"; exit 1; } + + # 不传给 v1 的既有签名文件做剥离:apksigner 重签时自行替换签名块与 + # META-INF 下的 JAR 签名文件。 + "$AS" sign \ + --ks signing/traetools.keystore --ks-type PKCS12 \ + --ks-pass pass:traetools \ + --ks-key-alias traetools \ + --key-pass pass:traetools \ + --v1-signing-enabled true \ + --v2-signing-enabled true \ + --v3-signing-enabled true \ + --out TraeTools-Android.apk "$SRC" + ls -lh TraeTools-Android.apk + + # 对齐检查(信息性,不阻断:对齐由 .NET 打包器负责) + ZA="$BT/zipalign" + if [ -x "$ZA" ]; then + if "$ZA" -c -v 4 TraeTools-Android.apk >/dev/null 2>&1; then + echo "✅ zipalign 4 字节对齐通过" + else + echo "::warning::zipalign 检查未通过(信息性,不影响签名有效性)" + fi + fi + + # ===== 签名校验门禁 ===== + # 旧实现只 `apksigner verify --print-certs | head -8 || true`:既不校验退出码, + # 也不比对指纹,等于「打印一下就放行」。签名一旦出问题(密钥被换、方案缺失、 + # 误传未签名包),用户侧表现为「应用未安装 / 签名验证不通过」,而 CI 全绿。 + # 现在改为硬门禁:任一条件不满足即中断发布。 + - name: Verify APK signature (gate) + run: | + set -euo pipefail + BT=$(ls -d "${ANDROID_HOME:-/usr/local/lib/android/sdk}"/build-tools/* 2>/dev/null | sort -V | tail -1) + [ -n "$BT" ] && [ -x "$BT/apksigner" ] || { echo "::error::apksigner not found"; exit 1; } + echo "== apksigner verify --verbose --print-certs ==" + # 退出码是主判据:apksigner 对签名无效/缺方案会返回非 0 + "$BT/apksigner" verify --verbose --print-certs TraeTools-Android.apk | tee verify.txt + + # 指纹比对:基准由 rotate-keystore.yml 生成,避免两处手工同步。 + # 必须做大小写归一——keytool 输出大写、apksigner 输出小写, + # 直接字符串比较会因大小写差异误报"不一致"(实测踩过)。 + EXPECTED=$(tr -cd '0-9A-Fa-f' < signing/EXPECTED_CERT_SHA256.txt 2>/dev/null | tr 'a-f' 'A-F') + ACTUAL=$(sed -n 's/.*certificate SHA-256 digest: *//p' verify.txt | head -1 | tr -cd '0-9A-Fa-f' | tr 'a-f' 'A-F') + echo "expected = ${EXPECTED:-<缺失>}" + echo "actual = ${ACTUAL:-<未解析到>}" + [ -n "$EXPECTED" ] || { echo "::error::缺少 signing/EXPECTED_CERT_SHA256.txt"; exit 1; } + [ -n "$ACTUAL" ] || { echo "::error::未能从 apksigner 输出解析证书 SHA-256"; exit 1; } + [ "$EXPECTED" = "$ACTUAL" ] || { echo "::error::签名证书指纹与 EXPECTED_CERT_SHA256.txt 不一致(密钥被换或产物来源不对)"; exit 1; } + echo "✅ 证书指纹一致" + + # 结构性校验三种签名方案是否齐全。刻意不复用 apksigner 的文本输出—— + # 不同 build-tools 版本的行格式不同(实测本仓库 CI 输出的是 "V3.0 Signer:" 而非 + # "Signer #1 certificate ..."),文本 grep 不可靠;直接解析 APK 二进制最稳。 + - name: Verify signature schemes present (v1 + v2 + v3) + run: | + python3 - <<'PY' + import os + import struct + import sys + import zipfile + + APK = "TraeTools-Android.apk" + ok = True + + # --- v1:JAR 签名文件存在于 META-INF --- + with zipfile.ZipFile(APK) as z: + names = [n for n in z.namelist() if n.upper().startswith("META-INF/")] + blk = [n for n in names if n.upper().endswith((".RSA", ".DSA", ".EC"))] + sf = [n for n in names if n.upper().endswith(".SF")] + mf = [n for n in names if n.upper().endswith("MANIFEST.MF")] + v1 = bool(blk and sf and mf) + print(f"v1 (JAR) : {'存在' if v1 else '缺失'} block={blk} SF={sf} MF={mf}") + ok = ok and v1 + + # --- v2 / v3:APK Signing Block 中的方案 ID --- + # ⚠️ 不能只读「文件尾部 64KB」:中央目录本身在本包里就有 ~73KB, + # 签名块位于中央目录**之前**,固定窗口会取不到它而误报「缺失」。 + # 正确做法:先从尾部 EOCD 读中央目录偏移,再自该偏移向前多读 64KB。 + total = os.path.getsize(APK) + cd_off = total + with open(APK, "rb") as f: + f.seek(max(0, total - 65536)) + last = f.read() + e = last.rfind(b"PK\x05\x06") + if e >= 0: + cd_off = struct.unpack_from(" end: + break + ids[struct.unpack_from(" signing/EXPECTED_CERT_SHA256.txt + echo "---- signing/EXPECTED_CERT_SHA256.txt ----" + cat signing/EXPECTED_CERT_SHA256.txt + echo + echo "===== base64(便于本地核对,与文件内容一致)=====" + base64 -w 0 "$KS"; echo + + - name: 提交回仓库 + if: ${{ inputs.commit }} + run: | + set -euo pipefail + git config user.name "akexin" + git config user.email "315600306+akexin@users.noreply.github.com" + git add signing/traetools.keystore signing/EXPECTED_CERT_SHA256.txt + if git diff --cached --quiet; then + echo "密钥与指纹均未变化,无需提交" + else + git commit \ + -m "chore(signing): 轮换 APK 签名密钥(keytool 生成,alias=${{ inputs.alias }})" \ + -m "由 .github/workflows/rotate-keystore.yml 手动触发。轮换后所有已安装用户必须卸载旧版才能安装新版。同时更新 signing/EXPECTED_CERT_SHA256.txt(release.yml 的签名校验门禁基准)。" + git push origin "HEAD:${GITHUB_REF_NAME}" + fi diff --git a/Android/App.axaml b/Android/App.axaml deleted file mode 100644 index 7aa3b1e..0000000 --- a/Android/App.axaml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - diff --git a/Android/App.axaml.cs b/Android/App.axaml.cs deleted file mode 100644 index 3995e61..0000000 --- a/Android/App.axaml.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Markup.Xaml; -using TraeTools.Platform; -using TraeTools.ViewModels; -using TraeTools.Views; - -namespace TraeTools; - -/// -/// Android 版应用入口。 -/// -/// 与仓库根的 App.axaml.cs 的区别只有两处: -/// 1) 没有 IClassicDesktopStyleApplicationLifetime 分支(单视图平台无 Window); -/// 2) Application 一律写全称 Avalonia.Application,与 Android.App.Application 区分。 -/// -public partial class App : Avalonia.Application -{ - public override void Initialize() - { - try - { - AvaloniaXamlLoader.Load(this); - CrashLog.Boot("App.Initialize completed"); - } - catch (Exception ex) - { - CrashLog.Write("App.Initialize", ex, true); - throw; - } - } - - public override void OnFrameworkInitializationCompleted() - { - CrashLog.Install(text => CrashReportActivity.Launch(text)); - CrashLog.Boot($"App.OnFrameworkInitializationCompleted lifetime={ApplicationLifetime?.GetType().FullName}"); - - if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) - { - // 上次运行崩溃过:本次先进错误页(可复制堆栈 / 重试),避免反复闪退 - var flag = CrashLog.ReadFlag(); - if (!string.IsNullOrEmpty(flag)) - { - CrashLog.Boot("App: previous crash detected -> startup error view"); - singleView.MainView = BuildErrorView(singleView, "上次启动崩溃了", flag!); - base.OnFrameworkInitializationCompleted(); - return; - } - - try - { - // 单视图生命周期:MainView(UserControl)作为根视图全屏展示。 - // 不能用 MainWindow(Window):单视图平台创建 Window 会抛 NotSupportedException。 - singleView.MainView = new MainView - { - DataContext = new MainViewModel(), - }; - CrashLog.Boot("App: single-view MainView created"); - } - catch (Exception ex) - { - // 主界面构建失败也不能闪退:显示可读的错误页 - CrashLog.Write("App: create MainView failed", ex, true); - singleView.MainView = BuildErrorView(singleView, "界面初始化失败", ex.ToString()); - } - } - - base.OnFrameworkInitializationCompleted(); - } - - /// 构建启动错误页:异常堆栈 + 启动日志 + 「清除并重新加载界面」。 - private static Control BuildErrorView( - ISingleViewApplicationLifetime lifetime, - string title, - string detail) - { - var body = detail + Environment.NewLine + Environment.NewLine - + "==== 启动日志(尾部)====" + Environment.NewLine - + CrashLog.ReadRecent(); - - var text = new SelectableTextBlock - { - Text = title + Environment.NewLine + Environment.NewLine + body, - TextWrapping = Avalonia.Media.TextWrapping.Wrap, - FontFamily = new Avalonia.Media.FontFamily("Consolas,monospace"), - FontSize = 12, - Margin = new Thickness(16), - }; - - var retry = new Button - { - Content = "清除崩溃标记并重新加载界面", - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, - Margin = new Thickness(16, 0, 16, 24), - Padding = new Thickness(16, 8), - }; - retry.Click += (_, _) => - { - CrashLog.ClearFlag(); - try - { - lifetime.MainView = new MainView { DataContext = new MainViewModel() }; - CrashLog.Boot("App: retry create MainView ok"); - } - catch (Exception ex) - { - CrashLog.Write("App: retry create MainView failed", ex, true); - } - }; - - var grid = new Grid { RowDefinitions = new RowDefinitions("*,Auto") }; - grid.Children.Add(new ScrollViewer { Content = text }); - Grid.SetRow(retry, 1); - grid.Children.Add(retry); - return grid; - } -} diff --git a/Android/Platform/BrowserLauncher.cs b/Android/Platform/BrowserLauncher.cs deleted file mode 100644 index 08addf6..0000000 --- a/Android/Platform/BrowserLauncher.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; - -namespace TraeTools.Platform; - -/// -/// 用系统默认浏览器打开 URL(Android 实现)。 -/// -/// 失败必须可见:早先的实现把异常全部吞掉且返回 void,于是「授权 GitHub」 -/// 按钮点了没反应时,用户与日志都拿不到任何线索。现在返回 bool, -/// 并把失败原因写进平台日志(category = browser)。 -/// -/// 三级回退: -/// -/// 优先用当前 Activity 启动(前台切换最自然,无需 FLAG_ACTIVITY_NEW_TASK); -/// Activity 不可用时改用 Application 上下文 —— 此时必须带 NEW_TASK, -/// 否则抛 AndroidRuntimeException; -/// 个别定制 ROM 会拦截直接 startActivity,最后用 Intent.CreateChooser 再试一次。 -/// -/// -/// 浏览器属于「隐式 Intent 解析」,Android 11+ 的包可见性限制默认不影响 -/// startActivity,但为兼容个别 ROM 仍在清单里声明了 <queries>(见 Properties/AndroidManifest.xml)。 -/// -internal static class BrowserLauncher -{ - /// 打开 URL:成功返回 true;失败返回 false 并已写日志。 - public static bool Open(string? url) - { - if (string.IsNullOrWhiteSpace(url)) - { - PlatformLog.Warn("browser", "打开浏览器失败:URL 为空"); - return false; - } - try - { - var uri = Android.Net.Uri.Parse(url); - if (uri == null) - { - PlatformLog.Warn("browser", $"打开浏览器失败:URL 无法解析({url})"); - return false; - } - - var activity = MainActivity.Current; - var context = Android.App.Application.Context; - - // ① 首选:Activity 直接启动 - if (activity != null) - { - try - { - activity.StartActivity(new Android.Content.Intent(Android.Content.Intent.ActionView, uri)); - return true; - } - catch (Exception ex) - { - PlatformLog.Warn("browser", "Activity 启动浏览器失败,回退 Application 上下文:" + ex.Message); - } - } - - if (context == null) - { - PlatformLog.Warn("browser", "打开浏览器失败:Android 上下文不可用(Application.Context 为 null)"); - return false; - } - - // ② 回退:Application 上下文(必须 NEW_TASK) - try - { - var intent = new Android.Content.Intent(Android.Content.Intent.ActionView, uri); - intent.AddFlags(Android.Content.ActivityFlags.NewTask); - context.StartActivity(intent); - return true; - } - catch (Exception ex) - { - PlatformLog.Warn("browser", "Application 启动浏览器失败,改用选择器:" + ex.Message); - } - - // ③ 再回退:让系统弹「用哪个应用打开」 - try - { - var view = new Android.Content.Intent(Android.Content.Intent.ActionView, uri); - view.AddFlags(Android.Content.ActivityFlags.NewTask); - var chooser = Android.Content.Intent.CreateChooser(view, "选择浏览器打开")!; - chooser.AddFlags(Android.Content.ActivityFlags.NewTask); - context.StartActivity(chooser); - return true; - } - catch (Exception ex) - { - PlatformLog.Warn("browser", - $"打开浏览器失败(三种方式均未成功):{ex.GetType().Name}: {ex.Message}(URL={url})"); - return false; - } - } - catch (Exception ex) - { - PlatformLog.Warn("browser", $"打开浏览器失败:{ex.GetType().Name}: {ex.Message}(URL={url})"); - return false; - } - } -} diff --git a/Android/Platform/DpapiCompat.cs b/Android/Platform/DpapiCompat.cs deleted file mode 100644 index bb4369d..0000000 --- a/Android/Platform/DpapiCompat.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Text; - -// ⚠️ 在 System.Security.Cryptography 命名空间下定义 BCL 同名类型是刻意的,理由见下方注释。 -namespace System.Security.Cryptography; - -/// 与 DPAPI 同名的作用域枚举(Android 上只有应用本地密钥库,语义上等同 CurrentUser)。 -public enum DataProtectionScope -{ - CurrentUser, - LocalMachine, -} - -/// -/// DPAPI 兼容层(仅 Android 编译)。 -/// -/// 背景:共享的 Services/Checkin/AppConfig.cs 用 -/// ProtectedData.Protect/Unprotect 加密 Token / Session / DeviceId 等敏感字段; -/// 而 System.Security.Cryptography.ProtectedData 是 Windows 专属包, -/// net10.0-android 上不存在该类型,直接编译会失败。 -/// -/// 本工程的前提是不改动仓库根目录下的任何文件,因此这里提供一份 -/// 同名同签名的兼容层,把 DPAPI 的语义映射到 -/// (Keystore + AES-256-GCM), -/// 于是上游的业务代码一行不改即可在手机上正常工作。 -/// -/// 两处语义差异,均已被上游代码的用法包容: -/// -/// 密文格式不同 —— AppConfig 只把结果当不透明字符串存取,跨平台拷贝时解密失败会 -/// 走它自己的兜底(字段置 null → 提示重新登录),不会把密文当明文使用; -/// 失败时抛 —— 与 DPAPI 行为一致, -/// AppConfig 的 DecryptSecret 本来就 catch 全部异常。 -/// -/// -/// 注:若某个 .NET Android 版本将来自带了同名类型,编译器会以本文件为准并给出 -/// CS0436 警告(构建时已抑制),行为仍然是我们的 Keystore 实现 —— 正是期望的结果。 -/// -public static class ProtectedData -{ - public static byte[] Protect(byte[] userData, byte[]? optionalEntropy, DataProtectionScope scope) - { - ArgumentNullException.ThrowIfNull(userData); - var encrypted = TraeTools.Platform.AndroidVault.Protect(Encoding.UTF8.GetString(userData)) - ?? throw new CryptographicException("Android Keystore 加密失败"); - return Encoding.UTF8.GetBytes(encrypted); - } - - public static byte[] Unprotect(byte[] encryptedData, byte[]? optionalEntropy, DataProtectionScope scope) - { - ArgumentNullException.ThrowIfNull(encryptedData); - var plain = TraeTools.Platform.AndroidVault.Unprotect(Encoding.UTF8.GetString(encryptedData)) - ?? throw new CryptographicException("Android Keystore 解密失败(换机 / 清除数据 / 跨平台拷贝)"); - return Encoding.UTF8.GetBytes(plain); - } -} diff --git a/Android/Platform/PlatformLog.cs b/Android/Platform/PlatformLog.cs deleted file mode 100644 index 6fde30d..0000000 --- a/Android/Platform/PlatformLog.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.IO; - -namespace TraeTools.Platform; - -/// -/// 平台层的最小日志出口。 -/// -/// 页面级日志走 ViewModels.AccountHelpers.AppLog(按业务分类归档), -/// 但 Platform/ 下的代码属于更底层的设施(加解密、浏览器跳转、崩溃捕获), -/// 不应反向依赖 ViewModels,因此在这里提供一个同格式、同目录的写入点: -/// 文件名与格式与页面日志保持一致({category}_log_yyyyMM.txt), -/// 打开日志目录时能看到完整的一批记录。 -/// -internal static class PlatformLog -{ - private static readonly object Lock = new(); - - public static void Info(string category, string message) => Write(category, "INFO", message); - - public static void Warn(string category, string message) => Write(category, "WARN", message); - - public static void Error(string category, string message) => Write(category, "ERROR", message); - - private static void Write(string category, string level, string message) - { - try - { - lock (Lock) - { - var file = Path.Combine( - TraeTools.Services.DataPaths.LogsDir, - $"{category}_log_{DateTime.Now:yyyyMM}.txt"); - File.AppendAllText(file, - $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{level}] {message}{Environment.NewLine}"); - } - } - catch - { - // 日志写入失败绝不能影响主流程(尤其是崩溃与加解密路径) - } - } -} diff --git a/Android/README.md b/Android/README.md deleted file mode 100644 index ba28ebe..0000000 --- a/Android/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# TraeTools for Android - -给 TraeTools 加上 **Android 端**。本目录是一个独立的 `net10.0-android` 工程, -Windows 版的构建、发布流程与产物**不受影响**。 - -根目录只有 `TraeTools.csproj` 增加了 3 行 `Remove`(把本目录排除出主程序的默认通配符), -原因见下方「与根工程的关系」。除此之外,仓库其余文件一行未动。 - -## 包含内容 - -| 路径 | 说明 | -|---|---| -| `TraeTools.Android.csproj` | 独立的 `net10.0-android` 工程。共享代码(`Models/` `Services/` `ViewModels/` `Views/` 的六个页面、`Styles/` `Assets/`)以链接方式复用,单一真源仍在仓库根目录 | -| `App.axaml(.cs)` | Android 应用入口(单视图生命周期) | -| `Platform/` | `AndroidVault`(Keystore + AES-256-GCM)、`DpapiCompat`(DPAPI 兼容层)、`BrowserLauncher`、`CrashLog`、`CrashReportActivity`、`MainActivity`、`PlatformLog` | -| `Views/` | `MainView`(单视图外壳:顶栏 + 横向标签导航 + 覆盖层对话框宿主)、`OverlayDialog`、5 个对话框(`PromptWindow` / `InputWindow` / `PatInputWindow` / `LoginWindow` / `EulaWindow`) | -| `Services/` | `DataPaths`、`AutoStartManager` 的 Android 实现 | -| `ViewModels/UiHost.cs` | 把宿主解析结果放宽为 `TopLevel`,使上游 ViewModel 无需改动即可正常弹窗与访问剪贴板 | -| `Properties/` `Resources/` | 应用清单(INTERNET + 浏览器查询意图)、自适应图标、主题与配色 | - -## 编译 - -需要 .NET 10 SDK,并安装 Android 工作负载: - -```bash -dotnet workload install android -dotnet build Android/TraeTools.Android.csproj -c Release -``` - -产物:`Android/bin/Release/net10.0-android/*.apk`(最低 Android 8.0 / API 26)。 -包名 `com.traetools.android`,`versionName` 取自根目录 `version.json`。 -Release 默认使用调试密钥签名,正式分发请自行配置 keystore。 - -CI 见仓库根的 `.github/workflows/android.yml`(编译并上传 APK)。 - -## 与根工程的关系 - -本工程位于 Windows 主程序的工程目录之内,而 MSBuild 与 Avalonia 的默认通配符会向下递归, -且并不认「子目录里有自己的 csproj」: - -| 来源 | 默认通配符 | -|---|---| -| .NET SDK | `Compile Include="**/*.cs"`、`None Include="**/*"` | -| Avalonia | `AvaloniaXaml Include="**\*.axaml"`(另有 `**\*.paml`) | - -若不排除,主程序会把 Android 源码一并编译,实测报 -`AVLN2002: Duplicate x:Class directive, TraeTools.App is already used in /Android/App.axaml`, -并且 `Android/Platform/*.cs` 依赖的 `Android.App` / `Avalonia.Android` 在主程序里无法解析。 - -因此 `TraeTools.csproj` 中增加了: - -```xml - - - - - -``` - -写法与仓库里既有的 `` 一致。 - -## 平台差异 - -| 能力 | Windows | Android | -|---|---|---| -| 签到 / 自动签到 / 云端签到 / 用量统计 / 仪表盘 | ✅ | ✅ | -| 账号切换(vault 建档 / 冷切换) | ✅ 直接操作本地客户端 | ⚠️ 手机端无本地 Trae 客户端,页面仅作展示 | -| 登录方式 | ✅ 内嵌浏览器(WebView2 宿主)自动取凭证 | ✅ 手动粘贴 Cloud-IDE-Token | -| 开机自启 | ✅ 注册表 `Run` | ❌ 无等价机制,已降级为无操作 | -| 凭证加密 | DPAPI(当前用户) | Android Keystore(RSA-2048 包裹 AES-256-GCM 数据密钥) | -| 系统托盘 / 通知栏 | ✅ 托盘图标 + 原生菜单 | ❌ 无对应概念(自动签到在进程存活期间执行) | - -## 已知限制 - -- **应用内自动更新不可用**:上游 `SettingsViewModel` 的更新流程会下载 zip 并替换 exe, - 手机上无法执行。建议直接到 Releases 下载新 APK 覆盖安装。 -- **部分「打开网页」入口点了没反应**:上游 `CloudViewModel` / `SettingsViewModel` 里 - 打开浏览器走的是 `Process.Start(...)`,该调用在 Android 上不可用(失败已被 try/catch 捕获, - 日志里会留痕)。设备码授权流程仍可用 —— 授权码会自动复制到剪贴板, - 按日志提示手动访问授权页即可。要修好这两处需要给上游 VM 加一个平台跳转抽象(约 7 处调用点)。 -- 仅编译过 `net10.0-android`;未在真机长时间运行验证。 diff --git a/Android/Services/Checkin/AutoStartManager.cs b/Android/Services/Checkin/AutoStartManager.cs deleted file mode 100644 index 225ec1d..0000000 --- a/Android/Services/Checkin/AutoStartManager.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace TraeCheckin; - -/// -/// 开机自启动(Android 实现)。 -/// -/// Windows 版通过注册表 HKCU\...\CurrentVersion\Run 注册 exe 路径; -/// Android 在应用层面没有等价的、无需额外权限的通用机制: -/// -/// BOOT_COMPLETED 广播 + 前台服务需要 RECEIVE_BOOT_COMPLETED 权限, -/// 且 Android 12+ 起后台启动前台服务受限,Play 政策也不允许「静默常驻」; -/// 本应用真正的「无人值守签到」场景由「云端签到」承担 -/// (GitHub Actions 每天定时跑 checkin.py,不依赖手机开机)。 -/// -/// 因此这里保留与原实现完全一致的 API,但固定返回「未启用 / 设置无效」, -/// 让设置页能如实展示「本平台不支持」,而不是静默假装成功。 -/// -internal static class AutoStartManager -{ - /// 是否已注册开机自启动 —— Android 恒为 false。 - public static bool IsEnabled() => false; - - /// 开启或关闭开机自启动 —— Android 为空操作(无注册表可写)。 - public static void SetEnabled(bool enabled) - { - // 有意留空:调用方(设置页)会依据 IsEnabled() 的返回值把开关复位并给出提示。 - } -} diff --git a/Android/Services/DataPaths.cs b/Android/Services/DataPaths.cs deleted file mode 100644 index a76cb3a..0000000 --- a/Android/Services/DataPaths.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.IO; - -namespace TraeTools.Services; - -/// -/// 统一数据根目录(Android 实现)。 -/// -/// Windows 版落在 %APPDATA%\TraeTools;Android 没有「用户漫游目录」的概念, -/// 应用私有目录就是 Context.FilesDir(/data/data/<包名>/files, -/// 非 root 不可读、随卸载清除),因此根目录取它的 TraeTools 子目录。 -/// -/// 目录布局与桌面端保持一致(config.json / data / logs / switch / vault), -/// 这样 Services/Checkin/*、Services/Switch/* 等共享代码无需任何分支。 -/// -/// Migrate() 在本平台是空操作:TraeCheckin / TraeSwitch 的历史目录只存在于 Windows。 -/// -public static class DataPaths -{ - /// 根目录:应用私有 files 目录下的 TraeTools 子目录。 - public static string Root - { - get - { - try - { - var files = Android.App.Application.Context?.FilesDir?.AbsolutePath; - if (!string.IsNullOrEmpty(files)) - return Path.Combine(files!, "TraeTools"); - } - catch - { - // Context 尚未就绪时走下面的兜底路径 - } - // 兜底:极早期调用(Application.Context 为 null)时退化到 HOME 目录, - // 保证 Root 永远返回可用字符串而不是抛异常。 - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Personal), "TraeTools"); - } - } - - /// 签到配置 config.json。 - public static string ConfigPath => Path.Combine(Root, "config.json"); - - /// 数据目录:存放 checkin.db、history_*.txt、credits_total_*.txt、usage_*.jsonl。 - public static string DataDir - { - get - { - var dir = Path.Combine(Root, "data"); - Directory.CreateDirectory(dir); - return dir; - } - } - - /// 日志目录:存放各模块调试日志(checkin_log / account_log / cloud_log / switch_log / usage_log)。 - public static string LogsDir - { - get - { - var dir = Path.Combine(Root, "logs"); - Directory.CreateDirectory(dir); - return dir; - } - } - - /// 账号切换配置(settings.json)。 - public static string SwitchDir => Path.Combine(Root, "switch"); - - /// 登录态备份 vault。手机端不做客户端切换,该目录仅用于放置共享代码。 - public static string VaultDir => Path.Combine(Root, "vault"); - - /// 确保所有子目录存在(启动时调用一次)。 - public static void EnsureDirectories() - { - Directory.CreateDirectory(Root); - Directory.CreateDirectory(DataDir); - Directory.CreateDirectory(LogsDir); - Directory.CreateDirectory(SwitchDir); - Directory.CreateDirectory(VaultDir); - } - - /// - /// Windows 版用于把 TraeCheckin / TraeSwitch 历史目录迁移到统一根; - /// Android 是全新平台、没有历史目录可迁移,因此只保证目录存在。 - /// 保留同名方法是为了让共享的调用方(如各 ViewModel 的初始化路径)不改一行代码。 - /// - public static void Migrate() - { - try - { - EnsureDirectories(); - } - catch - { - // 目录创建失败不阻塞启动:后续写入会各自失败并提示 - } - } -} diff --git a/Android/TraeTools.Android.csproj b/Android/TraeTools.Android.csproj deleted file mode 100644 index 5655c47..0000000 --- a/Android/TraeTools.Android.csproj +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - net10.0-android - Exe - - TraeTools - TraeTools - enable - enable - true - true - CA1416;XA0121;CS0436 - - - - - <_VJsonRaw Condition="Exists('$(MSBuildProjectDirectory)\..\version.json')">$([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\..\version.json')) - <_VJsonVer>$([System.Text.RegularExpressions.Regex]::Match('$(_VJsonRaw)', '"version"\s*:\s*"([^"]+)"').Groups[1].Value) - $(_VJsonVer) - 1.0.0 - $(_VJsonVer) - 1.0.0 - - - - - - 26 - - com.traetools.android - - $(_VJsonVer) - 1.0.0 - - 1 - apk - - - android-arm64;android-arm - - - false - false - None - false - - false - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Android/ViewModels/UiHost.cs b/Android/ViewModels/UiHost.cs deleted file mode 100644 index 669117e..0000000 --- a/Android/ViewModels/UiHost.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; - -namespace TraeTools.ViewModels; - -/// -/// 解析当前可用于承载对话框的宿主,供页面 VM 弹模态与访问剪贴板 / 文件选择器。 -/// -/// 关键设计(整个 Android 移植能「零改动共享 VM」的原因): -/// 上游桌面版的 MainWindow 属性声明为 Window?,各 VM 的用法只有四种 —— -/// -/// dlg.ShowDialog<T>(owner):把 owner 透传给对话框; -/// owner != null ? … : …:判空守卫; -/// top?.Clipboard:复制 Token / 授权码; -/// win.StorageProvider:文件选择器。 -/// -/// 这四者全部是 的成员,而 Window 只是 TopLevel 的子类。 -/// 上游 VM 一律用 var 接收该属性,因此这里把返回类型放宽成 TopLevel? 后: -/// -/// 上游 VM 一行都不用改; -/// Android 单视图平台也能拿到非 null 的宿主(单视图的根视图反查 TopLevel), -/// owner != null 的守卫照样通过,弹窗与剪贴板全部可用。 -/// -/// 若这里坚持返回 Window?,Android 上恒为 null, -/// 上游那些 owner != null ? await … : false 的确认框会被整体跳过(功能性缺失)。 -/// -internal static class UiHost -{ - /// - /// 对话框宿主 / 剪贴板来源。桌面端是主窗口,Android 是承载单视图的根 TopLevel; - /// 尚未挂载到视觉树时返回 null(调用方已有判空守卫)。 - /// - public static TopLevel? MainWindow => Resolve(); - - private static TopLevel? Resolve() - { - try - { - var lifetime = Avalonia.Application.Current?.ApplicationLifetime; - - // 桌面端(本工程不编译该分支,保留以便共享代码语义完整) - if (lifetime is IClassicDesktopStyleApplicationLifetime desktop) - return desktop.MainWindow; - - // Android:单视图生命周期,从根视图反查 TopLevel - if (lifetime is ISingleViewApplicationLifetime singleView && singleView.MainView is Control root) - return TopLevel.GetTopLevel(root); - } - catch - { - // 视觉树尚未就绪 / 生命周期切换中:返回 null,由调用方的守卫兜底 - } - return null; - } -} diff --git a/Android/Views/EulaWindow.axaml b/Android/Views/EulaWindow.axaml deleted file mode 100644 index 8673c48..0000000 --- a/Android/Views/EulaWindow.axaml +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - -TRAETOOLS 最终用户许可协议(EULA) - -欢迎使用本软件。在使用本软件前,请仔细阅读以下条款。 -点击「同意并继续」即表示您接受本协议全部内容;如不同意,请立即关闭并删除本软件。 - -1. 软件性质 - 本软件为作者个人技术学习成果,以开源形式(GPL-3.0)发布,按"现状"提供, - 不包含任何形式的明示或默示担保。本软件由「TRAE 每日签到助手」与 - 「TRAE 多账号冷切换助手」整合而来,本协议同时适用于两项功能。 - -2. 账号归属限制(使用前提) - 您只能将本软件用于管理您本人合法拥有并有权使用的 Trae 账号。 - 签到与冷切换均仅限本人账号场景,您不得使用本软件从事批量注册、账号交易、 - 积分倒卖、共享账号租赁、工作室多开、破坏平台正常运营或任何违反 - 中华人民共和国法律法规及 Trae 平台服务条款的行为。 - -3. 数据安全责任 - 本软件会在您的本地设备上存储登录凭证(Token / 会话 Cookie)以及 - 账号登录态备份(vault 目录,等同账号凭证)。您有责任确保这些文件 - 不被上传至公共网络、云盘或分享给任何第三方。 - 因您未妥善保管本地数据导致的账号被盗、信息泄露或其他损失,作者不承担责任。 - -4. 切换与登录态说明 - 账号冷切换依赖 vault 中保存的登录态字节。切换仅在本机完成, - 不涉及对 Trae 客户端程序代码的任何修改、注入或破解。 - 请您务必保管好 vault 目录,不要将其置于公共云盘或分享。 - -5. 平台关系声明 - 本软件与 Trae 及其运营方无任何关联。 - 如官方推出同类功能或要求停止使用,您应立即停用本软件。 - -6. 禁止逆向与滥用 - 您不得对本软件进行反编译、破解或二次开发后用于违反平台规则的目的。 - 您不得移除、修改或绕过本软件中的任何使用限制或安全提示。 - -7. 责任免除 - 在法律允许的最大范围内,作者不对因使用或无法使用本软件而产生的任何 - 直接、间接、附带或惩罚性损害承担责任,包括但不限于账号限制、积分调整、 - 数据丢失、登录态失效等。 - -8. 协议更新 - 作者保留随时修改本协议的权利,修改后的协议将在软件更新或仓库中公布。 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Android/Views/MainView.axaml.cs b/Android/Views/MainView.axaml.cs deleted file mode 100644 index 8ffa194..0000000 --- a/Android/Views/MainView.axaml.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Threading; -using TraeTools.Platform; -using TraeTools.ViewModels; - -namespace TraeTools.Views; - -/// -/// Android 主视图(单视图根 UserControl)。 -/// -/// 承担三件上游由 MainWindow 负责的事: -/// 1) 把覆盖层挂给 (对话框宿主); -/// 2) 每 30 秒检查一次自动签到; -/// 3) 首次启动弹出 EULA。 -/// 桌面端那些能力(托盘图标、最小化到托盘、任务栏图标、系统通知)在手机上无对应概念,故不移植。 -/// -public partial class MainView : UserControl -{ - /// 自动签到定时器:每 30 秒检查一次,到点且当天未签时自动签到。 - private readonly DispatcherTimer _autoCheckinTimer; - - /// 计时器重入守卫:上一轮任务未结束前跳过本轮 Tick。 - private bool _tickBusy; - private bool _started; - private bool _eulaChecked; - - public MainView() - { - InitializeComponent(); - - // 覆盖层宿主:所有对话框以 UserControl 形式挂到这里 - OverlayDialog.Attach(OverlayRoot, OverlayContent); - CrashLog.Boot("MainView: ctor, overlay attached"); - - _autoCheckinTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) }; - _autoCheckinTimer.Tick += async (_, _) => - { - if (_tickBusy) return; - _tickBusy = true; - try - { - if (DataContext is MainViewModel vm) - { - // 与桌面端 MainWindow 的定时器保持一致的两步 - await vm.AutoCheckinIfDue(); - await vm.RefreshSnapshotIfNeeded(); - } - } - catch (Exception ex) - { - // 定时任务异常不允许带崩 UI(未捕获异常会直接闪退) - CrashLog.Write("AutoCheckin timer tick", ex); - } - finally - { - _tickBusy = false; - } - }; - - // EULA 需要视觉树就绪后才能取到 TopLevel,故放到 Loaded - Loaded += OnLoaded; - } - - private void OnLoaded(object? sender, RoutedEventArgs e) - { - if (_eulaChecked) return; - _eulaChecked = true; - CrashLog.Boot("MainView: loaded"); - try - { - _ = EulaCheckAsync(); - } - catch (Exception ex) - { - CrashLog.Write("MainView.OnLoaded", ex); - } - } - - protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) - { - base.OnAttachedToVisualTree(e); - if (_started) return; - _started = true; - CrashLog.Boot("MainView: attached to visual tree"); - try - { - // 自动签到仅在 App 运行期间有效;长期无人值守请使用「云端签到」 - _autoCheckinTimer.Start(); - } - catch (Exception ex) - { - CrashLog.Write("MainView.OnAttachedToVisualTree", ex); - } - } - - protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) - { - try { _autoCheckinTimer.Stop(); } - catch { /* 停止定时器失败不影响退出 */ } - base.OnDetachedFromVisualTree(e); - } - - /// - /// 首次启动弹出免责声明协议(EULA)。同意标记存放于数据根目录 eula_accepted, - /// 与 SettingsViewModel 里「重置协议同意状态」用的是同一个路径,两边保持一致。 - /// - private async Task EulaCheckAsync() - { - try - { - var flag = Path.Combine(TraeTools.Services.DataPaths.Root, "eula_accepted"); - if (File.Exists(flag)) return; - - CrashLog.Boot("EULA: showing agreement"); - var eula = new EulaWindow(); - bool agreed; - try - { - agreed = await eula.ShowDialog(TopLevel.GetTopLevel(this)); - } - catch (Exception ex) - { - // 兜底:对话框呈现失败则记录并视为同意,保证应用可用(日志留痕) - CrashLog.Write("EULA dialog failed", ex); - agreed = true; - } - CrashLog.Boot($"EULA: agreed={agreed}"); - - if (agreed) - { - try - { - Directory.CreateDirectory(Path.GetDirectoryName(flag)!); - File.WriteAllText(flag, DateTime.UtcNow.ToString("o")); - } - catch - { - // 标记写失败下次仍会弹,可接受 - } - } - else - { - // 不同意:与桌面端「直接退出」对齐 —— 手机端关闭唯一 Activity 即回到桌面。 - // 不做 Process.Kill:那会让系统记录成异常退出。 - CrashLog.Boot("EULA: declined -> finish activity"); - MainActivity.Current?.Finish(); - } - } - catch (Exception ex) - { - CrashLog.Write("MainView.EulaCheckAsync", ex); - } - } -} diff --git a/Android/Views/OverlayDialog.cs b/Android/Views/OverlayDialog.cs deleted file mode 100644 index 0fb5381..0000000 --- a/Android/Views/OverlayDialog.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System; -using System.Threading.Tasks; -using Avalonia.Controls; -using Avalonia.Controls.Presenters; - -namespace TraeTools.Views; - -/// -/// 覆盖层模态对话框宿主(Android)。 -/// -/// 为什么必须有这一层:Avalonia 的 Android 后端是单视图平台, -/// new Window() 会在 Avalonia.Android.WindowingPlatformStub.CreateWindow() -/// 抛 NotSupportedException,所以整个移植里所有对话框内容都写成 , -/// 由本类把它们挂到主视图的全屏覆盖层上呈现,并用 -/// 模拟「模态 + 返回值」的语义。 -/// -/// 与上游 VM 的衔接:上游 ViewModel 调用的是 dlg.ShowDialog<bool>(owner) -/// (Window 的实例方法)。Android\Views\ 下的对话框类因此都额外提供了一个 -/// 同名的 ShowDialog<TResult>(TopLevel? owner) 方法,内部转调本类 —— -/// 于是共享 ViewModel 一行都不用改。 -/// -public static class OverlayDialog -{ - private static Panel? _root; - private static ContentPresenter? _host; - private static UserControl? _current; - private static TaskCompletionSource? _tcs; - - /// 由主视图在构造时挂接覆盖层容器。 - public static void Attach(Panel root, ContentPresenter host) - { - _root = root; - _host = host; - } - - /// - /// 显示对话框并等待其关闭,返回值由 传入。 - /// 只是为了与 Window.ShowDialog(owner) 的调用形态对齐 —— - /// 覆盖层是全屏的,不需要 owner,允许为 null。 - /// - public static async Task ShowAsync(UserControl dialog, TopLevel? owner = null) - { - try - { - var result = await ShowCore(dialog).Task; - return result is T typed ? typed : default!; - } - catch (Exception ex) - { - // 呈现失败时降级返回默认值:绝不让对话框问题带崩应用(Android 上即闪退), - // 调用方拿到 false/null 会按「用户取消」处理。 - Platform.CrashLog.Write("OverlayDialog.ShowAsync", ex); - return default!; - } - } - - /// 对话框按钮的统一关闭入口(各对话框的 Click 处理里调用)。 - public static void CloseDialog(UserControl dialog, object? result) => Finish(dialog, result); - - private static TaskCompletionSource ShowCore(UserControl dialog) - { - if (_root == null || _host == null) - throw new InvalidOperationException("OverlayDialog 尚未 Attach(主视图未就绪)"); - if (_tcs != null) - throw new InvalidOperationException("已有对话框在显示中,不允许叠加"); - - // 手机屏宽只有桌面端的 1/3:清掉为桌面端写死的尺寸约束, - // 让对话框横向铺满覆盖层、纵向由各自的 VerticalAlignment 决定 - //(确认框 Center 只占内容高度;EULA 这类长内容用 Stretch 吃满可用高度并在内部滚动)。 - try - { - dialog.Width = double.NaN; - dialog.MinWidth = 0; - dialog.MinHeight = 0; - dialog.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch; - } - catch - { - // 尺寸调整失败不影响显示 - } - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _tcs = tcs; - _current = dialog; - _host.Content = dialog; - _root.IsVisible = true; - return tcs; - } - - private static void Finish(UserControl dialog, object? result) - { - if (!ReferenceEquals(_current, dialog) || _tcs == null || _host == null || _root == null) - return; - - _host.Content = null; - _root.IsVisible = false; - - var tcs = _tcs; - _tcs = null; - _current = null; - tcs.TrySetResult(result); - } -} diff --git a/Android/Views/PatInputWindow.axaml b/Android/Views/PatInputWindow.axaml deleted file mode 100644 index 6473851..0000000 --- a/Android/Views/PatInputWindow.axaml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Views/DashboardView.axaml.cs b/Views/DashboardView.axaml.cs deleted file mode 100644 index e606434..0000000 --- a/Views/DashboardView.axaml.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Input; -using TraeTools.ViewModels; - -namespace TraeTools.Views; - -public partial class DashboardView : UserControl -{ - public DashboardView() - { - InitializeComponent(); - } - - /// 趋势点命中:即时更新悬浮冒泡(替代有延迟的原生 ToolTip)。 - private void TrendHoverEnter(object? sender, PointerEventArgs e) - { - if (sender is Control c && c.DataContext is TrendPoint p && DataContext is DashboardViewModel vm) - vm.SetTrendHover(p); - } - - private void TrendHoverExit(object? sender, PointerEventArgs e) - { - if (DataContext is DashboardViewModel vm) vm.SetTrendHover(null); - } -} diff --git a/Views/DeviceAuthWindow.axaml b/Views/DeviceAuthWindow.axaml index e7eac0e..19f9c06 100644 --- a/Views/DeviceAuthWindow.axaml +++ b/Views/DeviceAuthWindow.axaml @@ -1,27 +1,26 @@ - - + - - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/MainView.axaml.cs b/Views/MainView.axaml.cs new file mode 100644 index 0000000..08a297f --- /dev/null +++ b/Views/MainView.axaml.cs @@ -0,0 +1,185 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; +using TraeTools.ViewModels; + +namespace TraeTools.Views; + +/// +/// 应用主视图(UserControl,双平台通用)。 +/// ⚠️ 关键:Android 是 Avalonia 单视图平台,new Window() 会在 +/// Avalonia.Android.WindowingPlatformStub.CreateWindow() 抛 NotSupportedException, +/// 因此 Android 的 MainView 必须是 UserControl(不是 Window);Windows 端由 +/// (Window)包一层承载本视图。 +/// 这里承载双平台共享逻辑:覆盖层宿主挂接、自动签到定时器、首次启动 EULA。 +/// +public partial class MainView : UserControl +{ + /// 自动签到定时器(双平台):每 30 秒检查一次,到点且当天未签时自动签到。 + private readonly DispatcherTimer _autoCheckinTimer; + private bool _tickBusy; + private bool _started; + private bool _eulaChecked; + + public MainView() + { + InitializeComponent(); + + // 覆盖层宿主:Android 用于承载对话框,桌面端挂上也不使用(IsOverlayMode=false) + OverlayDialog.Attach(OverlayRoot, OverlayContent); + TraeTools.Platform.CrashLog.Boot("MainView: ctor, overlay attached"); + + _autoCheckinTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) }; + _autoCheckinTimer.Tick += async (_, _) => + { + if (_tickBusy) return; + _tickBusy = true; + try + { + if (DataContext is MainViewModel vm) + { + await vm.AutoCheckinIfDue(); + // 保活:按各账号自己的间隔静默换新 Token(开启保活的账号才会执行) + await vm.KeepAliveIfDue(); +#if !ANDROID + await vm.RefreshSnapshotIfNeeded(); +#endif + } + } + catch (Exception ex) + { + // 定时任务异常不允许带崩 UI(Android 上未捕获异常会直接闪退) + TraeTools.Platform.CrashLog.Write("AutoCheckin timer tick", ex); + } + finally + { + _tickBusy = false; + } + }; + + // EULA 需要 owner 窗口居中(桌面端),故放到 Loaded:此时视觉树与宿主窗口均已就绪。 + // (构造期 desktop.MainWindow 尚未赋值,取 owner 会失败导致协议弹不出来) + Loaded += OnLoaded; + } + + private void OnLoaded(object? sender, RoutedEventArgs e) + { + if (_eulaChecked) return; + _eulaChecked = true; + TraeTools.Platform.CrashLog.Boot("MainView: loaded"); + try + { + _ = EulaCheckAsync(); + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("MainView.OnLoaded", ex); + } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (_started) return; + _started = true; + TraeTools.Platform.CrashLog.Boot("MainView: attached to visual tree"); + try + { + // 手机屏适配:按实际宽度切换紧凑模式(顶栏 + 底部标签栏) + UpdateCompactMode(Bounds.Width); + // Android 端自动签到仅 App 运行期间有效;无人值守场景请使用「云端签到」 + _autoCheckinTimer.Start(); + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("MainView.OnAttachedToVisualTree", ex); + } + } + + /// 宿主尺寸变化时切换紧凑模式(手机 <640 逻辑像素)。 + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + base.OnPropertyChanged(e); + if (e.Property == BoundsProperty && e.NewValue is Rect r) + UpdateCompactMode(r.Width); + } + + private void UpdateCompactMode(double width) + { + try + { + if (width <= 0) return; + if (DataContext is MainViewModel vm) + vm.IsCompact = width < 640; + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("MainView.UpdateCompactMode", ex); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + try + { + _autoCheckinTimer.Stop(); + } + catch + { + // 停止定时器失败不影响退出 + } + base.OnDetachedFromVisualTree(e); + } + + /// + /// 首次启动弹出免责声明协议(EULA)。同意标记独立存放于数据根目录 eula_accepted, + /// 与 TraeCheckin 的已同意配置互相独立,保证本程序首次启动必定展示协议。 + /// + private async Task EulaCheckAsync() + { + try + { + var flag = System.IO.Path.Combine( + TraeTools.Services.DataPaths.Root, "eula_accepted"); + if (System.IO.File.Exists(flag)) return; + + TraeTools.Platform.CrashLog.Boot("EULA: showing agreement"); + var eula = new EulaWindow(); + bool agreed; + try + { + // owner:桌面端用宿主窗口居中;Android 上为 null(走覆盖层,忽略 owner) + var owner = TopLevel.GetTopLevel(this) as Window; + agreed = await OverlayDialog.ShowAsync(eula, owner); + } + catch (Exception ex) + { + // 兜底:Android 上对话框呈现失败则记录并视为同意,保证应用可用(日志留痕) + TraeTools.Platform.CrashLog.Write("EULA dialog failed", ex); + agreed = true; + } + TraeTools.Platform.CrashLog.Boot($"EULA: agreed={agreed}"); + if (agreed) + { + try + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(flag)!); + System.IO.File.WriteAllText(flag, DateTime.UtcNow.ToString("o")); + } + catch { /* 标记写失败下次仍会弹,可接受 */ } + } + else if (TopLevel.GetTopLevel(this) is Window host) + { + // 不同意:桌面端关闭主窗口;Android 单视图无法关闭,保留界面 + host.Close(); + } + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("MainView.EulaCheckAsync", ex); + } + } +} diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml index 1ce8050..4274420 100644 --- a/Views/MainWindow.axaml +++ b/Views/MainWindow.axaml @@ -1,93 +1,22 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs index 23c3450..c82fac5 100644 --- a/Views/MainWindow.axaml.cs +++ b/Views/MainWindow.axaml.cs @@ -2,26 +2,42 @@ using Avalonia.Controls; using Avalonia.Controls.Notifications; using Avalonia.Platform; -using Avalonia.Threading; using TraeTools.ViewModels; namespace TraeTools.Views; +/// +/// Windows 桌面端主窗口:只负责桌面专属行为(尺寸/图标/托盘/关闭最小化), +/// 界面内容与业务逻辑在 (UserControl)中,供 Android 单视图复用。 +/// ⚠️ Android 上不能实例化本类:Avalonia 单视图平台的 WindowingPlatformStub.CreateWindow() +/// 会抛 NotSupportedException,Android 端请用 MainView。 +/// public partial class MainWindow : Window { - private readonly DispatcherTimer _autoCheckinTimer; +#if !ANDROID private TrayIcon? _trayIcon; /// true=用户从托盘「退出」真正退出;false=关闭窗口仅最小化到托盘。 private bool _realExit; - /// 计时器重入守卫:上一轮任务(可能数十秒)未结束前跳过本轮 Tick(#33)。 - private bool _tickBusy; private readonly WindowNotificationManager _notify; +#endif public MainWindow() { +#if !ANDROID _notify = new WindowNotificationManager(this) { Position = NotificationPosition.BottomRight, MaxItems = 3 }; + // 把通知能力交出去,供 Services/Notify/SystemNotifier 复用(它无法自行拿到窗口实例) + App.DesktopNotifier = ShowDesktopNotification; +#endif InitializeComponent(); +#if !ANDROID + // 桌面端固定窗口尺寸与居中 + Width = 1100; + Height = 720; + MinWidth = 940; + MinHeight = 600; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + // 任务栏窗口图标:复用托盘同款 avalonia-logo.ico(缺省时任务栏不显示图标) try { @@ -30,42 +46,34 @@ 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; +#endif } protected override void OnOpened(EventArgs e) { base.OnOpened(e); - OnOpenedEulaCheck(); - _autoCheckinTimer.Start(); + TraeTools.Platform.CrashLog.Boot("MainWindow.OnOpened"); } protected override void OnClosed(EventArgs e) { - _autoCheckinTimer.Stop(); +#if !ANDROID + // 窗口已销毁,及时摘掉委托,避免 SystemNotifier 调到已失效的 NotificationManager + App.DesktopNotifier = null; _trayIcon?.Dispose(); +#endif base.OnClosed(e); } +#if !ANDROID + /// 桌面端通知:显示在主窗口右下角,数秒后自动消失。 + private void ShowDesktopNotification(string title, string body) + { + _notify.Show(new Notification(title, body, NotificationType.Information, TimeSpan.FromSeconds(6))); + } + /// 关闭窗口默认最小化到托盘(与 TraeCheckin 一致);仅托盘「退出」真正退出。 private void OnWindowClosing(object? sender, WindowClosingEventArgs e) { @@ -105,6 +113,7 @@ private void BuildTray() }; // 左键单击托盘图标 → 显示/激活主窗口 _trayIcon.Clicked += (_, _) => ShowAndActivate(); + App.TrayRef = _trayIcon; } catch { @@ -119,7 +128,7 @@ private void ShowAndActivate() Activate(); } - private async Task TrayCheckinAsync() + private async System.Threading.Tasks.Task TrayCheckinAsync() { if (DataContext is MainViewModel vm) { @@ -130,36 +139,5 @@ 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(); - } - } +#endif } - - diff --git a/Views/OverlayDialog.cs b/Views/OverlayDialog.cs new file mode 100644 index 0000000..e02aff3 --- /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 直接挂到 MainView 的覆盖层(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(); + + /// MainView 启动时挂接覆盖层(桌面端挂上也不使用)。 + public static void Attach(Panel root, ContentPresenter host) + { + _root = root; + _host = host; + } + + /// + /// 双平台对话框入口:桌面端用 DialogHostWindow 包装后原生 ShowDialog, + /// Android 走覆盖层。返回对话框通过 CloseDialog 关闭时携带的结果。 + /// + public static async Task ShowAsync(UserControl dialog, Window? owner) + { + if (!IsOverlayMode) + { + try + { + var host = new DialogHostWindow(); + host.AttachDialog(dialog); + // ShowDialog 需要 owner;缺省时退回桌面端主窗口 + var target = owner ?? (Avalonia.Application.Current?.ApplicationLifetime + as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + if (target == null) + { + TraeTools.Platform.CrashLog.Write("OverlayDialog.ShowAsync", + new InvalidOperationException("桌面端无可用 owner 窗口,无法显示对话框")); + return default!; + } + var result = await host.ShowDialog(target); + host.DetachDialog(); + return result is T typed ? typed : default!; + } + catch (Exception ex) + { + TraeTools.Platform.CrashLog.Write("OverlayDialog.ShowAsync(desktop)", ex); + return default!; + } + } + + try + { + var tcs = ShowCore(dialog); + var result = await tcs.Task; + return result is T typed ? typed : default!; + } + catch (Exception ex) + { + // 覆盖层呈现失败时降级返回默认值:绝不让对话框问题带崩应用(Android 上即闪退) + TraeTools.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) + { + TraeTools.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..208a07a 100644 --- a/Views/PatInputWindow.axaml +++ b/Views/PatInputWindow.axaml @@ -1,22 +1,20 @@ - - - - + + + - + -