From bd281a3b2ec70ce844ea2cd502e68fbd94947a5c Mon Sep 17 00:00:00 2001 From: polv Date: Sat, 28 Feb 2026 00:45:51 +0700 Subject: [PATCH] minimal working on Android with multiple zips' bundle --- Cargo.lock | 2 + src-core/Cargo.toml | 1 + src-core/examples/add_yomitan_dict_folder.rs | 2 +- src-core/src/db.rs | 4 +- src-core/src/db/yomitan_writer.rs | 46 ++++--- src-core/src/error.rs | 49 +++++++- src-core/src/lib.rs | 2 +- src-core/src/service.rs | 44 +++---- src-tauri/Cargo.toml | 1 + src-tauri/gen/android/app/.gitignore | 3 +- src-tauri/resources/.gitignore | 2 + src-tauri/src/command.rs | 121 +++++++++---------- src-tauri/tauri.conf.json | 3 +- 13 files changed, 165 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c77bb91..d5233e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,6 +511,7 @@ version = "0.1.0" dependencies = [ "anyhow", "cjdic2-core", + "serde_json", "tauri", "tauri-build", "tauri-plugin-fs", @@ -524,6 +525,7 @@ version = "0.1.0" dependencies = [ "anyhow", "blake3", + "log", "rusqlite", "serde", "serde_json", diff --git a/src-core/Cargo.toml b/src-core/Cargo.toml index ea3bc90..78666b1 100644 --- a/src-core/Cargo.toml +++ b/src-core/Cargo.toml @@ -14,3 +14,4 @@ thiserror = "2.0.18" zip = "8.1.0" blake3 = "1.8.3" anyhow = "1.0.102" +log = "0.4.29" diff --git a/src-core/examples/add_yomitan_dict_folder.rs b/src-core/examples/add_yomitan_dict_folder.rs index 18c36c4..9d4fb40 100644 --- a/src-core/examples/add_yomitan_dict_folder.rs +++ b/src-core/examples/add_yomitan_dict_folder.rs @@ -29,7 +29,7 @@ fn main() -> Result<(), anyhow::Error> { println!("zip_file: {:?}", p); println!( "{:?}", - AppService::import_yomitan_zip_file(&mut writer, p, "ja", |progress| { + AppService::import_yomitan_zip_file(&mut writer, &p, "ja", |progress| { println!("{:?}", progress); })?, ); diff --git a/src-core/src/db.rs b/src-core/src/db.rs index f78acdf..0a7e396 100644 --- a/src-core/src/db.rs +++ b/src-core/src/db.rs @@ -14,7 +14,9 @@ mod yomitan; pub use yomitan::YomitanRow; mod yomitan_writer; -pub use yomitan_writer::{YomitanWriter, YomitanZipImportProgress, YomitanZipImportResult}; +pub use yomitan_writer::{ + YomitanWriter, YomitanZipImportProgress, YomitanZipImportResult, ZipSource, +}; #[derive(Clone)] pub(crate) struct Database { diff --git a/src-core/src/db/yomitan_writer.rs b/src-core/src/db/yomitan_writer.rs index 74aedec..9900725 100644 --- a/src-core/src/db/yomitan_writer.rs +++ b/src-core/src/db/yomitan_writer.rs @@ -1,9 +1,14 @@ -use anyhow::Context; use blake3::hash; use rusqlite::{Connection, Result, Transaction, params}; use serde::Serialize; use serde_json::Value; -use std::{collections::HashMap, fs::File, io::Read, path::PathBuf, time::Instant}; +use std::{ + borrow::Cow, + collections::HashMap, + io::{Cursor, Read}, + path::{Path, PathBuf}, + time::Instant, +}; use zip::ZipArchive; use crate::CJDicError; @@ -19,6 +24,21 @@ pub(super) fn normalize_term(s: &str) -> String { .collect() } +pub trait ZipSource { + fn file_name(&self) -> &str; + fn bytes(&self) -> std::io::Result>; +} + +impl ZipSource for PathBuf { + fn file_name(&self) -> &str { + Path::file_name(self).and_then(|n| n.to_str()).unwrap_or("") + } + + fn bytes(&self) -> std::io::Result> { + std::fs::read(self).map(Cow::Owned) + } +} + #[derive(Serialize, Debug, Clone)] pub struct YomitanZipImportProgress { pub message: String, @@ -238,29 +258,23 @@ impl YomitanWriter { Ok(()) } - pub fn import_dictionary_zip_file( + pub fn import_dictionary_zip_file( &mut self, - zip_file: PathBuf, + zip_file: &Z, lang: &str, progress_callback: Callback, ) -> anyhow::Result where + Z: ZipSource, Callback: Fn(YomitanZipImportProgress), { - let bundle_name = if let Some(fo) = zip_file.file_name() - && let Some(f) = fo.to_str() - { - f - } else { - return Err(CJDicError::FileNameNotFound); - }; + let bundle_name = zip_file.file_name(); let start_time = Instant::now(); self.conn.execute_batch("PRAGMA foreign_keys = off")?; - let f = - File::open(&zip_file).with_context(|| format!("opening zip {}", zip_file.display()))?; - let mut archive = ZipArchive::new(f).with_context(|| "reading zip archive")?; + let cursor = Cursor::new(zip_file.bytes()?); + let mut archive = ZipArchive::new(cursor)?; let index_file = match archive.by_name("index.json") { Ok(mut f) => { @@ -575,8 +589,8 @@ impl YomitanWriter { tx.commit()?; println!( - "Importing {:?} takes {:.2?}", - zip_file, + "Importing {} takes {:.2?}", + zip_file.file_name(), start_time.elapsed() ); diff --git a/src-core/src/error.rs b/src-core/src/error.rs index 8e948c7..fffccbc 100644 --- a/src-core/src/error.rs +++ b/src-core/src/error.rs @@ -1,26 +1,63 @@ +use serde::Serialize; use thiserror::Error; use zip::result::ZipError; -#[derive(Debug, Error)] +#[derive(Debug, Error, Serialize)] +#[serde(tag = "kind", content = "message")] pub enum CJDicError { #[error("Database error: {0}")] - Database(#[from] rusqlite::Error), + Database(String), #[error("Serialization error: {0}")] - Serialization(#[from] serde_json::Error), + Serialization(String), #[error("Anyhow error: {0}")] - AnyhowError(#[from] anyhow::Error), + AnyhowError(String), #[error("IO error: {0}")] - IOError(#[from] std::io::Error), + IOError(String), #[error("File name not found")] FileNameNotFound, #[error("Zip error: {0}")] - ZipError(#[from] ZipError), + ZipError(String), #[error("Not found")] NotFound, } + +impl From for CJDicError { + fn from(e: rusqlite::Error) -> Self { + log::error!("{e:#}"); + CJDicError::Database(e.to_string()) + } +} + +impl From for CJDicError { + fn from(e: serde_json::Error) -> Self { + log::error!("{e:#}"); + CJDicError::Serialization(e.to_string()) + } +} + +impl From for CJDicError { + fn from(e: anyhow::Error) -> Self { + log::error!("{e:#}"); + CJDicError::AnyhowError(e.to_string()) + } +} + +impl From for CJDicError { + fn from(e: std::io::Error) -> Self { + log::error!("{e:#}"); + CJDicError::IOError(e.to_string()) + } +} + +impl From for CJDicError { + fn from(e: ZipError) -> Self { + log::error!("{e:#}"); + CJDicError::ZipError(e.to_string()) + } +} diff --git a/src-core/src/lib.rs b/src-core/src/lib.rs index b835447..f330686 100644 --- a/src-core/src/lib.rs +++ b/src-core/src/lib.rs @@ -2,7 +2,7 @@ mod db; mod error; mod service; -pub use db::{YomitanRow, YomitanZipImportProgress, YomitanZipImportResult}; +pub use db::{YomitanRow, YomitanZipImportProgress, YomitanZipImportResult, ZipSource}; pub use error::CJDicError; pub use service::AppService; diff --git a/src-core/src/service.rs b/src-core/src/service.rs index 90c3d11..1ad041d 100644 --- a/src-core/src/service.rs +++ b/src-core/src/service.rs @@ -1,13 +1,13 @@ use std::{ - collections::HashSet, - fs::read_dir, - path::{Path, PathBuf}, + collections::{HashMap, HashSet}, + path::Path, }; use rusqlite::Connection; use serde::Serialize; use crate::{ + ZipSource, db::{ Database, YOMITAN_DBFILE, YomitanRow, YomitanWriter, YomitanZipImportProgress, YomitanZipImportResult, @@ -51,28 +51,22 @@ impl AppService { Ok(writer) } - pub fn load_yomitan_zip_dir( + pub fn load_yomitan_zip_dir( &self, - zip_dir: PathBuf, + zip_dir: Vec, lang: &str, load_callback: LoadCallback, import_callback: ImportCallback, ) -> Result where + Z: ZipSource, LoadCallback: Fn(LoadYomitanZipDirResult), ImportCallback: Fn(YomitanZipImportProgress), { - let mut dir_zip_list: HashSet = HashSet::new(); + let mut dir_zip_map: HashMap = HashMap::new(); - for entry in read_dir(&zip_dir)? { - let e = entry?; - let p = e.path(); - - if p.extension().and_then(|s| s.to_str()) == Some("zip") - && let Some(f) = e.file_name().to_str() - { - dir_zip_list.insert(f.to_string()); - } + for entry in zip_dir { + dir_zip_map.insert(entry.file_name().to_string(), entry); } { @@ -96,14 +90,14 @@ impl AppService { db_zip_list.insert(b_name); } - for z in dir_zip_list.iter() { - if !db_zip_list.contains(z) { + for z in dir_zip_map.keys() { + if !db_zip_list.contains(&z.to_string()) { new_dicts.push(z.to_string()); } } for z in db_zip_list.iter() { - if !dir_zip_list.contains(z) { + if !dir_zip_map.contains_key(z) { to_be_removed_dicts.push(z.to_string()); } } @@ -138,12 +132,9 @@ impl AppService { let mut writer = YomitanWriter::new(conn)?; for z in new_dicts.clone().iter() { - Self::import_yomitan_zip_file( - &mut writer, - zip_dir.join(z), - lang, - &import_callback, - )?; + if let Some(zip_file) = dir_zip_map.get(z) { + Self::import_yomitan_zip_file(&mut writer, zip_file, lang, &import_callback)?; + } } } @@ -153,13 +144,14 @@ impl AppService { }) } - pub fn import_yomitan_zip_file( + pub fn import_yomitan_zip_file( writer: &mut YomitanWriter, - zip_file: PathBuf, + zip_file: &Z, lang: &str, progress_callback: Callback, ) -> Result where + Z: ZipSource, Callback: Fn(YomitanZipImportProgress), { Ok(writer.import_dictionary_zip_file(zip_file, lang, progress_callback)?) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 928ef9e..f2b94ae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,3 +24,4 @@ cjdic2-core = { path = "../src-core" } tauri-plugin-http = "2" anyhow = "1.0.102" tauri-plugin-fs = "2" +serde_json = "1.0.149" diff --git a/src-tauri/gen/android/app/.gitignore b/src-tauri/gen/android/app/.gitignore index d63c035..762a56a 100644 --- a/src-tauri/gen/android/app/.gitignore +++ b/src-tauri/gen/android/app/.gitignore @@ -5,4 +5,5 @@ /proguard-tauri.pro /tauri.properties -/src/main/assets/resources/**/*.zip +/src/main/assets/**/*.zip +/src/main/assets/**/manifest.json diff --git a/src-tauri/resources/.gitignore b/src-tauri/resources/.gitignore index ece471a..b02ab73 100644 --- a/src-tauri/resources/.gitignore +++ b/src-tauri/resources/.gitignore @@ -2,3 +2,5 @@ # *.json.zip from https://github.com/scriptin/jmdict-simplified/releases # TODO: jmdict.db build script jmdict.db + +manifest.json diff --git a/src-tauri/src/command.rs b/src-tauri/src/command.rs index a80929f..0849a45 100644 --- a/src-tauri/src/command.rs +++ b/src-tauri/src/command.rs @@ -1,72 +1,74 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -use cjdic2_core::{AppService, YomitanRow}; -use tauri::{AppHandle, Emitter, Manager}; -#[cfg(target_os = "android")] +use std::{borrow::Cow, path::PathBuf}; + +use cjdic2_core::{AppService, CJDicError, YomitanRow, ZipSource}; +use tauri::{AppHandle, Emitter, Manager, Runtime, path::BaseDirectory}; use tauri_plugin_fs::FsExt; +pub struct BundledZip { + pub name: String, + pub path: PathBuf, // resolved asset://localhost/... path on Android + pub app: AppHandle, +} + +impl BundledZip { + pub fn new(app: AppHandle, relative_path: &str) -> Result { + let name = relative_path + .split('/') + .last() + .unwrap_or(relative_path) + .to_string(); + + let path = app.path().resolve(relative_path, BaseDirectory::Resource)?; + + Ok(Self { name, path, app }) + } +} + +impl ZipSource for BundledZip { + fn file_name(&self) -> &str { + &self.name + } + + fn bytes(&self) -> std::io::Result> { + self.app + .fs() + .read(&self.path) // PathBuf implements Into + .map(Cow::Owned) + } +} + #[tauri::command] pub async fn init_yomitan( app: AppHandle, state: tauri::State<'_, AppService>, -) -> Result<(), String> { +) -> Result<(), CJDicError> { let folder_name = "resources/yomitan"; - // 1. Resolve the internal Asset URI let zip_dir = app .path() .resolve(folder_name, tauri::path::BaseDirectory::Resource) - .map_err(|e| { - eprintln!("{:?}", e); - e.to_string() - })?; + .map_err(|e| CJDicError::AnyhowError(e.to_string()))?; - // Embedded files cannot be read with std fs directly, not to mention read_dir - // It need to be copied with app.fs().read(...) one-by-one, and no read_dir here - // @link https://v2.tauri.app/plugin/file-system/ - // app.config().bundle.resources don't seem to resolve **/* - // utils::resources::ResourcePaths::new(&["pattern*".to_string()], true); didn't work correctly in Android - #[cfg(target_os = "android")] - let zip_dir = { - let filename = "PixivLight_2026-02-24.zip"; + // Manifest is needed, as Android bundled fs can't read_dir + let files: Vec = + serde_json::from_str(&app.fs().read_to_string(zip_dir.join("manifest.json"))?)?; - // 2. Define the physical destination in AppData - let app_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let app_data_path = app_data_dir.join(filename); + let zip_files = files + .iter() + .map(|f| BundledZip::new(app.clone(), &format!("{folder_name}/{f}"))) + .collect::, _>>() + .map_err(|e| CJDicError::AnyhowError(e.to_string()))?; - if !app_data_path.exists() { - // 3. IMPORTANT: Use app.fs().read() to read from the APK asset - // std::fs::read will fail with "os error 2" on Android - let data = app - .fs() - .read(&zip_dir.join(filename)) - .map_err(|e| format!("Failed to read asset from APK: {}", e))?; - - // 4. Use std::fs to write to a real physical path in AppData - if let Some(parent) = app_data_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - std::fs::write(&app_data_path, data) - .map_err(|e| format!("Failed to write to AppData: {}", e))?; - } - - app_data_dir - }; - - let r = state - .load_yomitan_zip_dir( - zip_dir, - "ja", - |r| { - app.emit("load-yomitan-dir", r).unwrap(); - }, - |r| { - app.emit("yomitan-import-progress", r).unwrap(); - }, - ) - .map_err(|e| { - eprintln!("{:?}", e); - e.to_string() - })?; + let r = state.load_yomitan_zip_dir( + zip_files, + "ja", + |r| { + app.emit("load-yomitan-dir", r).unwrap(); + }, + |r| { + app.emit("yomitan-import-progress", r).unwrap(); + }, + )?; if r.new_dicts.len() + r.to_be_removed_dicts.len() > 0 { app.request_restart(); @@ -82,11 +84,6 @@ pub async fn search_yomitan( limit: u32, offset: u32, state: tauri::State<'_, AppService>, -) -> Result, String> { - state - .search_yomitan(q_term, q_reading, limit, offset) - .map_err(|e| { - eprintln!("{:?}", e); - e.to_string() - }) +) -> Result, CJDicError> { + state.search_yomitan(q_term, q_reading, limit, offset) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7da6d5a..4d6607a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -24,7 +24,8 @@ }, "bundle": { "resources": [ - "resources/**/*.zip" + "resources/**/*.zip", + "resources/**/manifest.json" ], "active": true, "targets": "all",