🎉 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
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 373 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 239 B |
|
After Width: | Height: | Size: 957 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 514 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 900 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
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>
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
include ':app'
|
||||
|
||||
apply from: 'tauri.settings.gradle'
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod bridge;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub mod pi_agent;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub mod utils;
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod app;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
app::run();
|
||||
}
|
||||