feat(desktop): implement phase 2 native capabilities
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-06-30 21:21:55 +08:00
parent 7c721d4e5c
commit 628ff8828b
64 changed files with 3516 additions and 222 deletions
+1377 -7
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -14,7 +14,23 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
time = { version = "=0.3.36", default-features = false, features = ["std", "parsing", "formatting", "macros"] }
url = "2"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "4.1.2", default-features = false, features = ["v1", "apple-native-keyring-store"] }
[target.'cfg(windows)'.dependencies]
keyring = { version = "4.1.2", default-features = false, features = ["v1", "windows-native-keyring-store"] }
[features]
default = ["custom-protocol"]
+21 -1
View File
@@ -3,5 +3,25 @@
"identifier": "default",
"description": "Default capability for the CTMS desktop window.",
"windows": ["main"],
"permissions": ["core:default"]
"permissions": [
"core:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-remove",
{
"identifier": "fs:scope",
"allow": [
{ "path": "$TEMP/ctms-desktop/**" }
]
},
"notification:default",
{
"identifier": "opener:allow-open-path",
"allow": [
{ "path": "$TEMP/ctms-desktop/**" }
]
}
]
}
+114
View File
@@ -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());
}
}
+25
View File
@@ -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");
}
+116
View File
@@ -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());
}
}
+8 -2
View File
@@ -21,11 +21,17 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self' customprotocol: asset:; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
}
},
"bundle": {
"active": true,
"targets": ["app", "dmg"]
"targets": ["app", "dmg"],
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwQjI5MUZFMjQ2NUM5QwpSV1NjWEViaUh5a0xBdE52U2Rhb29mZlVYZ3lnWGlDVGs1WE1RUGoyeWtSWW9pNzBnNW9qUGNaaAo="
}
}
}