chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进

This commit is contained in:
Logan.Li committed 2026-09-14 22:10:22 +08:00
1 parent f71eef0eba
commit ae6093c9b3
163 files changed
+4911 -2494

No files matched your search

+180 -46
View File
@@ -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")));
}
}
+45 -13
View File
@@ -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(),
+34 -11
View File
@@ -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",
),
},
)
}
+195 -80
View File
@@ -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",
),
},
)
}
+10 -8
View File
@@ -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";
}
+29
View File
@@ -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()),
}
}
}
+1 -1
View File
@@ -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,
+8 -1
View File
@@ -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);
}
+1
View File
@@ -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;
+66 -16
View File
@@ -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());
}
+13 -11
View File
@@ -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]