diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..6ea0d983 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,67 @@ +name: Build Android + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +jobs: + android: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup Java 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Accept Android licenses and install components + run: | + yes | sdkmanager --licenses + sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;26.1.10909125" + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + + - name: Install Tauri CLI + run: npm i -D @tauri-apps/cli@^2 + + - name: Install frontend dependencies + run: npm ci + + - name: Generate icons from flymd.png + run: npx tauri icon ./flymd.png + + - name: Init Android project (if missing) + run: | + if [ ! -d "src-tauri/gen/android" ]; then + npm run tauri:android:init -- --ci + fi + + - name: Build Android (release) + env: + ANDROID_NDK_HOME: ${{ env.ANDROID_SDK_ROOT }}/ndk/26.1.10909125 + run: npm run tauri:android:build -- --release + + - name: Upload APK artifacts + uses: actions/upload-artifact@v4 + with: + name: flymd-Android + path: | + src-tauri/gen/android/app/build/outputs/apk/**/*.apk + src-tauri/gen/android/app/build/outputs/bundle/**/*.aab + if-no-files-found: warn diff --git a/andriod.md b/andriod.md new file mode 100644 index 00000000..6973275e --- /dev/null +++ b/andriod.md @@ -0,0 +1,208 @@ +# Android 版本适配方案(Tauri v2) + +本文档给出在现有 flymd(Tauri 2 + Vite + Rust)项目上新增 Android 版本的整体方案、代码改造点与发布流程。目标是在尽量少改动现有代码的前提下,让核心功能在 Android 设备上稳定可用。 + +## 1. 技术路线 +- 跨端框架:沿用 Tauri 2(已内置移动端支持),前端不改栈(Vite/TS),后端 Rust 复用。 +- 打包工具链:Android Studio + Android SDK/NDK + Rust Android targets,使用 `tauri android` 子命令开发/构建。 +- 存储与权限:优先使用 Tauri 插件 `@tauri-apps/plugin-fs` 的 App 专用目录;跨目录访问通过系统文件选择器(SAF)。 +- 网络:使用 `reqwest` + `rustls`(已配置)和 `@tauri-apps/plugin-http`,Manifest 启用 `INTERNET`。 + +## 2. 兼容性清单(重要) +- 已用插件的 Android 适配 + - fs:可用,建议限制为 App 专用目录或经 SAF 授权的 URI。 + - http:可用;如需明文 HTTP,需要 Network Security Config 允许特定域名明文流量。 + - opener:可用,用于外部浏览器/应用唤起。 + - store:可用,保存偏好/轻量配置。 + - dialog:消息/确认对话可用;文件打开/保存依系统文件选择器,交互与桌面不同(路径多为 URI)。 + - window-state:桌面特性(窗口大小/位置),Android 不适用,需条件编译屏蔽注册。 +- 现有自定义命令影响 + - move_to_trash:Android 无“回收站”概念;建议在 Android 上退化为直接删除或移动到 App 自建回收目录。 + - read_text_file_any / write_text_file_any:直接路径在移动端不可靠;建议:前端改用 `plugin-fs` + BaseDirectory 或通过文件选择器取得 URI 后在 Rust 侧处理。 + - download_file:当前默认保存到 `HOME/Downloads`,Android 需改为 App 专用目录或通过 SAF 写入“下载”目录。 + - 更新检查/下载安装包:桌面逻辑不适用 Android。建议:Android 仅提示最新版本并跳转到 Release 页面/商店,或接入应用分发平台的内置更新能力。 + +## 3. 开发环境准备 +- 安装 Android Studio(含 SDK/Platform-Tools/Build-Tools)。 +- 安装 NDK(建议 r25+),在 Android Studio SDK Manager 勾选。 +- JDK 17(Android Gradle 推荐)。配置 `JAVA_HOME`。 +- Rust 目标: + ```bash + rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android + ``` +- Node 版本:已满足(本项目 Node v22+)。 + +## 4. 工程初始化与脚本 +- 初始化 Android 工程(首次执行): + ```bash + npx tauri android init + ``` + 将在 `src-tauri/gen/android` 生成原生工程与 Manifest/Gradle 配置。 +- 推荐在 `package.json` 增加脚本: + ```json + { + "scripts": { + "tauri:android:init": "tauri android init", + "tauri:android:dev": "tauri android dev", + "tauri:android:build": "tauri android build" + } + } + ``` +- 运行到真机/模拟器: + ```bash + npm run tauri:android:dev + ``` +- 构建发布包: + ```bash + npm run tauri:android:build + ``` + +## 5. 必要代码改造(最小改动) +1) 条件注册桌面专属插件(`window-state`) + - 修改 `src-tauri/src/main.rs` 插件注册处: + ```rust + #[cfg(not(target_os = "android"))] + .plugin(tauri_plugin_window_state::Builder::default().build()) + ``` +2) 回收站能力降级 + - 在 `move_to_trash` 内做平台分支:Android 直接删除或移入 App 私有回收目录。 + ```rust + #[tauri::command] + async fn move_to_trash(path: String) -> Result<(), String> { + #[cfg(target_os = "android")] + { + std::fs::remove_file(&path).map_err(|e| format!("remove error: {e}"))?; + return Ok(()); + } + #[cfg(not(target_os = "android"))] + { + tauri::async_runtime::spawn_blocking(move || { + trash::delete(path).map_err(|e| format!("move_to_trash error: {e}")) + }) + .await + .map_err(|e| format!("join error: {e}"))??; + Ok(()) + } + } + ``` +3) 文件读写路径策略 + - 前端优先使用 `@tauri-apps/plugin-fs` 的 `BaseDirectory`,避免裸路径: + ```ts + import { readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs' + await writeTextFile('flymd/notes/demo.md', '# 标题', { dir: BaseDirectory.AppData }) + const text = await readTextFile('flymd/notes/demo.md', { dir: BaseDirectory.AppData }) + ``` + - 如需“任意目录”访问,通过 `dialog.open()` 选文件后获得句柄(Android 可能是 URI),由后端结合 SAF/插件处理。 +4) 下载保存位置 + - Android 上将 `download_file` 保存位置改到 App 专用目录,或在前端通过 SAF 选择保存目标;避免直接写 `Downloads` 物理路径。 +5) 更新检查行为 + - Android 构建下隐藏“下载并安装”按钮,仅展示最新版本并通过 `opener` 打开 Release 页;或集成第三方分发平台的 In-App Update。 + +## 6. Manifest 与权限 +- `INTERNET`:网络访问(`reqwest`/`plugin-http`)。 +- 明文 HTTP(可选):若需访问 `http://`,添加 Network Security Config 并在 Manifest 绑定,最好只放行特定域名。 +- 存储:尽量不申请 `READ/WRITE_EXTERNAL_STORAGE`(新系统已废弃),改用 SAF(系统文件选择器)。 +- 外链唤起:`opener` 使用 Intent,不额外权限。 + +生成后的位置:`src-tauri/gen/android/app/src/main/AndroidManifest.xml`。按需添加 `queries`、`provider` 或 `networkSecurityConfig`。 + +## 7. UI/交互差异 +- 单窗口模型:Android 只有一个 WebView 窗口,窗口大小/位置相关功能不适用。 +- 返回键:建议前端监听并优先执行路由后退,无路由可退时再退出。 +- 文件选择:通过系统选择器进行授权访问,路径多为 URI 而非传统文件路径。 + +## 8. 构建、签名与发布 +- 构建产物:默认生成 `apk`;生产发布建议使用 `aab`(Android App Bundle)。 +- 签名:在 `android` 工程配置 keystore,或使用环境变量在 CI 中注入。 +- 架构:发布至少包含 `arm64-v8a`(aarch64),可按需增加 `armeabi-v7a`、`x86_64`(模拟器)。 +- 命令: + ```bash + # 开发调试 + tauri android dev --target aarch64 + # 发布构建 + tauri android build --release + ``` + +## 9. CI/CD(可选) +- 使用 GitHub Actions: + - runner: ubuntu-latest + - 安装 Android SDK/NDK,配置 `JAVA_HOME`、`ANDROID_SDK_ROOT`。 + - 安装 Rust 目标并执行 `tauri android build`。 + - 产物上传到 Release。 + +## 10. 风险与里程碑 +- 体积:AWS SDK + Rust 依赖会抬升 APK 体积;建议按需裁剪功能或拆分 feature。 +- 文件访问:跨目录访问依赖 SAF,需调整前端交互引导用户授权。 +- 更新机制:桌面与 Android 差异较大,建议独立规范。 +- 里程碑: + 1) 环境联调(设备可运行) + 2) 核心阅读/编辑/保存流程稳定 + 3) 对话框与外链唤起验证 + 4) S3 上传/预签名、XML-RPC 等网络能力验证 + 5) 签名发布与渠道分发 + +--- +如需,我可以按本方案提交一个最小改造 PR: +- 条件编译去掉 `window-state` 插件(Android) +- 调整 `move_to_trash` 降级 +- 新增 Android 命令脚本与初始化工程 +- 将下载/存储切换为 App 专用目录 + +## Kotlin 支持与配置 + +- 可以完全使用 Kotlin 替代 Java。Tauri v2 的 Android 模板默认即为 Kotlin(若你看到 Java 模板,也可用 Android Studio 的“Convert Java File to Kotlin File”一键转换)。 +- 关键点: + 1) 在 app 模块启用 Kotlin 插件,并设置 JDK 17 目标 + 2) 用 Kotlin 定义 `MainActivity` 继承 Tauri 的 `TauriActivity` + +- Gradle(Groovy DSL)示例: +```gradle +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'com.flymd' + compileSdk 34 + + defaultConfig { + applicationId 'com.flymd' + minSdk 24 + targetSdk 34 + versionCode 1 + versionName '0.1.0' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } +} +``` + +- Kotlin 入口 Activity 示例: +```kotlin +package com.flymd + +import app.tauri.TauriActivity + +class MainActivity : TauriActivity() +``` + +- 说明: + - 业务与插件调用仍走 Rust/Tauri 通道;是否使用 Kotlin 不影响现有前端或 Rust 代码。 + - 如果 Gradle 使用 Kotlin DSL(`build.gradle.kts`),请按等价方式启用 `org.jetbrains.kotlin.android` 插件并设置 `jvmTarget = "17"`。 + +## GitHub Actions(Android 独立工作流) + +- 路径:`.github/workflows/android.yml` +- 触发:`push` 标签(`v*`)和手动 `workflow_dispatch` +- 主要步骤: + - 安装 Node 20、Java 17、Android SDK/NDK、Rust 目标 + - 若 `src-tauri/gen/android` 不存在则在 CI 中执行初始化 + - 构建 `--release` 并上传 APK/AAB 工件 +- 不影响现有桌面端工作流(Windows/Linux 的 `build.yml` 保持不变)。 diff --git a/package.json b/package.json index 7086409e..3928514e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "build": "vite build", "preview": "vite preview", "tauri:dev": "tauri dev", - "tauri:build": "tauri build" + "tauri:build": "tauri build", + "tauri:android:init": "tauri android init", + "tauri:android:dev": "tauri android dev", + "tauri:android:build": "tauri android build" }, "devDependencies": { "@tauri-apps/cli": "^2.0.0", @@ -31,4 +34,3 @@ "punycode": "^2.3.0" } } - diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 94d94775..986fe903 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,7 +16,6 @@ tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-dialog = "2" tauri-plugin-fs = "2" tauri-plugin-store = "2" -tauri-plugin-window-state = "2" tauri-plugin-opener = "2" tauri-plugin-http = { version = "2", features = ["unsafe-headers"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } @@ -29,7 +28,6 @@ hex = "0.4" chrono = { version = "0.4", default-features = false, features = ["std"] } aws-config = { version = "1", features = ["rt-tokio", "behavior-version-latest"] } aws-sdk-s3 = { version = "1", features = ["rt-tokio"] } -trash = "3" futures-util = "0.3" [profile.release] @@ -40,3 +38,7 @@ lto = true strip = true + +[target.'cfg(not(target_os = "android"))'.dependencies] +tauri-plugin-window-state = "2" +trash = "3" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a951c11c..2a989c06 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,375 +1,375 @@ -// flymd 桌面端:Tauri 2 -// 职责:对话框、文件系统、存储、窗口状态、外链打开等插件初始化 - -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -use tauri::{Manager, Emitter, State}; -// 全局共享:保存通过“打开方式/默认程序”传入且可能早于前端监听的文件路径 -#[derive(Default)] -struct PendingOpenPath(std::sync::Mutex>); -use serde::{Deserialize, Serialize}; -use sha2::Digest; -use chrono::{DateTime, Utc}; -use std::time::Duration; - - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UploadReq { - access_key_id: String, - secret_access_key: String, - bucket: String, - #[serde(default)] - region: Option, - #[serde(default)] - endpoint: Option, - #[serde(default = "UploadReq::default_true")] - force_path_style: bool, - #[serde(default = "UploadReq::default_true")] - acl_public_read: bool, - #[serde(default)] - custom_domain: Option, - key: String, - #[serde(default)] - content_type: Option, - // 前端可传 Uint8Array -> Vec - bytes: Vec, -} - -impl UploadReq { - fn default_true() -> bool { true } -} - -#[derive(Debug, Serialize)] -struct UploadResp { - key: String, - public_url: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PresignReq { - access_key_id: String, - secret_access_key: String, - bucket: String, - #[serde(default)] - region: Option, - #[serde(default)] - endpoint: Option, - #[serde(default)] - force_path_style: bool, - #[serde(default)] - custom_domain: Option, - key: String, - #[serde(default)] - expires: Option, -} - -#[derive(Debug, Serialize)] -struct PresignResp { - put_url: String, - public_url: String, -} - -#[tauri::command] -async fn upload_to_s3(req: UploadReq) -> Result { - // 使用 AWS SDK for Rust 直传,行为与 PicList(SDK)一致;仅构建机需工具链,用户零依赖。 - use aws_sdk_s3 as s3; - use aws_config::meta::region::RegionProviderChain; - use s3::config::Region; - use s3::types::ObjectCannedAcl; - use s3::primitives::ByteStream; - - let region_str = req.region.clone().unwrap_or_else(|| "us-east-1".to_string()); - let region = Region::new(region_str.clone()); - let region_provider = RegionProviderChain::first_try(region.clone()); - let base_conf = aws_config::defaults(aws_config::BehaviorVersion::latest()) - .region(region_provider) - .load() - .await; - - let creds = s3::config::Credentials::new( - req.access_key_id.clone(), - req.secret_access_key.clone(), - None, - None, - "flymd", - ); - let mut conf_builder = s3::config::Builder::from(&base_conf) - .credentials_provider(creds) - .force_path_style(req.force_path_style); - if let Some(ep) = &req.endpoint { if !ep.trim().is_empty() { conf_builder = conf_builder.endpoint_url(ep.trim()); } } - let conf = conf_builder.build(); - let client = s3::Client::from_conf(conf); - - let mut put = client - .put_object() - .bucket(req.bucket.clone()) - .key(req.key.clone()) - .body(ByteStream::from(req.bytes.clone())); - if let Some(ct) = &req.content_type { if !ct.is_empty() { put = put.content_type(ct); } } - if req.acl_public_read { put = put.acl(ObjectCannedAcl::PublicRead); } - put.send().await.map_err(|e| format!("put_object error: {e}"))?; - - // 生成外链 - let key_enc = percent_encoding::utf8_percent_encode(&req.key, percent_encoding::NON_ALPHANUMERIC).to_string(); - let public_url = if let Some(custom) = &req.custom_domain { - let base = custom.trim_end_matches('/'); - format!("{}/{}", base, key_enc) - } else if let Some(ep) = &req.endpoint { - let ep = ep.trim_end_matches('/'); - if req.force_path_style { - // path-style: // - format!("{}/{}/{}", ep, req.bucket, key_enc) - } else { - // virtual-host: https://./ - match ep.parse::() { - Ok(u) => format!("{}://{}.{}{}{}{}{}", u.scheme(), req.bucket, u.host_str().unwrap_or(""), if u.port().is_some() { ":" } else { "" }, u.port().map(|p| p.to_string()).unwrap_or_default(), if u.path() == "/" { "" } else { u.path() }, format!("/{}", key_enc)), - Err(_) => format!("{}/{}/{}", ep, req.bucket, key_enc), - } - } - } else { - // 默认 S3 公域名 - if req.force_path_style { format!("https://s3.amazonaws.com/{}/{}", req.bucket, key_enc) } else { format!("https://{}.s3.amazonaws.com/{}", req.bucket, key_enc) } - }; - - Ok(UploadResp { key: req.key, public_url }) -} - -#[tauri::command] -async fn presign_put(req: PresignReq) -> Result { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - use std::time::SystemTime; - - let region_str = req.region.clone().unwrap_or_else(|| "us-east-1".to_string()); - let service = "s3"; - let expires = req.expires.unwrap_or(600); - - // 构建基础 URL 与 CanonicalURI - let ep = req.endpoint.clone().unwrap_or_else(|| "https://s3.amazonaws.com".to_string()); - let ep_url = ep.parse::().map_err(|e| format!("invalid endpoint: {e}"))?; - - fn aws_uri_encode_segment(seg: &str) -> String { - let mut out = String::with_capacity(seg.len()); - for &b in seg.as_bytes() { - let c = b as char; - let is_unreserved = (b'A'..=b'Z').contains(&b) - || (b'a'..=b'z').contains(&b) - || (b'0'..=b'9').contains(&b) - || c == '-' || c == '_' || c == '.' || c == '~'; - if is_unreserved { out.push(c) } else { out.push('%'); out.push_str(&format!("{:02X}", b)); } - } - out - } - let key_enc = req.key.split('/').map(aws_uri_encode_segment).collect::>().join("/"); - - let (mut base_url, host_for_sig, canonical_uri) = if req.force_path_style { - // // - let mut u = ep_url.clone(); - let mut new_path = u.path().trim_end_matches('/').to_string(); - new_path.push('/'); new_path.push_str(&req.bucket); - new_path.push('/'); new_path.push_str(&key_enc); - u.set_path(&new_path); - let host_sig = u.host_str().unwrap_or("").to_string(); - (u, host_sig, new_path) - } else { - // https://./ - let host = format!("{}.{}", req.bucket, ep_url.host_str().unwrap_or("")); - let u = url::Url::parse(&format!("{}://{}/{}", ep_url.scheme(), host, key_enc)) - .map_err(|e| format!("build url error: {e}"))?; - (u, host, format!("/{}", key_enc)) - }; - - // 构建 X-Amz-* 查询参数(不包含 Signature) - let sys_now = SystemTime::now(); - let datetime: DateTime = sys_now.into(); - let amz_date = datetime.format("%Y%m%dT%H%M%SZ").to_string(); - let date_stamp = datetime.format("%Y%m%d").to_string(); - let scope = format!("{}/{}/{}/aws4_request", date_stamp, region_str, service); - - // Query 编码(RFC3986,空格用 %20) - fn enc_q(v: &str) -> String { - let mut out = String::new(); - for &b in v.as_bytes() { - let c = b as char; - let unreserved = (b'A'..=b'Z').contains(&b) - || (b'a'..=b'z').contains(&b) - || (b'0'..=b'9').contains(&b) - || c == '-' || c == '_' || c == '.' || c == '~'; - if unreserved { out.push(c) } else { out.push('%'); out.push_str(&format!("{:02X}", b)); } - } - out - } - - let mut query: Vec<(String, String)> = vec![ - ("X-Amz-Algorithm".into(), "AWS4-HMAC-SHA256".into()), - ("X-Amz-Credential".into(), format!("{}/{}", req.access_key_id, scope)), - ("X-Amz-Date".into(), amz_date.clone()), - ("X-Amz-Expires".into(), expires.to_string()), - ("X-Amz-SignedHeaders".into(), "host".into()), - ]; - query.sort_by(|a,b| a.0.cmp(&b.0)); - let canonical_query = query.iter().map(|(k,v)| format!("{}={}", enc_q(k), enc_q(v))).collect::>().join("&"); - - // CanonicalHeaders / SignedHeaders / HashedPayload - let canonical_headers = format!("host:{}\n", host_for_sig); - let signed_headers = "host"; - let hashed_payload = "UNSIGNED-PAYLOAD"; - - // CanonicalRequest - let canonical_request = format!( - "PUT\n{}\n{}\n{}\n{}\n{}", - canonical_uri, canonical_query, canonical_headers, signed_headers, hashed_payload - ); - - // StringToSign - let string_to_sign = format!( - "AWS4-HMAC-SHA256\n{}\n{}\n{}", - amz_date, - scope, - hex::encode(sha2::Sha256::digest(canonical_request.as_bytes())) - ); - - // 派生签名密钥 - type HmacSha256 = Hmac; - fn hmac(key: &[u8], data: &str) -> Vec { let mut mac = HmacSha256::new_from_slice(key).unwrap(); mac.update(data.as_bytes()); mac.finalize().into_bytes().to_vec() } - let k_date = hmac(format!("AWS4{}", req.secret_access_key).as_bytes(), &date_stamp); - let k_region = hmac(&k_date, ®ion_str); - let k_service = hmac(&k_region, service); - let k_signing = hmac(&k_service, "aws4_request"); - let signature = hex::encode(hmac(&k_signing, &string_to_sign)); - - // 构造最终 URL(附加 Signature) - let mut final_q = canonical_query.clone(); - final_q.push_str(&format!("&X-Amz-Signature={}", signature)); - base_url.set_query(Some(&final_q)); - - // 生成外链 - let public_url = if let Some(custom) = &req.custom_domain { - let base = custom.trim_end_matches('/'); - format!("{}/{}", base, key_enc) - } else if req.force_path_style { - format!("{}/{}/{}", ep.trim_end_matches('/'), req.bucket, key_enc) - } else { - format!("{}://{}.{}{}{}{}{}", - ep_url.scheme(), req.bucket, ep_url.host_str().unwrap_or(""), - if ep_url.port().is_some() { ":" } else { "" }, ep_url.port().map(|p| p.to_string()).unwrap_or_default(), - if ep_url.path() == "/" { "" } else { ep_url.path() }, - format!("/{}", key_enc) - ) - }; - - Ok(PresignResp { put_url: base_url.to_string(), public_url }) -} - -#[derive(Debug, Deserialize)] -struct XmlHttpReq { - url: String, - xml: String, -} - -#[tauri::command] -async fn http_xmlrpc_post(req: XmlHttpReq) -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(20)) - .build() - .map_err(|e| format!("client error: {e}"))?; - let res = client - .post(&req.url) - .header("Content-Type", "text/xml; charset=UTF-8") - .header("Accept", "text/xml, */*;q=0.1") - .header("User-Agent", "flymd-typecho-publisher/0.1") - .body(req.xml) - .send() - .await - .map_err(|e| format!("send error: {e}"))?; - let status = res.status(); - let text = res.text().await.map_err(|e| format!("read error: {e}"))?; - if !status.is_success() { - return Err(format!("HTTP {}: {}", status.as_u16(), text)); - } - Ok(text) -} - - -// ===== CODEX SYNC S3 START ===== -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct S3Conn { - access_key_id: String, - secret_access_key: String, - bucket: String, - #[serde(default)] - region: Option, - #[serde(default)] - endpoint: Option, - #[serde(default = "S3Conn::default_true")] - force_path_style: bool, -} -impl S3Conn { fn default_true() -> bool { true } } - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct S3ListReq { conn: S3Conn, prefix: String } - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct S3ObjectMeta { key: String, size: i64, etag: Option, last_modified_ms: Option } - -#[tauri::command] -async fn s3_list_objects(req: S3ListReq) -> Result, String> { - use aws_sdk_s3 as s3; - use aws_config::meta::region::RegionProviderChain; - use s3::config::Region; - - let region_str = req.conn.region.clone().unwrap_or_else(|| "us-east-1".to_string()); - let region = Region::new(region_str.clone()); - let region_provider = RegionProviderChain::first_try(region.clone()); - let base_conf = aws_config::defaults(aws_config::BehaviorVersion::latest()) - .region(region_provider) - .load() - .await; - - let creds = s3::config::Credentials::new( - req.conn.access_key_id.clone(), - req.conn.secret_access_key.clone(), - None, None, "flymd" - ); - let mut conf_builder = s3::config::Builder::from(&base_conf) - .credentials_provider(creds) - .force_path_style(req.conn.force_path_style); - if let Some(ep) = &req.conn.endpoint { if !ep.trim().is_empty() { conf_builder = conf_builder.endpoint_url(ep.trim()); } } - let conf = conf_builder.build(); - let client = s3::Client::from_conf(conf); - - let mut out: Vec = Vec::new(); - let mut token: Option = None; - loop { - let mut call = client - .list_objects_v2() - .bucket(&req.conn.bucket) - .prefix(&req.prefix) - .max_keys(1000); - if let Some(t) = &token { call = call.continuation_token(t); } - let resp = call.send().await.map_err(|e| format!("list error: {e}"))?; - for o in resp.contents() { - let key = o.key().unwrap_or("").to_string(); - let size = o.size().unwrap_or(0); - let etag = o.e_tag().map(|s| s.to_string()); - let lm_ms = o.last_modified().map(|dt| dt.secs() * 1000 + (dt.subsec_nanos() as i64)/1_000_000); - out.push(S3ObjectMeta { key, size, etag, last_modified_ms: lm_ms }); - } - if resp.is_truncated().unwrap_or(false) { - token = resp.next_continuation_token().map(|s| s.to_string()); - if token.is_none() { break; } - } else { break; } -} - Ok(out) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +// flymd 桌面端:Tauri 2 +// 职责:对话框、文件系统、存储、窗口状态、外链打开等插件初始化 + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use tauri::{Manager, Emitter, State}; +// 全局共享:保存通过“打开方式/默认程序”传入且可能早于前端监听的文件路径 +#[derive(Default)] +struct PendingOpenPath(std::sync::Mutex>); +use serde::{Deserialize, Serialize}; +use sha2::Digest; +use chrono::{DateTime, Utc}; +use std::time::Duration; + + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UploadReq { + access_key_id: String, + secret_access_key: String, + bucket: String, + #[serde(default)] + region: Option, + #[serde(default)] + endpoint: Option, + #[serde(default = "UploadReq::default_true")] + force_path_style: bool, + #[serde(default = "UploadReq::default_true")] + acl_public_read: bool, + #[serde(default)] + custom_domain: Option, + key: String, + #[serde(default)] + content_type: Option, + // 前端可传 Uint8Array -> Vec + bytes: Vec, +} + +impl UploadReq { + fn default_true() -> bool { true } +} + +#[derive(Debug, Serialize)] +struct UploadResp { + key: String, + public_url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PresignReq { + access_key_id: String, + secret_access_key: String, + bucket: String, + #[serde(default)] + region: Option, + #[serde(default)] + endpoint: Option, + #[serde(default)] + force_path_style: bool, + #[serde(default)] + custom_domain: Option, + key: String, + #[serde(default)] + expires: Option, +} + +#[derive(Debug, Serialize)] +struct PresignResp { + put_url: String, + public_url: String, +} + +#[tauri::command] +async fn upload_to_s3(req: UploadReq) -> Result { + // 使用 AWS SDK for Rust 直传,行为与 PicList(SDK)一致;仅构建机需工具链,用户零依赖。 + use aws_sdk_s3 as s3; + use aws_config::meta::region::RegionProviderChain; + use s3::config::Region; + use s3::types::ObjectCannedAcl; + use s3::primitives::ByteStream; + + let region_str = req.region.clone().unwrap_or_else(|| "us-east-1".to_string()); + let region = Region::new(region_str.clone()); + let region_provider = RegionProviderChain::first_try(region.clone()); + let base_conf = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(region_provider) + .load() + .await; + + let creds = s3::config::Credentials::new( + req.access_key_id.clone(), + req.secret_access_key.clone(), + None, + None, + "flymd", + ); + let mut conf_builder = s3::config::Builder::from(&base_conf) + .credentials_provider(creds) + .force_path_style(req.force_path_style); + if let Some(ep) = &req.endpoint { if !ep.trim().is_empty() { conf_builder = conf_builder.endpoint_url(ep.trim()); } } + let conf = conf_builder.build(); + let client = s3::Client::from_conf(conf); + + let mut put = client + .put_object() + .bucket(req.bucket.clone()) + .key(req.key.clone()) + .body(ByteStream::from(req.bytes.clone())); + if let Some(ct) = &req.content_type { if !ct.is_empty() { put = put.content_type(ct); } } + if req.acl_public_read { put = put.acl(ObjectCannedAcl::PublicRead); } + put.send().await.map_err(|e| format!("put_object error: {e}"))?; + + // 生成外链 + let key_enc = percent_encoding::utf8_percent_encode(&req.key, percent_encoding::NON_ALPHANUMERIC).to_string(); + let public_url = if let Some(custom) = &req.custom_domain { + let base = custom.trim_end_matches('/'); + format!("{}/{}", base, key_enc) + } else if let Some(ep) = &req.endpoint { + let ep = ep.trim_end_matches('/'); + if req.force_path_style { + // path-style: // + format!("{}/{}/{}", ep, req.bucket, key_enc) + } else { + // virtual-host: https://./ + match ep.parse::() { + Ok(u) => format!("{}://{}.{}{}{}{}{}", u.scheme(), req.bucket, u.host_str().unwrap_or(""), if u.port().is_some() { ":" } else { "" }, u.port().map(|p| p.to_string()).unwrap_or_default(), if u.path() == "/" { "" } else { u.path() }, format!("/{}", key_enc)), + Err(_) => format!("{}/{}/{}", ep, req.bucket, key_enc), + } + } + } else { + // 默认 S3 公域名 + if req.force_path_style { format!("https://s3.amazonaws.com/{}/{}", req.bucket, key_enc) } else { format!("https://{}.s3.amazonaws.com/{}", req.bucket, key_enc) } + }; + + Ok(UploadResp { key: req.key, public_url }) +} + +#[tauri::command] +async fn presign_put(req: PresignReq) -> Result { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use std::time::SystemTime; + + let region_str = req.region.clone().unwrap_or_else(|| "us-east-1".to_string()); + let service = "s3"; + let expires = req.expires.unwrap_or(600); + + // 构建基础 URL 与 CanonicalURI + let ep = req.endpoint.clone().unwrap_or_else(|| "https://s3.amazonaws.com".to_string()); + let ep_url = ep.parse::().map_err(|e| format!("invalid endpoint: {e}"))?; + + fn aws_uri_encode_segment(seg: &str) -> String { + let mut out = String::with_capacity(seg.len()); + for &b in seg.as_bytes() { + let c = b as char; + let is_unreserved = (b'A'..=b'Z').contains(&b) + || (b'a'..=b'z').contains(&b) + || (b'0'..=b'9').contains(&b) + || c == '-' || c == '_' || c == '.' || c == '~'; + if is_unreserved { out.push(c) } else { out.push('%'); out.push_str(&format!("{:02X}", b)); } + } + out + } + let key_enc = req.key.split('/').map(aws_uri_encode_segment).collect::>().join("/"); + + let (mut base_url, host_for_sig, canonical_uri) = if req.force_path_style { + // // + let mut u = ep_url.clone(); + let mut new_path = u.path().trim_end_matches('/').to_string(); + new_path.push('/'); new_path.push_str(&req.bucket); + new_path.push('/'); new_path.push_str(&key_enc); + u.set_path(&new_path); + let host_sig = u.host_str().unwrap_or("").to_string(); + (u, host_sig, new_path) + } else { + // https://./ + let host = format!("{}.{}", req.bucket, ep_url.host_str().unwrap_or("")); + let u = url::Url::parse(&format!("{}://{}/{}", ep_url.scheme(), host, key_enc)) + .map_err(|e| format!("build url error: {e}"))?; + (u, host, format!("/{}", key_enc)) + }; + + // 构建 X-Amz-* 查询参数(不包含 Signature) + let sys_now = SystemTime::now(); + let datetime: DateTime = sys_now.into(); + let amz_date = datetime.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = datetime.format("%Y%m%d").to_string(); + let scope = format!("{}/{}/{}/aws4_request", date_stamp, region_str, service); + + // Query 编码(RFC3986,空格用 %20) + fn enc_q(v: &str) -> String { + let mut out = String::new(); + for &b in v.as_bytes() { + let c = b as char; + let unreserved = (b'A'..=b'Z').contains(&b) + || (b'a'..=b'z').contains(&b) + || (b'0'..=b'9').contains(&b) + || c == '-' || c == '_' || c == '.' || c == '~'; + if unreserved { out.push(c) } else { out.push('%'); out.push_str(&format!("{:02X}", b)); } + } + out + } + + let mut query: Vec<(String, String)> = vec![ + ("X-Amz-Algorithm".into(), "AWS4-HMAC-SHA256".into()), + ("X-Amz-Credential".into(), format!("{}/{}", req.access_key_id, scope)), + ("X-Amz-Date".into(), amz_date.clone()), + ("X-Amz-Expires".into(), expires.to_string()), + ("X-Amz-SignedHeaders".into(), "host".into()), + ]; + query.sort_by(|a,b| a.0.cmp(&b.0)); + let canonical_query = query.iter().map(|(k,v)| format!("{}={}", enc_q(k), enc_q(v))).collect::>().join("&"); + + // CanonicalHeaders / SignedHeaders / HashedPayload + let canonical_headers = format!("host:{}\n", host_for_sig); + let signed_headers = "host"; + let hashed_payload = "UNSIGNED-PAYLOAD"; + + // CanonicalRequest + let canonical_request = format!( + "PUT\n{}\n{}\n{}\n{}\n{}", + canonical_uri, canonical_query, canonical_headers, signed_headers, hashed_payload + ); + + // StringToSign + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, + scope, + hex::encode(sha2::Sha256::digest(canonical_request.as_bytes())) + ); + + // 派生签名密钥 + type HmacSha256 = Hmac; + fn hmac(key: &[u8], data: &str) -> Vec { let mut mac = HmacSha256::new_from_slice(key).unwrap(); mac.update(data.as_bytes()); mac.finalize().into_bytes().to_vec() } + let k_date = hmac(format!("AWS4{}", req.secret_access_key).as_bytes(), &date_stamp); + let k_region = hmac(&k_date, ®ion_str); + let k_service = hmac(&k_region, service); + let k_signing = hmac(&k_service, "aws4_request"); + let signature = hex::encode(hmac(&k_signing, &string_to_sign)); + + // 构造最终 URL(附加 Signature) + let mut final_q = canonical_query.clone(); + final_q.push_str(&format!("&X-Amz-Signature={}", signature)); + base_url.set_query(Some(&final_q)); + + // 生成外链 + let public_url = if let Some(custom) = &req.custom_domain { + let base = custom.trim_end_matches('/'); + format!("{}/{}", base, key_enc) + } else if req.force_path_style { + format!("{}/{}/{}", ep.trim_end_matches('/'), req.bucket, key_enc) + } else { + format!("{}://{}.{}{}{}{}{}", + ep_url.scheme(), req.bucket, ep_url.host_str().unwrap_or(""), + if ep_url.port().is_some() { ":" } else { "" }, ep_url.port().map(|p| p.to_string()).unwrap_or_default(), + if ep_url.path() == "/" { "" } else { ep_url.path() }, + format!("/{}", key_enc) + ) + }; + + Ok(PresignResp { put_url: base_url.to_string(), public_url }) +} + +#[derive(Debug, Deserialize)] +struct XmlHttpReq { + url: String, + xml: String, +} + +#[tauri::command] +async fn http_xmlrpc_post(req: XmlHttpReq) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .map_err(|e| format!("client error: {e}"))?; + let res = client + .post(&req.url) + .header("Content-Type", "text/xml; charset=UTF-8") + .header("Accept", "text/xml, */*;q=0.1") + .header("User-Agent", "flymd-typecho-publisher/0.1") + .body(req.xml) + .send() + .await + .map_err(|e| format!("send error: {e}"))?; + let status = res.status(); + let text = res.text().await.map_err(|e| format!("read error: {e}"))?; + if !status.is_success() { + return Err(format!("HTTP {}: {}", status.as_u16(), text)); + } + Ok(text) +} + + +// ===== CODEX SYNC S3 START ===== +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct S3Conn { + access_key_id: String, + secret_access_key: String, + bucket: String, + #[serde(default)] + region: Option, + #[serde(default)] + endpoint: Option, + #[serde(default = "S3Conn::default_true")] + force_path_style: bool, +} +impl S3Conn { fn default_true() -> bool { true } } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct S3ListReq { conn: S3Conn, prefix: String } + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct S3ObjectMeta { key: String, size: i64, etag: Option, last_modified_ms: Option } + +#[tauri::command] +async fn s3_list_objects(req: S3ListReq) -> Result, String> { + use aws_sdk_s3 as s3; + use aws_config::meta::region::RegionProviderChain; + use s3::config::Region; + + let region_str = req.conn.region.clone().unwrap_or_else(|| "us-east-1".to_string()); + let region = Region::new(region_str.clone()); + let region_provider = RegionProviderChain::first_try(region.clone()); + let base_conf = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(region_provider) + .load() + .await; + + let creds = s3::config::Credentials::new( + req.conn.access_key_id.clone(), + req.conn.secret_access_key.clone(), + None, None, "flymd" + ); + let mut conf_builder = s3::config::Builder::from(&base_conf) + .credentials_provider(creds) + .force_path_style(req.conn.force_path_style); + if let Some(ep) = &req.conn.endpoint { if !ep.trim().is_empty() { conf_builder = conf_builder.endpoint_url(ep.trim()); } } + let conf = conf_builder.build(); + let client = s3::Client::from_conf(conf); + + let mut out: Vec = Vec::new(); + let mut token: Option = None; + loop { + let mut call = client + .list_objects_v2() + .bucket(&req.conn.bucket) + .prefix(&req.prefix) + .max_keys(1000); + if let Some(t) = &token { call = call.continuation_token(t); } + let resp = call.send().await.map_err(|e| format!("list error: {e}"))?; + for o in resp.contents() { + let key = o.key().unwrap_or("").to_string(); + let size = o.size().unwrap_or(0); + let etag = o.e_tag().map(|s| s.to_string()); + let lm_ms = o.last_modified().map(|dt| dt.secs() * 1000 + (dt.subsec_nanos() as i64)/1_000_000); + out.push(S3ObjectMeta { key, size, etag, last_modified_ms: lm_ms }); + } + if resp.is_truncated().unwrap_or(false) { + token = resp.next_continuation_token().map(|s| s.to_string()); + if token.is_none() { break; } + } else { break; } +} + Ok(out) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] struct S3GetReq { conn: S3Conn, key: String, dest_path: String } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -390,473 +390,482 @@ async fn s3_delete_object(req: S3DeleteReq) -> Result<(), String> { let client = s3::Client::from_conf(conf_builder.build()); client.delete_object().bucket(&req.conn.bucket).key(&req.key).send().await.map_err(|e| format!("delete error: {e}"))?; Ok(()) -} - -#[tauri::command] -async fn s3_get_object_to_path(req: S3GetReq) -> Result<(), String> { - use aws_sdk_s3 as s3; - use aws_config::meta::region::RegionProviderChain; - use s3::config::Region; - - let region_str = req.conn.region.clone().unwrap_or_else(|| "us-east-1".to_string()); - let region = Region::new(region_str.clone()); - let region_provider = RegionProviderChain::first_try(region.clone()); - let base_conf = aws_config::defaults(aws_config::BehaviorVersion::latest()) - .region(region_provider) - .load() - .await; - - let creds = s3::config::Credentials::new( - req.conn.access_key_id.clone(), req.conn.secret_access_key.clone(), None, None, "flymd" - ); - let mut conf_builder = s3::config::Builder::from(&base_conf) - .credentials_provider(creds) - .force_path_style(req.conn.force_path_style); - if let Some(ep) = &req.conn.endpoint { if !ep.trim().is_empty() { conf_builder = conf_builder.endpoint_url(ep.trim()); } } - let conf = conf_builder.build(); - let client = s3::Client::from_conf(conf); - - let resp = client - .get_object() - .bucket(&req.conn.bucket) - .key(&req.key) - .send() - .await - .map_err(|e| format!("get_object error: {e}"))?; - - { - use std::path::Path; - use tokio::fs; - if let Some(parent) = Path::new(&req.dest_path).parent() { fs::create_dir_all(parent).await.map_err(|e| format!("create_dir_all error: {e}"))?; } - } - let bytes = resp.body.collect().await.map_err(|e| format!("collect error: {e}"))?; - tokio::fs::write(&req.dest_path, &bytes.into_bytes()).await.map_err(|e| format!("write error: {e}"))?; - Ok(()) -} -// ===== CODEX SYNC S3 END ===== -fn main() { - tauri::Builder::default() - .manage(PendingOpenPath::default()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_store::Builder::default().build()) - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_window_state::Builder::default().build()) - .invoke_handler(tauri::generate_handler![upload_to_s3, presign_put, move_to_trash, force_remove_path, read_text_file_any, write_text_file_any, get_pending_open_path, http_xmlrpc_post, check_update, download_file, run_installer, s3_list_objects, s3_get_object_to_path, s3_delete_object]) - .setup(|app| { - // Windows "打开方式/默认程序" 传入的文件参数处理 - #[cfg(target_os = "windows")] - { - use std::env; - use std::path::PathBuf; - use std::time::Duration; - if let Some(win) = app.get_webview_window("main") { - let args: Vec = env::args_os().skip(1).map(PathBuf::from).collect(); - if let Some(p) = args.into_iter().find(|p| { - if !p.exists() { return false; } - match p.extension().and_then(|s| s.to_str()).map(|s| s.to_ascii_lowercase()) { - Some(ext) => ext == "md" || ext == "markdown" || ext == "txt" || ext == "pdf", - None => false, - } - }) { - // 延迟发送事件,确保渲染侧事件监听已注册 - let win_clone = win.clone(); - let path = p.to_string_lossy().to_string(); - // 同时把路径写入共享状态,前端可在启动后主动拉取 - if let Some(state) = app.try_state::() { - if let Ok(mut slot) = state.0.lock() { *slot = Some(path.clone()); } - } - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(500)); - let _ = win_clone.emit("open-file", path); - let _ = win_clone.set_focus(); - }); - } - } - } - // 其它初始化逻辑 - if let Some(win) = app.get_webview_window("main") { - #[cfg(target_os = "windows")] - { - let win_clone = win.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(120)); - let _ = win_clone.show(); - let _ = win_clone.set_focus(); - }); - } - #[cfg(not(target_os = "windows"))] - { - let _ = win.show(); - let _ = win.set_focus(); - } - } - Ok(()) - }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdateAssetInfo { - name: String, - size: u64, - direct_url: String, - proxy_url: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct CheckUpdateResp { - has_update: bool, - current: String, - latest: String, - release_name: String, - notes: String, - html_url: String, - // Windows 推荐资产 - asset_win: Option, - // Linux 双资产 - asset_linux_appimage: Option, - asset_linux_deb: Option, -} - -fn norm_ver(v: &str) -> (i64, i64, i64, i64) { - // 版本比较:major.minor.patch + 权重(fix>无后缀>预发行) - let s = v.trim().trim_start_matches('v'); - let mut parts = s.splitn(2, '-'); - let core = parts.next().unwrap_or(""); - let suffix = parts.next().unwrap_or("").to_ascii_lowercase(); - let mut nums = core.split('.').take(3).map(|x| x.parse::().unwrap_or(0)).collect::>(); - while nums.len() < 3 { nums.push(0); } - let weight = if suffix.starts_with("fix") { 2 } else if suffix.is_empty() { 1 } else { 0 }; - (nums[0], nums[1], nums[2], weight) -} - -fn is_better(a: &(i64,i64,i64,i64), b: &(i64,i64,i64,i64)) -> bool { - // a > b ? - a.0 > b.0 || (a.0==b.0 && (a.1 > b.1 || (a.1==b.1 && (a.2 > b.2 || (a.2==b.2 && a.3 > b.3))))) -} - -#[derive(Debug, Deserialize)] -struct GhAsset { - name: String, - browser_download_url: String, - size: Option, - #[allow(dead_code)] - content_type: Option, -} -#[derive(Debug, Deserialize)] -struct GhRelease { - tag_name: String, - name: Option, - body: Option, - draft: bool, - prerelease: bool, - html_url: String, - assets: Vec, -} - -fn gh_proxy_url(raw: &str) -> String { - // 代理前缀:按“https://gh-proxy.comb/原始URL”拼接 - let prefix = "https://gh-proxy.com/"; - if raw.starts_with(prefix) { raw.to_string() } else { format!("{}{}", prefix, raw) } -} - -fn os_arch_tag() -> (&'static str, &'static str) { - let os = { - #[cfg(target_os = "windows")] { "windows" } - #[cfg(target_os = "linux")] { "linux" } - #[cfg(target_os = "macos")] { "macos" } - #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] { "other" } - }; - let arch = { - #[cfg(target_arch = "x86_64")] { "x86_64" } - #[cfg(target_arch = "aarch64")] { "aarch64" } - #[cfg(target_arch = "x86")] { "x86" } - #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "x86")))] { "other" } - }; - (os, arch) -} - -fn match_linux_assets(assets: &[GhAsset]) -> (Option<&GhAsset>, Option<&GhAsset>) { - // 返回 (AppImage, Deb) - let mut appimage: Option<&GhAsset> = None; - let mut deb: Option<&GhAsset> = None; - for a in assets { - let n = a.name.to_ascii_lowercase(); - // 排除 ARM 相关 - let is_arm = n.contains("arm64") || n.contains("aarch64") || n.contains("armv7"); - if is_arm { continue; } - if n.ends_with(".appimage") && (n.contains("x86_64") || n.contains("amd64")) { - if appimage.is_none() { appimage = Some(a); } - } else if n.ends_with(".deb") && (n.contains("x86_64") || n.contains("amd64")) { - if deb.is_none() { deb = Some(a); } - } - } - (appimage, deb) -} - -fn match_windows_asset(assets: &[GhAsset]) -> Option<&GhAsset> { - for a in assets { - let n = a.name.to_ascii_lowercase(); - let is_arm = n.contains("arm64") || n.contains("aarch64") || n.contains("armv7"); - if is_arm { continue; } - if (n.ends_with(".exe") || n.ends_with(".msi")) && (n.contains("x64") || n.contains("x86_64") || n.contains("amd64")) { - return Some(a); - } - } - None -} - -#[tauri::command] -async fn check_update(_force: Option, include_prerelease: Option) -> Result { - // 当前版本:与 tauri.conf.json 一致(构建时可由环境注入,这里直接读取 Cargo.toml 同步版本) - let current = env!("CARGO_PKG_VERSION").to_string(); - let (os_tag, _arch_tag) = os_arch_tag(); - - // 节流留空:简单实现始终请求(前端可决定调用频率) - - let url = "https://api.github.com/repos/flyhunterl/flymd/releases"; - let client = reqwest::Client::builder() - .user_agent("flymd-updater") - .build() - .map_err(|e| format!("build client error: {e}"))?; - let resp = client - .get(url) - .header("Accept", "application/vnd.github+json") - .send().await.map_err(|e| format!("request error: {e}"))?; - if !resp.status().is_success() { return Err(format!("http status {}", resp.status())); } - let releases: Vec = resp.json().await.map_err(|e| format!("json error: {e}"))?; - let include_pre = include_prerelease.unwrap_or(false); - let latest = releases.into_iter().find(|r| !r.draft && (include_pre || !r.prerelease)) - .ok_or_else(|| "no release found".to_string())?; - - let latest_tag = latest.tag_name.trim().to_string(); - let n_cur = norm_ver(¤t); - let n_new = norm_ver(&latest_tag); - let has_update = is_better(&n_new, &n_cur); - - // 组装资产信息 - let mut asset_win = None; - let mut asset_linux_appimage = None; - let mut asset_linux_deb = None; - if os_tag == "windows" { - if let Some(a) = match_windows_asset(&latest.assets) { - asset_win = Some(UpdateAssetInfo{ - name: a.name.clone(), - size: a.size.unwrap_or(0), - direct_url: a.browser_download_url.clone(), - proxy_url: gh_proxy_url(&a.browser_download_url), - }); - } - } else if os_tag == "linux" { - let (ai, deb) = match_linux_assets(&latest.assets); - if let Some(a) = ai { - asset_linux_appimage = Some(UpdateAssetInfo{ - name: a.name.clone(), - size: a.size.unwrap_or(0), - direct_url: a.browser_download_url.clone(), - proxy_url: gh_proxy_url(&a.browser_download_url), - }); - } - if let Some(a) = deb { - asset_linux_deb = Some(UpdateAssetInfo{ - name: a.name.clone(), - size: a.size.unwrap_or(0), - direct_url: a.browser_download_url.clone(), - proxy_url: gh_proxy_url(&a.browser_download_url), - }); - } - } - - let notes = latest.body.unwrap_or_default(); - let name = latest.name.unwrap_or_else(|| latest_tag.clone()); - - Ok(CheckUpdateResp{ - has_update, - current, - latest: latest_tag, - release_name: name, - notes, - html_url: latest.html_url, - asset_win, - asset_linux_appimage, - asset_linux_deb, - }) -} - -#[tauri::command] -#[allow(unused_assignments)] -async fn download_file(url: String, use_proxy: Option) -> Result { - let client = reqwest::Client::builder() - .user_agent("flymd-updater") - .build() - .map_err(|e| format!("build client error: {e}"))?; - - // 解析文件名 - let (direct, proxy) = { - let u = url::Url::parse(&url).map_err(|e| format!("invalid url: {e}"))?; - let fname = u - .path_segments() - .and_then(|mut s| s.next_back()) - .unwrap_or("download.bin") - .to_string(); - // 保存到用户下载目录(不可用时回退到临时目录) - #[cfg(target_os = "windows")] - let base_download = std::env::var("USERPROFILE") - .map(|p| std::path::PathBuf::from(p).join("Downloads")) - .unwrap_or_else(|_| std::env::temp_dir()); - #[cfg(not(target_os = "windows"))] - let base_download = std::env::var("HOME") - .map(|p| std::path::PathBuf::from(p).join("Downloads")) - .unwrap_or_else(|_| std::env::temp_dir()); - let mut path = base_download.clone(); - path.push(&fname); - let direct = (u, path); - let proxy = ( - url::Url::parse(&gh_proxy_url(&url)).map_err(|e| format!("invalid proxy url: {e}"))?, - base_download.join(&fname) - ); - (direct, proxy) - }; - - // 下载函数 - async fn do_fetch(client: &reqwest::Client, url: &url::Url, save: &std::path::Path) -> Result<(), String> { - let resp = client.get(url.clone()).send().await.map_err(|e| format!("request error: {e}"))?; - if !resp.status().is_success() { return Err(format!("http status {}", resp.status())); } - let mut f = std::fs::File::create(save).map_err(|e| format!("create file error: {e}"))?; - let mut stream = resp.bytes_stream(); - use futures_util::StreamExt; - while let Some(chunk) = stream.next().await { - let bytes = chunk.map_err(|e| format!("read chunk error: {e}"))?; - std::io::Write::write_all(&mut f, &bytes).map_err(|e| format!("write error: {e}"))?; - } - Ok(()) - } - - let want_proxy = use_proxy.unwrap_or(false); - let mut last_err: Option = None; - if want_proxy { - if let Err(e) = do_fetch(&client, &proxy.0, &proxy.1).await { last_err = Some(e); } else { return Ok(proxy.1.to_string_lossy().to_string()); } - // 代理失败 -> 尝试直连 - if let Err(e) = do_fetch(&client, &direct.0, &direct.1).await { last_err = Some(e); } else { return Ok(direct.1.to_string_lossy().to_string()); } - } else { - if let Err(e) = do_fetch(&client, &direct.0, &direct.1).await { last_err = Some(e); } else { return Ok(direct.1.to_string_lossy().to_string()); } - // 直连失败 -> 尝试代理 - if let Err(e) = do_fetch(&client, &proxy.0, &proxy.1).await { last_err = Some(e); } else { return Ok(proxy.1.to_string_lossy().to_string()); } - } - Err(last_err.unwrap_or_else(|| "download failed".into())) -} - -#[tauri::command] -async fn read_text_file_any(path: String) -> Result { - use std::fs::File; - use std::io::Read; - use std::path::PathBuf; - - let pathbuf = PathBuf::from(path); - if !pathbuf.exists() { - return Err("path not found".into()); - } - - // 后台线程读取,避免阻塞异步运行时 - let res = tauri::async_runtime::spawn_blocking(move || { - let mut f = File::open(&pathbuf).map_err(|e| format!("open error: {e}"))?; - let mut buf = Vec::new(); - f.read_to_end(&mut buf).map_err(|e| format!("read error: {e}"))?; - let s = String::from_utf8_lossy(&buf).to_string(); - Ok::(s) - }) - .await - .map_err(|e| format!("join error: {e}"))?; - - res -} - -#[tauri::command] -async fn write_text_file_any(path: String, content: String) -> Result<(), String> { - use std::fs; - use std::path::PathBuf; - - let pathbuf = PathBuf::from(path); - // 后台线程写入,避免阻塞异步执行器 - tauri::async_runtime::spawn_blocking(move || { - if let Some(parent) = pathbuf.parent() { - fs::create_dir_all(parent).map_err(|e| format!("create_dir_all error: {e}"))?; - } - fs::write(&pathbuf, content.as_bytes()).map_err(|e| format!("write error: {e}"))?; - Ok::<(), String>(()) - }) - .await - .map_err(|e| format!("join error: {e}"))??; - - Ok(()) -} - -// 前端兜底查询:获取并清空待打开路径,避免事件竞态丢失 -#[tauri::command] -async fn get_pending_open_path(state: State<'_, PendingOpenPath>) -> Result, ()> { - if let Ok(mut slot) = state.0.lock() { - Ok(slot.take()) - } else { - Ok(None) - } -} - -#[tauri::command] -async fn move_to_trash(path: String) -> Result<(), String> { - // 使用 trash crate 跨平台移动到回收站 - tauri::async_runtime::spawn_blocking(move || { - trash::delete(path).map_err(|e| format!("move_to_trash error: {e}")) - }) - .await - .map_err(|e| format!("join error: {e}"))??; - Ok(()) -} - -#[tauri::command] -async fn force_remove_path(path: String) -> Result<(), String> { - use std::fs; - use std::path::PathBuf; - let pathbuf = PathBuf::from(path); - tauri::async_runtime::spawn_blocking(move || { - if pathbuf.is_dir() { - fs::remove_dir_all(&pathbuf).map_err(|e| format!("remove_dir_all error: {e}")) - } else { - fs::remove_file(&pathbuf).map_err(|e| format!("remove_file error: {e}")) - } - }) - .await - .map_err(|e| format!("join error: {e}"))??; - Ok(()) -} - -#[tauri::command] -async fn run_installer(path: String) -> Result<(), String> { - #[cfg(target_os = "windows")] - { - use std::process::Command; - // 使用 PowerShell 以管理员权限启动安装程序 - let status = Command::new("powershell") - .args([ - "-NoProfile", - "-Command", - "Start-Process", - "-FilePath", - &path, - "-Verb", - "runas", - ]) - .status() - .map_err(|e| format!("spawn error: {e}"))?; - let _ = status; // 忽略返回码,由安装器自行处理 - Ok(()) - } - #[cfg(not(target_os = "windows"))] - { - Err("run_installer only supports Windows".into()) - } -} +} + +#[tauri::command] +async fn s3_get_object_to_path(req: S3GetReq) -> Result<(), String> { + use aws_sdk_s3 as s3; + use aws_config::meta::region::RegionProviderChain; + use s3::config::Region; + + let region_str = req.conn.region.clone().unwrap_or_else(|| "us-east-1".to_string()); + let region = Region::new(region_str.clone()); + let region_provider = RegionProviderChain::first_try(region.clone()); + let base_conf = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(region_provider) + .load() + .await; + + let creds = s3::config::Credentials::new( + req.conn.access_key_id.clone(), req.conn.secret_access_key.clone(), None, None, "flymd" + ); + let mut conf_builder = s3::config::Builder::from(&base_conf) + .credentials_provider(creds) + .force_path_style(req.conn.force_path_style); + if let Some(ep) = &req.conn.endpoint { if !ep.trim().is_empty() { conf_builder = conf_builder.endpoint_url(ep.trim()); } } + let conf = conf_builder.build(); + let client = s3::Client::from_conf(conf); + + let resp = client + .get_object() + .bucket(&req.conn.bucket) + .key(&req.key) + .send() + .await + .map_err(|e| format!("get_object error: {e}"))?; + + { + use std::path::Path; + use tokio::fs; + if let Some(parent) = Path::new(&req.dest_path).parent() { fs::create_dir_all(parent).await.map_err(|e| format!("create_dir_all error: {e}"))?; } + } + let bytes = resp.body.collect().await.map_err(|e| format!("collect error: {e}"))?; + tokio::fs::write(&req.dest_path, &bytes.into_bytes()).await.map_err(|e| format!("write error: {e}"))?; + Ok(()) +} +// ===== CODEX SYNC S3 END ===== + +fn main() { + let mut builder = tauri::Builder::default() + .manage(PendingOpenPath::default()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_store::Builder::default().build()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_http::init()); + + #[cfg(not(target_os = "android"))] + { + builder = builder.plugin(tauri_plugin_window_state::Builder::default().build()); + } + + builder + + .invoke_handler(tauri::generate_handler![upload_to_s3, presign_put, move_to_trash, force_remove_path, read_text_file_any, write_text_file_any, get_pending_open_path, http_xmlrpc_post, check_update, download_file, run_installer, s3_list_objects, s3_get_object_to_path, s3_delete_object]) + .setup(|app| { + // Windows "打开方式/默认程序" 传入的文件参数处理 + #[cfg(target_os = "windows")] + { + use std::env; + use std::path::PathBuf; + use std::time::Duration; + if let Some(win) = app.get_webview_window("main") { + let args: Vec = env::args_os().skip(1).map(PathBuf::from).collect(); + if let Some(p) = args.into_iter().find(|p| { + if !p.exists() { return false; } + match p.extension().and_then(|s| s.to_str()).map(|s| s.to_ascii_lowercase()) { + Some(ext) => ext == "md" || ext == "markdown" || ext == "txt" || ext == "pdf", + None => false, + } + }) { + // 延迟发送事件,确保渲染侧事件监听已注册 + let win_clone = win.clone(); + let path = p.to_string_lossy().to_string(); + // 同时把路径写入共享状态,前端可在启动后主动拉取 + if let Some(state) = app.try_state::() { + if let Ok(mut slot) = state.0.lock() { *slot = Some(path.clone()); } + } + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(500)); + let _ = win_clone.emit("open-file", path); + let _ = win_clone.set_focus(); + }); + } + } + } + // 其它初始化逻辑 + if let Some(win) = app.get_webview_window("main") { + #[cfg(target_os = "windows")] + { + let win_clone = win.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(120)); + let _ = win_clone.show(); + let _ = win_clone.set_focus(); + }); + } + #[cfg(not(target_os = "windows"))] + { + let _ = win.show(); + let _ = win.set_focus(); + } + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UpdateAssetInfo { + name: String, + size: u64, + direct_url: String, + proxy_url: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CheckUpdateResp { + has_update: bool, + current: String, + latest: String, + release_name: String, + notes: String, + html_url: String, + // Windows 推荐资产 + asset_win: Option, + // Linux 双资产 + asset_linux_appimage: Option, + asset_linux_deb: Option, +} + +fn norm_ver(v: &str) -> (i64, i64, i64, i64) { + // 版本比较:major.minor.patch + 权重(fix>无后缀>预发行) + let s = v.trim().trim_start_matches('v'); + let mut parts = s.splitn(2, '-'); + let core = parts.next().unwrap_or(""); + let suffix = parts.next().unwrap_or("").to_ascii_lowercase(); + let mut nums = core.split('.').take(3).map(|x| x.parse::().unwrap_or(0)).collect::>(); + while nums.len() < 3 { nums.push(0); } + let weight = if suffix.starts_with("fix") { 2 } else if suffix.is_empty() { 1 } else { 0 }; + (nums[0], nums[1], nums[2], weight) +} + +fn is_better(a: &(i64,i64,i64,i64), b: &(i64,i64,i64,i64)) -> bool { + // a > b ? + a.0 > b.0 || (a.0==b.0 && (a.1 > b.1 || (a.1==b.1 && (a.2 > b.2 || (a.2==b.2 && a.3 > b.3))))) +} + +#[derive(Debug, Deserialize)] +struct GhAsset { + name: String, + browser_download_url: String, + size: Option, + #[allow(dead_code)] + content_type: Option, +} +#[derive(Debug, Deserialize)] +struct GhRelease { + tag_name: String, + name: Option, + body: Option, + draft: bool, + prerelease: bool, + html_url: String, + assets: Vec, +} + +fn gh_proxy_url(raw: &str) -> String { + // 代理前缀:按“https://gh-proxy.comb/原始URL”拼接 + let prefix = "https://gh-proxy.com/"; + if raw.starts_with(prefix) { raw.to_string() } else { format!("{}{}", prefix, raw) } +} + +fn os_arch_tag() -> (&'static str, &'static str) { + let os = { + #[cfg(target_os = "windows")] { "windows" } + #[cfg(target_os = "linux")] { "linux" } + #[cfg(target_os = "macos")] { "macos" } + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] { "other" } + }; + let arch = { + #[cfg(target_arch = "x86_64")] { "x86_64" } + #[cfg(target_arch = "aarch64")] { "aarch64" } + #[cfg(target_arch = "x86")] { "x86" } + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "x86")))] { "other" } + }; + (os, arch) +} + +fn match_linux_assets(assets: &[GhAsset]) -> (Option<&GhAsset>, Option<&GhAsset>) { + // 返回 (AppImage, Deb) + let mut appimage: Option<&GhAsset> = None; + let mut deb: Option<&GhAsset> = None; + for a in assets { + let n = a.name.to_ascii_lowercase(); + // 排除 ARM 相关 + let is_arm = n.contains("arm64") || n.contains("aarch64") || n.contains("armv7"); + if is_arm { continue; } + if n.ends_with(".appimage") && (n.contains("x86_64") || n.contains("amd64")) { + if appimage.is_none() { appimage = Some(a); } + } else if n.ends_with(".deb") && (n.contains("x86_64") || n.contains("amd64")) { + if deb.is_none() { deb = Some(a); } + } + } + (appimage, deb) +} + +fn match_windows_asset(assets: &[GhAsset]) -> Option<&GhAsset> { + for a in assets { + let n = a.name.to_ascii_lowercase(); + let is_arm = n.contains("arm64") || n.contains("aarch64") || n.contains("armv7"); + if is_arm { continue; } + if (n.ends_with(".exe") || n.ends_with(".msi")) && (n.contains("x64") || n.contains("x86_64") || n.contains("amd64")) { + return Some(a); + } + } + None +} + +#[tauri::command] +async fn check_update(_force: Option, include_prerelease: Option) -> Result { + // 当前版本:与 tauri.conf.json 一致(构建时可由环境注入,这里直接读取 Cargo.toml 同步版本) + let current = env!("CARGO_PKG_VERSION").to_string(); + let (os_tag, _arch_tag) = os_arch_tag(); + + // 节流留空:简单实现始终请求(前端可决定调用频率) + + let url = "https://api.github.com/repos/flyhunterl/flymd/releases"; + let client = reqwest::Client::builder() + .user_agent("flymd-updater") + .build() + .map_err(|e| format!("build client error: {e}"))?; + let resp = client + .get(url) + .header("Accept", "application/vnd.github+json") + .send().await.map_err(|e| format!("request error: {e}"))?; + if !resp.status().is_success() { return Err(format!("http status {}", resp.status())); } + let releases: Vec = resp.json().await.map_err(|e| format!("json error: {e}"))?; + let include_pre = include_prerelease.unwrap_or(false); + let latest = releases.into_iter().find(|r| !r.draft && (include_pre || !r.prerelease)) + .ok_or_else(|| "no release found".to_string())?; + + let latest_tag = latest.tag_name.trim().to_string(); + let n_cur = norm_ver(¤t); + let n_new = norm_ver(&latest_tag); + let has_update = is_better(&n_new, &n_cur); + + // 组装资产信息 + let mut asset_win = None; + let mut asset_linux_appimage = None; + let mut asset_linux_deb = None; + if os_tag == "windows" { + if let Some(a) = match_windows_asset(&latest.assets) { + asset_win = Some(UpdateAssetInfo{ + name: a.name.clone(), + size: a.size.unwrap_or(0), + direct_url: a.browser_download_url.clone(), + proxy_url: gh_proxy_url(&a.browser_download_url), + }); + } + } else if os_tag == "linux" { + let (ai, deb) = match_linux_assets(&latest.assets); + if let Some(a) = ai { + asset_linux_appimage = Some(UpdateAssetInfo{ + name: a.name.clone(), + size: a.size.unwrap_or(0), + direct_url: a.browser_download_url.clone(), + proxy_url: gh_proxy_url(&a.browser_download_url), + }); + } + if let Some(a) = deb { + asset_linux_deb = Some(UpdateAssetInfo{ + name: a.name.clone(), + size: a.size.unwrap_or(0), + direct_url: a.browser_download_url.clone(), + proxy_url: gh_proxy_url(&a.browser_download_url), + }); + } + } + + let notes = latest.body.unwrap_or_default(); + let name = latest.name.unwrap_or_else(|| latest_tag.clone()); + + Ok(CheckUpdateResp{ + has_update, + current, + latest: latest_tag, + release_name: name, + notes, + html_url: latest.html_url, + asset_win, + asset_linux_appimage, + asset_linux_deb, + }) +} + +#[tauri::command] +#[allow(unused_assignments)] +async fn download_file(url: String, use_proxy: Option) -> Result { + let client = reqwest::Client::builder() + .user_agent("flymd-updater") + .build() + .map_err(|e| format!("build client error: {e}"))?; + + // 解析文件名 + let (direct, proxy) = { + let u = url::Url::parse(&url).map_err(|e| format!("invalid url: {e}"))?; + let fname = u + .path_segments() + .and_then(|mut s| s.next_back()) + .unwrap_or("download.bin") + .to_string(); + // 保存到用户下载目录(不可用时回退到临时目录) + #[cfg(target_os = "windows")] + let base_download = std::env::var("USERPROFILE") + .map(|p| std::path::PathBuf::from(p).join("Downloads")) + .unwrap_or_else(|_| std::env::temp_dir()); + #[cfg(not(target_os = "windows"))] + let base_download = std::env::var("HOME") + .map(|p| std::path::PathBuf::from(p).join("Downloads")) + .unwrap_or_else(|_| std::env::temp_dir()); + let mut path = base_download.clone(); + path.push(&fname); + let direct = (u, path); + let proxy = ( + url::Url::parse(&gh_proxy_url(&url)).map_err(|e| format!("invalid proxy url: {e}"))?, + base_download.join(&fname) + ); + (direct, proxy) + }; + + // 下载函数 + async fn do_fetch(client: &reqwest::Client, url: &url::Url, save: &std::path::Path) -> Result<(), String> { + let resp = client.get(url.clone()).send().await.map_err(|e| format!("request error: {e}"))?; + if !resp.status().is_success() { return Err(format!("http status {}", resp.status())); } + let mut f = std::fs::File::create(save).map_err(|e| format!("create file error: {e}"))?; + let mut stream = resp.bytes_stream(); + use futures_util::StreamExt; + while let Some(chunk) = stream.next().await { + let bytes = chunk.map_err(|e| format!("read chunk error: {e}"))?; + std::io::Write::write_all(&mut f, &bytes).map_err(|e| format!("write error: {e}"))?; + } + Ok(()) + } + + let want_proxy = use_proxy.unwrap_or(false); + let mut last_err: Option = None; + if want_proxy { + if let Err(e) = do_fetch(&client, &proxy.0, &proxy.1).await { last_err = Some(e); } else { return Ok(proxy.1.to_string_lossy().to_string()); } + // 代理失败 -> 尝试直连 + if let Err(e) = do_fetch(&client, &direct.0, &direct.1).await { last_err = Some(e); } else { return Ok(direct.1.to_string_lossy().to_string()); } + } else { + if let Err(e) = do_fetch(&client, &direct.0, &direct.1).await { last_err = Some(e); } else { return Ok(direct.1.to_string_lossy().to_string()); } + // 直连失败 -> 尝试代理 + if let Err(e) = do_fetch(&client, &proxy.0, &proxy.1).await { last_err = Some(e); } else { return Ok(proxy.1.to_string_lossy().to_string()); } + } + Err(last_err.unwrap_or_else(|| "download failed".into())) +} + +#[tauri::command] +async fn read_text_file_any(path: String) -> Result { + use std::fs::File; + use std::io::Read; + use std::path::PathBuf; + + let pathbuf = PathBuf::from(path); + if !pathbuf.exists() { + return Err("path not found".into()); + } + + // 后台线程读取,避免阻塞异步运行时 + let res = tauri::async_runtime::spawn_blocking(move || { + let mut f = File::open(&pathbuf).map_err(|e| format!("open error: {e}"))?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf).map_err(|e| format!("read error: {e}"))?; + let s = String::from_utf8_lossy(&buf).to_string(); + Ok::(s) + }) + .await + .map_err(|e| format!("join error: {e}"))?; + + res +} + +#[tauri::command] +async fn write_text_file_any(path: String, content: String) -> Result<(), String> { + use std::fs; + use std::path::PathBuf; + + let pathbuf = PathBuf::from(path); + // 后台线程写入,避免阻塞异步执行器 + tauri::async_runtime::spawn_blocking(move || { + if let Some(parent) = pathbuf.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create_dir_all error: {e}"))?; + } + fs::write(&pathbuf, content.as_bytes()).map_err(|e| format!("write error: {e}"))?; + Ok::<(), String>(()) + }) + .await + .map_err(|e| format!("join error: {e}"))??; + + Ok(()) +} + +// 前端兜底查询:获取并清空待打开路径,避免事件竞态丢失 +#[tauri::command] +async fn get_pending_open_path(state: State<'_, PendingOpenPath>) -> Result, ()> { + if let Ok(mut slot) = state.0.lock() { + Ok(slot.take()) + } else { + Ok(None) + } +} + +#[tauri::command] +async fn move_to_trash(path: String) -> Result<(), String> { + // 使用 trash crate 跨平台移动到回收站 + tauri::async_runtime::spawn_blocking(move || { + trash::delete(path).map_err(|e| format!("move_to_trash error: {e}")) + }) + .await + .map_err(|e| format!("join error: {e}"))??; + Ok(()) +} + +#[tauri::command] +async fn force_remove_path(path: String) -> Result<(), String> { + use std::fs; + use std::path::PathBuf; + let pathbuf = PathBuf::from(path); + tauri::async_runtime::spawn_blocking(move || { + if pathbuf.is_dir() { + fs::remove_dir_all(&pathbuf).map_err(|e| format!("remove_dir_all error: {e}")) + } else { + fs::remove_file(&pathbuf).map_err(|e| format!("remove_file error: {e}")) + } + }) + .await + .map_err(|e| format!("join error: {e}"))??; + Ok(()) +} + +#[tauri::command] +async fn run_installer(path: String) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + use std::process::Command; + // 使用 PowerShell 以管理员权限启动安装程序 + let status = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "Start-Process", + "-FilePath", + &path, + "-Verb", + "runas", + ]) + .status() + .map_err(|e| format!("spawn error: {e}"))?; + let _ = status; // 忽略返回码,由安装器自行处理 + Ok(()) + } + #[cfg(not(target_os = "windows"))] + { + Err("run_installer only supports Windows".into()) + } +} +