use std::sync::Arc;
use serde::Deserialize;
use serde_json::{json, Value};
use tauri::ipc::{Channel, Invoke};
use tauri::{AppHandle, Runtime, State, Webview};
use crate::bus::LockGrant;
use crate::error::{Error, Result};
use crate::hub::{Instance, ENGINE_LABEL};
use crate::{engine, Callbacks, Shared, Unread, PLUGIN_VERSION, PROTOCOL_VERSION};
pub fn handler<R: Runtime>() -> impl Fn(Invoke<R>) -> bool + Send + Sync + 'static {
tauri::generate_handler![
hello,
session_hint,
window_state,
engine_ready,
engine_alive,
engine_exit_done,
secure_load,
secure_set,
unread_report,
lock_request,
lock_release,
lock_cancel,
bus_join,
bus_leave,
bus_post,
bus_will,
log_write,
diagnostics_export,
crate::store::commands::db_open,
crate::store::commands::db_close,
crate::store::commands::db_delete,
crate::store::commands::tx_begin,
crate::store::commands::tx_read,
crate::store::commands::tx_write,
crate::store::commands::tx_commit,
crate::store::commands::tx_abort,
crate::notify::commands::notify_show,
crate::notify::commands::notify_clear,
crate::notify::commands::notify_permission,
crate::notify::commands::notify_request_permission,
crate::notify::commands::notify_claim,
crate::badge::commands::badge_set,
crate::badge::commands::attention_request,
crate::badge::commands::tray_blink,
crate::files::commands::files_pick,
crate::files::commands::files_pick_save,
crate::files::commands::files_stat,
crate::files::commands::files_read,
crate::files::commands::files_write,
crate::files::commands::files_persist,
crate::files::commands::files_release,
crate::files::commands::files_upload_part,
crate::files::commands::files_download,
crate::files::commands::files_cancel,
crate::files::commands::files_open,
crate::files::commands::files_reveal,
crate::files::commands::files_prepare_image,
crate::files::commands::files_prepare_video,
]
}
pub fn require_engine<R: Runtime>(webview: &Webview<R>) -> Result<()> {
if webview.label() == ENGINE_LABEL {
Ok(())
} else {
Err(Error::not_engine())
}
}
pub fn caller<R: Runtime>(shared: &Shared, webview: &Webview<R>) -> Result<Arc<Instance>> {
shared.hub.get(webview.label()).ok_or_else(|| Error::invalid_state("not_attached"))
}
#[tauri::command(rename_all = "snake_case")]
async fn hello<R: Runtime>(app: AppHandle<R>, webview: Webview<R>, state: State<'_, Arc<Shared>>, js_version: String, protocol: u32, channel: Channel<Value>) -> Result<Value> {
let shared = state.inner().clone();
let label = webview.label().to_string();
let (instance, old) = shared.hub.attach(&label, channel);
if let Some(old) = old {
crate::release_instance(&shared, &old);
}
if js_version != PLUGIN_VERSION || protocol != PROTOCOL_VERSION {
shared.logs.plugin("error", &format!("JavaScript 包({js_version},协议 {protocol})与插件({PLUGIN_VERSION},协议 {PROTOCOL_VERSION})的版本不同"));
}
let info = crate::system::info();
let launch_click = if instance.engine { None } else { shared.notify.launch_click() };
Ok(json!({
"instance": instance.id,
"label": label,
"engine": instance.engine,
"plugin_version": PLUGIN_VERSION,
"protocol": PROTOCOL_VERSION,
"os": info.os,
"os_version": info.os_version,
"arch": std::env::consts::ARCH,
"computer_name": info.computer_name,
"webview_version": tauri::webview_version().unwrap_or_default(),
"device_id": shared.device.id,
"data_dir": shared.paths.data,
"temp_dir": shared.paths.temp,
"another_instance": shared.another_instance,
"engine_state": shared.engine.state(),
"app_keys": shared.engine.app_keys(),
"launch_click": launch_click,
"main_window": shared.config.main_window,
"log_file": shared.config.log_file,
"window": if instance.engine { Value::Null } else { engine::window_state(&app, &shared, &label) },
}))
}
#[tauri::command(rename_all = "snake_case")]
fn session_hint<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, app_key: String) -> Result<Value> {
caller(&state, &webview)?;
Ok(hint_of(&state, &app_key))
}
fn hint_of(shared: &Shared, app_key: &str) -> Value {
let values = shared.secure.load(app_key).unwrap_or_default();
let Some(text) = values.get("session") else { return Value::Null };
let session: Value = serde_json::from_str(text).unwrap_or(Value::Null);
match (session.get("username").and_then(Value::as_str), session.get("session_id").and_then(Value::as_str)) {
(Some(username), Some(session_id)) => json!({ "username": username, "session_id": session_id }),
_ => Value::Null,
}
}
#[tauri::command(rename_all = "snake_case")]
fn window_state<R: Runtime>(app: AppHandle<R>, webview: Webview<R>, state: State<'_, Arc<Shared>>) -> Result<Value> {
Ok(engine::window_state(&app, &state, webview.label()))
}
#[tauri::command(rename_all = "snake_case")]
fn engine_ready<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, app_key: String, signed_in: bool) -> Result<()> {
require_engine(&webview)?;
caller(&state, &webview)?;
let changed = state.engine.ready(&app_key, signed_in);
crate::system::app_nap(state.config.disable_app_nap && state.engine.any_signed_in());
if changed {
state.logs.plugin("info", "引擎就绪");
}
engine::broadcast_state(&state);
Ok(())
}
#[tauri::command(rename_all = "snake_case")]
fn engine_alive<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>) -> Result<()> {
require_engine(&webview)?;
state.engine.alive();
Ok(())
}
#[tauri::command(rename_all = "snake_case")]
fn engine_exit_done<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>) -> Result<()> {
require_engine(&webview)?;
state.engine.exit_done();
Ok(())
}
#[tauri::command(rename_all = "snake_case")]
fn secure_load<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, app_key: String) -> Result<Value> {
require_engine(&webview)?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
if state.secure.check_session_device(&app_key, &state.device.id, now)? {
broadcast_hint(&state, &app_key);
}
let values = state.secure.load(&app_key)?;
let users = std::fs::read_to_string(state.paths.users_json(&app_key)).ok();
Ok(json!({
"session": values.get("session"),
"pending_logout": values.get("pending_logout"),
"db_keys": Value::Null,
"users": users,
"device_id": state.device.id,
}))
}
#[tauri::command(rename_all = "snake_case")]
fn secure_set<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, app_key: String, key: String, value: Option<String>) -> Result<()> {
require_engine(&webview)?;
match key.as_str() {
"users" => {
let path = state.paths.users_json(&app_key);
match value {
Some(text) => crate::paths::write_atomic(&path, keep_plugin_fields(&path, &text).as_bytes())?,
None => {
let _ = std::fs::remove_file(&path);
}
}
}
"session" | "pending_logout" => {
state.secure.set(&app_key, &key, value)?;
if key == "session" {
broadcast_hint(&state, &app_key);
}
}
_ => return Err(Error::validation("invalid_key", format!("不认识的键 {key}"))),
}
Ok(())
}
fn keep_plugin_fields(path: &std::path::Path, text: &str) -> String {
let Ok(mut next) = serde_json::from_str::<Value>(text) else { return text.to_string() };
let old: Value = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
let encrypted: std::collections::HashMap<String, Value> = old
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|i| Some((i.get("username")?.as_str()?.to_string(), i.get("encrypted")?.clone())))
.collect()
})
.unwrap_or_default();
if let Some(items) = next.get_mut("items").and_then(Value::as_array_mut) {
for item in items {
let name = item.get("username").and_then(Value::as_str).map(str::to_string);
if let (Some(name), Some(obj)) = (name, item.as_object_mut()) {
if !obj.contains_key("encrypted") {
if let Some(flag) = encrypted.get(&name) {
obj.insert("encrypted".into(), flag.clone());
}
}
}
}
}
next.to_string()
}
fn broadcast_hint(shared: &Shared, app_key: &str) {
shared.hub.broadcast_windows(json!({ "t": "kv", "app_key": app_key, "key": "session", "hint": hint_of(shared, app_key) }));
}
#[tauri::command(rename_all = "snake_case")]
fn unread_report<R: Runtime>(app: AppHandle<R>, webview: Webview<R>, state: State<'_, Arc<Shared>>, total: u64, badge: u64) -> Result<()> {
require_engine(&webview)?;
let next = Unread { total, badge };
let changed = {
let mut current = state.unread.lock().unwrap();
let changed = *current != next;
*current = next;
changed
};
if changed {
if let Some(callbacks) = tauri::Manager::try_state::<Callbacks<R>>(&app) {
if let Some(f) = &callbacks.on_unread_changed {
f(&app, next);
}
}
}
Ok(())
}
#[tauri::command(rename_all = "snake_case")]
async fn lock_request<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, name: String, id: String, steal: bool, if_available: bool) -> Result<bool> {
let instance = caller(&state, &webview)?;
let grant = state.bus.request(&state.hub, &name, &instance, &id, steal, if_available);
match grant {
LockGrant::Granted => Ok(true),
LockGrant::NotAvailable => Ok(false),
LockGrant::Wait(rx) => rx.await.unwrap_or_else(|_| Err(Error::aborted())),
}
}
#[tauri::command(rename_all = "snake_case")]
fn lock_release<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, id: String) -> Result<()> {
let instance = caller(&state, &webview)?;
state.bus.release(&instance.label, instance.id, &id);
Ok(())
}
#[tauri::command(rename_all = "snake_case")]
fn lock_cancel<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, id: String) -> Result<()> {
let instance = caller(&state, &webview)?;
state.bus.release(&instance.label, instance.id, &id);
Ok(())
}
#[tauri::command(rename_all = "snake_case")]
fn bus_join<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, name: String) -> Result<()> {
let instance = caller(&state, &webview)?;
state.bus.join(&name, &instance);
Ok(())
}
#[tauri::command(rename_all = "snake_case")]
fn bus_leave<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, name: String) -> Result<()> {
let instance = caller(&state, &webview)?;
state.bus.leave(&name, &instance);
Ok(())
}
#[tauri::command(rename_all = "snake_case")]
fn bus_post<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, name: String, msgs: Vec<Value>) -> Result<()> {
let instance = caller(&state, &webview)?;
for msg in &msgs {
let size = serde_json::to_vec(msg).map(|v| v.len()).unwrap_or(0);
state.bus.post(&state.hub, &name, &instance, msg, size)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn users_json_keeps_encrypted_flag() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("users.json");
std::fs::write(&path, r#"{"v":1,"items":[{"username":"alice","encrypted":true},{"username":"bob","encrypted":false}]}"#).unwrap();
let out: Value = serde_json::from_str(&keep_plugin_fields(&path, r#"{"v":1,"items":[{"username":"alice","last_used_at":5},{"username":"carol"}]}"#)).unwrap();
assert_eq!(out["items"][0]["encrypted"], json!(true));
assert_eq!(out["items"][0]["last_used_at"], json!(5));
assert!(out["items"][1].get("encrypted").is_none());
}
}
#[tauri::command(rename_all = "snake_case")]
fn bus_will<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, name: String, msg: Value) -> Result<()> {
let instance = caller(&state, &webview)?;
state.bus.set_will(&name, &instance, msg);
Ok(())
}
#[derive(Deserialize)]
pub struct LogEntryIn {
pub level: String,
pub time: Option<f64>,
pub module: String,
pub message: String,
pub code: Option<String>,
pub reason: Option<String>,
}
#[tauri::command(rename_all = "snake_case")]
fn log_write<R: Runtime>(webview: Webview<R>, state: State<'_, Arc<Shared>>, entries: Vec<LogEntryIn>) -> Result<()> {
let source = if webview.label() == ENGINE_LABEL { "engine".to_string() } else { format!("window:{}", webview.label()) };
state.logs.js(&source, &entries);
Ok(())
}
#[tauri::command(rename_all = "snake_case")]
async fn diagnostics_export<R: Runtime>(app: AppHandle<R>, webview: Webview<R>, state: State<'_, Arc<Shared>>, save_to: Option<String>, info: Value) -> Result<String> {
caller(&state, &webview)?;
let shared = state.inner().clone();
let dest = match save_to {
Some(path) => Some(shared.files.check_write(std::path::Path::new(&path))?),
None => None,
};
let base = json!({
"plugin_version": PLUGIN_VERSION,
"identifier": shared.identifier,
"device": { "regenerated_this_run": shared.device.regenerated, "machine_bound": !shared.device.unbound },
"log_dir": shared.paths.logs,
"system": crate::system::info(),
"webview_version": tauri::webview_version().unwrap_or_default(),
"engine_state": shared.engine.state(),
"engine_app_keys": shared.engine.app_keys(),
"another_instance": shared.another_instance,
"key_source": shared.secure.key_source(),
"proxy": crate::logs::redact_proxy(shared.config.proxy.as_deref()),
"system_proxy": crate::logs::system_proxy(),
"databases": shared.store.sizes(),
"windows": tauri::Manager::webview_windows(&app).keys().cloned().collect::<Vec<_>>(),
"js": info,
});
let path = tauri::async_runtime::spawn_blocking(move || shared.logs.export(&shared.paths.temp, dest.as_deref(), &base))
.await
.map_err(|e| Error::internal(e.to_string()))??;
Ok(path.to_string_lossy().into_owned())
}