🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s

A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
This commit is contained in:
Evandro committed 2026-06-01 00:50:06 -03:00
commit b3489e1ede
983 files changed
+158475

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+6565
View File
File diff suppressed because it is too large. Load diff
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "piagentui"
version = "0.6.10"
description = "Pi Agent UI - Tauri Desktop & Mobile App"
authors = ["lehhair"]
license = "GPL-3.0-only"
repository = ""
edition = "2021"
rust-version = "1.85.0"
[lib]
crate-type = ["cdylib", "rlib", "staticlib"]
name = "app_lib"
[dependencies]
futures-util = "0.3"
log = "0.4"
papaya = "0.2.3"
rapidhash = { version = "4.4.1", features = ["unsafe"] }
reqwest = { version = "0.12", default-features = false, features = [
"rustls-tls",
"stream"
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = ["devtools"] }
tauri-plugin-decorum = "1.1.1"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-http = "2"
tauri-plugin-log = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
[build-dependencies]
tauri-build = { version = "2", features = [] }
[profile.release]
strip = true
lto = true
panic = "abort"
codegen-units = 1
opt-level = "s"
+6
View File
@@ -0,0 +1,6 @@
{
"default": "../src/assets/images/logo/PiAgentUiLogoBlackletterBGTransp.png",
"android_bg": "../assets/app-icons/app-icon-android-background.svg",
"android_fg": "../assets/app-icons/app-icon-android-foreground.svg",
"android_fg_scale": 66
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for Pi Agent UI",
"windows": ["main", "win-*"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-center",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-set-size",
"core:window:allow-is-maximized",
"core:window:allow-toggle-maximize",
"decorum:allow-show-snap-overlay",
{
"identifier": "http:default",
"allow": [
{ "url": "http://**" },
{ "url": "https://**" },
{ "url": "http://**:*" },
{ "url": "https://**:*" },
{ "url": "http://**:*/**" },
{ "url": "https://**:*/**" }
]
},
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:default",
"fs:allow-read-file",
"fs:allow-write-file",
"opener:default"
]
}
+12
View File
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
+19
View File
@@ -0,0 +1,19 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
/.tauri
/tauri.settings.gradle
+6
View File
@@ -0,0 +1,6 @@
/src/main/java/com/opencodeui/app/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties
@@ -0,0 +1,95 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
// 读取签名配置(CI 环境通过 keystore.properties 注入)
val keystoreProperties = Properties().apply {
val propFile = file("keystore.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
namespace = "com.opencodeui.app"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.opencodeui.app"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
signingConfigs {
create("release") {
val ksFile = keystoreProperties.getProperty("storeFile")
if (ksFile != null) {
storeFile = file(ksFile)
storePassword = keystoreProperties.getProperty("password")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("password")
}
}
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging {
jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isMinifyEnabled = true
val ksFile = keystoreProperties.getProperty("storeFile")
if (ksFile != null) {
signingConfig = signingConfigs.getByName("release")
}
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.opencodeui"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,304 @@
package com.opencodeui.app
import android.content.Context
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.activity.enableEdgeToEdge
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper())
private var cachedInsetsJs: String? = null
private var themeSyncRunnable: Runnable? = null
private var cachedWebView: WebView? = null
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// 初始状态栏样式(后续由 WebView 主题同步驱动)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.isAppearanceLightStatusBars = true
controller.isAppearanceLightNavigationBars = true
// 禁用系统对比度强制(避免状态栏自动加黑/渐变)
if (Build.VERSION.SDK_INT >= 29) {
window.isStatusBarContrastEnforced = false
window.isNavigationBarContrastEnforced = false
}
// 监听 WindowInsets 变化:
// 1. 对内容容器 setPadding,让 WebView 物理 resize(键盘弹出时 window.innerHeight 自动变小)
// 2. 注入 CSS 变量供前端做精细布局
val contentView = findViewById<View>(android.R.id.content)
ViewCompat.setOnApplyWindowInsetsListener(contentView) { view, windowInsets ->
val systemInsets = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
)
val imeInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime())
// 键盘弹出时底部取 IME 和系统栏的较大值,让 WebView 整体 resize
val bottomPadding = maxOf(imeInsets.bottom, systemInsets.bottom)
view.setPadding(
systemInsets.left,
systemInsets.top,
systemInsets.right,
bottomPadding
)
cachedInsetsJs = """
(function() {
var s = document.documentElement.style;
s.setProperty('--safe-area-inset-top', '0px');
s.setProperty('--safe-area-inset-bottom', '0px');
s.setProperty('--safe-area-inset-left', '0px');
s.setProperty('--safe-area-inset-right', '0px');
})();
""".trimIndent()
// 立即尝试注入
tryInjectInsets(view)
WindowInsetsCompat.CONSUMED
}
// WebView 可能还没创建好,轮询几次确保注入成功
scheduleInsetsInjection(contentView, 0)
}
override fun onResume() {
super.onResume()
startThemeSync()
}
override fun onPause() {
stopThemeSync()
super.onPause()
}
private fun startThemeSync() {
if (themeSyncRunnable != null) return
themeSyncRunnable = Runnable {
val rootView = window.decorView.findViewById<View>(android.R.id.content)
syncSystemBars(rootView)
handler.postDelayed(themeSyncRunnable!!, 800L)
}
handler.post(themeSyncRunnable!!)
}
private fun stopThemeSync() {
themeSyncRunnable?.let { handler.removeCallbacks(it) }
themeSyncRunnable = null
}
private fun syncSystemBars(rootView: View) {
val webView = cachedWebView ?: findWebView(rootView) ?: return
// 在 JS 端用 Canvas 2D 将 getComputedStyle 返回的任意格式颜色
// 统一转为 #rrggbb hex,避免不同 WebView 版本返回不同格式
// (rgb 逗号/空格分隔, color(srgb ...), oklch(...) 等)
val js = """
(function() {
var mode = document.documentElement.getAttribute('data-mode') || 'system';
var raw = getComputedStyle(document.documentElement).getPropertyValue('--color-bg-100').trim();
if (!raw) return JSON.stringify({ mode: mode, bg: '' });
var c = document.createElement('canvas').getContext('2d');
c.fillStyle = raw;
var hex = c.fillStyle;
return JSON.stringify({ mode: mode, bg: hex });
})();
""".trimIndent()
webView.evaluateJavascript(js) { result ->
applySystemBarsFromJs(result)
}
}
private fun applySystemBarsFromJs(result: String?) {
if (result == null || result == "null") return
val unescaped = result
.trim('"')
.replace("\\\\", "\\")
.replace("\\\"", "\"")
val json = try {
org.json.JSONObject(unescaped)
} catch (_: Exception) {
return
}
val mode = json.optString("mode", "system")
val bg = json.optString("bg", "")
val color = parseCssColor(bg) ?: return
val isLightBg = isColorLight(color)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.isAppearanceLightStatusBars = isLightBg && mode != "dark"
controller.isAppearanceLightNavigationBars = isLightBg && mode != "dark"
window.statusBarColor = color
window.navigationBarColor = color
window.decorView.setBackgroundColor(color)
}
private inner class SystemBarBridge {
@android.webkit.JavascriptInterface
fun setSystemBars(mode: String, bg: String) {
val color = parseCssColor(bg) ?: return
val isLightBg = isColorLight(color)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.isAppearanceLightStatusBars = isLightBg && mode != "dark"
controller.isAppearanceLightNavigationBars = isLightBg && mode != "dark"
window.statusBarColor = color
window.navigationBarColor = color
window.decorView.setBackgroundColor(color)
}
@android.webkit.JavascriptInterface
fun vibrate(ms: Int) {
val duration = ms.coerceIn(1, 50).toLong()
val vibrator = if (android.os.Build.VERSION.SDK_INT >= 31) {
val vm = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vm.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
if (!vibrator.hasVibrator()) return
if (android.os.Build.VERSION.SDK_INT >= 26) {
vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(duration)
}
}
}
private fun parseCssColor(value: String): Int? {
val v = value.trim()
if (v.isEmpty()) return null
// 优先处理 #hex 格式(JS 端已统一转为此格式)
if (v.startsWith("#")) {
return try {
Color.parseColor(v)
} catch (_: Exception) {
null
}
}
// 兼容处理 rgb/rgba — 同时支持逗号分隔和空格分隔
if (v.startsWith("rgb")) {
val inner = v.substringAfter('(').substringBefore(')')
// 提取所有数字(整数或浮点)
val nums = Regex("""\d+\.?\d*""").findAll(inner).map { it.value }.toList()
if (nums.size < 3) return null
val r = nums[0].toFloatOrNull()?.toInt()?.coerceIn(0, 255) ?: return null
val g = nums[1].toFloatOrNull()?.toInt()?.coerceIn(0, 255) ?: return null
val b = nums[2].toFloatOrNull()?.toInt()?.coerceIn(0, 255) ?: return null
return Color.rgb(r, g, b)
}
// 兼容处理 hsl/hsla — 同时支持逗号分隔和空格分隔
if (v.startsWith("hsl")) {
val inner = v.substringAfter('(').substringBefore(')')
.replace("%", "")
val parts = Regex("""\d+\.?\d*""").findAll(inner).map { it.value }.toList()
if (parts.size < 3) return null
val h = parts[0].toFloatOrNull() ?: return null
val s = (parts[1].toFloatOrNull() ?: return null) / 100f
val l = (parts[2].toFloatOrNull() ?: return null) / 100f
return hslToColor(h, s, l)
}
// 最后尝试 Color.parseColor (支持 named colors 等)
return try {
Color.parseColor(v)
} catch (_: Exception) {
null
}
}
private fun hslToColor(h: Float, s: Float, l: Float): Int {
val c = (1 - kotlin.math.abs(2 * l - 1)) * s
val hh = (h % 360) / 60f
val x = c * (1 - kotlin.math.abs(hh % 2 - 1))
val (r1, g1, b1) = when {
hh < 1 -> Triple(c, x, 0f)
hh < 2 -> Triple(x, c, 0f)
hh < 3 -> Triple(0f, c, x)
hh < 4 -> Triple(0f, x, c)
hh < 5 -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
val m = l - c / 2
val r = ((r1 + m) * 255).toInt().coerceIn(0, 255)
val g = ((g1 + m) * 255).toInt().coerceIn(0, 255)
val b = ((b1 + m) * 255).toInt().coerceIn(0, 255)
return Color.rgb(r, g, b)
}
private fun isColorLight(color: Int): Boolean {
val r = Color.red(color) / 255f
val g = Color.green(color) / 255f
val b = Color.blue(color) / 255f
val luminance = 0.299f * r + 0.587f * g + 0.114f * b
return luminance > 0.6f
}
/**
* 延迟重试注入 insets,确保 WebView 加载完成后 CSS 变量被设置
* 最多重试 10 次,间隔递增
*/
private fun scheduleInsetsInjection(rootView: View, attempt: Int) {
if (attempt >= 10) return
val delay = if (attempt < 3) 200L else 1000L
handler.postDelayed({
if (tryInjectInsets(rootView)) {
// 注入成功后再补几次,确保页面导航后也有值
if (attempt < 5) {
scheduleInsetsInjection(rootView, attempt + 1)
}
} else {
scheduleInsetsInjection(rootView, attempt + 1)
}
}, delay)
}
/**
* 尝试向 WebView 注入 insets CSS 变量
* @return 是否找到了 WebView 并成功注入
*/
private fun tryInjectInsets(view: View): Boolean {
val js = cachedInsetsJs ?: return false
val webView = findWebView(view) ?: return false
cachedWebView = webView
ensureJsBridge(webView)
webView.evaluateJavascript(js, null)
return true
}
private fun ensureJsBridge(webView: WebView) {
try {
webView.addJavascriptInterface(SystemBarBridge(), "__opencode_android")
} catch (_: Exception) {
// ignore - may be added already
}
}
private fun findWebView(view: View): WebView? {
if (view is WebView) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findWebView(view.getChildAt(i))?.let { return it }
}
}
return null
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@mipmap/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

@@ -0,0 +1,14 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme (dark). -->
<style name="Theme.opencodeui" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- 状态栏和导航栏透明,实现沉浸式 -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- 暗色模式:浅色状态栏图标 -->
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
<!-- 确保内容延伸到状态栏下方 -->
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowTranslucentNavigation">false</item>
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">OpenCode</string>
<string name="main_activity_title">OpenCode</string>
</resources>
@@ -0,0 +1,14 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.opencodeui" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- 状态栏和导航栏透明,实现沉浸式 -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- 亮色状态栏图标(深色图标,适合浅色背景) -->
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
<!-- 确保内容延伸到状态栏下方 -->
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowTranslucentNavigation">false</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+22
View File
@@ -0,0 +1,22 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}
@@ -0,0 +1,23 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}
@@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """npm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}
@@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
Binary file not shown.
@@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+3
View File
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+74
View File
@@ -0,0 +1,74 @@
use serde::Deserialize;
/// Arguments for `bridge_connect`.
///
/// `bridge_id` is an opaque label chosen by the frontend (e.g. `"sse"`,
/// a PTY id, etc.). Together with the window label it forms the unique
/// connection key.
///
/// The Rust layer inspects the URL scheme to pick the transport:
/// - `ws://` / `wss://` → WebSocket (bidirectional)
/// - `http://` / `https://` → HTTP streaming (read-only)
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectArgs {
bridge_id: String,
url: String,
auth_header: Option<String>,
}
impl ConnectArgs {
#[inline(always)]
pub fn bridge_id(&self) -> &str {
&self.bridge_id
}
#[inline(always)]
pub fn url(&self) -> &str {
&self.url
}
#[inline(always)]
pub fn auth_header(&self) -> Option<&str> {
self.auth_header.as_deref()
}
/// Returns `true` when the URL uses WebSocket scheme.
pub fn is_websocket(&self) -> bool {
self.url.starts_with("ws://") || self.url.starts_with("wss://")
}
}
/// Arguments for `bridge_send` (WebSocket only).
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendArgs {
bridge_id: String,
data: String,
}
impl SendArgs {
#[inline(always)]
pub fn bridge_id(&self) -> &str {
&self.bridge_id
}
#[inline(always)]
pub fn data(&self) -> &str {
&self.data
}
}
/// Arguments for `bridge_disconnect`.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DisconnectArgs {
bridge_id: String,
}
impl DisconnectArgs {
#[inline(always)]
pub fn bridge_id(&self) -> &str {
&self.bridge_id
}
}
+15
View File
@@ -0,0 +1,15 @@
use serde::Serialize;
/// Unified bridge event pushed to the frontend via Tauri Channel.
///
/// The Rust layer is a transparent proxy — `data` is forwarded as-is
/// without parsing or field renaming. The frontend decides how to
/// interpret it (SSE line parsing, terminal output, etc.).
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
pub enum BridgeEvent {
Connected,
Data { data: String },
Disconnected { code: Option<u16>, reason: String },
Error { message: String },
}
+7
View File
@@ -0,0 +1,7 @@
mod args;
mod event;
mod state;
pub use args::{ConnectArgs, DisconnectArgs, SendArgs};
pub use event::BridgeEvent;
pub use state::{BridgeCommand, BridgeConnection, BridgeKey, BridgeState};
+142
View File
@@ -0,0 +1,142 @@
use std::{
collections::HashMap,
sync::{
atomic::{AtomicU64, Ordering},
Mutex,
},
};
use tokio::sync::mpsc::UnboundedSender;
/// Command sent from the frontend to an active WebSocket bridge.
#[derive(Debug)]
pub enum BridgeCommand {
Send(String),
Close,
}
/// A single active bridge connection.
pub struct BridgeConnection {
pub id: u64,
/// `Some` for WebSocket connections (bidirectional),
/// `None` for HTTP stream connections (read-only, cancelled via id mismatch).
pub tx: Option<UnboundedSender<BridgeCommand>>,
}
impl BridgeConnection {
pub fn new_ws(id: u64, tx: UnboundedSender<BridgeCommand>) -> Self {
Self { id, tx: Some(tx) }
}
pub fn new_stream(id: u64) -> Self {
Self { id, tx: None }
}
}
/// Composite key: (window label, bridge id).
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct BridgeKey {
window_label: String,
bridge_id: String,
}
impl BridgeKey {
pub fn new(window_label: &str, bridge_id: &str) -> Self {
Self {
window_label: window_label.to_string(),
bridge_id: bridge_id.to_string(),
}
}
pub fn window_label(&self) -> &str {
&self.window_label
}
}
/// Global bridge state shared across all windows.
#[derive(Default)]
pub struct BridgeState {
next_id: AtomicU64,
active: Mutex<HashMap<BridgeKey, BridgeConnection>>,
}
impl BridgeState {
/// Allocate the next connection id.
pub fn next_conn_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::SeqCst) + 1
}
/// Insert a new connection, returning the previous one (if any) so
/// the caller can shut it down.
pub fn replace(&self, key: BridgeKey, conn: BridgeConnection) -> Option<BridgeConnection> {
self.active
.lock()
.expect("bridge state poisoned")
.insert(key, conn)
}
/// Get the sender for a WebSocket connection.
pub fn sender(&self, key: &BridgeKey) -> Option<UnboundedSender<BridgeCommand>> {
self.active
.lock()
.expect("bridge state poisoned")
.get(key)
.and_then(|conn| conn.tx.clone())
}
/// Remove the connection only if its id matches (prevents a new
/// connection from being removed by an old task's cleanup).
pub fn remove_if_current(&self, key: &BridgeKey, id: u64) {
let mut guard = self.active.lock().expect("bridge state poisoned");
if guard.get(key).is_some_and(|conn| conn.id == id) {
guard.remove(key);
}
}
/// Gracefully disconnect a specific bridge.
pub fn disconnect(&self, key: &BridgeKey) -> bool {
let removed = self
.active
.lock()
.expect("bridge state poisoned")
.remove(key);
if let Some(conn) = removed {
if let Some(tx) = conn.tx {
let _ = tx.send(BridgeCommand::Close);
}
return true;
}
false
}
/// Disconnect all bridges belonging to a window (called on window destroy).
pub fn disconnect_window(&self, window_label: &str) {
let removed = {
let mut guard = self.active.lock().expect("bridge state poisoned");
let keys: Vec<_> = guard
.keys()
.filter(|k| k.window_label() == window_label)
.cloned()
.collect();
keys.into_iter()
.filter_map(|k| guard.remove(&k))
.collect::<Vec<_>>()
};
for conn in removed {
if let Some(tx) = conn.tx {
let _ = tx.send(BridgeCommand::Close);
}
}
}
/// Check whether a connection id is still current (used by HTTP
/// stream loops to detect cancellation).
pub fn is_current(&self, key: &BridgeKey, id: u64) -> bool {
self.active
.lock()
.expect("bridge state poisoned")
.get(key)
.is_some_and(|conn| conn.id == id)
}
}
+402
View File
@@ -0,0 +1,402 @@
// ============================================
// Unified Bridge Commands
//
// A single set of Tauri commands that transparently proxies both
// HTTP streaming (SSE) and WebSocket (PTY) connections.
//
// The frontend picks the transport by URL scheme:
// ws:// / wss:// → WebSocket (bidirectional)
// http:// / https:// → HTTP stream (read-only)
// ============================================
use crate::app::bridge::{
BridgeCommand, BridgeConnection, BridgeEvent, BridgeKey, BridgeState, ConnectArgs,
DisconnectArgs, SendArgs,
};
use futures_util::{SinkExt, StreamExt};
use std::time::Duration;
use tauri::{ipc::Channel, State};
use tokio::sync::mpsc;
fn emit(channel: &Channel<BridgeEvent>, event: BridgeEvent) {
let _ = channel.send(event);
}
fn split_valid_utf8_prefix(bytes: &[u8]) -> Option<(String, usize)> {
if bytes.is_empty() {
return None;
}
match std::str::from_utf8(bytes) {
Ok(text) => Some((text.to_string(), bytes.len())),
Err(error) => {
let valid_up_to = error.valid_up_to();
if let Some(error_len) = error.error_len() {
let consumed = valid_up_to + error_len;
let text = String::from_utf8_lossy(&bytes[..consumed]).into_owned();
Some((text, consumed))
} else if valid_up_to > 0 {
let text = std::str::from_utf8(&bytes[..valid_up_to])
.expect("valid_up_to must point to a valid UTF-8 prefix")
.to_string();
Some((text, valid_up_to))
} else {
None
}
}
}
}
fn emit_stream_chunk(channel: &Channel<BridgeEvent>, pending_utf8: &mut Vec<u8>, chunk: &[u8]) {
if chunk.is_empty() {
return;
}
pending_utf8.extend_from_slice(chunk);
while let Some((text, consumed)) = split_valid_utf8_prefix(pending_utf8.as_slice()) {
pending_utf8.drain(..consumed);
if !text.is_empty() {
emit(channel, BridgeEvent::Data { data: text });
}
}
}
// ============================================
// bridge_connect — auto-selects transport
// ============================================
#[tauri::command]
pub async fn bridge_connect(
window: tauri::Window,
state: State<'_, BridgeState>,
args: ConnectArgs,
on_event: Channel<BridgeEvent>,
) -> Result<(), String> {
if args.is_websocket() {
connect_ws(window, state, args, on_event).await
} else {
connect_stream(window, state, args, on_event).await
}
}
// ============================================
// bridge_send — WebSocket only
// ============================================
#[tauri::command]
pub async fn bridge_send(
window: tauri::Window,
state: State<'_, BridgeState>,
args: SendArgs,
) -> Result<(), String> {
let key = BridgeKey::new(window.label(), args.bridge_id());
let sender = state
.sender(&key)
.ok_or_else(|| format!("bridge '{}' is not active", args.bridge_id()))?;
sender
.send(BridgeCommand::Send(args.data().to_string()))
.map_err(|_| format!("bridge '{}' is closed", args.bridge_id()))
}
// ============================================
// bridge_disconnect
// ============================================
#[tauri::command]
pub async fn bridge_disconnect(
window: tauri::Window,
state: State<'_, BridgeState>,
args: DisconnectArgs,
) -> Result<(), String> {
let key = BridgeKey::new(window.label(), args.bridge_id());
state.disconnect(&key);
Ok(())
}
// ============================================
// HTTP stream transport (for SSE)
// ============================================
async fn connect_stream(
window: tauri::Window,
state: State<'_, BridgeState>,
args: ConnectArgs,
on_event: Channel<BridgeEvent>,
) -> Result<(), String> {
let conn_id = state.next_conn_id();
let key = BridgeKey::new(window.label(), args.bridge_id());
// Replace any previous connection with the same key
if let Some(prev) = state.replace(key.clone(), BridgeConnection::new_stream(conn_id)) {
if let Some(tx) = prev.tx {
let _ = tx.send(BridgeCommand::Close);
}
}
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(15))
.tcp_keepalive(Duration::from_secs(30))
.build()
.map_err(|e| format!("failed to create HTTP client: {}", e))?;
let mut req = client.get(args.url());
if let Some(auth) = args.auth_header() {
req = req.header("Authorization", auth);
}
let response = match req.send().await {
Ok(r) => r,
Err(e) => {
let msg = format!("HTTP stream connection failed: {}", e);
emit(
&on_event,
BridgeEvent::Error {
message: msg.clone(),
},
);
state.remove_if_current(&key, conn_id);
return Err(msg);
}
};
if !response.status().is_success() {
let msg = format!("HTTP stream server returned {}", response.status());
emit(
&on_event,
BridgeEvent::Error {
message: msg.clone(),
},
);
state.remove_if_current(&key, conn_id);
return Err(msg);
}
emit(&on_event, BridgeEvent::Connected);
// Read timeout — if no data arrives for 90s the connection is likely dead
const READ_TIMEOUT: Duration = Duration::from_secs(90);
let mut stream = response.bytes_stream();
let mut pending_utf8 = Vec::new();
loop {
// Check cancellation (disconnect or replaced by a new connect)
if !state.is_current(&key, conn_id) {
emit(
&on_event,
BridgeEvent::Disconnected {
code: None,
reason: "Disconnected by client".to_string(),
},
);
return Ok(());
}
match tokio::time::timeout(READ_TIMEOUT, stream.next()).await {
Ok(Some(Ok(chunk))) => {
emit_stream_chunk(&on_event, &mut pending_utf8, chunk.as_ref());
}
Ok(Some(Err(e))) => {
let msg = format!("HTTP stream error: {}", e);
emit(
&on_event,
BridgeEvent::Error {
message: msg.clone(),
},
);
state.remove_if_current(&key, conn_id);
return Err(msg);
}
Ok(None) => {
state.remove_if_current(&key, conn_id);
emit(
&on_event,
BridgeEvent::Disconnected {
code: None,
reason: "Stream ended".to_string(),
},
);
return Ok(());
}
Err(_) => {
let msg = format!(
"HTTP stream read timeout ({}s without data)",
READ_TIMEOUT.as_secs()
);
emit(
&on_event,
BridgeEvent::Error {
message: msg.clone(),
},
);
state.remove_if_current(&key, conn_id);
return Err(msg);
}
}
}
}
#[cfg(test)]
mod tests {
use super::split_valid_utf8_prefix;
#[test]
fn split_valid_utf8_prefix_waits_for_incomplete_chinese_bytes() {
let text = "中文A";
let bytes = text.as_bytes();
assert_eq!(split_valid_utf8_prefix(&bytes[..2]), None);
assert_eq!(
split_valid_utf8_prefix(&bytes[..3]),
Some(("中".to_string(), 3))
);
assert_eq!(
split_valid_utf8_prefix(bytes),
Some((text.to_string(), bytes.len()))
);
}
#[test]
fn split_valid_utf8_prefix_returns_valid_prefix_before_incomplete_tail() {
let mut bytes = "中文".as_bytes().to_vec();
bytes.extend_from_slice(&"世".as_bytes()[..2]);
assert_eq!(
split_valid_utf8_prefix(&bytes),
Some(("中文".to_string(), "中文".len()))
);
}
}
// ============================================
// WebSocket transport (for PTY)
// ============================================
async fn connect_ws(
window: tauri::Window,
state: State<'_, BridgeState>,
args: ConnectArgs,
on_event: Channel<BridgeEvent>,
) -> Result<(), String> {
use tokio_tungstenite::{
connect_async,
tungstenite::{client::IntoClientRequest, http::HeaderValue, Error as WsError, Message},
};
let conn_id = state.next_conn_id();
let key = BridgeKey::new(window.label(), args.bridge_id());
let (tx, mut rx) = mpsc::unbounded_channel();
// Replace any previous connection with the same key
if let Some(prev) = state.replace(key.clone(), BridgeConnection::new_ws(conn_id, tx)) {
if let Some(prev_tx) = prev.tx {
let _ = prev_tx.send(BridgeCommand::Close);
}
}
let mut request = args
.url()
.into_client_request()
.map_err(|e| format!("invalid WebSocket URL: {}", e))?;
if let Some(auth) = args.auth_header() {
let value = HeaderValue::from_str(auth)
.map_err(|e| format!("invalid Authorization header: {}", e))?;
request.headers_mut().insert("Authorization", value);
}
let (ws_stream, _) = match connect_async(request).await {
Ok(result) => result,
Err(error) => {
let message = match error {
WsError::Http(response) => {
format!("WebSocket server returned {}", response.status())
}
other => format!("WebSocket connection failed: {}", other),
};
emit(
&on_event,
BridgeEvent::Error {
message: message.clone(),
},
);
state.remove_if_current(&key, conn_id);
return Err(message);
}
};
emit(&on_event, BridgeEvent::Connected);
let (mut write, mut read) = ws_stream.split();
loop {
tokio::select! {
outbound = rx.recv() => match outbound {
Some(BridgeCommand::Send(data)) => {
if let Err(error) = write.send(Message::Text(data.into())).await {
let msg = format!("WebSocket write failed: {}", error);
emit(&on_event, BridgeEvent::Error { message: msg.clone() });
state.remove_if_current(&key, conn_id);
return Err(msg);
}
}
Some(BridgeCommand::Close) | None => {
let _ = write.close().await;
state.remove_if_current(&key, conn_id);
emit(&on_event, BridgeEvent::Disconnected {
code: Some(1000),
reason: "Disconnected by client".to_string(),
});
return Ok(());
}
},
inbound = read.next() => match inbound {
Some(Ok(message)) => match message {
Message::Text(text) => {
emit(&on_event, BridgeEvent::Data { data: text.to_string() });
}
Message::Binary(bytes) => {
emit(&on_event, BridgeEvent::Data {
data: String::from_utf8_lossy(&bytes).into_owned(),
});
}
Message::Ping(payload) => {
if let Err(error) = write.send(Message::Pong(payload)).await {
let msg = format!("WebSocket pong failed: {}", error);
emit(&on_event, BridgeEvent::Error { message: msg.clone() });
state.remove_if_current(&key, conn_id);
return Err(msg);
}
}
Message::Pong(_) => {}
Message::Close(frame) => {
let code = frame.as_ref().map(|f| u16::from(f.code));
let reason = frame
.map(|f| f.reason.to_string())
.unwrap_or_else(|| "Connection closed by server".to_string());
state.remove_if_current(&key, conn_id);
emit(&on_event, BridgeEvent::Disconnected { code, reason });
return Ok(());
}
_ => {}
},
Some(Err(error)) => {
let msg = format!("WebSocket stream error: {}", error);
emit(&on_event, BridgeEvent::Error { message: msg.clone() });
state.remove_if_current(&key, conn_id);
return Err(msg);
}
None => {
state.remove_if_current(&key, conn_id);
emit(&on_event, BridgeEvent::Disconnected {
code: None,
reason: "Stream ended".to_string(),
});
return Ok(());
}
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod bridge;
#[cfg(not(target_os = "android"))]
pub mod pi_agent;
#[cfg(not(target_os = "android"))]
pub mod utils;
+166
View File
@@ -0,0 +1,166 @@
// ============================================
// Pi Agent Service Management (desktop only)
// Android 不支持子进程管理和 window.destroy()
// ============================================
use crate::app::service::ServiceState;
use std::{
process::{Command, Stdio},
sync::atomic::Ordering,
time::Duration,
};
use tauri::State;
/// 检查 pi 服务是否在运行(通过 health endpoint)
pub async fn is_service_running(url: &str) -> bool {
let health_url = format!("{}/api/health", url.trim_end_matches('/'));
match reqwest::Client::builder()
.connect_timeout(Duration::from_secs(3))
.build()
{
Ok(client) => client
.get(&health_url)
.timeout(Duration::from_secs(5))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false),
Err(_) => false,
}
}
/// 启动 pi serve 进程
fn spawn_pi_serve(
binary_path: &str,
env_vars: &std::collections::HashMap<String, String>,
) -> Result<std::process::Child, String> {
log::info!("Starting pi serve with binary: {}", binary_path);
if !env_vars.is_empty() {
log::info!("Injecting {} environment variable(s)", env_vars.len());
}
let mut cmd = Command::new(binary_path);
cmd.arg("rpc").stdout(Stdio::null()).stderr(Stdio::null());
// 注入用户配置的环境变量
for (key, value) in env_vars {
cmd.env(key, value);
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd.spawn().map_err(|e| {
format!(
"Failed to start '{}': {}. Check that the path is correct.",
binary_path, e
)
})
}
/// 跨平台杀进程
pub fn kill_process_by_pid(pid: u32) {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F", "/T"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(not(target_os = "windows"))]
{
let _ = Command::new("kill")
.arg(pid.to_string())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
/// 检查 pi 服务是否在运行
#[tauri::command]
pub async fn check_pi_service(url: String) -> Result<bool, String> {
Ok(is_service_running(&url).await)
}
/// 启动 pi serve
#[tauri::command]
pub async fn start_pi_service(
state: State<'_, ServiceState>,
url: String,
binary_path: String,
env_vars: std::collections::HashMap<String, String>,
) -> Result<bool, String> {
if is_service_running(&url).await {
log::info!("pi service already running at {}", url);
return Ok(false);
}
let child = spawn_pi_serve(&binary_path, &env_vars)?;
let pid = child.id();
log::info!("Started pi serve, PID: {}", pid);
state.child_pid.store(pid, Ordering::SeqCst);
state.we_started.store(true, Ordering::SeqCst);
for _ in 0..30 {
tokio::time::sleep(Duration::from_millis(500)).await;
if is_service_running(&url).await {
log::info!("pi service is ready at {}", url);
return Ok(true);
}
}
log::warn!("pi service started but health check not passing yet");
Ok(true)
}
/// 停止 pi serve
#[tauri::command]
pub async fn stop_pi_service(state: State<'_, ServiceState>) -> Result<(), String> {
let pid = state.child_pid.swap(0, Ordering::SeqCst);
state.we_started.store(false, Ordering::SeqCst);
if pid > 0 {
log::info!("Stopping pi serve, PID: {}", pid);
kill_process_by_pid(pid);
}
Ok(())
}
/// 查询是否由我们启动了 pi 服务
#[tauri::command]
pub async fn get_service_started_by_us(state: State<'_, ServiceState>) -> Result<bool, String> {
Ok(state.we_started.load(Ordering::SeqCst))
}
/// 确认关闭应用(前端调用,可选择是否同时停止服务)
#[tauri::command]
pub async fn confirm_close_app(
window: tauri::Window,
state: State<'_, ServiceState>,
stop_service: bool,
) -> Result<(), String> {
if stop_service {
let pid = state.child_pid.swap(0, Ordering::SeqCst);
if pid > 0 {
log::info!("Closing app and stopping pi serve, PID: {}", pid);
kill_process_by_pid(pid);
}
state.we_started.store(false, Ordering::SeqCst);
} else {
log::info!("Closing app, keeping pi serve running");
}
window.destroy().map_err(|e| e.to_string())
}
+45
View File
@@ -0,0 +1,45 @@
use crate::app::dir_state::OpenDirectoryState;
use std::sync::Arc;
use tauri::State;
/// 获取启动时传入的目录路径(一次性读取后清空)
#[tauri::command]
pub fn get_cli_directory(
window: tauri::Window,
state: State<'_, OpenDirectoryState>,
) -> Option<Arc<str>> {
state.pending().pin().remove(window.label()).cloned()
}
/// 新建桌面窗口
#[cfg(not(target_os = "android"))]
#[tauri::command]
pub async fn open_new_window(app: tauri::AppHandle, directory: Option<String>) {
crate::app::create_new_window(&app, directory);
}
/// 桌面窗口前端首帧完成后,通知 Rust 显示真实窗口并关闭 loading 窗口
#[cfg(not(target_os = "android"))]
#[tauri::command]
pub fn desktop_window_ready(window: tauri::Window) -> Result<(), String> {
crate::app::mark_window_ready(&window).map_err(|err| err.to_string())
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct PiAgentConnection {
pub port: u16,
pub token: String,
}
#[tauri::command]
pub fn get_pi_agent_connection() -> Option<PiAgentConnection> {
let home = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(std::path::PathBuf::from)?;
let conn_file = home.join(".pi").join("agent").join("piagentui-port.json");
if !conn_file.exists() {
return None;
}
let file = std::fs::File::open(conn_file).ok()?;
serde_json::from_reader(file).ok()
}
+27
View File
@@ -0,0 +1,27 @@
// ============================================
// Open Directory State (desktop only)
// 存储启动时传入的目录路径(右键菜单、拖放等)
// ============================================
use papaya::HashMap as PaHashMap;
use rapidhash::fast::RandomState;
use std::sync::Arc;
pub struct OpenDirectoryState {
/// per-window 待处理目录: window label → directory path
pending: PaHashMap<String, Arc<str>, RandomState>,
}
impl Default for OpenDirectoryState {
fn default() -> Self {
Self {
pending: PaHashMap::with_hasher(RandomState::new()),
}
}
}
impl OpenDirectoryState {
pub fn pending(&self) -> &PaHashMap<String, Arc<str>, RandomState> {
&self.pending
}
}
+301
View File
@@ -0,0 +1,301 @@
// ============================================
// Tauri Application Entry Point
// Unified Bridge + Plugin Registration + Service Management
// ============================================
mod bridge;
mod commands;
#[cfg(not(target_os = "android"))]
mod dir_state;
mod service;
use bridge::BridgeState;
use std::sync::atomic::{AtomicU64, Ordering};
use tauri::Manager;
#[cfg(windows)]
use tauri_plugin_decorum::WebviewWindowExt;
// Desktop-only imports for service management
#[cfg(not(target_os = "android"))]
use dir_state::OpenDirectoryState;
#[cfg(not(target_os = "android"))]
use std::sync::Arc;
#[cfg(not(target_os = "android"))]
use tauri::Emitter;
/// 从命令行参数中提取目录路径
#[cfg(not(target_os = "android"))]
fn extract_directory_from_args(args: &[String]) -> Option<String> {
for arg in args.iter().skip(1) {
if arg.starts_with('-') {
continue;
}
if std::path::Path::new(arg).is_dir() {
return Some(arg.clone());
}
}
None
}
#[cfg(not(target_os = "android"))]
fn create_main_window(app: &tauri::AppHandle) -> Result<tauri::WebviewWindow, tauri::Error> {
if let Some(window) = app.get_webview_window("main") {
return Ok(window);
}
let config = app
.config()
.app
.windows
.iter()
.find(|window| window.label == "main")
.cloned()
.expect("main window config missing");
configure_desktop_window_builder(tauri::WebviewWindowBuilder::from_config(app, &config)?)
.visible(false)
.build()
}
#[cfg(target_os = "android")]
fn create_main_window(app: &tauri::AppHandle) -> Result<tauri::WebviewWindow, tauri::Error> {
if let Some(window) = app.get_webview_window("main") {
return Ok(window);
}
let config = app
.config()
.app
.windows
.iter()
.find(|window| window.label == "main")
.cloned()
.expect("main window config missing");
tauri::WebviewWindowBuilder::from_config(app, &config)?.build()
}
#[cfg(not(target_os = "android"))]
fn create_hidden_content_window(
app: &tauri::AppHandle,
label: &str,
) -> Result<tauri::WebviewWindow, tauri::Error> {
let builder = configure_desktop_window_builder(tauri::WebviewWindowBuilder::new(
app,
label,
tauri::WebviewUrl::App("index.html".into()),
))
.title("PiAgentUI")
.inner_size(800.0, 600.0);
#[cfg(windows)]
let builder = builder.disable_drag_drop_handler();
builder.visible(false).build()
}
#[cfg(not(target_os = "android"))]
fn finish_desktop_window_setup(window: &tauri::WebviewWindow) {
#[cfg(windows)]
let _ = window.create_overlay_titlebar();
}
#[cfg(not(target_os = "android"))]
pub(crate) fn mark_window_ready<R: tauri::Runtime>(
window: &tauri::Window<R>,
) -> Result<(), tauri::Error> {
window.show()?;
let _ = window.set_focus();
Ok(())
}
/// 创建新窗口,可选地关联一个目录(多窗口支持)
#[cfg(not(target_os = "android"))]
pub(crate) fn create_new_window(app: &tauri::AppHandle, directory: Option<String>) {
static WIN_COUNTER: AtomicU64 = AtomicU64::new(1);
let label = format!("win-{}", WIN_COUNTER.fetch_add(1, Ordering::SeqCst));
if let Some(ref dir) = directory {
if let Some(state) = app.try_state::<OpenDirectoryState>() {
state
.pending()
.pin()
.insert(label.clone(), Arc::from(dir.clone()));
}
}
match create_hidden_content_window(app, &label) {
Ok(window) => {
finish_desktop_window_setup(&window);
log::info!(
"Created new window '{}' for directory: {:?}",
label,
directory
)
}
Err(e) => log::error!("Failed to create new window: {}", e),
}
}
#[cfg(not(target_os = "android"))]
fn configure_desktop_window_builder<'a, R: tauri::Runtime, M: tauri::Manager<R>>(
window_builder: tauri::WebviewWindowBuilder<'a, R, M>,
) -> tauri::WebviewWindowBuilder<'a, R, M> {
let window_builder = window_builder;
#[cfg(target_os = "macos")]
let window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true)
.traffic_light_position(tauri::LogicalPosition::new(12.0, 16.0));
window_builder
}
pub fn run() {
let builder = tauri::Builder::default().manage(BridgeState::default());
#[cfg(not(target_os = "android"))]
let builder = builder.plugin(tauri_plugin_decorum::init());
// Desktop: 注册 OpenDirectoryState + single-instance 插件(需在 setup 之前)
#[cfg(not(target_os = "android"))]
let builder =
builder
.manage(OpenDirectoryState::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// 始终新建窗口(类似 VSCode:双击图标 = 新窗口)
let dir = extract_directory_from_args(&args);
log::info!("Single-instance: opening new window, directory: {:?}", dir);
create_new_window(app, dir);
}));
let builder = builder
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
// 始终启用 log 插件,方便排查问题
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
#[cfg(not(target_os = "android"))]
{
let main_window = create_main_window(&app.handle())?;
finish_desktop_window_setup(&main_window);
#[cfg(debug_assertions)]
main_window.open_devtools();
}
#[cfg(target_os = "android")]
{
let _main_window = create_main_window(&app.handle())?;
}
// Desktop: 解析 CLI 参数,存入 pending state
#[cfg(not(target_os = "android"))]
{
let args: Vec<String> = std::env::args().collect();
if let Some(dir) = extract_directory_from_args(&args) {
log::info!("CLI directory argument: {}", dir);
if let Some(state) = app.try_state::<OpenDirectoryState>() {
state
.pending()
.pin()
.insert("main".to_string(), Arc::from(dir));
}
}
}
Ok(())
});
// Desktop: 注册 service management commands + 窗口关闭拦截
#[cfg(not(target_os = "android"))]
let builder = builder
.manage(service::ServiceState::default())
.on_window_event(|window, event| {
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
// 只在最后一个窗口关闭时询问是否停止服务
let is_last = window.app_handle().webview_windows().len() <= 1;
if is_last {
let state = window.state::<service::ServiceState>();
if state.we_started.load(Ordering::SeqCst) {
api.prevent_close();
let _ = window.emit("close-requested", ());
}
}
}
tauri::WindowEvent::Destroyed => {
// 窗口销毁时清理该窗口的所有桥接连接
let state = window.state::<BridgeState>();
state.disconnect_window(window.label());
}
_ => {}
}
})
.invoke_handler(tauri::generate_handler![
commands::bridge::bridge_connect,
commands::bridge::bridge_send,
commands::bridge::bridge_disconnect,
commands::utils::get_cli_directory,
commands::utils::open_new_window,
commands::utils::desktop_window_ready,
commands::utils::get_pi_agent_connection,
commands::pi_agent::check_pi_service,
commands::pi_agent::start_pi_service,
commands::pi_agent::stop_pi_service,
commands::pi_agent::get_service_started_by_us,
commands::pi_agent::confirm_close_app,
]);
// Android: 注册 bridge commands
#[cfg(target_os = "android")]
let builder = builder.invoke_handler(tauri::generate_handler![
commands::bridge::bridge_connect,
commands::bridge::bridge_send,
commands::bridge::bridge_disconnect,
]);
// build + run 分开调用,以支持 macOS RunEvent::Opened
let app = builder
.build(tauri::generate_context!())
.unwrap_or_else(|err| panic!("error while building tauri application: {err}"));
app.run(|_app_handle, _event| {
// macOS: 处理 Finder "Open with" / 拖文件夹到 Dock 图标
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = &_event {
for url in urls {
if let Ok(path) = url.to_file_path() {
if path.is_dir() {
let dir = path.to_string_lossy().to_string();
log::info!("macOS Opened directory: {}", dir);
// 如果只有 main 窗口且它还没消费目录,说明是冷启动,设给 main
// 否则新建窗口
if let Some(state) = _app_handle.try_state::<OpenDirectoryState>() {
let pending = state.pending().pin();
let win_count = _app_handle.webview_windows().len();
if win_count <= 1 && !pending.contains_key("main") {
pending.insert("main".to_string(), Arc::from(dir.clone()));
let _ = _app_handle.emit("open-directory", dir);
} else {
create_new_window(_app_handle, Some(dir));
}
}
}
}
}
}
});
}
+18
View File
@@ -0,0 +1,18 @@
use std::sync::atomic::{AtomicBool, AtomicU32};
/// 跟踪我们是否启动了 pi serve 进程
pub struct ServiceState {
/// 我们启动的子进程 PID
pub child_pid: AtomicU32,
/// 是否由我们启动(用于关闭时判断是否需要询问)
pub we_started: AtomicBool,
}
impl Default for ServiceState {
fn default() -> Self {
Self {
child_pid: AtomicU32::new(0),
we_started: AtomicBool::new(false),
}
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod app;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
app::run();
}
Loaded 100 of 983 files, more files were not shown because too many files have changed in this diff. Show more