chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进
This commit is contained in:
1 parent
f71eef0eba
commit
ae6093c9b3
163 files changed
+4911
-2494
No files matched your search
+180
-46
@@ -118,16 +118,28 @@ pub fn show_open_dialog(options: DialogOptions) -> DialogResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn show_save_dialog(options: DialogOptions) -> DialogResult {
|
||||
configure_dialog(rfd::FileDialog::new(), &options)
|
||||
pub fn show_save_dialog(app: AppHandle, options: DialogOptions) -> DialogResult {
|
||||
let mut dialog = configure_dialog(rfd::FileDialog::new(), &options);
|
||||
let missing_dir = options
|
||||
.default_path
|
||||
.as_deref()
|
||||
.map(resolve_dialog_path)
|
||||
.map(|path| path.directory.is_none())
|
||||
.unwrap_or(true);
|
||||
if missing_dir {
|
||||
if let Ok(downloads) = app.path().download_dir() {
|
||||
dialog = dialog.set_directory(downloads);
|
||||
}
|
||||
}
|
||||
dialog
|
||||
.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)) {
|
||||
pub async fn read_file(file_path: String) -> ReadFileResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match fs::read(expand_home(&file_path)) {
|
||||
Ok(data) => ReadFileResponse {
|
||||
success: true,
|
||||
data: Some(STANDARD.encode(data)),
|
||||
@@ -138,18 +150,33 @@ pub fn read_file(file_path: String) -> ReadFileResponse {
|
||||
data: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| ReadFileResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.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)
|
||||
pub async fn write_file(file_path: String, data: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match STANDARD.decode(&data) {
|
||||
Err(error) => OperationResponse::coded(
|
||||
crate::domain::error::codes::INVALID_BASE64,
|
||||
error.to_string(),
|
||||
),
|
||||
Ok(bytes) => {
|
||||
operation(fs::write(expand_home(&file_path), bytes).map_err(|error| error.to_string()))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"write file background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -187,8 +214,8 @@ pub fn open_downloads_folder(app: AppHandle) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
match read_local_dir(&expand_home(&path)) {
|
||||
pub async fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match read_local_dir(&expand_home(&path)) {
|
||||
Ok(files) => LocalFilesResponse {
|
||||
success: true,
|
||||
files: Some(files),
|
||||
@@ -199,48 +226,89 @@ pub fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
files: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalFilesResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[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),
|
||||
},
|
||||
}
|
||||
pub async fn local_get_stats(path: String) -> LocalStatsResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
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),
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_mkdir(path: String) -> OperationResponse {
|
||||
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string()))
|
||||
pub async fn local_mkdir(path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string()))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"mkdir background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[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()))
|
||||
pub async fn local_delete(path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
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()))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"delete background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[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()),
|
||||
)
|
||||
pub async fn local_rename(old_path: String, new_path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
operation(
|
||||
fs::rename(expand_home(&old_path), expand_home(&new_path))
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"rename background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -251,12 +319,52 @@ pub fn open_external(url: String) -> Result<(), String> {
|
||||
open::that(url).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct DialogPath {
|
||||
directory: Option<PathBuf>,
|
||||
file_name: Option<String>,
|
||||
}
|
||||
|
||||
fn resolve_dialog_path(raw: &str) -> DialogPath {
|
||||
let expanded = expand_home(raw);
|
||||
if expanded.as_os_str().is_empty() {
|
||||
return DialogPath::default();
|
||||
}
|
||||
if expanded.is_dir() {
|
||||
return DialogPath {
|
||||
directory: Some(expanded),
|
||||
file_name: None,
|
||||
};
|
||||
}
|
||||
let file_name = expanded
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.filter(|name| !name.is_empty());
|
||||
let directory = expanded.parent().and_then(|parent| {
|
||||
if parent.as_os_str().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parent.to_path_buf())
|
||||
}
|
||||
});
|
||||
DialogPath {
|
||||
directory,
|
||||
file_name,
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
let resolved = resolve_dialog_path(path);
|
||||
if let Some(dir) = resolved.directory {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
if let Some(name) = resolved.file_name {
|
||||
dialog = dialog.set_file_name(name);
|
||||
}
|
||||
}
|
||||
for filter in &options.filters {
|
||||
dialog = dialog.add_filter(&filter.name, &filter.extensions);
|
||||
@@ -393,3 +501,29 @@ fn operation(result: Result<(), String>) -> OperationResponse {
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bare_backup_filename_fills_save_as_not_directory() {
|
||||
let path = resolve_dialog_path("opsxshell-hosts.oxs");
|
||||
assert_eq!(path.file_name.as_deref(), Some("opsxshell-hosts.oxs"));
|
||||
assert_eq!(path.directory, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_file_path_splits_directory_and_name() {
|
||||
let path = resolve_dialog_path("/tmp/opsxshell-hosts.oxs");
|
||||
assert_eq!(path.file_name.as_deref(), Some("opsxshell-hosts.oxs"));
|
||||
assert_eq!(path.directory.as_deref(), Some(Path::new("/tmp")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_directory_is_picker_folder() {
|
||||
let path = resolve_dialog_path("/tmp");
|
||||
assert_eq!(path.file_name, None);
|
||||
assert_eq!(path.directory.as_deref(), Some(Path::new("/tmp")));
|
||||
}
|
||||
}
|
||||
@@ -29,25 +29,52 @@ pub struct KnownHostsResponse {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn known_hosts_list() -> KnownHostsResponse {
|
||||
match list_entries() {
|
||||
Ok(entries) => KnownHostsResponse {
|
||||
pub async fn known_hosts_list() -> KnownHostsResponse {
|
||||
tauri::async_runtime::spawn_blocking(list_entries)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(crate::domain::error::codes::TASK_JOIN.to_string()))
|
||||
.map(|entries| KnownHostsResponse {
|
||||
success: true,
|
||||
entries: Some(entries),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => KnownHostsResponse {
|
||||
})
|
||||
.unwrap_or_else(|error| KnownHostsResponse {
|
||||
success: false,
|
||||
entries: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn known_hosts_remove(host: String, port: u16) -> OperationResponse {
|
||||
pub async fn known_hosts_remove(host: String, port: u16) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || known_hosts_remove_inner(host, port))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"known_hosts remove background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn known_hosts_clear() -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(known_hosts_clear_inner)
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"known_hosts clear background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn known_hosts_remove_inner(host: String, port: u16) -> OperationResponse {
|
||||
let Some(path) = known_hosts_path() else {
|
||||
return OperationResponse::error("无法确定 known_hosts 路径");
|
||||
return OperationResponse::coded(
|
||||
crate::domain::error::codes::KNOWN_HOSTS_PATH,
|
||||
"known_hosts path unavailable",
|
||||
);
|
||||
};
|
||||
if !path.is_file() {
|
||||
return OperationResponse::ok();
|
||||
@@ -85,10 +112,12 @@ pub fn known_hosts_remove(host: String, port: u16) -> OperationResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn known_hosts_clear() -> OperationResponse {
|
||||
fn known_hosts_clear_inner() -> OperationResponse {
|
||||
let Some(path) = known_hosts_path() else {
|
||||
return OperationResponse::error("无法确定 known_hosts 路径");
|
||||
return OperationResponse::coded(
|
||||
crate::domain::error::codes::KNOWN_HOSTS_PATH,
|
||||
"known_hosts path unavailable",
|
||||
);
|
||||
};
|
||||
match fs::write(path, "") {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
@@ -99,7 +128,10 @@ pub fn known_hosts_clear() -> OperationResponse {
|
||||
#[tauri::command]
|
||||
pub fn ssh_respond_host_key(request_id: String, decision: String) -> OperationResponse {
|
||||
let Some(parsed) = HostKeyDecision::parse(&decision) else {
|
||||
return OperationResponse::error("无效的主机密钥决定");
|
||||
return OperationResponse::coded(
|
||||
crate::domain::error::codes::INVALID_HOST_KEY_DECISION,
|
||||
"invalid host key decision",
|
||||
);
|
||||
};
|
||||
match respond_host_key(&request_id, parsed) {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
|
||||
@@ -19,7 +19,10 @@ pub async fn ssh_connect(
|
||||
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}")),
|
||||
Err(_) => IdResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"SSH connect background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -30,18 +33,20 @@ pub async fn ssh_disconnect(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
retain_forwards: Option<bool>,
|
||||
reason: Option<String>,
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
let retain = retain_forwards.unwrap_or(false);
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
service.disconnect(&app, &connection_id, retain)
|
||||
service.disconnect(&app, &connection_id, retain, reason)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(operation(result)),
|
||||
Err(error) => Ok(OperationResponse::error(format!(
|
||||
"断开连接任务失败: {error}"
|
||||
))),
|
||||
Err(_) => Ok(OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"disconnect background task failed",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +57,8 @@ pub async fn ssh_exec(
|
||||
command: String,
|
||||
from_agent: Option<bool>,
|
||||
) -> Result<ExecResponse, String> {
|
||||
if from_agent.unwrap_or(false) {
|
||||
let from_agent = from_agent.unwrap_or(false);
|
||||
if from_agent {
|
||||
if let Err(error) = crate::services::ai::guardrail::reject_critical(&command) {
|
||||
return Ok(ExecResponse {
|
||||
success: false,
|
||||
@@ -71,7 +77,11 @@ pub async fn ssh_exec(
|
||||
{
|
||||
Ok(Ok((result, exit_code))) => ExecResponse {
|
||||
success: true,
|
||||
result: Some(result),
|
||||
result: Some(if from_agent {
|
||||
crate::services::ai::redact_sensitive(&result).0
|
||||
} else {
|
||||
result
|
||||
}),
|
||||
exit_code: Some(exit_code),
|
||||
error: None,
|
||||
},
|
||||
@@ -79,7 +89,11 @@ pub async fn ssh_exec(
|
||||
success: false,
|
||||
result: None,
|
||||
exit_code: None,
|
||||
error: Some(error),
|
||||
error: Some(if from_agent {
|
||||
crate::services::ai::redact_sensitive(&error).0
|
||||
} else {
|
||||
error
|
||||
}),
|
||||
},
|
||||
Err(error) => ExecResponse {
|
||||
success: false,
|
||||
@@ -118,7 +132,10 @@ pub async fn ssh_keepalive(
|
||||
let service = state.ssh.clone();
|
||||
match tauri::async_runtime::spawn_blocking(move || service.keepalive(&connection_id)).await {
|
||||
Ok(result) => Ok(operation(result)),
|
||||
Err(error) => Ok(OperationResponse::error(format!("保活任务失败: {error}"))),
|
||||
Err(_) => Ok(OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"keepalive background task failed",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +163,10 @@ pub async fn ssh_create_shell(
|
||||
{
|
||||
Ok(Ok(id)) => IdResponse::shell(id),
|
||||
Ok(Err(error)) => IdResponse::error(error),
|
||||
Err(error) => IdResponse::error(format!("创建 Shell 任务失败: {error}")),
|
||||
Err(_) => IdResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"create shell background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -202,7 +222,10 @@ pub async fn sftp_connect(
|
||||
{
|
||||
Ok(Ok(id)) => IdResponse::sftp(id),
|
||||
Ok(Err(error)) => IdResponse::error(error),
|
||||
Err(error) => IdResponse::error(format!("创建 SFTP 任务失败: {error}")),
|
||||
Err(_) => IdResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"create SFTP background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,154 +5,269 @@ use tauri::{AppHandle, State};
|
||||
use crate::{application::AppState, domain::ssh::OperationResponse};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_code: Option<String>,
|
||||
}
|
||||
|
||||
fn join_failed() -> String {
|
||||
crate::domain::error::codes::TASK_JOIN.to_string()
|
||||
}
|
||||
|
||||
fn join_code() -> Option<String> {
|
||||
Some(crate::domain::error::codes::TASK_JOIN.to_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,
|
||||
pub async fn session_get_all(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SessionsResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.sessions(&app)).await {
|
||||
Ok(Ok(sessions)) => SessionsResponse {
|
||||
success: true,
|
||||
sessions: Some(sessions),
|
||||
error: None,
|
||||
error_code: None,
|
||||
},
|
||||
Ok(Err(error)) => SessionsResponse {
|
||||
success: false,
|
||||
sessions: None,
|
||||
error: Some(error),
|
||||
error_code: None,
|
||||
},
|
||||
Err(_) => SessionsResponse {
|
||||
success: false,
|
||||
sessions: None,
|
||||
error: Some(join_failed()),
|
||||
error_code: join_code(),
|
||||
},
|
||||
},
|
||||
Err(error) => SessionsResponse {
|
||||
success: false,
|
||||
sessions: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_get_by_id(
|
||||
pub async 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,
|
||||
) -> Result<SessionResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.session(&app, &id)).await {
|
||||
Ok(Ok(session)) => SessionResponse {
|
||||
success: session.is_some(),
|
||||
session,
|
||||
error: None,
|
||||
error_code: None,
|
||||
},
|
||||
Ok(Err(error)) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
error_code: None,
|
||||
},
|
||||
Err(_) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(join_failed()),
|
||||
error_code: join_code(),
|
||||
},
|
||||
},
|
||||
Err(error) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_create(
|
||||
pub async 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,
|
||||
) -> Result<SessionResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.create_session(&app, session))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(session)) => SessionResponse {
|
||||
success: true,
|
||||
session: Some(session),
|
||||
error: None,
|
||||
error_code: None,
|
||||
},
|
||||
Ok(Err(error)) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
error_code: None,
|
||||
},
|
||||
Err(_) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(join_failed()),
|
||||
error_code: join_code(),
|
||||
},
|
||||
},
|
||||
Err(error) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_update(
|
||||
pub async 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,
|
||||
) -> Result<SessionResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
storage.update_session(&app, &id, updates)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(session)) => SessionResponse {
|
||||
success: true,
|
||||
session: Some(session),
|
||||
error: None,
|
||||
error_code: None,
|
||||
},
|
||||
Ok(Err(error)) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
error_code: None,
|
||||
},
|
||||
Err(_) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(join_failed()),
|
||||
error_code: join_code(),
|
||||
},
|
||||
},
|
||||
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),
|
||||
}
|
||||
pub async fn session_delete(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<OperationResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.delete_session(&app, &id)).await
|
||||
{
|
||||
Ok(Ok(())) => OperationResponse::ok(),
|
||||
Ok(Err(error)) => OperationResponse::error(error),
|
||||
Err(_) => OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"delete session background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_reorder(
|
||||
pub async fn session_reorder(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
updates: Vec<Value>,
|
||||
) -> OperationResponse {
|
||||
match state.storage.reorder_sessions(&app, updates) {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
) -> Result<OperationResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.reorder_sessions(&app, updates))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => OperationResponse::ok(),
|
||||
Ok(Err(error)) => OperationResponse::error(error),
|
||||
Err(_) => OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"reorder sessions background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[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,
|
||||
pub async fn settings_get(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
key: String,
|
||||
) -> Result<SettingResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.get_setting(&app, &key)).await {
|
||||
Ok(Ok(value)) => SettingResponse {
|
||||
success: true,
|
||||
value,
|
||||
error: None,
|
||||
error_code: None,
|
||||
},
|
||||
Ok(Err(error)) => SettingResponse {
|
||||
success: false,
|
||||
value: None,
|
||||
error: Some(error),
|
||||
error_code: None,
|
||||
},
|
||||
Err(_) => SettingResponse {
|
||||
success: false,
|
||||
value: None,
|
||||
error: Some(join_failed()),
|
||||
error_code: join_code(),
|
||||
},
|
||||
},
|
||||
Err(error) => SettingResponse {
|
||||
success: false,
|
||||
value: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn settings_set(
|
||||
pub async 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),
|
||||
}
|
||||
) -> Result<OperationResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.set_setting(&app, key, value))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => OperationResponse::ok(),
|
||||
Ok(Err(error)) => OperationResponse::error(error),
|
||||
Err(_) => OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"settings set background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DomainError {
|
||||
#[error("resource not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("operation is not available: {0}")]
|
||||
Unsupported(String),
|
||||
/**
|
||||
* Stable IPC error codes. The renderer maps `errors:native.*` (and other
|
||||
* `errors:` keys that match these dotted paths). Command envelopes may also
|
||||
* still carry a human `error` string for logs.
|
||||
*/
|
||||
pub mod codes {
|
||||
pub const TASK_JOIN: &str = "native.taskJoin";
|
||||
pub const INVALID_BASE64: &str = "native.invalidBase64";
|
||||
pub const KNOWN_HOSTS_PATH: &str = "native.knownHostsPath";
|
||||
pub const INVALID_HOST_KEY_DECISION: &str = "native.invalidHostKeyDecision";
|
||||
}
|
||||
@@ -27,6 +27,8 @@ pub struct OperationResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
impl OperationResponse {
|
||||
@@ -34,6 +36,7 @@ impl OperationResponse {
|
||||
Self {
|
||||
success: true,
|
||||
error: None,
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +44,15 @@ impl OperationResponse {
|
||||
Self {
|
||||
success: false,
|
||||
error: Some(error.into()),
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn coded(code: &'static str, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
error: Some(error.into()),
|
||||
error_code: Some(code.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +69,8 @@ pub struct IdResponse {
|
||||
pub sftp_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
impl IdResponse {
|
||||
@@ -67,6 +81,7 @@ impl IdResponse {
|
||||
shell_id: None,
|
||||
sftp_id: None,
|
||||
error: None,
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +92,7 @@ impl IdResponse {
|
||||
shell_id: Some(id),
|
||||
sftp_id: None,
|
||||
error: None,
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +103,7 @@ impl IdResponse {
|
||||
shell_id: None,
|
||||
sftp_id: Some(id),
|
||||
error: None,
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +114,18 @@ impl IdResponse {
|
||||
shell_id: None,
|
||||
sftp_id: None,
|
||||
error: Some(error.into()),
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn coded(code: &'static str, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
connection_id: None,
|
||||
shell_id: None,
|
||||
sftp_id: None,
|
||||
error: Some(error.into()),
|
||||
error_code: Some(code.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use url::Url;
|
||||
|
||||
use crate::domain::ai::{AiCatalogDocument, AiCompleteRequest, AiProviderConfig};
|
||||
|
||||
use redaction::redact_sensitive;
|
||||
pub(crate) use redaction::redact_sensitive;
|
||||
|
||||
pub struct AiCompletion {
|
||||
pub text: String,
|
||||
|
||||
@@ -168,14 +168,21 @@ fn set_file_permissions(_path: &std::path::Path) -> Result<(), String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static TEST_HOME_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn unique_home() -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time")
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("opsxshell-cred-test-{nanos}"));
|
||||
let seq = TEST_HOME_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"opsxshell-cred-test-{nanos}-{seq}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("temp home");
|
||||
dir
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Clipboard text cleanup shared by VNC CutText (and tests).
|
||||
//!
|
||||
//! Terminal emulators wrap clipboard paste in bracketed-paste markers
|
||||
//! (`ESC [ 200 ~` … `ESC [ 201 ~`). If we inject Ctrl+V into a remote
|
||||
//! terminal, those markers show up as `^[[200~…~`. Strip them on both
|
||||
//! directions and type Unicode keysyms instead of a paste chord.
|
||||
|
||||
/// Max characters typed as RFB key events. Larger payloads stay on CutText
|
||||
/// and fall back to Ctrl+V so a huge clipboard cannot stall the session.
|
||||
pub const MAX_TYPE_CHARS: usize = 8192;
|
||||
|
||||
pub fn sanitize_clipboard_text(text: &str) -> String {
|
||||
let stripped = text
|
||||
.replace("\u{1b}[200~", "")
|
||||
.replace("\u{1b}[201~", "")
|
||||
.replace("^[[200~", "")
|
||||
.replace("^[[201~", "");
|
||||
|
||||
let mut out = String::with_capacity(stripped.len());
|
||||
let mut prev_cr = false;
|
||||
for ch in stripped.chars() {
|
||||
if prev_cr && ch == '\n' {
|
||||
prev_cr = false;
|
||||
continue;
|
||||
}
|
||||
prev_cr = false;
|
||||
match ch {
|
||||
'\n' => out.push('\n'),
|
||||
'\r' => {
|
||||
out.push('\n');
|
||||
prev_cr = true;
|
||||
}
|
||||
'\t' => out.push('\t'),
|
||||
'\u{feff}' => {}
|
||||
c if c.is_control() => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn vnc_keysym_for_char(ch: char) -> Option<u32> {
|
||||
match ch {
|
||||
'\n' => Some(0xff0d),
|
||||
'\t' => Some(0xff09),
|
||||
c if c.is_control() => None,
|
||||
c => {
|
||||
let codepoint = c as u32;
|
||||
if codepoint < 0x100 {
|
||||
Some(codepoint)
|
||||
} else {
|
||||
Some(0x01000000 | codepoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_type_as_keys(text: &str) -> bool {
|
||||
!text.is_empty() && text.chars().count() <= MAX_TYPE_CHARS
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_bracketed_paste_around_ip() {
|
||||
let wrapped = "\u{1b}[200~47.110.68.252\u{1b}[201~";
|
||||
assert_eq!(sanitize_clipboard_text(wrapped), "47.110.68.252");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_caret_notation_copied_from_screen() {
|
||||
assert_eq!(
|
||||
sanitize_clipboard_text("^[[200~47.110.68.252^[[201~"),
|
||||
"47.110.68.252"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_crlf_and_drops_other_controls() {
|
||||
assert_eq!(sanitize_clipboard_text("a\r\nb\rc\u{0007}d"), "a\nb\ncd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_cjk_and_tabs() {
|
||||
assert_eq!(sanitize_clipboard_text("主机\t名称"), "主机\t名称");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keysym_for_ascii_and_return() {
|
||||
assert_eq!(vnc_keysym_for_char('4'), Some(0x34));
|
||||
assert_eq!(vnc_keysym_for_char('.'), Some(0x2e));
|
||||
assert_eq!(vnc_keysym_for_char('\n'), Some(0xff0d));
|
||||
assert_eq!(vnc_keysym_for_char('中'), Some(0x01004e2d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn types_small_payloads_not_huge_ones() {
|
||||
assert!(should_type_as_keys("47.110.68.252"));
|
||||
let huge = "x".repeat(MAX_TYPE_CHARS + 1);
|
||||
assert!(!should_type_as_keys(&huge));
|
||||
assert!(!should_type_as_keys(""));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ use tracing::{debug, warn};
|
||||
|
||||
use crate::domain::desktop::RdpClipboardEvent;
|
||||
|
||||
use super::clipboard_text::sanitize_clipboard_text;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OpsxCliprdrBackend {
|
||||
app: AppHandle,
|
||||
@@ -144,6 +146,7 @@ impl CliprdrBackend for OpsxCliprdrBackend {
|
||||
.to_unicode_string()
|
||||
.or_else(|_| response.to_string())
|
||||
.unwrap_or_default();
|
||||
let text = sanitize_clipboard_text(&text);
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -179,6 +182,10 @@ pub fn queue_local_clipboard_set(
|
||||
want_paste_inject: &AtomicBool,
|
||||
text: String,
|
||||
) {
|
||||
let text = sanitize_clipboard_text(&text);
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut guard) = local_text.lock() {
|
||||
*guard = Some(text);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! IronRDP and vnc-rs drive Canvas frames over Tauri events. Session runtime
|
||||
//! state lives only in memory; disconnect cancels the worker thread.
|
||||
|
||||
mod clipboard_text;
|
||||
mod cliprdr_backend;
|
||||
mod common;
|
||||
mod input_map;
|
||||
|
||||
@@ -16,6 +16,7 @@ use vnc::{
|
||||
|
||||
use crate::domain::desktop::{VncClipboardEvent, VncConfig};
|
||||
|
||||
use super::clipboard_text::{sanitize_clipboard_text, should_type_as_keys, vnc_keysym_for_char};
|
||||
use super::common::{coalesce_desktop_inputs, emit_frame, emit_status, DesktopInput};
|
||||
use super::input_map;
|
||||
|
||||
@@ -125,6 +126,7 @@ async fn run_session_async(
|
||||
let mut fb_height = height;
|
||||
let mut framebuffer: Vec<u8> = vec![0; usize::from(fb_width) * usize::from(fb_height) * 4];
|
||||
let mut buttons: u8 = 0;
|
||||
let mut last_outbound_cut: Option<String> = None;
|
||||
let mut last_refresh = std::time::Instant::now();
|
||||
|
||||
emit_status(
|
||||
@@ -152,7 +154,7 @@ async fn run_session_async(
|
||||
let _ = vnc.input(X11Event::FullRefresh).await;
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
drain_input(&vnc, &input, &mut buttons).await?;
|
||||
drain_input(&vnc, &input, &mut buttons, &mut last_outbound_cut).await?;
|
||||
|
||||
match vnc.poll_event().await {
|
||||
Ok(Some(event)) => {
|
||||
@@ -163,6 +165,7 @@ async fn run_session_async(
|
||||
&mut fb_width,
|
||||
&mut fb_height,
|
||||
&mut framebuffer,
|
||||
&mut last_outbound_cut,
|
||||
)?;
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -187,6 +190,7 @@ async fn drain_input(
|
||||
vnc: &vnc::VncClient,
|
||||
input: &Mutex<Vec<DesktopInput>>,
|
||||
buttons: &mut u8,
|
||||
last_outbound_cut: &mut Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let queued = {
|
||||
let mut guard = input
|
||||
@@ -233,15 +237,20 @@ async fn drain_input(
|
||||
.map_err(|error| format!("发送键盘事件失败: {error}"))?;
|
||||
}
|
||||
DesktopInput::ClipboardSet { text } => {
|
||||
let cut = sanitize_clipboard_text(&text);
|
||||
if cut.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Classic RFB CutText is Latin-1, but vnc-rs writes String::as_bytes()
|
||||
// (UTF-8). Prefer UTF-8 on the wire so CJK survives on modern servers;
|
||||
// fall back to Latin-1 substitution only when the payload is pure BMP Latin-1.
|
||||
let cut = prepare_cut_text(&text);
|
||||
vnc.input(X11Event::CopyText(cut))
|
||||
// (UTF-8). Prefer UTF-8 on the wire so CJK survives on modern servers.
|
||||
*last_outbound_cut = Some(cut.clone());
|
||||
vnc.input(X11Event::CopyText(cut.clone()))
|
||||
.await
|
||||
.map_err(|error| format!("发送剪贴板失败: {error}"))?;
|
||||
// Most servers only update remote clipboard; inject Ctrl+V for paste UX.
|
||||
inject_remote_paste(vnc).await?;
|
||||
// Do not inject Ctrl+V: remote terminals wrap clipboard paste in
|
||||
// bracketed-paste (`ESC[200~` … `ESC[201~`), which shows up as
|
||||
// `^[[200~47.110.68.252~`. Type Unicode keysyms instead.
|
||||
inject_clipboard_into_remote(vnc, &cut).await?;
|
||||
}
|
||||
DesktopInput::UploadFile { .. } | DesktopInput::Resize { .. } => {}
|
||||
}
|
||||
@@ -249,7 +258,46 @@ async fn drain_input(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inject_remote_paste(vnc: &vnc::VncClient) -> Result<(), String> {
|
||||
async fn inject_clipboard_into_remote(vnc: &vnc::VncClient, text: &str) -> Result<(), String> {
|
||||
release_modifier_keys(vnc).await?;
|
||||
if should_type_as_keys(text) {
|
||||
inject_text_as_keys(vnc, text).await
|
||||
} else {
|
||||
inject_ctrl_v(vnc).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl was already forwarded as Super/Control-down before the V key.
|
||||
/// Release modifiers so typed characters are not Super+digit / Ctrl+letter.
|
||||
async fn release_modifier_keys(vnc: &vnc::VncClient) -> Result<(), String> {
|
||||
const MODIFIERS: [u32; 8] = [
|
||||
0xffe3, // Control_L
|
||||
0xffe4, // Control_R
|
||||
0xffe1, // Shift_L
|
||||
0xffe2, // Shift_R
|
||||
0xffe9, // Alt_L
|
||||
0xffea, // Alt_R
|
||||
0xffeb, // Super_L
|
||||
0xffec, // Super_R
|
||||
];
|
||||
for keycode in MODIFIERS {
|
||||
send_key(vnc, keycode, false).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inject_text_as_keys(vnc: &vnc::VncClient, text: &str) -> Result<(), String> {
|
||||
for ch in text.chars() {
|
||||
let Some(keycode) = vnc_keysym_for_char(ch) else {
|
||||
continue;
|
||||
};
|
||||
send_key(vnc, keycode, true).await?;
|
||||
send_key(vnc, keycode, false).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inject_ctrl_v(vnc: &vnc::VncClient) -> Result<(), String> {
|
||||
const CONTROL: u32 = 0xffe3;
|
||||
const KEY_V: u32 = 0x0076;
|
||||
for (keycode, down) in [
|
||||
@@ -258,18 +306,15 @@ async fn inject_remote_paste(vnc: &vnc::VncClient) -> Result<(), String> {
|
||||
(KEY_V, false),
|
||||
(CONTROL, false),
|
||||
] {
|
||||
vnc.input(X11Event::KeyEvent(ClientKeyEvent { keycode, down }))
|
||||
.await
|
||||
.map_err(|error| format!("注入粘贴快捷键失败: {error}"))?;
|
||||
send_key(vnc, keycode, down).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_cut_text(text: &str) -> String {
|
||||
// Send UTF-8 as-is. Classic Latin-1 servers may mojibake non-ASCII, but replacing
|
||||
// CJK with '?' is worse for OpsXShell's primary locale. Extended Clipboard (UTF-8)
|
||||
// needs a vnc-rs protocol upgrade beyond CopyText.
|
||||
text.to_string()
|
||||
async fn send_key(vnc: &vnc::VncClient, keycode: u32, down: bool) -> Result<(), String> {
|
||||
vnc.input(X11Event::KeyEvent(ClientKeyEvent { keycode, down }))
|
||||
.await
|
||||
.map_err(|error| format!("发送键盘事件失败: {error}"))
|
||||
}
|
||||
|
||||
fn decode_tight_jpeg(data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
@@ -292,6 +337,7 @@ fn handle_vnc_event(
|
||||
fb_width: &mut u16,
|
||||
fb_height: &mut u16,
|
||||
framebuffer: &mut Vec<u8>,
|
||||
last_outbound_cut: &mut Option<String>,
|
||||
) -> Result<(), String> {
|
||||
match event {
|
||||
VncEvent::SetResolution(screen) => {
|
||||
@@ -372,9 +418,13 @@ fn handle_vnc_event(
|
||||
},
|
||||
VncEvent::Error(message) => return Err(message),
|
||||
VncEvent::Text(text) => {
|
||||
let text = sanitize_clipboard_text(&text);
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if last_outbound_cut.as_ref() == Some(&text) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||
let _ = clipboard.set_text(text.clone());
|
||||
}
|
||||
|
||||
@@ -96,7 +96,12 @@ impl SshService {
|
||||
app: &AppHandle,
|
||||
connection_id: &str,
|
||||
retain_forwards: bool,
|
||||
reason: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let reason = reason
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "connection closed".to_string());
|
||||
// Dedicated tunnel sessions keep listening across terminal flaps when
|
||||
// retain_forwards is true. Explicit teardown (tab close / panel disconnect)
|
||||
// must still close real Listeners.
|
||||
@@ -123,7 +128,7 @@ impl SshService {
|
||||
for shell_id in shell_ids {
|
||||
if let Some(mut shell) = self.shells.lock().map_err(lock_error)?.remove(&shell_id) {
|
||||
let _ = shell.channel.close();
|
||||
let _ = shell.session.disconnect(None, "closed by user", None);
|
||||
let _ = shell.session.disconnect(None, &reason, None);
|
||||
}
|
||||
}
|
||||
let sftp_ids = {
|
||||
@@ -148,13 +153,13 @@ impl SshService {
|
||||
.map_err(lock_error)?
|
||||
.remove(connection_id)
|
||||
{
|
||||
let _ = connection.session.disconnect(None, "closed by user", None);
|
||||
let _ = connection.session.disconnect(None, &reason, None);
|
||||
}
|
||||
let _ = app.emit(
|
||||
"ssh:connection-closed",
|
||||
ConnectionClosedEvent {
|
||||
connection_id: connection_id.to_string(),
|
||||
reason: "closed by user".to_string(),
|
||||
reason,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
@@ -657,15 +662,12 @@ fn classify_shell_close(output_tail: &[u8], read_error: Option<&str>) -> (String
|
||||
|| output.contains("auto-logout")
|
||||
|| output.contains("auto logout");
|
||||
if idle_timeout {
|
||||
return ("远端 Shell 因空闲策略(TMOUT)自动退出".to_string(), true);
|
||||
return ("shell.idleTimeout".to_string(), true);
|
||||
}
|
||||
if let Some(error) = read_error {
|
||||
return (format!("远端 Shell 读取失败: {error}"), false);
|
||||
return (format!("shell.readFailed: {error}"), false);
|
||||
}
|
||||
(
|
||||
"远端 Shell 已退出(可能执行了 exit、Ctrl+D,或被服务器策略关闭)".to_string(),
|
||||
false,
|
||||
)
|
||||
("shell.exited".to_string(), false)
|
||||
}
|
||||
|
||||
fn write_nonblocking(channel: &mut ssh2::Channel, data: &[u8]) -> io::Result<()> {
|
||||
@@ -814,7 +816,7 @@ mod tests {
|
||||
classify_shell_close(b"timed out waiting for input: auto-logout\r\n", None);
|
||||
|
||||
assert!(idle_timeout);
|
||||
assert!(reason.contains("TMOUT"));
|
||||
assert_eq!(reason, "shell.idleTimeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -831,7 +833,7 @@ mod tests {
|
||||
let (reason, idle_timeout) = classify_shell_close(b"logout\r\n", None);
|
||||
|
||||
assert!(!idle_timeout);
|
||||
assert!(reason.contains("Shell 已退出"));
|
||||
assert_eq!(reason, "shell.exited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -34,7 +34,21 @@
|
||||
"targets": "all",
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||
"macOS": {
|
||||
"signingIdentity": "-"
|
||||
"signingIdentity": "-",
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 720,
|
||||
"height": 440
|
||||
},
|
||||
"appPosition": {
|
||||
"x": 180,
|
||||
"y": 190
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 520,
|
||||
"y": 190
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Simple native SSH / SFTP / desktop ops terminal",
|
||||
|
||||
Reference in new issue
Block a user