Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Android

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
build-android:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"

- name: Install Android workload
run: dotnet workload install android

- name: Build Android (Release)
run: dotnet build Android/TraeTools.Android.csproj -c Release --nologo

- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: trae-tools-android-apk
path: Android/bin/Release/net10.0-android/*.apk
if-no-files-found: error
21 changes: 21 additions & 0 deletions Android/App.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="TraeTools.App"
xmlns:local="using:TraeTools"
RequestedThemeVariant="Light">

<!-- Android 版 Application。
与仓库根的 App.axaml 内容一致,仅入口与生命周期不同:
Android 走 ISingleViewApplicationLifetime,没有 MainWindow。
主题固定 Light:Styles/AppTheme.axaml 的颜色是写死的浅色调色板,
跟随系统深色会出现浅底白字的不可读组合。 -->

<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>

<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://TraeTools/Styles/AppTheme.axaml" />
</Application.Styles>
</Application>
119 changes: 119 additions & 0 deletions Android/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using TraeTools.Platform;
using TraeTools.ViewModels;
using TraeTools.Views;

namespace TraeTools;

/// <summary>
/// Android 版应用入口。
///
/// 与仓库根的 App.axaml.cs 的区别只有两处:
/// 1) 没有 <c>IClassicDesktopStyleApplicationLifetime</c> 分支(单视图平台无 Window);
/// 2) Application 一律写全称 <c>Avalonia.Application</c>,与 Android.App.Application 区分。
/// </summary>
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();
}

/// <summary>构建启动错误页:异常堆栈 + 启动日志 + 「清除并重新加载界面」。</summary>
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;
}
}
138 changes: 138 additions & 0 deletions Android/Platform/AndroidVault.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System.Security.Cryptography;
using System.Text;
using Android.Security.Keystore;
using Java.Security;
using Javax.Crypto;

namespace TraeTools.Platform;

/// <summary>
/// Android 本地密钥库(对应 Windows 侧的 DPAPI)。
///
/// <para>做法:Keystore 生成一对 RSA-2048(私钥不可导出,随应用卸载/清除数据消失),
/// 每次加密随机生成 AES-256-GCM 数据密钥,用 RSA 公钥包裹该密钥。</para>
///
/// <para>密文格式:<c>and-v1:</c> + Base64(RSA 包裹的 AES 密钥) + <c>:</c> + Base64(nonce)
/// + <c>:</c> + Base64(tag + ciphertext)。前缀既是版本标记也是平台标记 ——
/// Windows 的 DPAPI 密文是裸 Base64,两边的密文天然互不通用,
/// 解密时一律先校验前缀,避免把明文误当密文解析。</para>
/// </summary>
internal static class AndroidVault
{
/// <summary>密文版本/平台标记。</summary>
public const string Version = "and-v1";

/// <summary>密文前缀。</summary>
public const string Prefix = Version + ":";

private const string KeyAlias = "TraeTools.Secrets.RSA.v1";
private const int NonceSize = 12; // AES-GCM 推荐 nonce 长度
private const int TagSize = 16; // 128-bit 认证标签

/// <summary>加密;失败返回 null(调用方保留原值,不写入半成品)。</summary>
public static string? Protect(string plain)
{
if (string.IsNullOrEmpty(plain)) return plain;
try
{
var plainBytes = Encoding.UTF8.GetBytes(plain);

// 随机 AES-256 会话密钥 + GCM 加密
var key = RandomNumberGenerator.GetBytes(32);
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var cipher = new byte[plainBytes.Length];
var tag = new byte[TagSize];
using (var aes = new AesGcm(key, TagSize))
aes.Encrypt(nonce, plainBytes, cipher, tag);

// RSA 公钥包裹 AES 密钥
var pub = GetOrCreatePublicKey();
if (pub == null) return null;
var wrapCipher = Cipher.GetInstance("RSA/ECB/PKCS1Padding")!;
// Cipher.EncryptMode 常量已废弃(CS0619,报错级),改用 CipherMode 枚举成员;
// 本文件同时 using 了 System.Security.Cryptography(含同名 CipherMode),故写全称
wrapCipher.Init(Javax.Crypto.CipherMode.EncryptMode, pub);
var wrapped = wrapCipher.DoFinal(key);
if (wrapped == null) return null;

var tagAndCipher = new byte[tag.Length + cipher.Length];
Buffer.BlockCopy(tag, 0, tagAndCipher, 0, tag.Length);
Buffer.BlockCopy(cipher, 0, tagAndCipher, tag.Length, cipher.Length);

return Prefix
+ Convert.ToBase64String(wrapped) + ":"
+ Convert.ToBase64String(nonce) + ":"
+ Convert.ToBase64String(tagAndCipher);
}
catch { return null; }
}

/// <summary>解密;失败(换机/清除数据/跨平台拷贝来的密文)返回 null。</summary>
public static string? Unprotect(string enc)
{
// 必须带本平台前缀:历史上出现过「未加密的明文被当作密文送来」的情况,
// 此时应直接判失败让上层保留原值,而不是盲切前若干个字符去解析。
if (!enc.StartsWith(Prefix, StringComparison.Ordinal)) return null;
try
{
var parts = enc[Prefix.Length..].Split(':', 3);
if (parts.Length != 3) return null;

var wrapped = Convert.FromBase64String(parts[0]);
var nonce = Convert.FromBase64String(parts[1]);
var tagAndCipher = Convert.FromBase64String(parts[2]);
if (tagAndCipher.Length < TagSize) return null;

// RSA 私钥解出 AES 密钥
var priv = GetPrivateKey();
if (priv == null) return null;
var unwrapCipher = Cipher.GetInstance("RSA/ECB/PKCS1Padding")!;
unwrapCipher.Init(Javax.Crypto.CipherMode.DecryptMode, priv);
var key = unwrapCipher.DoFinal(wrapped);
if (key == null) return null;

var tag = new byte[TagSize];
var cipher = new byte[tagAndCipher.Length - TagSize];
Buffer.BlockCopy(tagAndCipher, 0, tag, 0, TagSize);
Buffer.BlockCopy(tagAndCipher, TagSize, cipher, 0, cipher.Length);

var plain = new byte[cipher.Length];
using (var aes = new AesGcm(key, TagSize))
aes.Decrypt(nonce, cipher, tag, plain);
return Encoding.UTF8.GetString(plain);
}
catch { return null; }
}

/// <summary>取(不存在则创建)Keystore 中的 RSA 公钥。</summary>
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;
}

/// <summary>取 Keystore 中的 RSA 私钥。</summary>
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();
}
}
Loading
Loading