feat(desktop): implement phase 2 native capabilities
This commit is contained in:
Generated
+1377
-7
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
|
||||
@@ -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/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user