feat(desktop): implement phase 2 native capabilities
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
|
||||
|
||||
fn credential_account(server_origin: &str) -> Result<String, String> {
|
||||
let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?;
|
||||
let is_https = parsed.scheme() == "https";
|
||||
let is_local_http = parsed.scheme() == "http"
|
||||
&& matches!(
|
||||
parsed.host_str(),
|
||||
Some("localhost") | Some("127.0.0.1") | Some("::1")
|
||||
);
|
||||
if !is_https && !is_local_http {
|
||||
return Err("非本地服务必须使用 HTTPS".to_string());
|
||||
}
|
||||
if parsed.username() != "" || parsed.password().is_some() {
|
||||
return Err("服务器地址不能包含凭据".to_string());
|
||||
}
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
let digest = Sha256::digest(origin.as_bytes());
|
||||
Ok(format!("{digest:x}"))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
fn get_entry(server_origin: &str) -> Result<keyring::Entry, String> {
|
||||
let account = credential_account(server_origin)?;
|
||||
keyring::Entry::new(CREDENTIAL_SERVICE, &account)
|
||||
.map_err(|error| format!("无法访问系统凭据库:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_get(server_origin: String) -> Result<Option<String>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(&server_origin)?;
|
||||
return match entry.get_password() {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(error) => Err(format!("读取系统凭据失败:{error}")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("读取系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_set(server_origin: String, token: String) -> Result<(), String> {
|
||||
if token.trim().is_empty() {
|
||||
return Err("拒绝保存空凭据".to_string());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
return get_entry(&server_origin)?
|
||||
.set_password(&token)
|
||||
.map_err(|error| format!("保存系统凭据失败:{error}"));
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("保存系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_delete(server_origin: String) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(&server_origin)?;
|
||||
return match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(error) => Err(format!("删除系统凭据失败:{error}")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("删除系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::credential_account;
|
||||
|
||||
#[test]
|
||||
fn account_is_stable_for_same_origin() {
|
||||
assert_eq!(
|
||||
credential_account("https://ctms.example.com/path").unwrap(),
|
||||
credential_account("https://ctms.example.com/other").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_insecure_remote_origin() {
|
||||
assert!(credential_account("http://ctms.example.com").is_err());
|
||||
assert!(credential_account("http://localhost:8000").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,30 @@
|
||||
mod credentials;
|
||||
mod updates;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(updates::PendingUpdate::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
credentials::credential_get,
|
||||
credentials::credential_set,
|
||||
credentials::credential_delete,
|
||||
updates::desktop_update_check,
|
||||
updates::desktop_update_install,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running CTMS desktop application");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PendingUpdate(pub Mutex<Option<Update>>);
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DesktopUpdateMetadata {
|
||||
version: String,
|
||||
current_version: String,
|
||||
notes: Option<String>,
|
||||
date: Option<String>,
|
||||
}
|
||||
|
||||
fn normalized_origin(raw: &str) -> Result<Url, String> {
|
||||
let parsed = Url::parse(raw).map_err(|_| "invalid server origin".to_string())?;
|
||||
let scheme = parsed.scheme();
|
||||
if parsed.username() != "" || parsed.password().is_some() {
|
||||
return Err("server origin must not include credentials".to_string());
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "server origin must include a host".to_string())?;
|
||||
let localhost = matches!(host, "localhost" | "127.0.0.1" | "::1");
|
||||
if scheme != "https" && !(scheme == "http" && localhost) {
|
||||
return Err("desktop updates require HTTPS outside localhost".to_string());
|
||||
}
|
||||
let port = parsed.port().map(|value| format!(":{value}")).unwrap_or_default();
|
||||
Url::parse(&format!("{scheme}://{host}{port}/")).map_err(|_| "invalid normalized origin".to_string())
|
||||
}
|
||||
|
||||
fn update_endpoint(server_origin: &str) -> Result<Url, String> {
|
||||
let origin = normalized_origin(server_origin)?;
|
||||
origin
|
||||
.join("desktop-updates/stable/latest.json")
|
||||
.map_err(|_| "invalid update endpoint".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn desktop_update_check(
|
||||
app: AppHandle,
|
||||
pending_update: State<'_, PendingUpdate>,
|
||||
server_origin: String,
|
||||
) -> Result<Option<DesktopUpdateMetadata>, String> {
|
||||
let endpoint = update_endpoint(&server_origin)?;
|
||||
let update = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![endpoint])
|
||||
.map_err(|err| err.to_string())?
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let metadata = update.as_ref().map(|update| DesktopUpdateMetadata {
|
||||
version: update.version.clone(),
|
||||
current_version: update.current_version.clone(),
|
||||
notes: update.body.clone(),
|
||||
date: update.date.map(|value| value.to_string()),
|
||||
});
|
||||
|
||||
let mut guard = pending_update
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "pending update lock poisoned".to_string())?;
|
||||
*guard = update;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn desktop_update_install(
|
||||
app: AppHandle,
|
||||
pending_update: State<'_, PendingUpdate>,
|
||||
) -> Result<(), String> {
|
||||
let update = {
|
||||
let mut guard = pending_update
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "pending update lock poisoned".to_string())?;
|
||||
guard
|
||||
.take()
|
||||
.ok_or_else(|| "there is no pending update".to_string())?
|
||||
};
|
||||
|
||||
update
|
||||
.download_and_install(|_, _| {}, || {})
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
app.restart();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::update_endpoint;
|
||||
|
||||
#[test]
|
||||
fn update_endpoint_uses_fixed_https_path() {
|
||||
let endpoint = update_endpoint("https://ctms.example.com/app/").unwrap();
|
||||
assert_eq!(
|
||||
endpoint.as_str(),
|
||||
"https://ctms.example.com/desktop-updates/stable/latest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_endpoint_allows_localhost_http_only() {
|
||||
assert!(update_endpoint("http://localhost:8888").is_ok());
|
||||
assert!(update_endpoint("http://ctms.example.com").is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user