feat: release OpsXShell 2.1.0 native Tauri edition

This commit is contained in:
Logan.Li committed 2026-07-30 23:32:51 +08:00
1 parent e9a0d21758
commit a3dba0c6ed
311 files changed
+31748 -14897

No files matched your search

+181
View File
@@ -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());
}
}
+8
View File
@@ -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()
}
+379
View File
@@ -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),
}
}
+153
View File
@@ -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"))
}
+47
View File
@@ -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)
}
+9
View File
@@ -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;
+37
View File
@@ -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)
}
+425
View File
@@ -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),
}
}
+146
View File
@@ -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),
}
}
+64
View File
@@ -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())
}