fix(android): persist prefs/logs under app filesDir (#877)
* fix(android): use app filesDir for persistence and SAF log export Honor devices reject writes under /data/local/tmp, which caused PreferencesStore to fall back to openless_prefs_fallback.json and settings saves to fail. Initialize JNI storage roots before Coordinator and file logging, route logs to files/logs/openless.log, and export via ContentResolver for SAF content:// URIs. * fix(android): guard StylePack empty asset_root and Android-only coord logs Reject persisting style pack icons when asset_root is empty to avoid writing under cwd; no-op cleanup on empty root. Limit Coordinator persistence degrade log suffix to Android only. Add unit test. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
761e6c5c80
commit
7ad24d3e94
22 files changed
+488
-83
No files matched your search
@@ -101,12 +101,14 @@ object OpenLessAndroidPreferences {
|
||||
|
||||
private fun preferenceFiles(context: Context): List<File> {
|
||||
val files = mutableListOf<File>()
|
||||
// Prefer app-private filesDir (Rust data_dir); never rely on /data/local/tmp.
|
||||
files += File(File(context.filesDir, APP_DIR), PREFERENCES_FILE)
|
||||
val envDir = System.getenv("TAURI_ANDROID_APP_DATA_DIR")
|
||||
if (!envDir.isNullOrBlank()) {
|
||||
files += File(File(envDir), APP_DIR).resolve(PREFERENCES_FILE)
|
||||
}
|
||||
// Legacy probe only — some older builds may have written under cacheDir.
|
||||
files += File(File(context.cacheDir, APP_DIR), PREFERENCES_FILE)
|
||||
files += File(File(context.filesDir, APP_DIR), PREFERENCES_FILE)
|
||||
return files
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.openless.app
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.annotation.Keep
|
||||
|
||||
/**
|
||||
* Writes bytes to a SAF content:// URI via ContentResolver.
|
||||
*
|
||||
* Prefer this over tauri-plugin-fs for exports: fs detaches the FD early and
|
||||
* some providers finalize a 0-byte file before Rust finishes writing.
|
||||
*/
|
||||
@Keep
|
||||
object OpenLessContentWriter {
|
||||
private const val TAG = "OpenLessContentWriter"
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
fun writeBytes(context: Context, uriString: String, bytes: ByteArray): Boolean {
|
||||
return try {
|
||||
val uri = Uri.parse(uriString)
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
output.write(bytes)
|
||||
output.flush()
|
||||
} ?: run {
|
||||
Log.w(TAG, "openOutputStream returned null for $uriString")
|
||||
return false
|
||||
}
|
||||
Log.i(TAG, "wrote ${bytes.size} bytes to $uriString")
|
||||
true
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "failed to write $uriString", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+4
-30
@@ -14,7 +14,7 @@
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
@@ -2233,9 +2233,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
|
||||
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
@@ -6237,7 +6237,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6254,7 +6253,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6271,7 +6269,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6288,7 +6285,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6305,7 +6301,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6322,7 +6317,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6339,7 +6333,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6356,7 +6349,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6373,7 +6365,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6390,7 +6381,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6407,7 +6397,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6424,7 +6413,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6441,7 +6429,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6458,7 +6445,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6475,7 +6461,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6492,7 +6477,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6509,7 +6493,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6526,7 +6509,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6543,7 +6525,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6560,7 +6541,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6577,7 +6557,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6594,7 +6573,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6611,7 +6589,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6628,7 +6605,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6645,7 +6621,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6662,7 +6637,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -34,6 +34,7 @@ const KOTLIN_FILES = [
|
||||
'OpenLessAccessibilityCommandReceiver.kt',
|
||||
'OverlayPermissionActivity.kt',
|
||||
'OpenLessUpdateInstaller.kt',
|
||||
'OpenLessContentWriter.kt',
|
||||
];
|
||||
|
||||
const KOTLIN_TEST_FILES = ['OpenLessCredentialCipherTest.kt'];
|
||||
|
||||
Generated
+2
-2
@@ -5868,9 +5868,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
|
||||
@@ -23,7 +23,7 @@ cc = "1.1"
|
||||
tauri = { version = "~2.11", features = ["macos-private-api"] }
|
||||
# 锁 >=2.3.5 修复 GHSA-c9pr-q8gx-3mgp:tauri-plugin-shell `open` 端点作用域校验绕过(CRITICAL,issue #668)。
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。
|
||||
|
||||
@@ -925,4 +925,36 @@ pub mod android {
|
||||
&[JValue::Object(context), JValue::Object(path_obj)],
|
||||
)
|
||||
}
|
||||
|
||||
/// Write `bytes` to a SAF `content://` URI via Kotlin ContentResolver.
|
||||
pub fn write_content_uri(uri: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
with_android_env(|env, context| {
|
||||
let class = load_context_class(env, context, "com.openless.app.OpenLessContentWriter")?;
|
||||
let uri_obj = jobject_str(env, uri)?;
|
||||
let bytes_array = env
|
||||
.byte_array_from_slice(bytes)
|
||||
.map_err(|error| format!("create byte array for content URI write: {error}"))?;
|
||||
let bytes_obj = JObject::from(bytes_array);
|
||||
let ok = env
|
||||
.call_static_method(
|
||||
class,
|
||||
"writeBytes",
|
||||
"(Landroid/content/Context;Ljava/lang/String;[B)Z",
|
||||
&[
|
||||
JValue::Object(context),
|
||||
JValue::Object(&uri_obj),
|
||||
JValue::Object(&bytes_obj),
|
||||
],
|
||||
)
|
||||
.and_then(|value| value.z())
|
||||
.map_err(|error| {
|
||||
format!("call OpenLessContentWriter.writeBytes: {error}")
|
||||
})?;
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("写入 content URI 失败:{uri}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -156,15 +156,53 @@ pub async fn stop_microphone_level_monitor(app: AppHandle) {
|
||||
/// 把当前会话的 openless.log 复制到用户选择的位置(前端用 plugin-dialog 拿 target_path)。
|
||||
/// 路径来自 lib::log_dir_path() —— mac: ~/Library/Logs/OpenLess/openless.log,
|
||||
/// windows: %LOCALAPPDATA%\OpenLess\Logs\openless.log。
|
||||
///
|
||||
/// Android 上 dialog 返回 `content://` URI,不能用 `std::fs::copy`;走 JNI
|
||||
/// ContentResolver 写入,避免 tauri-plugin-fs detachFd 导致 0 字节文件。
|
||||
#[tauri::command]
|
||||
pub fn export_error_log(target_path: String) -> Result<(), String> {
|
||||
let src = crate::log_dir_path().join("openless.log");
|
||||
if !src.exists() {
|
||||
return Err(format!("日志文件不存在:{}", src.display()));
|
||||
let src = resolve_openless_log_path()?;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if target_path.starts_with("content://") {
|
||||
let bytes = std::fs::read(&src).map_err(|e| format!("读取日志失败:{e}"))?;
|
||||
return crate::android::jni::android::write_content_uri(&target_path, &bytes)
|
||||
.map_err(|e| format!("复制日志失败:{e}"));
|
||||
}
|
||||
let path = target_path
|
||||
.strip_prefix("file://")
|
||||
.unwrap_or(target_path.as_str());
|
||||
return std::fs::copy(&src, std::path::Path::new(path))
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("复制日志失败:{e}"));
|
||||
}
|
||||
std::fs::copy(&src, std::path::Path::new(&target_path))
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("复制日志失败:{}", e))
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
std::fs::copy(&src, std::path::Path::new(&target_path))
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("复制日志失败:{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_openless_log_path() -> Result<std::path::PathBuf, String> {
|
||||
let mut candidates = Vec::new();
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
candidates.extend(crate::persistence::android_openless_log_candidates());
|
||||
}
|
||||
candidates.push(crate::log_dir_path().join("openless.log"));
|
||||
|
||||
if let Some(src) = candidates.iter().find(|path| path.exists()) {
|
||||
return Ok(src.clone());
|
||||
}
|
||||
let tried = candidates
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(format!("日志文件不存在(已尝试:{tried})"))
|
||||
}
|
||||
|
||||
// ─────────────────────────── unused but exported (silences dead_code) ───────────────────────────
|
||||
|
||||
@@ -642,24 +642,39 @@ impl Coordinator {
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
#[cfg(target_os = "android")]
|
||||
const PERSIST_DEGRADE_SUFFIX: &str = " (Android 禁止 /data/local/tmp)";
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const PERSIST_DEGRADE_SUFFIX: &str = "";
|
||||
|
||||
let history = HistoryStore::new().unwrap_or_else(|e| {
|
||||
log::error!("[coord] HistoryStore init failed: {e}; 降级为空历史记录");
|
||||
log::error!(
|
||||
"[coord] HistoryStore init failed: {e}; 降级为空历史记录{PERSIST_DEGRADE_SUFFIX}"
|
||||
);
|
||||
HistoryStore::new_fallback()
|
||||
});
|
||||
let prefs = PreferencesStore::new().unwrap_or_else(|e| {
|
||||
log::error!("[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置");
|
||||
log::error!(
|
||||
"[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置{PERSIST_DEGRADE_SUFFIX}"
|
||||
);
|
||||
PreferencesStore::new_fallback()
|
||||
});
|
||||
let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| {
|
||||
log::error!("[coord] StylePackStore init failed: {e}; 降级为空样式包列表");
|
||||
log::error!(
|
||||
"[coord] StylePackStore init failed: {e}; 降级为空样式包列表{PERSIST_DEGRADE_SUFFIX}"
|
||||
);
|
||||
StylePackStore::new_fallback()
|
||||
});
|
||||
let vocab = DictionaryStore::new().unwrap_or_else(|e| {
|
||||
log::error!("[coord] DictionaryStore init failed: {e}; 降级为空词库");
|
||||
log::error!(
|
||||
"[coord] DictionaryStore init failed: {e}; 降级为空词库{PERSIST_DEGRADE_SUFFIX}"
|
||||
);
|
||||
DictionaryStore::new_fallback()
|
||||
});
|
||||
let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| {
|
||||
log::error!("[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则");
|
||||
log::error!(
|
||||
"[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则{PERSIST_DEGRADE_SUFFIX}"
|
||||
);
|
||||
CorrectionRuleStore::new_fallback()
|
||||
});
|
||||
|
||||
|
||||
@@ -1279,13 +1279,18 @@ fn reset_tcc_service_for_restart(service: &str, reason: &str) {
|
||||
}
|
||||
|
||||
/// 把日志同时写到 stderr + ~/Library/Logs/OpenLess/openless.log(match Swift `Log.swift`)。
|
||||
fn init_file_logger() {
|
||||
pub(crate) fn init_file_logger() {
|
||||
use simplelog::{
|
||||
ColorChoice, CombinedLogger, ConfigBuilder, LevelFilter, TermLogger, TerminalMode,
|
||||
WriteLogger,
|
||||
};
|
||||
let log_dir = log_dir_path();
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
if let Err(e) = std::fs::create_dir_all(&log_dir) {
|
||||
eprintln!(
|
||||
"[logger] WARN create log dir failed path={}: {e}",
|
||||
log_dir.display()
|
||||
);
|
||||
}
|
||||
let log_file = log_dir.join("openless.log");
|
||||
if let Err(e) = rotate_log_if_too_large(&log_file) {
|
||||
eprintln!("[logger] WARN 日志轮转失败: {e}");
|
||||
@@ -1297,12 +1302,21 @@ fn init_file_logger() {
|
||||
TerminalMode::Mixed,
|
||||
ColorChoice::Auto,
|
||||
)];
|
||||
if let Ok(file) = std::fs::OpenOptions::new()
|
||||
match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_file)
|
||||
{
|
||||
loggers.push(WriteLogger::new(LevelFilter::Info, config, file));
|
||||
Ok(file) => {
|
||||
loggers.push(WriteLogger::new(LevelFilter::Info, config, file));
|
||||
eprintln!("[logger] file logger ready path={}", log_file.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[logger] ERROR open log file failed path={}: {e}",
|
||||
log_file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = CombinedLogger::init(loggers);
|
||||
}
|
||||
@@ -1354,11 +1368,17 @@ pub fn log_dir_path() -> std::path::PathBuf {
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") {
|
||||
return std::path::PathBuf::from(dir).join("logs");
|
||||
// Prefer cached JNI filesDir/logs; never use /data/local/tmp.
|
||||
if let Ok(dir) = crate::persistence::android_log_dir() {
|
||||
return dir;
|
||||
}
|
||||
eprintln!("[logger] ERROR android_log_dir unavailable; file logging disabled");
|
||||
return std::path::PathBuf::from("/__openless_android_log_uninitialized__");
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
std::env::temp_dir().join("OpenLess")
|
||||
}
|
||||
std::env::temp_dir().join("OpenLess")
|
||||
}
|
||||
|
||||
pub(crate) fn show_main_window<R: Runtime>(app: &AppHandle<R>) {
|
||||
|
||||
@@ -7,17 +7,24 @@ use tauri::{AppHandle, Manager, RunEvent};
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
pub fn run() {
|
||||
let coordinator = Arc::new(Coordinator::new());
|
||||
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init());
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
let builder = builder.plugin(tauri_plugin_fs::init());
|
||||
|
||||
// Coordinator is created inside setup (after Android storage roots are ready).
|
||||
// Managing state in setup is supported by Tauri 2 and avoids constructing
|
||||
// PreferencesStore against /data/local/tmp before JNI Context exists.
|
||||
builder
|
||||
.manage(coordinator.clone())
|
||||
.setup(move |app| {
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(error) = crate::persistence::init_android_storage_roots() {
|
||||
eprintln!("[android-storage] ERROR init failed: {error:#}");
|
||||
}
|
||||
}
|
||||
|
||||
crate::init_file_logger();
|
||||
log::info!("=== OpenLess mobile 启动 ===");
|
||||
initialize_android_ndk_context_for_audio();
|
||||
@@ -29,6 +36,8 @@ pub fn run() {
|
||||
let _ = qa.hide();
|
||||
}
|
||||
|
||||
let coordinator = Arc::new(Coordinator::new());
|
||||
app.manage(coordinator.clone());
|
||||
coordinator.bind_app(app.handle().clone());
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
@@ -42,8 +51,9 @@ pub fn run() {
|
||||
.expect("error while building tauri mobile application")
|
||||
.run(|app, event| match event {
|
||||
RunEvent::Exit => {
|
||||
let coordinator = app.state::<Arc<Coordinator>>();
|
||||
coordinator.stop_hotkey_listener();
|
||||
if let Some(coordinator) = app.try_state::<Arc<Coordinator>>() {
|
||||
coordinator.stop_hotkey_listener();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Android app-private storage roots.
|
||||
//!
|
||||
//! Honor / Huawei devices reject writes under `/data/local/tmp`. Persistence and
|
||||
//! file logging must use `Context.getFilesDir()` (or Tauri's
|
||||
//! `TAURI_ANDROID_APP_DATA_DIR`) and never fall back to `std::env::temp_dir()`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
static ANDROID_APP_FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
static ANDROID_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
static ANDROID_LOG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
const INIT_ATTEMPTS: usize = 5;
|
||||
const INIT_RETRY_DELAY: Duration = Duration::from_millis(40);
|
||||
|
||||
/// Pure resolution used by runtime and unit tests. Prefers JNI `filesDir`, then env.
|
||||
/// Never returns a temp-dir path.
|
||||
pub(crate) fn resolve_android_app_files_dir(
|
||||
jni_path: Option<&str>,
|
||||
env_path: Option<&str>,
|
||||
) -> Result<PathBuf> {
|
||||
if let Some(path) = jni_path.map(str::trim).filter(|p| !p.is_empty()) {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
if let Some(path) = env_path.map(str::trim).filter(|p| !p.is_empty()) {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
bail!("Android app files dir unavailable (JNI getFilesDir + TAURI_ANDROID_APP_DATA_DIR)")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn probe_jni_files_dir() -> Option<String> {
|
||||
crate::android::jni::android::app_files_dir().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn probe_jni_files_dir() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn env_app_data_dir() -> Option<String> {
|
||||
std::env::var("TAURI_ANDROID_APP_DATA_DIR").ok()
|
||||
}
|
||||
|
||||
/// Resolve and cache `filesDir`, `{filesDir}/OpenLess`, and `{filesDir}/logs`.
|
||||
/// Safe to call multiple times; subsequent calls are no-ops once initialized.
|
||||
pub fn init_android_storage_roots() -> Result<()> {
|
||||
if ANDROID_DATA_DIR.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut last_err = None;
|
||||
let mut files_dir = None;
|
||||
for attempt in 0..INIT_ATTEMPTS {
|
||||
match resolve_android_app_files_dir(
|
||||
probe_jni_files_dir().as_deref(),
|
||||
env_app_data_dir().as_deref(),
|
||||
) {
|
||||
Ok(path) => {
|
||||
files_dir = Some(path);
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
last_err = Some(error);
|
||||
if attempt + 1 < INIT_ATTEMPTS {
|
||||
thread::sleep(INIT_RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let files_dir = files_dir.ok_or_else(|| {
|
||||
last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!("Android app files dir unavailable after {INIT_ATTEMPTS} attempts")
|
||||
})
|
||||
})?;
|
||||
|
||||
let data_dir = files_dir.join("OpenLess");
|
||||
let log_dir = files_dir.join("logs");
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.with_context(|| format!("create Android data dir {}", data_dir.display()))?;
|
||||
std::fs::create_dir_all(&log_dir)
|
||||
.with_context(|| format!("create Android log dir {}", log_dir.display()))?;
|
||||
|
||||
let _ = ANDROID_APP_FILES_DIR.set(files_dir.clone());
|
||||
let _ = ANDROID_DATA_DIR.set(data_dir.clone());
|
||||
let _ = ANDROID_LOG_DIR.set(log_dir.clone());
|
||||
|
||||
eprintln!(
|
||||
"[android-storage] roots ready filesDir={} dataDir={} logDir={}",
|
||||
files_dir.display(),
|
||||
data_dir.display(),
|
||||
log_dir.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn android_app_files_dir() -> Result<PathBuf> {
|
||||
if let Some(dir) = ANDROID_APP_FILES_DIR.get() {
|
||||
return Ok(dir.clone());
|
||||
}
|
||||
let path = resolve_android_app_files_dir(
|
||||
probe_jni_files_dir().as_deref(),
|
||||
env_app_data_dir().as_deref(),
|
||||
)?;
|
||||
let _ = ANDROID_APP_FILES_DIR.set(path.clone());
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(crate) fn android_data_dir() -> Result<PathBuf> {
|
||||
if let Some(dir) = ANDROID_DATA_DIR.get() {
|
||||
return Ok(dir.clone());
|
||||
}
|
||||
let files = android_app_files_dir()?;
|
||||
let data = files.join("OpenLess");
|
||||
std::fs::create_dir_all(&data)
|
||||
.with_context(|| format!("create Android data dir {}", data.display()))?;
|
||||
let _ = ANDROID_DATA_DIR.set(data.clone());
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub(crate) fn android_log_dir() -> Result<PathBuf> {
|
||||
if let Some(dir) = ANDROID_LOG_DIR.get() {
|
||||
return Ok(dir.clone());
|
||||
}
|
||||
let files = android_app_files_dir()?;
|
||||
let logs = files.join("logs");
|
||||
std::fs::create_dir_all(&logs)
|
||||
.with_context(|| format!("create Android log dir {}", logs.display()))?;
|
||||
let _ = ANDROID_LOG_DIR.set(logs.clone());
|
||||
Ok(logs)
|
||||
}
|
||||
|
||||
/// Candidate paths for `openless.log` (export + ADB dump).
|
||||
pub(crate) fn android_openless_log_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Ok(files) = android_app_files_dir() {
|
||||
candidates.push(files.join("logs").join("openless.log"));
|
||||
candidates.push(files.join("openless.log"));
|
||||
if let Some(parent) = files.parent() {
|
||||
candidates.push(parent.join("logs").join("openless.log"));
|
||||
}
|
||||
}
|
||||
if let Ok(logs) = android_log_dir() {
|
||||
let path = logs.join("openless.log");
|
||||
if !candidates.iter().any(|c| c == &path) {
|
||||
candidates.push(path);
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
pub(crate) fn is_memory_only_path(path: &Path) -> bool {
|
||||
path.as_os_str().is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_android_app_files_dir;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn prefers_jni_files_dir_over_env() {
|
||||
let path = resolve_android_app_files_dir(
|
||||
Some("/data/user/0/com.openless.app/files"),
|
||||
Some("/data/local/tmp/wrong"),
|
||||
)
|
||||
.expect("resolve");
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("/data/user/0/com.openless.app/files")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_env_when_jni_missing() {
|
||||
let path =
|
||||
resolve_android_app_files_dir(None, Some("/data/user/0/com.openless.app/files"))
|
||||
.expect("resolve");
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("/data/user/0/com.openless.app/files")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_sources_without_temp_fallback() {
|
||||
let err = resolve_android_app_files_dir(Some(""), Some(" ")).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("unavailable"), "{msg}");
|
||||
assert!(!msg.to_ascii_lowercase().contains("temp"), "{msg}");
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,10 @@ impl CorrectionRuleStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。
|
||||
/// 降级实例:data_dir 不可用时使用临时路径(桌面)或空 path(Android 内存态)。
|
||||
pub(crate) fn new_fallback() -> Self {
|
||||
Self {
|
||||
path: std::env::temp_dir().join("openless_correction_rules_fallback.json"),
|
||||
path: super::fallback_store_path("openless_correction_rules_fallback.json"),
|
||||
lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@ impl DictionaryStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。
|
||||
/// 降级实例:data_dir 不可用时使用临时路径(桌面)或空 path(Android 内存态)。
|
||||
pub(crate) fn new_fallback() -> Self {
|
||||
Self {
|
||||
path: std::env::temp_dir().join("openless_vocab_fallback.json"),
|
||||
path: super::fallback_store_path("openless_vocab_fallback.json"),
|
||||
lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,11 @@ impl HistoryStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// 在 data_dir 不可用时构造一个降级实例(指向临时目录)。
|
||||
/// 该实例在运行期间读写会安静地失败或返回空,不会 panic,
|
||||
/// 也不会影响正常启动路径。
|
||||
/// 在 data_dir 不可用时构造一个降级实例。
|
||||
/// Android 使用空 path(内存态),禁止落 `/data/local/tmp`。
|
||||
pub(crate) fn new_fallback() -> Self {
|
||||
Self {
|
||||
path: std::env::temp_dir().join("openless_history_fallback.json"),
|
||||
path: super::fallback_store_path("openless_history_fallback.json"),
|
||||
lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! - macOS: `~/Library/Application Support/OpenLess`
|
||||
//! - Windows: `%APPDATA%\OpenLess`
|
||||
//! - Linux: `$XDG_DATA_HOME/OpenLess` or `~/.local/share/OpenLess`
|
||||
//! - Android: `{Context.getFilesDir()}/OpenLess` (never `/data/local/tmp`)
|
||||
//!
|
||||
//! Credential storage policy: provider credentials are stored in the OS
|
||||
//! credential vault (macOS Keychain, Windows Credential Manager, Linux keyring).
|
||||
@@ -20,13 +21,15 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod activity;
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
mod android_credentials;
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
mod android_storage;
|
||||
mod correction;
|
||||
mod credentials;
|
||||
mod dictionary;
|
||||
@@ -46,6 +49,13 @@ pub use preferences::*;
|
||||
pub use style_pack::*;
|
||||
pub(crate) use style_pack_archive::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android_storage::init_android_storage_roots;
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) use android_storage::{android_log_dir, android_openless_log_candidates};
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
use android_storage::is_memory_only_path;
|
||||
|
||||
const HISTORY_CAP: usize = 200;
|
||||
const PREFERENCES_FILE: &str = "preferences.json";
|
||||
|
||||
@@ -81,10 +91,22 @@ fn data_dir() -> Result<PathBuf> {
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") {
|
||||
return Ok(PathBuf::from(dir).join("OpenLess"));
|
||||
}
|
||||
Ok(std::env::temp_dir().join("OpenLess"))
|
||||
// Never use std::env::temp_dir() (/data/local/tmp) — apps cannot write there.
|
||||
android_storage::android_data_dir()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback store path when `data_dir()` is unavailable.
|
||||
/// Android: empty path → memory-only (writes refused); never `/data/local/tmp`.
|
||||
fn fallback_store_path(file_name: &str) -> PathBuf {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let _ = file_name;
|
||||
PathBuf::new()
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
std::env::temp_dir().join(file_name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +119,14 @@ fn ensure_dir(dir: &Path) -> Result<()> {
|
||||
/// target path. The unique suffix lets concurrent writers each own their own
|
||||
/// tmp file, so a parallel rename never finds its source already taken.
|
||||
fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
if is_memory_only_path(path) {
|
||||
bail!("atomic write refused: empty path (memory-only store; Android data_dir unavailable)");
|
||||
}
|
||||
#[cfg(not(any(target_os = "android", test)))]
|
||||
if path.as_os_str().is_empty() {
|
||||
bail!("atomic write refused: empty path (memory-only store)");
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_dir(parent)?;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,11 @@ impl PreferencesStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// 降级实例:data_dir 不可用时使用默认配置,写操作会安静地失败。
|
||||
/// 降级实例:data_dir 不可用时使用默认配置。
|
||||
/// Android 使用空 path(内存态,写盘明确失败),禁止落 `/data/local/tmp`。
|
||||
pub(crate) fn new_fallback() -> Self {
|
||||
Self {
|
||||
path: std::env::temp_dir().join("openless_prefs_fallback.json"),
|
||||
path: super::fallback_store_path("openless_prefs_fallback.json"),
|
||||
state: Mutex::new(UserPreferences::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,12 +82,12 @@ impl StylePackStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// 降级实例:data_dir 不可用时使用临时路径和空列表,写操作会安静地失败。
|
||||
/// 降级实例:data_dir 不可用时使用空列表。
|
||||
/// Android 使用空 path(内存态),禁止落 `/data/local/tmp`。
|
||||
pub(crate) fn new_fallback() -> Self {
|
||||
let tmp = std::env::temp_dir();
|
||||
Self {
|
||||
path: tmp.join("openless_style_packs_fallback.json"),
|
||||
asset_root: tmp.join("openless_style_pack_assets_fallback"),
|
||||
path: super::fallback_store_path("openless_style_packs_fallback.json"),
|
||||
asset_root: super::fallback_store_path("openless_style_pack_assets_fallback"),
|
||||
state: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
@@ -781,6 +781,9 @@ fn sanitize_style_pack_id(requested_id: &str) -> String {
|
||||
}
|
||||
|
||||
fn remove_style_pack_assets(asset_root: &Path, pack: &StylePack) {
|
||||
if asset_root.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(icon_path) = pack.icon_path.as_deref() {
|
||||
let path = Path::new(icon_path);
|
||||
let _ = fs::remove_file(path);
|
||||
|
||||
@@ -778,6 +778,9 @@ pub(super) fn persist_style_pack_icon(
|
||||
pack_id: &str,
|
||||
icon: StylePackIcon,
|
||||
) -> Result<String> {
|
||||
if asset_root.as_os_str().is_empty() {
|
||||
bail!("style pack asset root unavailable (memory-only store)");
|
||||
}
|
||||
let target_dir = asset_root.join(pack_id);
|
||||
ensure_dir(&target_dir)?;
|
||||
let target_path = target_dir.join(format!("icon.{}", icon.extension));
|
||||
@@ -790,6 +793,9 @@ pub(super) fn persist_style_pack_icon(
|
||||
}
|
||||
|
||||
pub(super) fn cleanup_style_pack_asset_dir(asset_root: &Path, pack_id: &str) {
|
||||
if asset_root.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = fs::remove_dir_all(asset_root.join(pack_id));
|
||||
}
|
||||
|
||||
@@ -859,4 +865,33 @@ mod tests {
|
||||
png.extend_from_slice(data);
|
||||
png.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_style_pack_icon_rejects_empty_asset_root() {
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::persist_style_pack_icon;
|
||||
use super::StylePackIcon;
|
||||
|
||||
let pack_id = "fallback-pack-id";
|
||||
let cwd = std::env::current_dir().expect("cwd");
|
||||
let relative_dir = cwd.join(pack_id);
|
||||
let _ = fs::remove_dir_all(&relative_dir);
|
||||
|
||||
let icon = StylePackIcon {
|
||||
extension: "png".to_string(),
|
||||
bytes: vec![0x89, 0x50, 0x4e, 0x47],
|
||||
};
|
||||
let err = persist_style_pack_icon(&PathBuf::new(), pack_id, icon).unwrap_err();
|
||||
assert!(
|
||||
format!("{err:#}").contains("memory-only store"),
|
||||
"{err:#}"
|
||||
);
|
||||
assert!(
|
||||
!relative_dir.exists(),
|
||||
"empty asset_root must not create {} under cwd",
|
||||
relative_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ export async function openExternal(url: string): Promise<void> {
|
||||
/**
|
||||
* 让用户选 save 路径并把当前会话日志(openless.log)复制过去。
|
||||
* 浏览器开发模式下走 mock 不实际写盘。返回最终 save 的绝对路径,取消选择则返回 null。
|
||||
*
|
||||
* Android:省略 filters——部分 ROM 上 CREATE_DOCUMENT + EXTRA_MIME_TYPES 不稳定;
|
||||
* 文件名已带 .log,足够标识类型。
|
||||
*/
|
||||
export async function exportErrorLog(
|
||||
suggestedFileName: string,
|
||||
@@ -35,10 +38,16 @@ export async function exportErrorLog(
|
||||
return `~/Downloads/${suggestedFileName}`
|
||||
}
|
||||
const { save } = await import("@tauri-apps/plugin-dialog")
|
||||
const target = await save({
|
||||
defaultPath: suggestedFileName,
|
||||
filters: [{ name: "Log", extensions: ["log", "txt"] }],
|
||||
})
|
||||
const isAndroid =
|
||||
typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent || "")
|
||||
const target = await save(
|
||||
isAndroid
|
||||
? { defaultPath: suggestedFileName }
|
||||
: {
|
||||
defaultPath: suggestedFileName,
|
||||
filters: [{ name: "Log", extensions: ["log", "txt"] }],
|
||||
},
|
||||
)
|
||||
if (!target) return null
|
||||
await invokeOrMock<void>(
|
||||
"export_error_log",
|
||||
|
||||
@@ -97,10 +97,11 @@ export function DebugToolsSection() {
|
||||
)}
|
||||
{exportStatus === 'err' && (
|
||||
<span
|
||||
style={{ fontSize: 11, color: 'var(--ol-err)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 220 }}
|
||||
style={{ fontSize: 11, color: 'var(--ol-err)', lineHeight: 1.45, wordBreak: 'break-word', maxWidth: 280 }}
|
||||
title={exportMessage}
|
||||
>
|
||||
{t('modal.about.exportFailed')}
|
||||
{exportMessage ? `:${exportMessage}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user