feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -0,0 +1,181 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{
|
||||
application::AppState,
|
||||
domain::{
|
||||
ai::{
|
||||
AiCatalogFetchResponse, AiCompleteRequest, AiCompleteResponse, AiModelListResponse,
|
||||
AiProviderConfig, AiProviderTestRequest, AiProviderTestResponse,
|
||||
},
|
||||
ssh::OperationResponse,
|
||||
},
|
||||
};
|
||||
|
||||
fn provider_secret_key(provider_id: &str) -> Result<String, String> {
|
||||
let id = provider_id.trim();
|
||||
if id.is_empty()
|
||||
|| id.len() > 128
|
||||
|| !id
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
|
||||
{
|
||||
return Err("Provider ID 无效".to_string());
|
||||
}
|
||||
Ok(format!("ai-provider:{id}:api-key"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ai_provider_save_key(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
api_key: String,
|
||||
) -> OperationResponse {
|
||||
let result = provider_secret_key(&provider_id).and_then(|key| {
|
||||
state
|
||||
.storage
|
||||
.set_secret(&app, key, api_key.trim().to_string())
|
||||
});
|
||||
match result {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ai_provider_has_key(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
) -> Result<bool, String> {
|
||||
let key = provider_secret_key(&provider_id)?;
|
||||
state.storage.has_secret(&app, &key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ai_provider_delete_key(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
) -> OperationResponse {
|
||||
let result =
|
||||
provider_secret_key(&provider_id).and_then(|key| state.storage.delete_secret(&app, &key));
|
||||
match result {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ai_list_models(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
provider: AiProviderConfig,
|
||||
) -> Result<AiModelListResponse, String> {
|
||||
let api_key = match load_api_key(&app, &state, &provider) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Ok(AiModelListResponse::error(error)),
|
||||
};
|
||||
let ai = state.ai.clone();
|
||||
Ok(match ai.list_models(&provider, api_key.as_deref()).await {
|
||||
Ok(models) => AiModelListResponse::ok(models),
|
||||
Err(error) => AiModelListResponse::error(error),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ai_complete(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
request: AiCompleteRequest,
|
||||
) -> Result<AiCompleteResponse, String> {
|
||||
let api_key = match load_api_key(&app, &state, &request.provider) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Ok(AiCompleteResponse::error(error)),
|
||||
};
|
||||
let ai = state.ai.clone();
|
||||
Ok(match ai.complete(request, api_key).await {
|
||||
Ok(result) => AiCompleteResponse::ok(result.text, result.model, result.redacted_count),
|
||||
Err(error) => AiCompleteResponse::error(error),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify an unsaved provider configuration with a minimal real completion.
|
||||
/// A supplied API key is used only for this request and never enters StorageService.
|
||||
#[tauri::command]
|
||||
pub async fn ai_test_provider(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
request: AiProviderTestRequest,
|
||||
) -> Result<AiProviderTestResponse, String> {
|
||||
let transient_key = request
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|key| !key.is_empty());
|
||||
let api_key = match transient_key {
|
||||
Some(key) => Some(key.to_string()),
|
||||
None => match load_api_key(&app, &state, &request.provider) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Ok(AiProviderTestResponse::error(error)),
|
||||
},
|
||||
};
|
||||
let completion_request = AiCompleteRequest {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
action: "explain".to_string(),
|
||||
prompt: "你是什么模型?请简短回答。".to_string(),
|
||||
context: String::new(),
|
||||
};
|
||||
let started = Instant::now();
|
||||
let ai = state.ai.clone();
|
||||
Ok(match ai.complete(completion_request, api_key).await {
|
||||
Ok(result) => AiProviderTestResponse::ok(
|
||||
result.model,
|
||||
started.elapsed().as_millis(),
|
||||
result.text.trim().chars().take(160).collect(),
|
||||
),
|
||||
Err(error) => AiProviderTestResponse::error(error),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ai_catalog_fetch(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AiCatalogFetchResponse, String> {
|
||||
let ai = state.ai.clone();
|
||||
let (documents, errors) = ai.fetch_models_dev_catalog().await;
|
||||
Ok(AiCatalogFetchResponse {
|
||||
success: !documents.is_empty(),
|
||||
documents,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_api_key(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AppState>,
|
||||
provider: &AiProviderConfig,
|
||||
) -> Result<Option<String>, String> {
|
||||
if provider.auth_style == "none" {
|
||||
return Ok(None);
|
||||
}
|
||||
let key = provider_secret_key(&provider.id)?;
|
||||
state.storage.get_secret(app, &key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scopes_provider_secrets_and_rejects_arbitrary_keys() {
|
||||
assert_eq!(
|
||||
provider_secret_key("deepseek-main").unwrap(),
|
||||
"ai-provider:deepseek-main:api-key"
|
||||
);
|
||||
assert!(provider_secret_key("../../master.key").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::{application::AppState, services::deep_link::SshLinkTarget};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_pending_ssh_links(state: State<'_, AppState>) -> Result<Vec<SshLinkTarget>, String> {
|
||||
state.deep_links.take_pending()
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::domain::ssh::OperationResponse;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DialogOptions {
|
||||
title: Option<String>,
|
||||
default_path: Option<String>,
|
||||
#[serde(default)]
|
||||
filters: Vec<DialogFilter>,
|
||||
#[serde(default)]
|
||||
properties: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DialogFilter {
|
||||
name: String,
|
||||
extensions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DialogResult {
|
||||
canceled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
file_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
file_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PathResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ReadFileResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFileEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: String,
|
||||
size: u64,
|
||||
modified: u64,
|
||||
permissions: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFilesResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
files: Option<Vec<LocalFileEntry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalStatsResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stats: Option<LocalFileEntry>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn show_open_dialog(options: DialogOptions) -> DialogResult {
|
||||
let dialog = configure_dialog(rfd::FileDialog::new(), &options);
|
||||
if options
|
||||
.properties
|
||||
.iter()
|
||||
.any(|value| value == "openDirectory")
|
||||
{
|
||||
if options
|
||||
.properties
|
||||
.iter()
|
||||
.any(|value| value == "multiSelections")
|
||||
{
|
||||
let paths = dialog.pick_folders().unwrap_or_default();
|
||||
return paths_result(paths);
|
||||
}
|
||||
return dialog
|
||||
.pick_folder()
|
||||
.map(|path| path_result(path, true))
|
||||
.unwrap_or_else(canceled);
|
||||
}
|
||||
if options
|
||||
.properties
|
||||
.iter()
|
||||
.any(|value| value == "multiSelections")
|
||||
{
|
||||
return paths_result(dialog.pick_files().unwrap_or_default());
|
||||
}
|
||||
dialog
|
||||
.pick_file()
|
||||
.map(|path| path_result(path, true))
|
||||
.unwrap_or_else(canceled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn show_save_dialog(options: DialogOptions) -> DialogResult {
|
||||
configure_dialog(rfd::FileDialog::new(), &options)
|
||||
.save_file()
|
||||
.map(|path| path_result(path, false))
|
||||
.unwrap_or_else(canceled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_file(file_path: String) -> ReadFileResponse {
|
||||
match fs::read(expand_home(&file_path)) {
|
||||
Ok(data) => ReadFileResponse {
|
||||
success: true,
|
||||
data: Some(STANDARD.encode(data)),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => ReadFileResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_file(file_path: String, data: String) -> OperationResponse {
|
||||
let result = STANDARD
|
||||
.decode(data)
|
||||
.map_err(|error| format!("Base64 数据无效: {error}"))
|
||||
.and_then(|bytes| {
|
||||
fs::write(expand_home(&file_path), bytes).map_err(|error| error.to_string())
|
||||
});
|
||||
operation(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_home_path() -> PathResponse {
|
||||
match dirs::home_dir() {
|
||||
Some(path) => PathResponse {
|
||||
success: true,
|
||||
path: Some(path.to_string_lossy().to_string()),
|
||||
error: None,
|
||||
},
|
||||
None => PathResponse {
|
||||
success: false,
|
||||
path: None,
|
||||
error: Some("无法确定用户主目录".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_downloads_path(app: AppHandle) -> Result<String, String> {
|
||||
app.path()
|
||||
.download_dir()
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
match read_local_dir(&expand_home(&path)) {
|
||||
Ok(files) => LocalFilesResponse {
|
||||
success: true,
|
||||
files: Some(files),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalFilesResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_get_stats(path: String) -> LocalStatsResponse {
|
||||
let path = expand_home(&path);
|
||||
match local_entry(&path) {
|
||||
Ok(stats) => LocalStatsResponse {
|
||||
success: true,
|
||||
stats: Some(stats),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_mkdir(path: String) -> OperationResponse {
|
||||
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_delete(path: String) -> OperationResponse {
|
||||
let path = expand_home(&path);
|
||||
let result = if path.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
};
|
||||
operation(result.map_err(|error| error.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_rename(old_path: String, new_path: String) -> OperationResponse {
|
||||
operation(
|
||||
fs::rename(expand_home(&old_path), expand_home(&new_path))
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_external(url: String) -> Result<(), String> {
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err("只允许打开 HTTP/HTTPS 地址".to_string());
|
||||
}
|
||||
open::that(url).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn configure_dialog(mut dialog: rfd::FileDialog, options: &DialogOptions) -> rfd::FileDialog {
|
||||
if let Some(title) = &options.title {
|
||||
dialog = dialog.set_title(title);
|
||||
}
|
||||
if let Some(path) = &options.default_path {
|
||||
dialog = dialog.set_directory(expand_home(path));
|
||||
}
|
||||
for filter in &options.filters {
|
||||
dialog = dialog.add_filter(&filter.name, &filter.extensions);
|
||||
}
|
||||
dialog
|
||||
}
|
||||
|
||||
fn read_local_dir(path: &Path) -> Result<Vec<LocalFileEntry>, String> {
|
||||
let mut files = Vec::new();
|
||||
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
files.push(local_entry(&entry.path())?);
|
||||
}
|
||||
files.sort_by(|a, b| {
|
||||
((a.entry_type == "file"), a.name.to_lowercase())
|
||||
.cmp(&((b.entry_type == "file"), b.name.to_lowercase()))
|
||||
});
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn local_entry(path: &Path) -> Result<LocalFileEntry, String> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?;
|
||||
let entry_type = if metadata.file_type().is_symlink() {
|
||||
"symlink"
|
||||
} else if metadata.is_dir() {
|
||||
"directory"
|
||||
} else {
|
||||
"file"
|
||||
};
|
||||
Ok(LocalFileEntry {
|
||||
name: path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
entry_type: entry_type.to_string(),
|
||||
size: metadata.len(),
|
||||
modified: metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0),
|
||||
permissions: permissions(&metadata, entry_type),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn permissions(metadata: &fs::Metadata, kind: &str) -> String {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = metadata.permissions().mode();
|
||||
let prefix = if kind == "directory" {
|
||||
'd'
|
||||
} else if kind == "symlink" {
|
||||
'l'
|
||||
} else {
|
||||
'-'
|
||||
};
|
||||
format!("{}{}", prefix, permission_bits(mode))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn permissions(_metadata: &fs::Metadata, kind: &str) -> String {
|
||||
if kind == "directory" {
|
||||
"drwxr-xr-x".to_string()
|
||||
} else {
|
||||
"-rw-r--r--".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn permission_bits(mode: u32) -> String {
|
||||
let mut output = String::new();
|
||||
for shift in [6, 3, 0] {
|
||||
let bits = (mode >> shift) & 7;
|
||||
output.push(if bits & 4 != 0 { 'r' } else { '-' });
|
||||
output.push(if bits & 2 != 0 { 'w' } else { '-' });
|
||||
output.push(if bits & 1 != 0 { 'x' } else { '-' });
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn expand_home(value: &str) -> PathBuf {
|
||||
if let Some(relative) = value.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(relative);
|
||||
}
|
||||
}
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
fn path_result(path: PathBuf, open: bool) -> DialogResult {
|
||||
let value = path.to_string_lossy().to_string();
|
||||
DialogResult {
|
||||
canceled: false,
|
||||
file_path: Some(value.clone()),
|
||||
file_paths: open.then_some(vec![value]),
|
||||
}
|
||||
}
|
||||
|
||||
fn paths_result(paths: Vec<PathBuf>) -> DialogResult {
|
||||
if paths.is_empty() {
|
||||
return canceled();
|
||||
}
|
||||
let values = paths
|
||||
.into_iter()
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
DialogResult {
|
||||
canceled: false,
|
||||
file_path: values.first().cloned(),
|
||||
file_paths: Some(values),
|
||||
}
|
||||
}
|
||||
|
||||
fn canceled() -> DialogResult {
|
||||
DialogResult {
|
||||
canceled: true,
|
||||
file_path: None,
|
||||
file_paths: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn operation(result: Result<(), String>) -> OperationResponse {
|
||||
match result {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::fs;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::domain::ssh::OperationResponse;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnownHostEntry {
|
||||
host: String,
|
||||
port: u16,
|
||||
key_type: String,
|
||||
public_key: String,
|
||||
fingerprint: String,
|
||||
first_seen_at: u64,
|
||||
last_seen_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct KnownHostsResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
entries: Option<Vec<KnownHostEntry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn known_hosts_list() -> KnownHostsResponse {
|
||||
match list_entries() {
|
||||
Ok(entries) => KnownHostsResponse {
|
||||
success: true,
|
||||
entries: Some(entries),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => KnownHostsResponse {
|
||||
success: false,
|
||||
entries: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn known_hosts_remove(host: String, port: u16) -> OperationResponse {
|
||||
let Some(path) = known_hosts_path() else {
|
||||
return OperationResponse::error("无法确定 known_hosts 路径");
|
||||
};
|
||||
if !path.is_file() {
|
||||
return OperationResponse::ok();
|
||||
}
|
||||
let input = match fs::read_to_string(&path) {
|
||||
Ok(input) => input,
|
||||
Err(error) => return OperationResponse::error(error.to_string()),
|
||||
};
|
||||
let label = if port == 22 {
|
||||
host.clone()
|
||||
} else {
|
||||
format!("[{host}]:{port}")
|
||||
};
|
||||
let retained = input
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.split_whitespace().next().map_or(true, |hosts| {
|
||||
!hosts
|
||||
.split(',')
|
||||
.any(|candidate| candidate == label || (port == 22 && candidate == host))
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
match fs::write(
|
||||
path,
|
||||
if retained.is_empty() {
|
||||
retained
|
||||
} else {
|
||||
format!("{retained}\n")
|
||||
},
|
||||
) {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn known_hosts_clear() -> OperationResponse {
|
||||
let Some(path) = known_hosts_path() else {
|
||||
return OperationResponse::error("无法确定 known_hosts 路径");
|
||||
};
|
||||
match fs::write(path, "") {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_entries() -> Result<Vec<KnownHostEntry>, String> {
|
||||
let Some(path) = known_hosts_path() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if !path.is_file() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let metadata = fs::metadata(&path).map_err(|error| error.to_string())?;
|
||||
let timestamp = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let input = fs::read_to_string(path).map_err(|error| error.to_string())?;
|
||||
let mut entries = Vec::new();
|
||||
for line in input
|
||||
.lines()
|
||||
.filter(|line| !line.trim_start().starts_with('#'))
|
||||
{
|
||||
let fields = line.split_whitespace().collect::<Vec<_>>();
|
||||
if fields.len() < 3 || fields[0].starts_with('|') {
|
||||
continue;
|
||||
}
|
||||
let Ok(key) = STANDARD_NO_PAD.decode(fields[2]) else {
|
||||
continue;
|
||||
};
|
||||
let fingerprint = STANDARD_NO_PAD.encode(Sha256::digest(key));
|
||||
for label in fields[0].split(',') {
|
||||
let (host, port) = parse_label(label);
|
||||
entries.push(KnownHostEntry {
|
||||
host,
|
||||
port,
|
||||
key_type: fields[1].to_string(),
|
||||
public_key: fields[2].to_string(),
|
||||
fingerprint: fingerprint.clone(),
|
||||
first_seen_at: timestamp,
|
||||
last_seen_at: timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn parse_label(label: &str) -> (String, u16) {
|
||||
if let Some(rest) = label.strip_prefix('[') {
|
||||
if let Some((host, port)) = rest.split_once("]:") {
|
||||
return (host.to_string(), port.parse().unwrap_or(22));
|
||||
}
|
||||
}
|
||||
(label.to_string(), 22)
|
||||
}
|
||||
|
||||
fn known_hosts_path() -> Option<std::path::PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".ssh").join("known_hosts"))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{
|
||||
application::AppState,
|
||||
domain::local_terminal::{AvailableShell, LocalShellCreateResponse, LocalShellOptions},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_list_shells(state: State<'_, AppState>) -> Vec<AvailableShell> {
|
||||
state.local_terminal.list_shells()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_create_shell(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
options: Option<LocalShellOptions>,
|
||||
preferred_id: Option<String>,
|
||||
) -> Result<LocalShellCreateResponse, String> {
|
||||
state
|
||||
.local_terminal
|
||||
.create(app, options.unwrap_or_default(), preferred_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_write(
|
||||
state: State<'_, AppState>,
|
||||
shell_id: String,
|
||||
data: Vec<u8>,
|
||||
) -> Result<(), String> {
|
||||
state.local_terminal.write(&shell_id, &data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_resize(
|
||||
state: State<'_, AppState>,
|
||||
shell_id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> Result<(), String> {
|
||||
state.local_terminal.resize(&shell_id, cols, rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_kill(state: State<'_, AppState>, shell_id: String) -> Result<(), String> {
|
||||
state.local_terminal.kill(&shell_id)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod ai;
|
||||
pub mod deep_link;
|
||||
pub mod files;
|
||||
pub mod known_hosts;
|
||||
pub mod local_terminal;
|
||||
pub mod serial;
|
||||
pub mod ssh;
|
||||
pub mod storage;
|
||||
pub mod system;
|
||||
@@ -0,0 +1,37 @@
|
||||
use tauri::{AppHandle, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
application::AppState,
|
||||
domain::serial::{SerialConfig, SerialOpenResponse, SerialPortSummary},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn serial_list_ports(state: State<'_, AppState>) -> Result<Vec<SerialPortSummary>, String> {
|
||||
state.serial.list_ports()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn serial_open(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
config: SerialConfig,
|
||||
) -> Result<SerialOpenResponse, String> {
|
||||
let session_id = format!("serial-{}", Uuid::new_v4());
|
||||
state.serial.open(app, session_id.clone(), config)?;
|
||||
Ok(SerialOpenResponse { session_id })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn serial_write(
|
||||
state: State<'_, AppState>,
|
||||
session_id: String,
|
||||
data: String,
|
||||
) -> Result<(), String> {
|
||||
state.serial.write(&session_id, data.as_bytes())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn serial_close(state: State<'_, AppState>, session_id: String) -> Result<(), String> {
|
||||
state.serial.close(&session_id)
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{
|
||||
application::AppState,
|
||||
domain::ssh::{
|
||||
ExecResponse, ForwardResponse, ForwardStatus, IdResponse, OperationResponse, SftpDirEntry,
|
||||
SshConnectRequest,
|
||||
},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ssh_connect(
|
||||
state: State<'_, AppState>,
|
||||
config: SshConnectRequest,
|
||||
) -> Result<IdResponse, ()> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || service.connect(config)).await {
|
||||
Ok(Ok(id)) => IdResponse::connection(id),
|
||||
Ok(Err(error)) => IdResponse::error(error),
|
||||
Err(error) => IdResponse::error(format!("SSH 连接任务失败: {error}")),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_disconnect(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.disconnect(&app, &connection_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ssh_exec(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
command: String,
|
||||
) -> Result<ExecResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || service.exec(&connection_id, &command))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(result)) => ExecResponse {
|
||||
success: true,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
},
|
||||
Ok(Err(error)) => ExecResponse {
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(error),
|
||||
},
|
||||
Err(error) => ExecResponse {
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_keepalive(state: State<'_, AppState>, connection_id: String) -> OperationResponse {
|
||||
operation(state.ssh.keepalive(&connection_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ssh_create_shell(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
cols: Option<u32>,
|
||||
rows: Option<u32>,
|
||||
preferred_id: Option<String>,
|
||||
) -> Result<IdResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
service.create_shell(
|
||||
app,
|
||||
&connection_id,
|
||||
cols.unwrap_or(80),
|
||||
rows.unwrap_or(24),
|
||||
preferred_id,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(id)) => IdResponse::shell(id),
|
||||
Ok(Err(error)) => IdResponse::error(error),
|
||||
Err(error) => IdResponse::error(format!("创建 Shell 任务失败: {error}")),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_shell_write(
|
||||
state: State<'_, AppState>,
|
||||
shell_id: String,
|
||||
data: Vec<u8>,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.shell_write(&shell_id, &data))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_shell_resize(
|
||||
state: State<'_, AppState>,
|
||||
shell_id: String,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.shell_resize(&shell_id, cols, rows))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_connect(state: State<'_, AppState>, connection_id: String) -> IdResponse {
|
||||
match state.ssh.create_sftp(&connection_id) {
|
||||
Ok(id) => IdResponse::sftp(id),
|
||||
Err(error) => IdResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SftpListResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
files: Option<Vec<SftpDirEntry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_list_directory(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
) -> SftpListResponse {
|
||||
match state.ssh.sftp_list(&sftp_id, &path) {
|
||||
Ok(files) => SftpListResponse {
|
||||
success: true,
|
||||
files: Some(files),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => SftpListResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sftp_upload(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
local_path: String,
|
||||
remote_path: String,
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
service.sftp_upload(&app, &sftp_id, &local_path, &remote_path)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => operation(result),
|
||||
Err(error) => OperationResponse::error(error.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sftp_download(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
remote_path: String,
|
||||
local_path: String,
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
service.sftp_download(&app, &sftp_id, &remote_path, &local_path)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => operation(result),
|
||||
Err(error) => OperationResponse::error(error.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_rename(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
old_path: String,
|
||||
new_path: String,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.sftp_rename(&sftp_id, &old_path, &new_path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_delete(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
is_directory: bool,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.sftp_delete(&sftp_id, &path, is_directory))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_mkdir(state: State<'_, AppState>, sftp_id: String, path: String) -> OperationResponse {
|
||||
operation(state.ssh.sftp_mkdir(&sftp_id, &path))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SftpStatResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stats: Option<SftpStat>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SftpStat {
|
||||
size: u64,
|
||||
mode: u32,
|
||||
mtime: u64,
|
||||
atime: u64,
|
||||
is_directory: bool,
|
||||
is_file: bool,
|
||||
is_symbolic_link: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_stat(state: State<'_, AppState>, sftp_id: String, path: String) -> SftpStatResponse {
|
||||
match state.ssh.sftp_stat(&sftp_id, &path) {
|
||||
Ok(stat) => {
|
||||
let mode = stat.perm.unwrap_or(0);
|
||||
SftpStatResponse {
|
||||
success: true,
|
||||
stats: Some(SftpStat {
|
||||
size: stat.size.unwrap_or(0),
|
||||
mode,
|
||||
mtime: stat.mtime.unwrap_or(0),
|
||||
atime: stat.atime.unwrap_or(0),
|
||||
is_directory: mode & 0o170000 == 0o040000,
|
||||
is_file: mode & 0o170000 == 0o100000,
|
||||
is_symbolic_link: mode & 0o170000 == 0o120000,
|
||||
}),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(error) => SftpStatResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_chmod(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
mode: u32,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.sftp_chmod(&sftp_id, &path, mode))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SftpReadTextResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
size: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
encoding: Option<&'static str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_read_text(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
) -> SftpReadTextResponse {
|
||||
match state.ssh.sftp_read_text(&sftp_id, &path) {
|
||||
Ok((content, size)) => SftpReadTextResponse {
|
||||
success: true,
|
||||
content: Some(content),
|
||||
size: Some(size),
|
||||
encoding: Some("utf-8"),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => SftpReadTextResponse {
|
||||
success: false,
|
||||
content: None,
|
||||
size: None,
|
||||
encoding: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sftp_write_text(
|
||||
state: State<'_, AppState>,
|
||||
sftp_id: String,
|
||||
path: String,
|
||||
content: String,
|
||||
) -> OperationResponse {
|
||||
operation(state.ssh.sftp_write_text(&sftp_id, &path, &content))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_forward_local(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
forward_id: Option<String>,
|
||||
local_port: u16,
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
) -> ForwardResponse {
|
||||
forward(state.ssh.forward_local(
|
||||
&connection_id,
|
||||
forward_id,
|
||||
local_port,
|
||||
remote_host,
|
||||
remote_port,
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_forward_remote(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
forward_id: Option<String>,
|
||||
remote_port: u16,
|
||||
local_host: String,
|
||||
local_port: u16,
|
||||
) -> ForwardResponse {
|
||||
forward(state.ssh.forward_remote(
|
||||
&connection_id,
|
||||
forward_id,
|
||||
remote_port,
|
||||
local_host,
|
||||
local_port,
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_forward_dynamic(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
forward_id: Option<String>,
|
||||
local_port: u16,
|
||||
) -> ForwardResponse {
|
||||
forward(
|
||||
state
|
||||
.ssh
|
||||
.forward_dynamic(&connection_id, forward_id, local_port),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_forward_stop(state: State<'_, AppState>, forward_id: String) -> OperationResponse {
|
||||
operation(state.ssh.forward_stop(&forward_id))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ForwardListResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tunnels: Option<Vec<ForwardStatus>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ssh_forward_list(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: Option<String>,
|
||||
) -> ForwardListResponse {
|
||||
match state.ssh.forward_list(connection_id.as_deref()) {
|
||||
Ok(tunnels) => ForwardListResponse {
|
||||
success: true,
|
||||
tunnels: Some(tunnels),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => ForwardListResponse {
|
||||
success: false,
|
||||
tunnels: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn operation(result: Result<(), String>) -> OperationResponse {
|
||||
match result {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn forward(result: Result<ForwardStatus, String>) -> ForwardResponse {
|
||||
match result {
|
||||
Ok(status) => ForwardResponse::ok(status.forward_id, status.bound_port),
|
||||
Err(error) => ForwardResponse::error(error),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{application::AppState, domain::ssh::OperationResponse};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SessionsResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sessions: Option<Vec<Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SessionResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SettingResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
value: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_get_all(app: AppHandle, state: State<'_, AppState>) -> SessionsResponse {
|
||||
match state.storage.sessions(&app) {
|
||||
Ok(sessions) => SessionsResponse {
|
||||
success: true,
|
||||
sessions: Some(sessions),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => SessionsResponse {
|
||||
success: false,
|
||||
sessions: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_get_by_id(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> SessionResponse {
|
||||
match state.storage.session(&app, &id) {
|
||||
Ok(session) => SessionResponse {
|
||||
success: session.is_some(),
|
||||
session,
|
||||
error: None,
|
||||
},
|
||||
Err(error) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_create(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
session: Value,
|
||||
) -> SessionResponse {
|
||||
match state.storage.create_session(&app, session) {
|
||||
Ok(session) => SessionResponse {
|
||||
success: true,
|
||||
session: Some(session),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_update(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
updates: Value,
|
||||
) -> SessionResponse {
|
||||
match state.storage.update_session(&app, &id, updates) {
|
||||
Ok(session) => SessionResponse {
|
||||
success: true,
|
||||
session: Some(session),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_delete(app: AppHandle, state: State<'_, AppState>, id: String) -> OperationResponse {
|
||||
match state.storage.delete_session(&app, &id) {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn settings_get(app: AppHandle, state: State<'_, AppState>, key: String) -> SettingResponse {
|
||||
match state.storage.get_setting(&app, &key) {
|
||||
Ok(value) => SettingResponse {
|
||||
success: true,
|
||||
value,
|
||||
error: None,
|
||||
},
|
||||
Err(error) => SettingResponse {
|
||||
success: false,
|
||||
value: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn settings_set(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
key: String,
|
||||
value: Value,
|
||||
) -> OperationResponse {
|
||||
match state.storage.set_setting(&app, key, value) {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppRuntimeInfo {
|
||||
app_version: String,
|
||||
os: String,
|
||||
arch: String,
|
||||
native_backend: String,
|
||||
ssh_backend: String,
|
||||
data_path: String,
|
||||
credential_path: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn app_version(app: AppHandle) -> String {
|
||||
app.package_info().version.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn app_runtime_info(app: AppHandle) -> Result<AppRuntimeInfo, String> {
|
||||
let data_path = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("无法确定应用数据目录: {error}"))?
|
||||
.join("opsxshell-data.json");
|
||||
let credential_path = dirs::home_dir()
|
||||
.ok_or_else(|| "无法确定用户主目录".to_string())?
|
||||
.join(".opsxshsell")
|
||||
.join("master.key");
|
||||
|
||||
Ok(AppRuntimeInfo {
|
||||
app_version: app.package_info().version.to_string(),
|
||||
os: std::env::consts::OS.to_string(),
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
native_backend: "Tauri 2 / Rust".to_string(),
|
||||
ssh_backend: "libssh2 / ssh2 0.9".to_string(),
|
||||
data_path: data_path.display().to_string(),
|
||||
credential_path: credential_path.display().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_maximize(window: WebviewWindow) -> Result<(), String> {
|
||||
let maximized = window.is_maximized().map_err(|error| error.to_string())?;
|
||||
if maximized {
|
||||
window.unmaximize().map_err(|error| error.to_string())
|
||||
} else {
|
||||
window.maximize().map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clipboard_read_text() -> Result<String, String> {
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?;
|
||||
clipboard.get_text().map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clipboard_write_text(text: String) -> Result<(), String> {
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?;
|
||||
clipboard.set_text(text).map_err(|error| error.to_string())
|
||||
}
|
||||
Reference in new issue
Block a user