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:
浩彬(^-^)andCursor authored and GitHub committed 2026-08-02 20:04:18 +08:00
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
}
}
}
+4 -30
View File
@@ -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": [
+1 -1
View File
@@ -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'];
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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) ───────────────────────────
+20 -5
View File
@@ -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()
});
+27 -7
View File
@@ -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()
);
}
}
+13 -4
View File
@@ -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>