feat(v3): migrate workspace to API, Tauri desktop, and v3 crates; remove legacy MCP stack
Some checks failed
Secrets v3 CI / 检查 (push) Has been cancelled

- Add apps/api, desktop Tauri shell, domain/application/crypto/device-auth/infrastructure-db
- Replace desktop-daemon vault integration; drop secrets-core and secrets-mcp*
- Ignore apps/desktop/dist and generated Tauri icons; document icon/dist steps in AGENTS.md
- Apply rustfmt; fix clippy (collapsible_if, HTTP method as str)
This commit is contained in:
agent
2026-04-13 08:49:57 +08:00
parent cb5865b958
commit 0374899dab
130 changed files with 20447 additions and 21577 deletions

View File

@@ -0,0 +1,32 @@
[package]
name = "secrets-desktop"
version = "3.0.0"
edition.workspace = true
[build-dependencies]
tauri-build.workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
chrono.workspace = true
hex.workspace = true
sqlx.workspace = true
serde.workspace = true
serde_json.workspace = true
tauri.workspace = true
tokio.workspace = true
reqwest.workspace = true
sha2.workspace = true
url.workspace = true
uuid.workspace = true
base64 = "0.22.1"
secrets-client-integrations = { path = "../../../crates/client-integrations" }
secrets-crypto = { path = "../../../crates/crypto" }
secrets-device-auth = { path = "../../../crates/device-auth" }
secrets-domain = { path = "../../../crates/domain" }
[[bin]]
name = "Secrets"
path = "src/main.rs"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,2 @@
const fs = require('fs');
// Very simple check: read the first few bytes, maybe we can use an image library to find the bounding box

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
use anyhow::{Context, Result as AnyResult};
use axum::{
Router,
body::{Body, to_bytes},
extract::{Request, State as AxumState},
http::{StatusCode as AxumStatusCode, header},
response::Response,
routing::{any, get, post},
};
use url::Url;
use crate::local_vault::{
LocalEntryQuery, bootstrap as vault_bootstrap, create_entry as vault_create_entry,
create_secret as vault_create_secret, delete_entry as vault_delete_entry,
delete_secret as vault_delete_secret, entry_detail as vault_entry_detail,
list_entries as vault_list_entries, restore_entry as vault_restore_entry,
reveal_secret_value as vault_reveal_secret_value, rollback_secret as vault_rollback_secret,
secret_history as vault_secret_history, update_entry as vault_update_entry,
update_secret as vault_update_secret,
};
use crate::{
DesktopState, EntryDetail, EntryDraft, EntryListItem, EntryListQuery, SecretDraft,
SecretUpdateDraft, current_device_token, map_entry_detail_to_local, map_entry_draft_to_local,
map_local_entry_detail, map_local_history_item, map_local_secret_value,
map_secret_draft_to_local, map_secret_update_to_local, split_secret_ref_for_ui,
sync_local_vault,
};
pub async fn desktop_session_health(
AxumState(state): AxumState<DesktopState>,
) -> Result<&'static str, AxumStatusCode> {
current_device_token(&state)
.map(|_| "ok")
.map_err(|_| AxumStatusCode::UNAUTHORIZED)
}
pub async fn desktop_session_api(
AxumState(state): AxumState<DesktopState>,
request: Request<Body>,
) -> Response {
let (parts, body) = request.into_parts();
let path_and_query = parts
.uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let body_bytes = match to_bytes(body, 1024 * 1024).await {
Ok(bytes) => bytes,
Err(_) => {
return Response::builder()
.status(AxumStatusCode::BAD_REQUEST)
.body(Body::from("failed to read relay request body"))
.expect("build relay bad request");
}
};
handle_local_session_request(&state, parts.method.as_str(), path_and_query, &body_bytes)
.await
.unwrap_or_else(|| {
Response::builder()
.status(AxumStatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(
r#"{"error":"desktop local vault route not found"}"#,
))
.expect("build local session not found response")
})
}
async fn handle_local_session_request(
state: &DesktopState,
method: &str,
path_and_query: &str,
body_bytes: &[u8],
) -> Option<Response> {
let path = path_and_query.split('?').next().unwrap_or(path_and_query);
let make_json = |status: AxumStatusCode, value: serde_json::Value| {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(value.to_string()))
.expect("build local session response")
};
match (method, path) {
("GET", "/vault/status") => {
let status = vault_bootstrap(&state.local_vault).await.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({
"unlocked": status.unlocked,
"has_master_password": status.has_master_password
}),
))
}
("GET", "/vault/entries") => {
let url = format!("http://localhost{path_and_query}");
let parsed = Url::parse(&url).ok()?;
let mut query = EntryListQuery {
folder: None,
entry_type: None,
query: None,
deleted_only: false,
};
for (key, value) in parsed.query_pairs() {
match key.as_ref() {
"folder" => query.folder = Some(value.into_owned()),
"entry_type" => query.entry_type = Some(value.into_owned()),
"query" => query.query = Some(value.into_owned()),
"deleted_only" => query.deleted_only = value == "true",
_ => {}
}
}
let entries = vault_list_entries(
&state.local_vault,
&LocalEntryQuery {
folder: query.folder,
cipher_type: query.entry_type,
query: query.query,
deleted_only: query.deleted_only,
},
)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(
entries
.into_iter()
.map(|entry| EntryListItem {
id: entry.id,
title: entry.name,
subtitle: entry.cipher_type,
folder: entry.folder,
deleted: entry.deleted,
})
.collect::<Vec<_>>(),
)
.ok()?,
))
}
_ if method == "GET" && path.starts_with("/vault/entries/") => {
let entry_id = path.trim_start_matches("/vault/entries/");
let detail = vault_entry_detail(&state.local_vault, entry_id)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(detail)).ok()?,
))
}
("POST", "/vault/entries") => {
let draft: EntryDraft = serde_json::from_slice(body_bytes).ok()?;
let created = vault_create_entry(&state.local_vault, map_entry_draft_to_local(draft))
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(created)).ok()?,
))
}
_ if method == "PATCH" && path.starts_with("/vault/entries/") => {
let entry_id = path.trim_start_matches("/vault/entries/").to_string();
let mut detail: EntryDetail = serde_json::from_slice(body_bytes).ok()?;
detail.id = entry_id;
let updated = vault_update_entry(&state.local_vault, map_entry_detail_to_local(detail))
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ if method == "POST"
&& path.starts_with("/vault/entries/")
&& path.ends_with("/delete") =>
{
let entry_id = path
.trim_start_matches("/vault/entries/")
.trim_end_matches("/delete")
.trim_end_matches('/');
vault_delete_entry(&state.local_vault, entry_id)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({ "ok": true }),
))
}
_ if method == "POST"
&& path.starts_with("/vault/entries/")
&& path.ends_with("/restore") =>
{
let entry_id = path
.trim_start_matches("/vault/entries/")
.trim_end_matches("/restore")
.trim_end_matches('/');
vault_restore_entry(&state.local_vault, entry_id)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({ "ok": true }),
))
}
_ if method == "POST"
&& path.starts_with("/vault/entries/")
&& path.ends_with("/secrets") =>
{
let entry_id = path
.trim_start_matches("/vault/entries/")
.trim_end_matches("/secrets")
.trim_end_matches('/');
let secret: SecretDraft = serde_json::from_slice(body_bytes).ok()?;
let updated = vault_create_secret(
&state.local_vault,
entry_id,
map_secret_draft_to_local(secret),
)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ if method == "GET" && path.starts_with("/vault/secrets/") && path.ends_with("/value") => {
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/value")
.trim_end_matches('/')
.to_string();
let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id).ok()?;
let value = vault_reveal_secret_value(&state.local_vault, &entry_id, &secret_name)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_secret_value(value)).ok()?,
))
}
_ if method == "GET"
&& path.starts_with("/vault/secrets/")
&& path.ends_with("/history") =>
{
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/history")
.trim_end_matches('/')
.to_string();
let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id).ok()?;
let history = vault_secret_history(&state.local_vault, &entry_id, &secret_name)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(
history
.into_iter()
.map(map_local_history_item)
.collect::<Vec<_>>(),
)
.ok()?,
))
}
_ if method == "PATCH" && path.starts_with("/vault/secrets/") => {
let secret_id = path.trim_start_matches("/vault/secrets/").to_string();
let mut update: SecretUpdateDraft = serde_json::from_slice(body_bytes).ok()?;
update.id = secret_id;
let updated =
vault_update_secret(&state.local_vault, map_secret_update_to_local(update))
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ if method == "POST"
&& path.starts_with("/vault/secrets/")
&& path.ends_with("/delete") =>
{
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/delete")
.trim_end_matches('/');
vault_delete_secret(&state.local_vault, secret_id)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({ "ok": true }),
))
}
_ if method == "POST"
&& path.starts_with("/vault/secrets/")
&& path.ends_with("/rollback") =>
{
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/rollback")
.trim_end_matches('/')
.to_string();
let payload: serde_json::Value = serde_json::from_slice(body_bytes).ok()?;
let updated = vault_rollback_secret(
&state.local_vault,
&secret_id,
payload.get("history_id").and_then(|value| value.as_i64()),
)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ => None,
}
}
pub async fn start_desktop_session_server(state: DesktopState) -> AnyResult<()> {
let app = Router::new()
.route("/healthz", get(desktop_session_health))
.route("/vault/status", get(desktop_session_api))
.route("/vault/entries", any(desktop_session_api))
.route("/vault/entries/{id}", any(desktop_session_api))
.route("/vault/entries/{id}/delete", post(desktop_session_api))
.route("/vault/entries/{id}/restore", post(desktop_session_api))
.route("/vault/entries/{id}/secrets", post(desktop_session_api))
.route("/vault/secrets/{id}", any(desktop_session_api))
.route("/vault/secrets/{id}/value", get(desktop_session_api))
.route("/vault/secrets/{id}/history", get(desktop_session_api))
.route("/vault/secrets/{id}/delete", post(desktop_session_api))
.route("/vault/secrets/{id}/rollback", post(desktop_session_api))
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind(&state.session_bind)
.await
.with_context(|| {
format!(
"failed to bind desktop session relay {}",
state.session_bind
)
})?;
axum::serve(listener, app)
.await
.context("desktop session relay server error")
}

View File

@@ -0,0 +1,31 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Secrets",
"version": "3.0.0",
"identifier": "dev.refining.secrets",
"build": {
"beforeDevCommand": "",
"beforeBuildCommand": "",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Secrets",
"width": 420,
"height": 400,
"minWidth": 420,
"minHeight": 400,
"resizable": true,
"titleBarStyle": "overlay",
"hiddenTitle": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false
}
}