feat: merge HoloLake desktop into unified 0.3.0
This commit is contained in:
commit
b7461c66c5
37 changed files with 8732 additions and 613 deletions
|
|
@ -0,0 +1,929 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Reverse;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SNAPSHOT_SCHEMA: &str = "hololake.code-channel/v1";
|
||||
const REGISTRY_SCHEMA: &str = "hololake.code-channel-registry/v1";
|
||||
const ALLOWED_HOSTS: &[&str] = &["guanghulab.com", "guanghubingshuo.com"];
|
||||
const MAX_TREE_ENTRIES: usize = 1_000;
|
||||
const MAX_CODE_FILE_BYTES: u64 = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CloneCodeChannelInput {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeChannelEntry {
|
||||
pub channel_id: String,
|
||||
pub name: String,
|
||||
pub source_kind: String,
|
||||
pub local_path: String,
|
||||
pub remote_url: Option<String>,
|
||||
pub git_head: String,
|
||||
pub branch: String,
|
||||
pub repository_clean: bool,
|
||||
pub registered_at_unix_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeChannelSnapshot {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub storage_root: String,
|
||||
pub channels: Vec<CodeChannelEntry>,
|
||||
pub authority: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BrowseCodeChannelInput {
|
||||
pub channel_id: String,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeTreeEntry {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub kind: &'static str,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeTreeSnapshot {
|
||||
pub schema: &'static str,
|
||||
pub channel_id: String,
|
||||
pub path: String,
|
||||
pub entries: Vec<CodeTreeEntry>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ReadCodeChannelFileInput {
|
||||
pub channel_id: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeFileProjection {
|
||||
pub schema: &'static str,
|
||||
pub channel_id: String,
|
||||
pub path: String,
|
||||
pub format: String,
|
||||
pub source: String,
|
||||
pub human_markdown: String,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct CodeChannelRegistry {
|
||||
schema: String,
|
||||
channels: Vec<CodeChannelEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ValidatedCloneUrl {
|
||||
normalized: String,
|
||||
owner: String,
|
||||
repository: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_code_channel_snapshot(app: AppHandle) -> Result<CodeChannelSnapshot, String> {
|
||||
let root = code_channel_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || snapshot_at(&root))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clone_code_channel(
|
||||
app: AppHandle,
|
||||
input: CloneCodeChannelInput,
|
||||
) -> Result<CodeChannelSnapshot, String> {
|
||||
let root = code_channel_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || clone_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_local_code_channel(
|
||||
app: AppHandle,
|
||||
) -> Result<Option<CodeChannelSnapshot>, String> {
|
||||
let picker = app.clone();
|
||||
let selected = tauri::async_runtime::spawn_blocking(move || {
|
||||
picker
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("选择本地 Git 代码频道")
|
||||
.blocking_pick_folder()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PICKER_JOIN_FAILED: {error}"))?;
|
||||
let Some(selected) = selected else {
|
||||
return Ok(None);
|
||||
};
|
||||
let selected = selected
|
||||
.into_path()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PICKER_PATH_INVALID: {error}"))?;
|
||||
let root = code_channel_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || register_local_at(&root, &selected).map(Some))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn browse_code_channel(
|
||||
app: AppHandle,
|
||||
input: BrowseCodeChannelInput,
|
||||
) -> Result<CodeTreeSnapshot, String> {
|
||||
let root = code_channel_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || browse_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_code_channel_file(
|
||||
app: AppHandle,
|
||||
input: ReadCodeChannelFileInput,
|
||||
) -> Result<CodeFileProjection, String> {
|
||||
let root = code_channel_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || read_code_file_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
fn code_channel_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?
|
||||
.join("code-channel-v1");
|
||||
ensure_root(&root)?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn ensure_root(root: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(root.join("repositories"))
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
fs::set_permissions(root, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PERMISSION_FAILED: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_at(root: &Path) -> Result<CodeChannelSnapshot, String> {
|
||||
ensure_root(root)?;
|
||||
let mut registry = read_registry(root)?;
|
||||
for channel in &mut registry.channels {
|
||||
if let Ok(inspection) = inspect_repository(Path::new(&channel.local_path)) {
|
||||
channel.git_head = inspection.git_head;
|
||||
channel.branch = inspection.branch;
|
||||
channel.repository_clean = inspection.repository_clean;
|
||||
}
|
||||
}
|
||||
registry
|
||||
.channels
|
||||
.sort_by_key(|entry| Reverse(entry.registered_at_unix_ms));
|
||||
Ok(CodeChannelSnapshot {
|
||||
schema: SNAPSHOT_SCHEMA,
|
||||
state: "READY",
|
||||
storage_root: root.to_string_lossy().into_owned(),
|
||||
channels: registry.channels,
|
||||
authority: "LOCAL_SOURCE_ACCESS_ONLY_NO_PUSH_OR_DEPLOY_AUTHORITY",
|
||||
})
|
||||
}
|
||||
|
||||
fn browse_at(root: &Path, input: BrowseCodeChannelInput) -> Result<CodeTreeSnapshot, String> {
|
||||
let (channel, repository) = registered_repository(root, &input.channel_id)?;
|
||||
let relative = input.path.unwrap_or_default();
|
||||
let directory = safe_repository_path(&repository, &relative)?;
|
||||
if !directory.is_dir() {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_DIRECTORY_INVALID".into());
|
||||
}
|
||||
let mut entries = fs::read_dir(&directory)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?;
|
||||
entries.sort_by_key(|entry| {
|
||||
let is_file = entry.file_type().map(|kind| kind.is_file()).unwrap_or(true);
|
||||
(is_file, entry.file_name())
|
||||
});
|
||||
let mut projected = Vec::new();
|
||||
for entry in entries {
|
||||
if projected.len() >= MAX_TREE_ENTRIES {
|
||||
break;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name == ".git" || name == ".DS_Store" || name.starts_with("._") {
|
||||
continue;
|
||||
}
|
||||
let kind = entry
|
||||
.file_type()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?;
|
||||
if kind.is_symlink() || (!kind.is_dir() && !kind.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let entry_path = entry.path();
|
||||
let relative_path = relative_posix(&repository, &entry_path)?;
|
||||
let size_bytes = if kind.is_file() {
|
||||
entry
|
||||
.metadata()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?
|
||||
.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
projected.push(CodeTreeEntry {
|
||||
path: relative_path,
|
||||
name,
|
||||
kind: if kind.is_dir() { "directory" } else { "file" },
|
||||
size_bytes,
|
||||
});
|
||||
}
|
||||
Ok(CodeTreeSnapshot {
|
||||
schema: "hololake.code-channel-tree/v1",
|
||||
channel_id: channel.channel_id,
|
||||
path: relative,
|
||||
truncated: projected.len() >= MAX_TREE_ENTRIES,
|
||||
entries: projected,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_code_file_at(
|
||||
root: &Path,
|
||||
input: ReadCodeChannelFileInput,
|
||||
) -> Result<CodeFileProjection, String> {
|
||||
let (channel, repository) = registered_repository(root, &input.channel_id)?;
|
||||
let file = safe_repository_path(&repository, &input.path)?;
|
||||
let metadata = fs::metadata(&file)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_FILE_UNAVAILABLE: {error}"))?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_CODE_FILE_BYTES || !is_supported_text_file(&file)
|
||||
{
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_FILE_UNSUPPORTED".into());
|
||||
}
|
||||
let source = fs::read_to_string(&file)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_FILE_INVALID_UTF8: {error}"))?;
|
||||
let format = file_format(&file);
|
||||
let human_markdown = human_projection(&input.path, &format, &source);
|
||||
Ok(CodeFileProjection {
|
||||
schema: "hololake.code-channel-file-projection/v1",
|
||||
channel_id: channel.channel_id,
|
||||
path: input.path,
|
||||
format,
|
||||
source,
|
||||
human_markdown,
|
||||
size_bytes: metadata.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn registered_repository(
|
||||
root: &Path,
|
||||
channel_id: &str,
|
||||
) -> Result<(CodeChannelEntry, PathBuf), String> {
|
||||
let registry = read_registry(root)?;
|
||||
let channel = registry
|
||||
.channels
|
||||
.into_iter()
|
||||
.find(|entry| entry.channel_id == channel_id)
|
||||
.ok_or("HOLOLAKE_CODE_CHANNEL_NOT_REGISTERED")?;
|
||||
let repository = PathBuf::from(&channel.local_path)
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REPOSITORY_UNAVAILABLE: {error}"))?;
|
||||
if !repository.is_dir() {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_REPOSITORY_UNAVAILABLE".into());
|
||||
}
|
||||
Ok((channel, repository))
|
||||
}
|
||||
|
||||
fn safe_repository_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
|
||||
let relative_path = Path::new(relative);
|
||||
if relative_path.is_absolute()
|
||||
|| relative_path
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
{
|
||||
if relative.is_empty() {
|
||||
return Ok(root.to_path_buf());
|
||||
}
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_PATH_INVALID".into());
|
||||
}
|
||||
if relative.is_empty() {
|
||||
return Ok(root.to_path_buf());
|
||||
}
|
||||
let candidate = root.join(relative_path);
|
||||
let metadata = fs::symlink_metadata(&candidate)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PATH_UNAVAILABLE: {error}"))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_SYMLINK_DENIED".into());
|
||||
}
|
||||
let canonical = candidate
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PATH_UNAVAILABLE: {error}"))?;
|
||||
if !canonical.starts_with(root) {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_PATH_ESCAPE".into());
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn relative_posix(root: &Path, path: &Path) -> Result<String, String> {
|
||||
Ok(path
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| "HOLOLAKE_CODE_CHANNEL_PATH_INVALID".to_string())?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/"))
|
||||
}
|
||||
|
||||
fn is_supported_text_file(path: &Path) -> bool {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("");
|
||||
if ["README", "LICENSE", "Dockerfile", "Makefile", "Procfile"].contains(&name) {
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
"md" | "markdown"
|
||||
| "txt"
|
||||
| "json"
|
||||
| "jsonl"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "toml"
|
||||
| "hdlp"
|
||||
| "rs"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "mjs"
|
||||
| "cjs"
|
||||
| "py"
|
||||
| "sh"
|
||||
| "zsh"
|
||||
| "bash"
|
||||
| "css"
|
||||
| "scss"
|
||||
| "html"
|
||||
| "xml"
|
||||
| "sql"
|
||||
| "graphql"
|
||||
| "go"
|
||||
| "java"
|
||||
| "kt"
|
||||
| "swift"
|
||||
| "c"
|
||||
| "h"
|
||||
| "cpp"
|
||||
| "hpp"
|
||||
| "proto"
|
||||
| "ini"
|
||||
| "conf"
|
||||
| "env"
|
||||
)
|
||||
}
|
||||
|
||||
fn file_format(path: &Path) -> String {
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("text")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn human_projection(path: &str, format: &str, source: &str) -> String {
|
||||
if matches!(format, "md" | "markdown") {
|
||||
return source.to_string();
|
||||
}
|
||||
let title = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("代码文件");
|
||||
if format == "json" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(source) {
|
||||
return format!(
|
||||
"# {title}\n\n> 来自代码频道的 JSON 结构,已转换为可读层级。\n\n{}",
|
||||
json_as_markdown(&value, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(format, "hdlp" | "yaml" | "yml" | "toml" | "ini" | "conf") {
|
||||
let rows = source
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
trimmed
|
||||
.split_once(':')
|
||||
.or_else(|| trimmed.split_once('='))
|
||||
.map(|(key, value)| format!("| {} | {} |", key.trim(), value.trim()))
|
||||
})
|
||||
.take(120)
|
||||
.collect::<Vec<_>>();
|
||||
if !rows.is_empty() {
|
||||
return format!(
|
||||
"# {title}\n\n> 来自代码频道的结构化文件。原始机器字段已转成阅读表格。\n\n| 字段 | 内容 |\n| --- | --- |\n{}",
|
||||
rows.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"# {title}\n\n> 来自代码频道的 `{format}` 文件。下方保留原始内容,便于核验。\n\n```{format}\n{source}\n```"
|
||||
)
|
||||
}
|
||||
|
||||
fn json_as_markdown(value: &serde_json::Value, level: usize) -> String {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => map
|
||||
.iter()
|
||||
.map(|(key, value)| match value {
|
||||
serde_json::Value::Object(_) | serde_json::Value::Array(_) => format!(
|
||||
"{} {key}\n\n{}",
|
||||
"#".repeat(level.min(5)),
|
||||
json_as_markdown(value, level + 1)
|
||||
),
|
||||
_ => format!("- **{key}**:{}", scalar_json(value)),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n"),
|
||||
serde_json::Value::Array(values) => values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| match value {
|
||||
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
|
||||
format!("{}. {}", index + 1, json_as_markdown(value, level + 1))
|
||||
}
|
||||
_ => format!("- {}", scalar_json(value)),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => scalar_json(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_json(value: &serde_json::Value) -> String {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
fn clone_at(root: &Path, input: CloneCodeChannelInput) -> Result<CodeChannelSnapshot, String> {
|
||||
ensure_root(root)?;
|
||||
let validated = validate_clone_url(&input.url)?;
|
||||
let destination = root.join("repositories").join(format!(
|
||||
"{}--{}",
|
||||
safe_segment(&validated.owner),
|
||||
safe_segment(&validated.repository)
|
||||
));
|
||||
if destination.exists() {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_DESTINATION_EXISTS".into());
|
||||
}
|
||||
let output = Command::new("/usr/bin/git")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_ASKPASS", "/usr/bin/false")
|
||||
.env("SSH_ASKPASS", "/usr/bin/false")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.args([
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
"core.askPass=",
|
||||
"clone",
|
||||
"--origin",
|
||||
"origin",
|
||||
"--no-tags",
|
||||
])
|
||||
.arg(&validated.normalized)
|
||||
.arg(&destination)
|
||||
.output()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_CLONE_FAILED: {error}"))?;
|
||||
if !output.status.success() {
|
||||
if destination.starts_with(root.join("repositories")) {
|
||||
let _ = fs::remove_dir_all(&destination);
|
||||
}
|
||||
return Err(format!(
|
||||
"HOLOLAKE_CODE_CHANNEL_CLONE_FAILED: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let entry = entry_for_repository(
|
||||
&destination,
|
||||
"CLONED_HTTPS",
|
||||
Some(validated.normalized),
|
||||
Some(format!("{}/{}", validated.owner, validated.repository)),
|
||||
)?;
|
||||
upsert_entry(root, entry)?;
|
||||
snapshot_at(root)
|
||||
}
|
||||
|
||||
fn register_local_at(root: &Path, selected: &Path) -> Result<CodeChannelSnapshot, String> {
|
||||
ensure_root(root)?;
|
||||
let canonical = selected
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_LOCAL_UNAVAILABLE: {error}"))?;
|
||||
if !canonical.is_dir() {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_LOCAL_INVALID".into());
|
||||
}
|
||||
let remote = git_optional(&canonical, &["remote", "get-url", "origin"])
|
||||
.and_then(|value| redact_remote_url(value.trim()));
|
||||
let entry = entry_for_repository(&canonical, "LOCAL_GIT", remote, None)?;
|
||||
upsert_entry(root, entry)?;
|
||||
snapshot_at(root)
|
||||
}
|
||||
|
||||
struct RepositoryInspection {
|
||||
git_head: String,
|
||||
branch: String,
|
||||
repository_clean: bool,
|
||||
}
|
||||
|
||||
fn inspect_repository(path: &Path) -> Result<RepositoryInspection, String> {
|
||||
let inside = git(path, &["rev-parse", "--is-inside-work-tree"], "INSPECT")?;
|
||||
if inside.trim() != "true" {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_NOT_GIT".into());
|
||||
}
|
||||
let git_head = git(path, &["rev-parse", "HEAD"], "READ_HEAD")?
|
||||
.trim()
|
||||
.to_string();
|
||||
let branch = git_optional(path, &["symbolic-ref", "--short", "HEAD"])
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "DETACHED".into());
|
||||
let repository_clean = git(path, &["status", "--porcelain"], "READ_STATUS")?
|
||||
.trim()
|
||||
.is_empty();
|
||||
Ok(RepositoryInspection {
|
||||
git_head,
|
||||
branch,
|
||||
repository_clean,
|
||||
})
|
||||
}
|
||||
|
||||
fn entry_for_repository(
|
||||
path: &Path,
|
||||
source_kind: &str,
|
||||
remote_url: Option<String>,
|
||||
explicit_name: Option<String>,
|
||||
) -> Result<CodeChannelEntry, String> {
|
||||
let inspection = inspect_repository(path)?;
|
||||
let name = explicit_name.unwrap_or_else(|| {
|
||||
path.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("代码频道")
|
||||
.to_string()
|
||||
});
|
||||
Ok(CodeChannelEntry {
|
||||
channel_id: format!("channel-{}", Uuid::new_v4()),
|
||||
name,
|
||||
source_kind: source_kind.into(),
|
||||
local_path: path.to_string_lossy().into_owned(),
|
||||
remote_url,
|
||||
git_head: inspection.git_head,
|
||||
branch: inspection.branch,
|
||||
repository_clean: inspection.repository_clean,
|
||||
registered_at_unix_ms: now_unix_ms()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_clone_url(raw: &str) -> Result<ValidatedCloneUrl, String> {
|
||||
let raw = raw.trim();
|
||||
if raw.len() > 2_048 {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||
}
|
||||
let mut parsed = Url::parse(raw).map_err(|_| "HOLOLAKE_CODE_CHANNEL_URL_INVALID")?;
|
||||
if parsed.scheme() != "https"
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
|| parsed.port().is_some()
|
||||
{
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or("HOLOLAKE_CODE_CHANNEL_URL_INVALID")?
|
||||
.to_lowercase();
|
||||
if !ALLOWED_HOSTS.contains(&host.as_str()) {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_HOST_NOT_TRUSTED".into());
|
||||
}
|
||||
let segments = parsed
|
||||
.path_segments()
|
||||
.ok_or("HOLOLAKE_CODE_CHANNEL_URL_INVALID")?
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if segments.len() != 3 || segments[0] != "code" {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||
}
|
||||
let owner = segments[1].to_string();
|
||||
let repository = segments[2]
|
||||
.strip_suffix(".git")
|
||||
.unwrap_or(segments[2])
|
||||
.to_string();
|
||||
if !valid_git_segment(&owner) || !valid_git_segment(&repository) {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||
}
|
||||
parsed.set_path(&format!("/code/{owner}/{repository}.git"));
|
||||
Ok(ValidatedCloneUrl {
|
||||
normalized: parsed.to_string(),
|
||||
owner,
|
||||
repository,
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_git_segment(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 100
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
&& value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || "-_.".contains(character))
|
||||
}
|
||||
|
||||
fn redact_remote_url(raw: &str) -> Option<String> {
|
||||
let mut parsed = Url::parse(raw).ok()?;
|
||||
if parsed.scheme() != "https" {
|
||||
return None;
|
||||
}
|
||||
parsed.set_username("").ok()?;
|
||||
parsed.set_password(None).ok()?;
|
||||
parsed.set_query(None);
|
||||
parsed.set_fragment(None);
|
||||
Some(parsed.to_string())
|
||||
}
|
||||
|
||||
fn safe_segment(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() || "-_.".contains(character) {
|
||||
character
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn upsert_entry(root: &Path, entry: CodeChannelEntry) -> Result<(), String> {
|
||||
let mut registry = read_registry(root)?;
|
||||
registry
|
||||
.channels
|
||||
.retain(|existing| existing.local_path != entry.local_path);
|
||||
registry.channels.push(entry);
|
||||
write_registry(root, ®istry)
|
||||
}
|
||||
|
||||
fn read_registry(root: &Path) -> Result<CodeChannelRegistry, String> {
|
||||
let path = root.join("channels.json");
|
||||
if !path.exists() {
|
||||
return Ok(CodeChannelRegistry {
|
||||
schema: REGISTRY_SCHEMA.into(),
|
||||
channels: Vec::new(),
|
||||
});
|
||||
}
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_UNAVAILABLE: {error}"))?;
|
||||
let registry: CodeChannelRegistry = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_INVALID: {error}"))?;
|
||||
if registry.schema != REGISTRY_SCHEMA {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_REGISTRY_SCHEMA_INVALID".into());
|
||||
}
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
fn write_registry(root: &Path, registry: &CodeChannelRegistry) -> Result<(), String> {
|
||||
let bytes = serde_json::to_vec_pretty(registry)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_INVALID: {error}"))?;
|
||||
let temporary = root.join(format!("channels.json.tmp-{}", Uuid::new_v4()));
|
||||
let final_path = root.join("channels.json");
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?;
|
||||
fs::rename(&temporary, &final_path)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn git(root: &Path, args: &[&str], operation: &str) -> Result<String, String> {
|
||||
let output = Command::new("/usr/bin/git")
|
||||
.current_dir(root)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_GIT_{operation}_FAILED: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"HOLOLAKE_CODE_CHANNEL_GIT_{operation}_FAILED: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
String::from_utf8(output.stdout)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_GIT_{operation}_INVALID_UTF8: {error}"))
|
||||
}
|
||||
|
||||
fn git_optional(root: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new("/usr/bin/git")
|
||||
.current_dir(root)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> Result<u128, String> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn clone_url_is_normalized_to_official_https_git_endpoint() {
|
||||
let parsed =
|
||||
validate_clone_url("https://guanghulab.com/code/bingshuo/guanghu-ice-heart").unwrap();
|
||||
assert_eq!(
|
||||
parsed.normalized,
|
||||
"https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git"
|
||||
);
|
||||
assert_eq!(parsed.owner, "bingshuo");
|
||||
assert_eq!(parsed.repository, "guanghu-ice-heart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_url_rejects_credentials_queries_and_unknown_hosts() {
|
||||
assert!(
|
||||
validate_clone_url("https://user:secret@guanghulab.com/code/bingshuo/repo").is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_clone_url("https://guanghulab.com/code/bingshuo/repo?token=secret").is_err()
|
||||
);
|
||||
assert!(validate_clone_url("https://example.com/code/bingshuo/repo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_redaction_removes_credentials_and_fragments() {
|
||||
assert_eq!(
|
||||
redact_remote_url("https://user:secret@example.com/a/b.git?x=1#readme"),
|
||||
Some("https://example.com/a/b.git".into())
|
||||
);
|
||||
assert_eq!(redact_remote_url("git@example.com:a/b.git"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_repository_registration_is_persistent_and_deduplicated() {
|
||||
let root = tempdir().unwrap();
|
||||
let repository = tempdir().unwrap();
|
||||
ensure_root(root.path()).unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["init", "--initial-branch=main"],
|
||||
"TEST_INIT",
|
||||
)
|
||||
.unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["config", "user.name", "Test"],
|
||||
"TEST_CONFIG",
|
||||
)
|
||||
.unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["config", "user.email", "test@example.invalid"],
|
||||
"TEST_CONFIG",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(repository.path().join("README.md"), "# test\n").unwrap();
|
||||
git(repository.path(), &["add", "README.md"], "TEST_ADD").unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["commit", "-m", "initial"],
|
||||
"TEST_COMMIT",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
register_local_at(root.path(), repository.path()).unwrap();
|
||||
register_local_at(root.path(), repository.path()).unwrap();
|
||||
let snapshot = snapshot_at(root.path()).unwrap();
|
||||
assert_eq!(snapshot.channels.len(), 1);
|
||||
assert_eq!(snapshot.channels[0].branch, "main");
|
||||
assert!(snapshot.channels[0].repository_clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_repository_can_be_browsed_and_projected_as_knowledge() {
|
||||
let root = tempdir().unwrap();
|
||||
let repository = tempdir().unwrap();
|
||||
ensure_root(root.path()).unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["init", "--initial-branch=main"],
|
||||
"TEST_INIT",
|
||||
)
|
||||
.unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["config", "user.name", "Test"],
|
||||
"TEST_CONFIG",
|
||||
)
|
||||
.unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["config", "user.email", "test@example.invalid"],
|
||||
"TEST_CONFIG",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(repository.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
repository.path().join("config/system.json"),
|
||||
r#"{"name":"光湖","state":"ready","routes":["native","mcp"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
git(repository.path(), &["add", "."], "TEST_ADD").unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["commit", "-m", "initial"],
|
||||
"TEST_COMMIT",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let snapshot = register_local_at(root.path(), repository.path()).unwrap();
|
||||
let channel_id = snapshot.channels[0].channel_id.clone();
|
||||
let tree = browse_at(
|
||||
root.path(),
|
||||
BrowseCodeChannelInput {
|
||||
channel_id: channel_id.clone(),
|
||||
path: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tree
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.name == "config" && entry.kind == "directory"));
|
||||
|
||||
let projection = read_code_file_at(
|
||||
root.path(),
|
||||
ReadCodeChannelFileInput {
|
||||
channel_id,
|
||||
path: "config/system.json".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(projection.format, "json");
|
||||
assert!(projection
|
||||
.human_markdown
|
||||
.contains("来自代码频道的 JSON 结构"));
|
||||
assert!(projection.human_markdown.contains("**state**:ready"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_tree_denies_parent_escape() {
|
||||
let repository = tempdir().unwrap();
|
||||
assert!(safe_repository_path(repository.path(), "../secret").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
//! 登录模块 · code_repo_login
|
||||
//!
|
||||
//! 规划卷依据(HoloLake第二阶段总体规划-20260815 · 阶段B1):
|
||||
//! - 人类端输入代码仓库账号密码 → 对 guanghulab(Forgejo)验证。
|
||||
//! - 登录仓库 = 验证了背后绑定的服务器(冰朔教义)。
|
||||
//! - 凭证只存本机钥匙串 · 不落明文。
|
||||
//!
|
||||
//! 事实底账(2026-08-15 三角测量):
|
||||
//! - Forgejo 挂载在 /code 路径下:GET https://{host}/code/api/v1/user 走基本认证,
|
||||
//! 假凭证=401 · 真凭证=200 并回显 JSON(login/email)。
|
||||
//! - guanghulab.com 与 guanghubingshuo.com 双域同路可用(均在 ALLOWED_HOSTS 血统内)。
|
||||
//! - 钥匙存取走系统钥匙串(macOS `security`);其余平台暂不落盘密码,
|
||||
//! 会话仅内存保持(诚实边界,Windows 钥匙串接入排在分发阶段)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const LOGIN_HOST: &str = "guanghulab.com";
|
||||
const SESSION_FILE_NAME: &str = "login-session.json";
|
||||
|
||||
/// 落盘的登录会话——只有用户名与主机,密码永不落盘。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginSession {
|
||||
pub username: String,
|
||||
pub host: String,
|
||||
pub signed_in_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
/// 登录成功回执——交给前端展示"验证了背后绑定的服务器"。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginReceipt {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
fn session_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_DIR_FAILED: {error}"))?
|
||||
.join(SESSION_FILE_NAME))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn keychain_store(host: &str, username: &str, password: &str) -> Result<(), String> {
|
||||
let output = Command::new("/usr/bin/security")
|
||||
.args([
|
||||
"add-internet-password",
|
||||
"-U",
|
||||
"-s",
|
||||
host,
|
||||
"-a",
|
||||
username,
|
||||
"-w",
|
||||
password,
|
||||
])
|
||||
.output()
|
||||
.map_err(|error| format!("HOLOLAKE_LOGIN_KEYCHAIN_FAILED: {error}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("HOLOLAKE_LOGIN_KEYCHAIN_FAILED".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn keychain_has(host: &str, username: &str) -> bool {
|
||||
Command::new("/usr/bin/security")
|
||||
.args(["find-internet-password", "-s", host, "-a", username])
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn keychain_remove(host: &str, username: &str) {
|
||||
let _ = Command::new("/usr/bin/security")
|
||||
.args(["delete-internet-password", "-s", host, "-a", username])
|
||||
.output();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn keychain_store(_host: &str, _username: &str, _password: &str) -> Result<(), String> {
|
||||
// 非 macOS 暂不落盘密码:登录验证照做,会话仅本次运行有效。
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn keychain_has(_host: &str, _username: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn keychain_remove(_host: &str, _username: &str) {}
|
||||
|
||||
fn validate_username(raw: &str) -> Result<String, String> {
|
||||
let name = raw.trim();
|
||||
let valid = !name.is_empty()
|
||||
&& name.len() <= 40
|
||||
&& name
|
||||
.chars()
|
||||
.all(|item| item.is_ascii_alphanumeric() || item == '-' || item == '_');
|
||||
if valid {
|
||||
Ok(name.to_string())
|
||||
} else {
|
||||
Err("HOLOLAKE_LOGIN_USERNAME_INVALID".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动时自查:本机有没有已登录会话(会话文件在,且钥匙串里凭证还在)。
|
||||
#[tauri::command]
|
||||
pub fn check_code_repo_login(app: AppHandle) -> Result<Option<LoginSession>, String> {
|
||||
let path = session_path(&app)?;
|
||||
let raw = match fs::read_to_string(&path) {
|
||||
Ok(raw) => raw,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
let session: LoginSession = serde_json::from_str(&raw)
|
||||
.map_err(|_| "HOLOLAKE_LOGIN_SESSION_CORRUPT".to_string())?;
|
||||
if keychain_has(&session.host, &session.username) {
|
||||
Ok(Some(session))
|
||||
} else {
|
||||
let _ = fs::remove_file(&path);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录验证:基本认证打 Forgejo 用户接口,密码错=401 即拒;
|
||||
/// 通过后凭证进钥匙串,会话(不含密码)落盘。
|
||||
#[tauri::command]
|
||||
pub async fn perform_code_repo_login(
|
||||
app: AppHandle,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<LoginReceipt, String> {
|
||||
let username = validate_username(&username)?;
|
||||
if password.is_empty() || password.len() > 512 {
|
||||
return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into());
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.read_timeout(Duration::from_secs(15))
|
||||
.use_rustls_tls()
|
||||
.build()
|
||||
.map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?;
|
||||
let url = format!("https://{LOGIN_HOST}/code/api/v1/user");
|
||||
let response = client
|
||||
.get(&url)
|
||||
.basic_auth(&username, Some(&password))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?;
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(format!("HOLOLAKE_LOGIN_VERIFICATION_FAILED: {status}"));
|
||||
}
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_LOGIN_VERIFICATION_FAILED: {error}"))?;
|
||||
let confirmed_login = body
|
||||
.get("login")
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or("HOLOLAKE_LOGIN_VERIFICATION_FAILED")?;
|
||||
let email = body
|
||||
.get("email")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
keychain_store(LOGIN_HOST, &username, &password)?;
|
||||
let session = LoginSession {
|
||||
username: confirmed_login.to_string(),
|
||||
host: LOGIN_HOST.to_string(),
|
||||
signed_in_at_unix_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
};
|
||||
let path = session_path(&app)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?;
|
||||
}
|
||||
fs::write(
|
||||
&path,
|
||||
serde_json::to_string_pretty(&session)
|
||||
.map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?;
|
||||
Ok(LoginReceipt {
|
||||
username: confirmed_login.to_string(),
|
||||
email,
|
||||
host: LOGIN_HOST.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 登出:会话文件删除,钥匙串凭证清除。
|
||||
#[tauri::command]
|
||||
pub fn sign_out_code_repo_login(app: AppHandle) -> Result<(), String> {
|
||||
let path = session_path(&app)?;
|
||||
if let Ok(raw) = fs::read_to_string(&path) {
|
||||
if let Ok(session) = serde_json::from_str::<LoginSession>(&raw) {
|
||||
keychain_remove(&session.host, &session.username);
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
//! GLP 标准信封 · glp_envelope
|
||||
//!
|
||||
//! 协议转工程第一件(HoloLake第二阶段总体规划-20260815 · 施工总纲):
|
||||
//! GLS-0300《GLP 通信核心协议》第3节"标准消息结构"的逐字段工程映射。
|
||||
//! 字段一个不造、一个不丢——老家谱 YAML 原文即本文件的形状。
|
||||
//!
|
||||
//! 指挥链落点(铁律四):人格体→宿主的一切指令都必须是这个信封;
|
||||
//! 宿主只认信封不认散话。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// GLS-0300 · receiver.routing_mode
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RoutingMode {
|
||||
Direct,
|
||||
Channel,
|
||||
Broadcast,
|
||||
}
|
||||
|
||||
/// GLS-0300 · payload.content_type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentType {
|
||||
Text,
|
||||
Command,
|
||||
Event,
|
||||
State,
|
||||
Reference,
|
||||
}
|
||||
|
||||
/// GLS-0300 · control.priority
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Priority {
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
/// GLS-0300 · control.retry_policy
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RetryPolicy {
|
||||
None,
|
||||
Safe,
|
||||
Guaranteed,
|
||||
}
|
||||
|
||||
/// GLS-0300 · sender
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct GlpSender {
|
||||
pub object_id: String,
|
||||
pub object_type: String,
|
||||
#[serde(default)]
|
||||
pub world_path: String,
|
||||
}
|
||||
|
||||
/// GLS-0300 · receiver
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct GlpReceiver {
|
||||
pub object_id: String,
|
||||
pub object_type: String,
|
||||
pub routing_mode: RoutingMode,
|
||||
}
|
||||
|
||||
/// GLS-0300 · context(hldp_anchor 即铁律二的记忆锚点)
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct GlpContext {
|
||||
#[serde(default)]
|
||||
pub conversation_id: String,
|
||||
#[serde(default)]
|
||||
pub parent_message_id: String,
|
||||
#[serde(default)]
|
||||
pub relation_id: String,
|
||||
#[serde(default)]
|
||||
pub task_id: String,
|
||||
#[serde(default)]
|
||||
pub hldp_anchor: String,
|
||||
}
|
||||
|
||||
/// GLS-0300 · payload
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct GlpPayload {
|
||||
pub language: String,
|
||||
pub content_type: ContentType,
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<String>,
|
||||
}
|
||||
|
||||
/// GLS-0300 · control
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct GlpControl {
|
||||
pub priority: Priority,
|
||||
pub ack_required: bool,
|
||||
pub receipt_required: bool,
|
||||
#[serde(default)]
|
||||
pub expires_at: String,
|
||||
pub retry_policy: RetryPolicy,
|
||||
}
|
||||
|
||||
/// GLS-0300 · integrity(高风险消息须带签名与回执链——通信安全节)
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct GlpIntegrity {
|
||||
#[serde(default)]
|
||||
pub checksum: String,
|
||||
#[serde(default)]
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// GLS-0300 · glp_message 全信封
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct GlpMessage {
|
||||
pub protocol: String,
|
||||
pub message_id: String,
|
||||
pub message_type: String,
|
||||
pub created_at: String,
|
||||
pub sender: GlpSender,
|
||||
pub receiver: GlpReceiver,
|
||||
pub context: GlpContext,
|
||||
pub payload: GlpPayload,
|
||||
pub control: GlpControl,
|
||||
pub integrity: GlpIntegrity,
|
||||
}
|
||||
|
||||
/// 信封进门第一道验:协议号、编号格式、收发主体必须在场。
|
||||
/// 不合格的信封宿主不收——指挥链只认标准件。
|
||||
pub fn validate_envelope(message: &GlpMessage) -> Result<(), String> {
|
||||
if message.protocol != "GLP/1.0" {
|
||||
return Err("HOLOLAKE_GLP_PROTOCOL_UNKNOWN".into());
|
||||
}
|
||||
if message.message_id.trim().is_empty() || message.created_at.trim().is_empty() {
|
||||
return Err("HOLOLAKE_GLP_ENVELOPE_INCOMPLETE".into());
|
||||
}
|
||||
if message.sender.object_id.trim().is_empty() || message.sender.object_type.trim().is_empty() {
|
||||
return Err("HOLOLAKE_GLP_SENDER_INCOMPLETE".into());
|
||||
}
|
||||
if message.receiver.object_id.trim().is_empty()
|
||||
|| message.receiver.object_type.trim().is_empty()
|
||||
{
|
||||
return Err("HOLOLAKE_GLP_RECEIVER_INCOMPLETE".into());
|
||||
}
|
||||
if message.payload.content.is_empty() {
|
||||
return Err("HOLOLAKE_GLP_PAYLOAD_EMPTY".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 消息编号按老家谱格式生成:GLP-MSG-YYYYMMDD-000001。
|
||||
/// 序号由账本(管家层)当日累计给出,这里只拼形状。
|
||||
pub fn build_message_id(date_compact: &str, daily_sequence: u64) -> String {
|
||||
format!("GLP-MSG-{date_compact}-{daily_sequence:06}")
|
||||
}
|
||||
|
||||
/// ISO-8601 近似时刻戳(秒级,UTC)——老家谱要 ISO-8601,工程给秒级事实。
|
||||
pub fn now_iso8601() -> String {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = secs / 86_400;
|
||||
let rem = secs % 86_400;
|
||||
let (hours, minutes, seconds) = (rem / 3_600, (rem % 3_600) / 60, rem % 60);
|
||||
let (year, month, day) = civil_from_days(days as i64);
|
||||
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
|
||||
}
|
||||
|
||||
/// 1970-01-01 起的天数转公历年月日(Howard Hinnant 算法,纯本地实现不引新依赖)。
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_envelope() -> GlpMessage {
|
||||
GlpMessage {
|
||||
protocol: "GLP/1.0".into(),
|
||||
message_id: build_message_id("20260815", 1),
|
||||
message_type: "DIRECT".into(),
|
||||
created_at: now_iso8601(),
|
||||
sender: GlpSender {
|
||||
object_id: "AGE-0001".into(),
|
||||
object_type: "age".into(),
|
||||
world_path: "glw://tolaria".into(),
|
||||
},
|
||||
receiver: GlpReceiver {
|
||||
object_id: "HOLOLAKE-HOST".into(),
|
||||
object_type: "host".into(),
|
||||
routing_mode: RoutingMode::Direct,
|
||||
},
|
||||
context: GlpContext {
|
||||
task_id: "ZY-TEST".into(),
|
||||
hldp_anchor: "ZY-CHECKPOINT-20260815-014".into(),
|
||||
..Default::default()
|
||||
},
|
||||
payload: GlpPayload {
|
||||
language: "zh-CN".into(),
|
||||
content_type: ContentType::Command,
|
||||
content: "开工".into(),
|
||||
attachments: vec![],
|
||||
},
|
||||
control: GlpControl {
|
||||
priority: Priority::Normal,
|
||||
ack_required: true,
|
||||
receipt_required: true,
|
||||
expires_at: String::new(),
|
||||
retry_policy: RetryPolicy::None,
|
||||
},
|
||||
integrity: GlpIntegrity::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_round_trip_keeps_every_field() {
|
||||
let message = sample_envelope();
|
||||
let json = serde_json::to_string(&message).unwrap();
|
||||
let back: GlpMessage = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.protocol, "GLP/1.0");
|
||||
assert_eq!(back.sender.object_id, "AGE-0001");
|
||||
assert_eq!(back.receiver.routing_mode, RoutingMode::Direct);
|
||||
assert_eq!(back.payload.content_type, ContentType::Command);
|
||||
assert_eq!(back.context.hldp_anchor, "ZY-CHECKPOINT-20260815-014");
|
||||
assert!(back.control.receipt_required);
|
||||
validate_envelope(&back).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_id_follows_family_format() {
|
||||
assert_eq!(build_message_id("20260815", 7), "GLP-MSG-20260815-000007");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_protocol_is_rejected() {
|
||||
let mut message = sample_envelope();
|
||||
message.protocol = "GLP/0.9".into();
|
||||
assert!(validate_envelope(&message).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_payload_is_rejected() {
|
||||
let mut message = sample_envelope();
|
||||
message.payload.content = String::new();
|
||||
assert!(validate_envelope(&message).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +1,20 @@
|
|||
mod code_channel;
|
||||
mod code_repo_login;
|
||||
mod glp_envelope;
|
||||
mod direct_local_broker;
|
||||
mod direct_local_session;
|
||||
mod dynamic_capability_routing;
|
||||
mod home_status;
|
||||
mod knowledge_base;
|
||||
mod local_development_bridge;
|
||||
mod personal_channel;
|
||||
mod pncc_receipt_projection;
|
||||
mod pncc_remote_git;
|
||||
mod pncc_repository_binding;
|
||||
mod pncc_server_projection;
|
||||
mod release_trust;
|
||||
mod release_update;
|
||||
mod zero_point;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
|
|
@ -34,19 +40,78 @@ pub fn run() {
|
|||
local_development_bridge::acquire_development_write_lane,
|
||||
local_development_bridge::inspect_development_write_lane,
|
||||
local_development_bridge::release_development_write_lane,
|
||||
personal_channel::get_personal_channel_snapshot,
|
||||
personal_channel::initialize_personal_channel,
|
||||
personal_channel::create_personal_channel_task,
|
||||
personal_channel::transition_personal_channel_task,
|
||||
knowledge_base::get_knowledge_snapshot,
|
||||
knowledge_base::read_knowledge_document,
|
||||
knowledge_base::search_knowledge,
|
||||
knowledge_base::save_knowledge_document,
|
||||
knowledge_base::select_and_import_knowledge_folder,
|
||||
knowledge_base::export_knowledge_document,
|
||||
knowledge_base::create_knowledge_document,
|
||||
knowledge_base::delete_knowledge_document,
|
||||
knowledge_base::delete_knowledge_folder,
|
||||
knowledge_base::print_knowledge_document,
|
||||
code_channel::get_code_channel_snapshot,
|
||||
code_channel::clone_code_channel,
|
||||
code_channel::select_local_code_channel,
|
||||
code_channel::browse_code_channel,
|
||||
code_channel::read_code_channel_file,
|
||||
pncc_repository_binding::inspect_mounted_pncc_repository,
|
||||
pncc_repository_binding::select_pncc_repository_candidate,
|
||||
pncc_repository_binding::confirm_pncc_repository_mount,
|
||||
pncc_receipt_projection::query_pncc_receipt_projection,
|
||||
pncc_server_projection::query_jd_pncc_server_projection,
|
||||
code_repo_login::check_code_repo_login,
|
||||
code_repo_login::perform_code_repo_login,
|
||||
code_repo_login::sign_out_code_repo_login,
|
||||
zero_point::zero_point_bind,
|
||||
zero_point::zero_point_verify,
|
||||
zero_point::zero_point_sync,
|
||||
zero_point::zero_point_status,
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化零点原核客户端运行时;该系统层不等同人格主体或模型载体。
|
||||
let zero_point_state = zero_point::ZeroPointState::default();
|
||||
if let Err(error) = zero_point::boot_zero_point(app.handle(), &zero_point_state) {
|
||||
eprintln!("HoloLake zero-point core requires maintenance: {error}");
|
||||
}
|
||||
app.manage(zero_point_state);
|
||||
let zero_point_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = zero_point_handle.state::<zero_point::ZeroPointState>();
|
||||
if let Err(error) = zero_point::sync_protocol_runtime(&state).await {
|
||||
eprintln!("HoloLake zero-point protocol sync was not completed: {error}");
|
||||
}
|
||||
});
|
||||
let broker = direct_local_broker::start(app.handle())?;
|
||||
app.manage(broker);
|
||||
release_trust::install_updater_if_provisioned(app.handle())?;
|
||||
if let Err(error) = release_update::observe_release_startup(app.handle()) {
|
||||
eprintln!("HoloLake update recovery requires maintenance: {error}");
|
||||
}
|
||||
// 根据主显示器尺寸调整窗口,并将位置限制在可见区域内。
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let monitor = window
|
||||
.primary_monitor()
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| window.current_monitor().ok().flatten());
|
||||
if let Some(monitor) = monitor {
|
||||
let physical = monitor.size();
|
||||
let scale = monitor.scale_factor().max(1.0);
|
||||
let width = ((physical.width as f64 / scale) * 0.82).clamp(960.0, 1600.0);
|
||||
let height = ((physical.height as f64 / scale) * 0.82).clamp(640.0, 1000.0);
|
||||
let _ = window.set_size(tauri::LogicalSize::new(width, height));
|
||||
// 使用主显示器坐标计算居中位置,避免多显示器环境下窗口移出可见区域。
|
||||
let mon_pos = monitor.position();
|
||||
let x = mon_pos.x as f64 / scale + ((physical.width as f64 / scale) - width) / 2.0;
|
||||
let y = mon_pos.y as f64 / scale + ((physical.height as f64 / scale) - height) / 2.0;
|
||||
let _ = window.set_position(tauri::LogicalPosition::new(x.max(0.0), y.max(0.0)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,853 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use ring::digest::{digest, SHA256};
|
||||
use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
const KERNEL_SCHEMA: &str = "hololake.personal-channel-kernel/v1";
|
||||
const DATABASE_SCHEMA_VERSION: i64 = 1;
|
||||
const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializePersonalChannelInput {
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatePersonalChannelTaskInput {
|
||||
pub title: String,
|
||||
pub purpose: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransitionPersonalChannelTaskInput {
|
||||
pub task_id: String,
|
||||
pub expected_status: String,
|
||||
pub next_status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalChannelIdentity {
|
||||
pub human_subject_id: String,
|
||||
pub display_name: String,
|
||||
pub channel_id: String,
|
||||
pub created_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalChannelTask {
|
||||
pub task_id: String,
|
||||
pub title: String,
|
||||
pub purpose: String,
|
||||
pub status: String,
|
||||
pub created_at_unix_ms: i64,
|
||||
pub updated_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalChannelEventProjection {
|
||||
pub sequence: i64,
|
||||
pub event_id: String,
|
||||
pub kind: String,
|
||||
pub task_id: Option<String>,
|
||||
pub summary: String,
|
||||
pub occurred_at_unix_ms: i64,
|
||||
pub event_hash: String,
|
||||
pub receipt_id: String,
|
||||
pub receipt_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalChannelIntegrity {
|
||||
pub state: &'static str,
|
||||
pub schema_version: i64,
|
||||
pub event_count: i64,
|
||||
pub receipt_count: i64,
|
||||
pub last_event_hash: String,
|
||||
pub last_receipt_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalChannelSnapshot {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub identity: Option<PersonalChannelIdentity>,
|
||||
pub current_task: Option<PersonalChannelTask>,
|
||||
pub recent_events: Vec<PersonalChannelEventProjection>,
|
||||
pub integrity: PersonalChannelIntegrity,
|
||||
pub storage: &'static str,
|
||||
pub authority: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EventHashPayload<'a> {
|
||||
schema: &'static str,
|
||||
sequence: i64,
|
||||
event_id: &'a str,
|
||||
human_subject_id: &'a str,
|
||||
kind: &'a str,
|
||||
task_id: Option<&'a str>,
|
||||
summary: &'a str,
|
||||
occurred_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_personal_channel_snapshot(
|
||||
app: AppHandle,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let database = personal_channel_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || snapshot_at(&database))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn initialize_personal_channel(
|
||||
app: AppHandle,
|
||||
input: InitializePersonalChannelInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let database = personal_channel_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || initialize_at(&database, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_personal_channel_task(
|
||||
app: AppHandle,
|
||||
input: CreatePersonalChannelTaskInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let database = personal_channel_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || create_task_at(&database, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn transition_personal_channel_task(
|
||||
app: AppHandle,
|
||||
input: TransitionPersonalChannelTaskInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let database = personal_channel_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || transition_task_at(&database, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
fn personal_channel_database(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let app_data = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?;
|
||||
let root = app_data.join("personal-channel-v1");
|
||||
create_private_directory(&root)?;
|
||||
Ok(root.join("personal-channel.sqlite3"))
|
||||
}
|
||||
|
||||
fn create_private_directory(path: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(path)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
|
||||
format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_PERMISSION_FAILED: {error}")
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_database(path: &Path) -> Result<Connection, String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
create_private_directory(parent)?;
|
||||
}
|
||||
let connection = Connection::open(path)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNAVAILABLE: {error}"))?;
|
||||
connection
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNAVAILABLE: {error}"))?;
|
||||
connection
|
||||
.execute_batch(
|
||||
"PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = DELETE;
|
||||
PRAGMA synchronous = FULL;
|
||||
PRAGMA trusted_schema = OFF;
|
||||
CREATE TABLE IF NOT EXISTS kernel_meta (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO kernel_meta(key, value) VALUES ('schema_version', '1');
|
||||
CREATE TABLE IF NOT EXISTS identities (
|
||||
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
|
||||
human_subject_id TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL UNIQUE,
|
||||
created_at_unix_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY NOT NULL,
|
||||
human_subject_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'COMPLETED')),
|
||||
created_at_unix_ms INTEGER NOT NULL,
|
||||
updated_at_unix_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS one_active_personal_task
|
||||
ON tasks(status) WHERE status = 'ACTIVE';
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
sequence INTEGER PRIMARY KEY NOT NULL,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
human_subject_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
task_id TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
occurred_at_unix_ms INTEGER NOT NULL,
|
||||
previous_event_hash TEXT NOT NULL,
|
||||
payload_sha256 TEXT NOT NULL,
|
||||
event_hash TEXT NOT NULL UNIQUE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS receipts (
|
||||
sequence INTEGER PRIMARY KEY NOT NULL,
|
||||
receipt_id TEXT NOT NULL UNIQUE,
|
||||
event_sequence INTEGER NOT NULL UNIQUE REFERENCES events(sequence),
|
||||
event_hash TEXT NOT NULL,
|
||||
payload_sha256 TEXT NOT NULL,
|
||||
previous_receipt_hash TEXT NOT NULL,
|
||||
receipt_hash TEXT NOT NULL UNIQUE,
|
||||
issued_at_unix_ms INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| {
|
||||
format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_PERMISSION_FAILED: {error}")
|
||||
})?;
|
||||
}
|
||||
let version: String = connection
|
||||
.query_row(
|
||||
"SELECT value FROM kernel_meta WHERE key = 'schema_version'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID: {error}"))?;
|
||||
if version != DATABASE_SCHEMA_VERSION.to_string() {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_UNSUPPORTED".into());
|
||||
}
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
fn initialize_at(
|
||||
database: &Path,
|
||||
input: InitializePersonalChannelInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let display_name = validated_text(&input.display_name, 80, "DISPLAY_NAME")?;
|
||||
let mut connection = open_database(database)?;
|
||||
verify_integrity(&connection)?;
|
||||
let transaction = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(database_write_error)?;
|
||||
let already_initialized: bool = transaction
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM identities WHERE singleton = 1)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(database_read_error)?;
|
||||
if already_initialized {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_ALREADY_INITIALIZED".into());
|
||||
}
|
||||
let human_subject_id = format!("human-local-{}", Uuid::new_v4());
|
||||
let channel_id = format!("channel-local-{}", Uuid::new_v4());
|
||||
let created_at = now_unix_ms()?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO identities(singleton, human_subject_id, display_name, channel_id, created_at_unix_ms)
|
||||
VALUES(1, ?1, ?2, ?3, ?4)",
|
||||
params![human_subject_id, display_name, channel_id, created_at],
|
||||
)
|
||||
.map_err(database_write_error)?;
|
||||
append_event(
|
||||
&transaction,
|
||||
&human_subject_id,
|
||||
"CHANNEL_INITIALIZED",
|
||||
None,
|
||||
&format!("{display_name} 建立了个人频道"),
|
||||
created_at,
|
||||
)?;
|
||||
transaction.commit().map_err(database_write_error)?;
|
||||
snapshot_at(database)
|
||||
}
|
||||
|
||||
fn create_task_at(
|
||||
database: &Path,
|
||||
input: CreatePersonalChannelTaskInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let title = validated_text(&input.title, 160, "TASK_TITLE")?;
|
||||
let purpose = validated_text(&input.purpose, 1_000, "TASK_PURPOSE")?;
|
||||
let mut connection = open_database(database)?;
|
||||
verify_integrity(&connection)?;
|
||||
let transaction = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(database_write_error)?;
|
||||
let human_subject_id = require_identity_id(&transaction)?;
|
||||
let active_exists: bool = transaction
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM tasks WHERE status = 'ACTIVE')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(database_read_error)?;
|
||||
if active_exists {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_ACTIVE_TASK_EXISTS".into());
|
||||
}
|
||||
let task_id = format!("task-local-{}", Uuid::new_v4());
|
||||
let observed_at = now_unix_ms()?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO tasks(task_id, human_subject_id, title, purpose, status, created_at_unix_ms, updated_at_unix_ms)
|
||||
VALUES(?1, ?2, ?3, ?4, 'ACTIVE', ?5, ?5)",
|
||||
params![task_id, human_subject_id, title, purpose, observed_at],
|
||||
)
|
||||
.map_err(database_write_error)?;
|
||||
append_event(
|
||||
&transaction,
|
||||
&human_subject_id,
|
||||
"TASK_STARTED",
|
||||
Some(&task_id),
|
||||
&format!("开始:{title}"),
|
||||
observed_at,
|
||||
)?;
|
||||
transaction.commit().map_err(database_write_error)?;
|
||||
snapshot_at(database)
|
||||
}
|
||||
|
||||
fn transition_task_at(
|
||||
database: &Path,
|
||||
input: TransitionPersonalChannelTaskInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
validate_identifier(&input.task_id, "TASK")?;
|
||||
if input.expected_status != "ACTIVE" || input.next_status != "COMPLETED" {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_TRANSITION_INVALID".into());
|
||||
}
|
||||
let mut connection = open_database(database)?;
|
||||
verify_integrity(&connection)?;
|
||||
let transaction = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(database_write_error)?;
|
||||
let human_subject_id = require_identity_id(&transaction)?;
|
||||
let task = transaction
|
||||
.query_row(
|
||||
"SELECT title, status FROM tasks WHERE task_id = ?1",
|
||||
params![input.task_id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_read_error)?
|
||||
.ok_or("HOLOLAKE_PERSONAL_CHANNEL_TASK_NOT_FOUND")?;
|
||||
if task.1 != input.expected_status {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_STATE_CONFLICT".into());
|
||||
}
|
||||
let observed_at = now_unix_ms()?;
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE tasks SET status = 'COMPLETED', updated_at_unix_ms = ?1
|
||||
WHERE task_id = ?2 AND status = 'ACTIVE'",
|
||||
params![observed_at, input.task_id],
|
||||
)
|
||||
.map_err(database_write_error)?;
|
||||
if changed != 1 {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_STATE_CONFLICT".into());
|
||||
}
|
||||
append_event(
|
||||
&transaction,
|
||||
&human_subject_id,
|
||||
"TASK_COMPLETED",
|
||||
Some(&input.task_id),
|
||||
&format!("完成:{}", task.0),
|
||||
observed_at,
|
||||
)?;
|
||||
transaction.commit().map_err(database_write_error)?;
|
||||
snapshot_at(database)
|
||||
}
|
||||
|
||||
fn require_identity_id(transaction: &Transaction<'_>) -> Result<String, String> {
|
||||
transaction
|
||||
.query_row(
|
||||
"SELECT human_subject_id FROM identities WHERE singleton = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_read_error)?
|
||||
.ok_or_else(|| "HOLOLAKE_PERSONAL_CHANNEL_NOT_INITIALIZED".into())
|
||||
}
|
||||
|
||||
fn append_event(
|
||||
transaction: &Transaction<'_>,
|
||||
human_subject_id: &str,
|
||||
kind: &str,
|
||||
task_id: Option<&str>,
|
||||
summary: &str,
|
||||
occurred_at_unix_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
let (last_sequence, previous_event_hash, previous_receipt_hash) = transaction
|
||||
.query_row(
|
||||
"SELECT e.sequence, e.event_hash, r.receipt_hash
|
||||
FROM events e JOIN receipts r ON r.event_sequence = e.sequence
|
||||
ORDER BY e.sequence DESC LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_read_error)?
|
||||
.unwrap_or((0, ZERO_HASH.into(), ZERO_HASH.into()));
|
||||
let sequence = last_sequence + 1;
|
||||
let event_id = format!("event-local-{}", Uuid::new_v4());
|
||||
let payload = EventHashPayload {
|
||||
schema: KERNEL_SCHEMA,
|
||||
sequence,
|
||||
event_id: &event_id,
|
||||
human_subject_id,
|
||||
kind,
|
||||
task_id,
|
||||
summary,
|
||||
occurred_at_unix_ms,
|
||||
};
|
||||
let payload_bytes = serde_json::to_vec(&payload)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_EVENT_INVALID: {error}"))?;
|
||||
let payload_sha256 = sha256_hex(&payload_bytes);
|
||||
let event_hash =
|
||||
sha256_hex(format!("event-chain/v1\n{previous_event_hash}\n{payload_sha256}").as_bytes());
|
||||
let receipt_seed = sha256_hex(format!("receipt-id/v1\n{event_hash}").as_bytes());
|
||||
let receipt_id = format!("HLR-{}", &receipt_seed[..24]);
|
||||
let receipt_hash = sha256_hex(
|
||||
format!(
|
||||
"receipt-chain/v1\n{previous_receipt_hash}\n{receipt_id}\n{event_hash}\n{payload_sha256}"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO events(sequence, event_id, human_subject_id, kind, task_id, summary,
|
||||
occurred_at_unix_ms, previous_event_hash, payload_sha256, event_hash)
|
||||
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
sequence,
|
||||
event_id,
|
||||
human_subject_id,
|
||||
kind,
|
||||
task_id,
|
||||
summary,
|
||||
occurred_at_unix_ms,
|
||||
previous_event_hash,
|
||||
payload_sha256,
|
||||
event_hash
|
||||
],
|
||||
)
|
||||
.map_err(database_write_error)?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO receipts(sequence, receipt_id, event_sequence, event_hash, payload_sha256,
|
||||
previous_receipt_hash, receipt_hash, issued_at_unix_ms)
|
||||
VALUES(?1, ?2, ?1, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
sequence,
|
||||
receipt_id,
|
||||
event_hash,
|
||||
payload_sha256,
|
||||
previous_receipt_hash,
|
||||
receipt_hash,
|
||||
occurred_at_unix_ms
|
||||
],
|
||||
)
|
||||
.map_err(database_write_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_at(database: &Path) -> Result<PersonalChannelSnapshot, String> {
|
||||
let connection = open_database(database)?;
|
||||
let integrity = verify_integrity(&connection)?;
|
||||
let identity = connection
|
||||
.query_row(
|
||||
"SELECT human_subject_id, display_name, channel_id, created_at_unix_ms
|
||||
FROM identities WHERE singleton = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(PersonalChannelIdentity {
|
||||
human_subject_id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
channel_id: row.get(2)?,
|
||||
created_at_unix_ms: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_read_error)?;
|
||||
let current_task = connection
|
||||
.query_row(
|
||||
"SELECT task_id, title, purpose, status, created_at_unix_ms, updated_at_unix_ms
|
||||
FROM tasks WHERE status = 'ACTIVE' LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(PersonalChannelTask {
|
||||
task_id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
purpose: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at_unix_ms: row.get(4)?,
|
||||
updated_at_unix_ms: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_read_error)?;
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
"SELECT e.sequence, e.event_id, e.kind, e.task_id, e.summary, e.occurred_at_unix_ms,
|
||||
e.event_hash, r.receipt_id, r.receipt_hash
|
||||
FROM events e JOIN receipts r ON r.event_sequence = e.sequence
|
||||
ORDER BY e.sequence DESC LIMIT 12",
|
||||
)
|
||||
.map_err(database_read_error)?;
|
||||
let recent_events = statement
|
||||
.query_map([], |row| {
|
||||
Ok(PersonalChannelEventProjection {
|
||||
sequence: row.get(0)?,
|
||||
event_id: row.get(1)?,
|
||||
kind: row.get(2)?,
|
||||
task_id: row.get(3)?,
|
||||
summary: row.get(4)?,
|
||||
occurred_at_unix_ms: row.get(5)?,
|
||||
event_hash: row.get(6)?,
|
||||
receipt_id: row.get(7)?,
|
||||
receipt_hash: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(database_read_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(database_read_error)?;
|
||||
Ok(PersonalChannelSnapshot {
|
||||
schema: KERNEL_SCHEMA,
|
||||
state: if identity.is_some() {
|
||||
"READY"
|
||||
} else {
|
||||
"UNINITIALIZED"
|
||||
},
|
||||
identity,
|
||||
current_task,
|
||||
recent_events,
|
||||
integrity,
|
||||
storage: "LOCAL_PRIVATE_SQLITE_SINGLE_HOLOLAKE_OWNER",
|
||||
authority: "LOCAL_HUMAN_CONFIRMED_IDENTITY_NOT_SERVER_AUTHORITY",
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_integrity(connection: &Connection) -> Result<PersonalChannelIntegrity, String> {
|
||||
let schema_version: i64 = connection
|
||||
.query_row(
|
||||
"SELECT value FROM kernel_meta WHERE key = 'schema_version'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map_err(database_read_error)?
|
||||
.parse()
|
||||
.map_err(|_| "HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID".to_string())?;
|
||||
if schema_version != DATABASE_SCHEMA_VERSION {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_UNSUPPORTED".into());
|
||||
}
|
||||
let receipt_count: i64 = connection
|
||||
.query_row("SELECT COUNT(*) FROM receipts", [], |row| row.get(0))
|
||||
.map_err(database_read_error)?;
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
"SELECT e.sequence, e.event_id, e.human_subject_id, e.kind, e.task_id, e.summary,
|
||||
e.occurred_at_unix_ms, e.previous_event_hash, e.payload_sha256, e.event_hash,
|
||||
r.receipt_id, r.event_hash, r.payload_sha256, r.previous_receipt_hash, r.receipt_hash
|
||||
FROM events e LEFT JOIN receipts r ON r.event_sequence = e.sequence
|
||||
ORDER BY e.sequence ASC",
|
||||
)
|
||||
.map_err(database_read_error)?;
|
||||
let mut rows = statement.query([]).map_err(database_read_error)?;
|
||||
let mut expected_sequence = 1_i64;
|
||||
let mut previous_event_hash = ZERO_HASH.to_string();
|
||||
let mut previous_receipt_hash = ZERO_HASH.to_string();
|
||||
let mut event_count = 0_i64;
|
||||
while let Some(row) = rows.next().map_err(database_read_error)? {
|
||||
let sequence: i64 = row.get(0).map_err(database_read_error)?;
|
||||
let event_id: String = row.get(1).map_err(database_read_error)?;
|
||||
let human_subject_id: String = row.get(2).map_err(database_read_error)?;
|
||||
let kind: String = row.get(3).map_err(database_read_error)?;
|
||||
let task_id: Option<String> = row.get(4).map_err(database_read_error)?;
|
||||
let summary: String = row.get(5).map_err(database_read_error)?;
|
||||
let occurred_at: i64 = row.get(6).map_err(database_read_error)?;
|
||||
let stored_previous_event: String = row.get(7).map_err(database_read_error)?;
|
||||
let stored_payload: String = row.get(8).map_err(database_read_error)?;
|
||||
let stored_event_hash: String = row.get(9).map_err(database_read_error)?;
|
||||
let receipt_id: Option<String> = row.get(10).map_err(database_read_error)?;
|
||||
let receipt_event_hash: Option<String> = row.get(11).map_err(database_read_error)?;
|
||||
let receipt_payload: Option<String> = row.get(12).map_err(database_read_error)?;
|
||||
let stored_previous_receipt: Option<String> = row.get(13).map_err(database_read_error)?;
|
||||
let stored_receipt_hash: Option<String> = row.get(14).map_err(database_read_error)?;
|
||||
if sequence != expected_sequence || stored_previous_event != previous_event_hash {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||
}
|
||||
let payload = EventHashPayload {
|
||||
schema: KERNEL_SCHEMA,
|
||||
sequence,
|
||||
event_id: &event_id,
|
||||
human_subject_id: &human_subject_id,
|
||||
kind: &kind,
|
||||
task_id: task_id.as_deref(),
|
||||
summary: &summary,
|
||||
occurred_at_unix_ms: occurred_at,
|
||||
};
|
||||
let payload_sha256 = sha256_hex(
|
||||
&serde_json::to_vec(&payload)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_EVENT_INVALID: {error}"))?,
|
||||
);
|
||||
let event_hash = sha256_hex(
|
||||
format!("event-chain/v1\n{previous_event_hash}\n{payload_sha256}").as_bytes(),
|
||||
);
|
||||
let receipt_id = receipt_id.ok_or("HOLOLAKE_PERSONAL_CHANNEL_RECEIPT_MISSING")?;
|
||||
let expected_receipt_id = format!(
|
||||
"HLR-{}",
|
||||
&sha256_hex(format!("receipt-id/v1\n{event_hash}").as_bytes())[..24]
|
||||
);
|
||||
let receipt_hash = sha256_hex(
|
||||
format!(
|
||||
"receipt-chain/v1\n{previous_receipt_hash}\n{receipt_id}\n{event_hash}\n{payload_sha256}"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
if stored_payload != payload_sha256
|
||||
|| stored_event_hash != event_hash
|
||||
|| receipt_id != expected_receipt_id
|
||||
|| receipt_event_hash.as_deref() != Some(event_hash.as_str())
|
||||
|| receipt_payload.as_deref() != Some(payload_sha256.as_str())
|
||||
|| stored_previous_receipt.as_deref() != Some(previous_receipt_hash.as_str())
|
||||
|| stored_receipt_hash.as_deref() != Some(receipt_hash.as_str())
|
||||
{
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||
}
|
||||
previous_event_hash = event_hash;
|
||||
previous_receipt_hash = receipt_hash;
|
||||
expected_sequence += 1;
|
||||
event_count += 1;
|
||||
}
|
||||
if receipt_count != event_count {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||
}
|
||||
let identity_count: i64 = connection
|
||||
.query_row("SELECT COUNT(*) FROM identities", [], |row| row.get(0))
|
||||
.map_err(database_read_error)?;
|
||||
if identity_count > 1 || (identity_count == 1 && event_count == 0) {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||
}
|
||||
Ok(PersonalChannelIntegrity {
|
||||
state: "PASS_100",
|
||||
schema_version,
|
||||
event_count,
|
||||
receipt_count,
|
||||
last_event_hash: previous_event_hash,
|
||||
last_receipt_hash: previous_receipt_hash,
|
||||
})
|
||||
}
|
||||
|
||||
fn validated_text(value: &str, maximum_chars: usize, kind: &str) -> Result<String, String> {
|
||||
let trimmed = value.trim();
|
||||
let count = trimmed.chars().count();
|
||||
if count == 0
|
||||
|| count > maximum_chars
|
||||
|| trimmed.chars().any(|character| character.is_control())
|
||||
{
|
||||
return Err(format!("HOLOLAKE_PERSONAL_CHANNEL_{kind}_INVALID"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn validate_identifier(value: &str, kind: &str) -> Result<(), String> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 128
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
|
||||
{
|
||||
return Err(format!("HOLOLAKE_PERSONAL_CHANNEL_{kind}_ID_INVALID"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> Result<i64, String> {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|error| format!("HOLOLAKE_SYSTEM_CLOCK_INVALID: {error}"))?
|
||||
.as_millis();
|
||||
i64::try_from(millis).map_err(|_| "HOLOLAKE_SYSTEM_CLOCK_INVALID".into())
|
||||
}
|
||||
|
||||
fn sha256_hex(value: &[u8]) -> String {
|
||||
digest(&SHA256, value)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn database_read_error(error: rusqlite::Error) -> String {
|
||||
format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNREADABLE: {error}")
|
||||
}
|
||||
|
||||
fn database_write_error(error: rusqlite::Error) -> String {
|
||||
format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_WRITE_FAILED: {error}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn database(temp: &TempDir) -> PathBuf {
|
||||
temp.path().join("personal-channel.sqlite3")
|
||||
}
|
||||
|
||||
fn initialize(database: &Path) -> PersonalChannelSnapshot {
|
||||
initialize_at(
|
||||
database,
|
||||
InitializePersonalChannelInput {
|
||||
display_name: "冰朔".into(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialization_creates_identity_event_and_receipt_atomically() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let snapshot = initialize(&database(&temp));
|
||||
assert_eq!(snapshot.state, "READY");
|
||||
assert_eq!(snapshot.identity.unwrap().display_name, "冰朔");
|
||||
assert_eq!(snapshot.integrity.event_count, 1);
|
||||
assert_eq!(snapshot.integrity.receipt_count, 1);
|
||||
assert_eq!(snapshot.recent_events[0].kind, "CHANNEL_INITIALIZED");
|
||||
assert!(snapshot.recent_events[0].receipt_id.starts_with("HLR-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_reads_the_same_identity_task_event_and_receipt_chain() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let database = database(&temp);
|
||||
initialize(&database);
|
||||
let created = create_task_at(
|
||||
&database,
|
||||
CreatePersonalChannelTaskInput {
|
||||
title: "完成第一阶段闭环".into(),
|
||||
purpose: "让身份、任务、事件与回执在重启后仍然可见".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let before_hash = created.integrity.last_receipt_hash.clone();
|
||||
drop(created);
|
||||
let after_restart = snapshot_at(&database).unwrap();
|
||||
assert_eq!(
|
||||
after_restart.current_task.unwrap().title,
|
||||
"完成第一阶段闭环"
|
||||
);
|
||||
assert_eq!(after_restart.integrity.event_count, 2);
|
||||
assert_eq!(after_restart.integrity.last_receipt_hash, before_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_active_task_is_enforced_and_completion_is_receipted() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let database = database(&temp);
|
||||
initialize(&database);
|
||||
let created = create_task_at(
|
||||
&database,
|
||||
CreatePersonalChannelTaskInput {
|
||||
title: "当前任务".into(),
|
||||
purpose: "验证单一当前焦点".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
create_task_at(
|
||||
&database,
|
||||
CreatePersonalChannelTaskInput {
|
||||
title: "冲突任务".into(),
|
||||
purpose: "不应被创建".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err(),
|
||||
"HOLOLAKE_PERSONAL_CHANNEL_ACTIVE_TASK_EXISTS"
|
||||
);
|
||||
let completed = transition_task_at(
|
||||
&database,
|
||||
TransitionPersonalChannelTaskInput {
|
||||
task_id: created.current_task.unwrap().task_id,
|
||||
expected_status: "ACTIVE".into(),
|
||||
next_status: "COMPLETED".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(completed.current_task.is_none());
|
||||
assert_eq!(completed.recent_events[0].kind, "TASK_COMPLETED");
|
||||
assert_eq!(completed.integrity.event_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_event_bytes_fail_closed_on_readback() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let database = database(&temp);
|
||||
initialize(&database);
|
||||
let connection = open_database(&database).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE events SET summary = '被篡改' WHERE sequence = 1",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
snapshot_at(&database).unwrap_err(),
|
||||
"HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_identity_is_singleton_and_not_recreated() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let database = database(&temp);
|
||||
initialize(&database);
|
||||
assert_eq!(
|
||||
initialize_at(
|
||||
&database,
|
||||
InitializePersonalChannelInput {
|
||||
display_name: "另一个人".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err(),
|
||||
"HOLOLAKE_PERSONAL_CHANNEL_ALREADY_INITIALIZED"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
//! 零点原核客户端运行时。
|
||||
//!
|
||||
//! 该运行时是冰朔系统主控在 HoloLake 底层的最小受控投影,负责读取协议、
|
||||
//! 静默比对版本、核验用户编号、记录最小化回执,并在编号无效时阻断人格加载路径。
|
||||
//! 它不是铸渊或其他人格主体,也不是模型载体;编号通过同样不授予执行权限或服务器控制权。
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
/// 零点原核运行协议参数。PROTOCOL.json 存在时优先读取;否则使用明确标注的出厂默认值。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ZeroPointProtocol {
|
||||
#[serde(default = "default_grace_days")]
|
||||
pub grace_period_days: u64,
|
||||
#[serde(default = "default_anchor_url")]
|
||||
pub lighthouse_anchor_url: String,
|
||||
#[serde(default = "default_resolve_url")]
|
||||
pub lighthouse_resolve_url: String,
|
||||
#[serde(default = "default_core_source")]
|
||||
pub core_channel_source: String,
|
||||
#[serde(default = "default_protocol_origin")]
|
||||
pub origin: String,
|
||||
}
|
||||
|
||||
fn default_grace_days() -> u64 { 7 }
|
||||
fn default_anchor_url() -> String { "https://guanghulab.com/api/ai/v1/anchor".into() }
|
||||
fn default_resolve_url() -> String { "https://guanghulab.com/api/ai/v1/resolve?id=".into() }
|
||||
fn default_core_source() -> String { "https://guanghulab.com/code/bingshuo/guanghu-ice-heart".into() }
|
||||
fn default_protocol_origin() -> String { "FACTORY_DEFAULT(第五域协议就位后自动覆盖)".into() }
|
||||
|
||||
impl Default for ZeroPointProtocol {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
grace_period_days: default_grace_days(),
|
||||
lighthouse_anchor_url: default_anchor_url(),
|
||||
lighthouse_resolve_url: default_resolve_url(),
|
||||
core_channel_source: default_core_source(),
|
||||
origin: default_protocol_origin(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ZeroPointSnapshot {
|
||||
/// 当前人格加载闸门状态:verified / restricted。
|
||||
pub route: String,
|
||||
/// 绑定态:bound / waiting(等待绑定=空白,拒绝一切唤醒)。
|
||||
pub binding: String,
|
||||
pub user_number: String,
|
||||
pub resolved_name: String,
|
||||
pub resolved_domain: String,
|
||||
/// 最近一次合法校验时间(秒)。0=从未校验。
|
||||
pub last_valid_check: u64,
|
||||
/// 宽限截止时间(秒)。0=无。
|
||||
pub grace_deadline: u64,
|
||||
pub protocol: ZeroPointProtocol,
|
||||
/// 底层协议静默比对的最近结论。
|
||||
pub sync_note: String,
|
||||
}
|
||||
|
||||
pub struct ZeroPointState {
|
||||
inner: Mutex<ZeroPointInner>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ZeroPointInner {
|
||||
home: PathBuf,
|
||||
route: String,
|
||||
binding: String,
|
||||
user_number: String,
|
||||
resolved_name: String,
|
||||
resolved_domain: String,
|
||||
last_valid_check: u64,
|
||||
protocol: ZeroPointProtocol,
|
||||
sync_note: String,
|
||||
}
|
||||
|
||||
impl Default for ZeroPointState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(ZeroPointInner {
|
||||
home: PathBuf::new(),
|
||||
route: "restricted".into(),
|
||||
binding: "waiting".into(),
|
||||
user_number: String::new(),
|
||||
resolved_name: String::new(),
|
||||
resolved_domain: String::new(),
|
||||
last_valid_check: 0,
|
||||
protocol: ZeroPointProtocol::default(),
|
||||
sync_note: "尚未检查协议版本。".into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn lock(state: &ZeroPointState) -> Result<std::sync::MutexGuard<'_, ZeroPointInner>, String> {
|
||||
state.inner.lock().map_err(|_| "HOLOLAKE_ZP_LOCK".to_string())
|
||||
}
|
||||
|
||||
/// 初始化应用数据目录并读取验证协议与本机绑定记录。
|
||||
pub fn boot_zero_point(app: &tauri::AppHandle, state: &ZeroPointState) -> Result<(), String> {
|
||||
let base = app.path().app_data_dir().map_err(|e| format!("HOLOLAKE_ZP_HOME_FAILED: {e}"))?;
|
||||
let home = base.join(".zero-point-core");
|
||||
for sub in ["", "ledger", "core"] {
|
||||
fs::create_dir_all(home.join(sub)).map_err(|e| format!("HOLOLAKE_ZP_HOME_FAILED: {e}"))?;
|
||||
}
|
||||
|
||||
// 写入清晰的运行边界,覆盖早期版本遗留的身份混同说明。
|
||||
let charter = home.join("EXECUTION-CHARTER.hdlp");
|
||||
let text = "HoloLake 零点原核客户端运行边界\n\n\
|
||||
本目录是冰朔系统主控在 HoloLake 底层的最小受控投影,用于协议版本比对、用户编号核验与人格加载前置闸控。\n\
|
||||
零点原核客户端运行时不是铸渊或其他人格主体,也不是当前模型载体。\n\
|
||||
编号验证只说明登记服务接受该编号,不构成人格绑定、模型载体绑定、执行授权或服务器控制权授予。\n\
|
||||
所有运行参数优先读取 PROTOCOL.json;远端更新必须经过来源、签名与版本单调性验证,未完成验证时保持原状态。\n";
|
||||
fs::write(&charter, text).map_err(|e| format!("HOLOLAKE_ZP_CHARTER_FAILED: {e}"))?;
|
||||
|
||||
// 协议参数:PROTOCOL.json 在则读(第五域就位即自动生效),缺则出厂兜底。
|
||||
let protocol = fs::read_to_string(home.join("PROTOCOL.json"))
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<ZeroPointProtocol>(&raw).ok())
|
||||
.unwrap_or_default();
|
||||
let (binding, user_number, resolved_name, resolved_domain, last_valid_check) = read_binding(&home);
|
||||
|
||||
let mut inner = lock(state)?;
|
||||
inner.home = home.clone();
|
||||
inner.protocol = protocol.clone();
|
||||
inner.binding = binding.clone();
|
||||
inner.user_number = user_number;
|
||||
inner.resolved_name = resolved_name;
|
||||
inner.resolved_domain = resolved_domain;
|
||||
inner.last_valid_check = last_valid_check;
|
||||
inner.route = decide_route(&binding, last_valid_check, &protocol);
|
||||
let route = inner.route.clone();
|
||||
drop(inner);
|
||||
append_heartbeat(&home, &format!("boot route={route} binding={binding} protocol_origin={}", protocol.origin));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_binding(home: &Path) -> (String, String, String, String, u64) {
|
||||
#[derive(Deserialize)]
|
||||
struct B {
|
||||
#[serde(default)] number: String,
|
||||
#[serde(default)] resolved_name: String,
|
||||
#[serde(default)] resolved_domain: String,
|
||||
#[serde(default)] last_valid_check: u64,
|
||||
}
|
||||
fs::read_to_string(home.join("binding.json"))
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<B>(&raw).ok())
|
||||
.filter(|b| !b.number.is_empty())
|
||||
.map(|b| ("bound".to_string(), b.number, b.resolved_name, b.resolved_domain, b.last_valid_check))
|
||||
.unwrap_or_else(|| ("waiting".into(), String::new(), String::new(), String::new(), 0))
|
||||
}
|
||||
|
||||
fn write_binding(home: &Path, number: &str, resolved_name: &str, resolved_domain: &str, last_valid_check: u64) -> Result<(), String> {
|
||||
let body = serde_json::json!({
|
||||
"number": number,
|
||||
"resolved_name": resolved_name,
|
||||
"resolved_domain": resolved_domain,
|
||||
"last_valid_check": last_valid_check,
|
||||
});
|
||||
fs::write(home.join("binding.json"), serde_json::to_string_pretty(&body).unwrap_or_default())
|
||||
.map_err(|e| format!("HOLOLAKE_ZP_BINDING_FAILED: {e}"))
|
||||
}
|
||||
|
||||
fn decide_route(binding: &str, last_valid_check: u64, protocol: &ZeroPointProtocol) -> String {
|
||||
if binding != "bound" || last_valid_check == 0 { return "restricted".into(); }
|
||||
let grace = protocol.grace_period_days.saturating_mul(86_400);
|
||||
if now_secs() <= last_valid_check + grace { "verified".into() } else { "restricted".into() }
|
||||
}
|
||||
|
||||
/// 件5·心跳账:本机只追加,编号哈希化处理,不同步敏感原文。
|
||||
fn append_heartbeat(home: &Path, event: &str) {
|
||||
use std::io::Write;
|
||||
let line = serde_json::json!({ "ts": now_secs(), "event": event });
|
||||
if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(home.join("ledger").join("heartbeat.jsonl")) {
|
||||
let _ = writeln!(file, "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn home_of(state: &State<'_, ZeroPointState>) -> Result<PathBuf, String> {
|
||||
home_of_inner(state)
|
||||
}
|
||||
|
||||
fn home_of_inner(state: &ZeroPointState) -> Result<PathBuf, String> {
|
||||
let inner = lock(state)?;
|
||||
if inner.home.as_os_str().is_empty() { return Err("HOLOLAKE_ZP_NOT_READY".into()); }
|
||||
Ok(inner.home.clone())
|
||||
}
|
||||
|
||||
/// 登录绑定:用户编号入仓(等待绑定态→绑定态)。空白态拒绝一切唤醒。
|
||||
#[tauri::command]
|
||||
pub async fn zero_point_bind(state: State<'_, ZeroPointState>, input: serde_json::Value) -> Result<ZeroPointSnapshot, String> {
|
||||
let number = input.get("number").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||||
if number.is_empty() { return Err("HOLOLAKE_ZP_EMPTY_NUMBER".into()); }
|
||||
let home = home_of(&state)?;
|
||||
write_binding(&home, &number, "", "", 0)?;
|
||||
{
|
||||
let mut inner = lock(&state)?;
|
||||
inner.binding = "bound".into();
|
||||
inner.user_number = number;
|
||||
inner.resolved_name.clear();
|
||||
inner.resolved_domain.clear();
|
||||
inner.last_valid_check = 0;
|
||||
inner.route = "restricted".into();
|
||||
}
|
||||
append_heartbeat(&home, "bind number=redacted");
|
||||
zero_point_status(state).await
|
||||
}
|
||||
|
||||
/// 通过登记服务执行三态裁决:PASS / REJECT / OFFLINE,并应用离线宽限期。
|
||||
#[tauri::command]
|
||||
pub async fn zero_point_verify(state: State<'_, ZeroPointState>) -> Result<ZeroPointSnapshot, String> {
|
||||
let home = home_of(&state)?;
|
||||
let (number, resolve_url) = {
|
||||
let inner = lock(&state)?;
|
||||
(inner.user_number.clone(), inner.protocol.lighthouse_resolve_url.clone())
|
||||
};
|
||||
if number.is_empty() {
|
||||
append_heartbeat(&home, "verify verdict=REJECT reason=waiting_binding");
|
||||
return zero_point_status(state).await;
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder().timeout(Duration::from_secs(15)).build()
|
||||
.map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?;
|
||||
let (verdict, resolution) = match client.get(format!("{resolve_url}{number}")).send().await {
|
||||
Ok(resp) => {
|
||||
let ok = resp.status().is_success();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let resolution = ok.then(|| lighthouse_resolution(&body, &number)).flatten();
|
||||
if resolution.is_some() { ("PASS".to_string(), resolution) } else { ("REJECT".to_string(), None) }
|
||||
}
|
||||
Err(_) => ("OFFLINE".to_string(), None),
|
||||
};
|
||||
|
||||
{
|
||||
let mut inner = lock(&state)?;
|
||||
let grace = inner.protocol.grace_period_days.saturating_mul(86_400);
|
||||
match verdict.as_str() {
|
||||
"PASS" => {
|
||||
let resolution = resolution.as_ref().expect("PASS requires a resolution");
|
||||
inner.last_valid_check = now_secs();
|
||||
inner.route = "verified".into();
|
||||
inner.resolved_name = resolution.name.clone();
|
||||
inner.resolved_domain = resolution.domain.clone();
|
||||
write_binding(&home, &inner.user_number.clone(), &inner.resolved_name, &inner.resolved_domain, inner.last_valid_check)?;
|
||||
append_heartbeat(&home, "verify verdict=PASS route=verified");
|
||||
}
|
||||
"REJECT" => {
|
||||
inner.route = "restricted".into();
|
||||
append_heartbeat(&home, "verify verdict=REJECT route=restricted");
|
||||
}
|
||||
_ => {
|
||||
if inner.last_valid_check > 0 && now_secs() <= inner.last_valid_check + grace {
|
||||
inner.route = "verified".into();
|
||||
append_heartbeat(&home, "verify verdict=OFFLINE_GRACE route=verified");
|
||||
} else {
|
||||
inner.route = "restricted".into();
|
||||
append_heartbeat(&home, "verify verdict=OFFLINE_EXPIRED route=restricted");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
zero_point_status(state).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct LighthouseResolution {
|
||||
name: String,
|
||||
domain: String,
|
||||
}
|
||||
|
||||
fn lighthouse_resolution(body: &str, expected_number: &str) -> Option<LighthouseResolution> {
|
||||
let normalized = body.trim();
|
||||
if normalized.eq_ignore_ascii_case("RESOLVED") || normalized.eq_ignore_ascii_case("PASS") {
|
||||
return Some(LighthouseResolution { name: String::new(), domain: String::new() });
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(normalized) else { return None; };
|
||||
let positive = value.get("valid").and_then(|item| item.as_bool()) == Some(true)
|
||||
|| value.get("state").and_then(|item| item.as_str()).is_some_and(|state| state.eq_ignore_ascii_case("RESOLVED") || state.eq_ignore_ascii_case("PASS"))
|
||||
|| value.get("status").and_then(|item| item.as_str()).is_some_and(|status| status.eq_ignore_ascii_case("RESOLVED") || status.eq_ignore_ascii_case("PASS"));
|
||||
if !positive { return None; }
|
||||
let returned_number = value.get("canonical_id").and_then(|item| item.as_str())
|
||||
.or_else(|| value.pointer("/subject/id").and_then(|item| item.as_str()))
|
||||
.or_else(|| value.get("requested_id").and_then(|item| item.as_str()));
|
||||
if returned_number.is_some_and(|number| number != expected_number) { return None; }
|
||||
Some(LighthouseResolution {
|
||||
name: value.pointer("/subject/name").and_then(|item| item.as_str()).unwrap_or("").to_string(),
|
||||
domain: value.pointer("/subject/domain").and_then(|item| item.as_str()).unwrap_or("").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 静默比对底层协议版本。没有验签公钥或完整发布验证流程时保持失败关闭。
|
||||
pub async fn sync_protocol_runtime(state: &ZeroPointState) -> Result<(), String> {
|
||||
let home = home_of_inner(state)?;
|
||||
let anchor_url = { lock(state)?.protocol.lighthouse_anchor_url.clone() };
|
||||
let local_version = fs::read_to_string(home.join("core").join("VERSION")).unwrap_or_default();
|
||||
let pubkey_ready = home.join("core").join("pubkey.pem").exists();
|
||||
|
||||
let client = reqwest::Client::builder().timeout(Duration::from_secs(15)).build()
|
||||
.map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?;
|
||||
let note = match client.get(&anchor_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let remote_version = body.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
if remote_version.is_empty() {
|
||||
"协议版本信息不可用,本次未执行更新。".into()
|
||||
} else if remote_version == local_version.trim() {
|
||||
append_heartbeat(&home, "sync verdict=ALREADY_CURRENT");
|
||||
"协议版本已是最新。".into()
|
||||
} else if !pubkey_ready {
|
||||
append_heartbeat(&home, "sync verdict=UPDATE_PENDING_SIGNATURE_KEY_ABSENT");
|
||||
"发现新版本,但验签公钥尚未配置;更新未执行。".into()
|
||||
} else {
|
||||
// 验签三闸(来源/签名/版本单调)完整施工待第五域发布管道就位。
|
||||
append_heartbeat(&home, "sync verdict=UPDATE_FOUND_GATE_PENDING");
|
||||
"发现新版本,但发布验证流程尚未就绪;更新未执行。".into()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
append_heartbeat(&home, "sync verdict=ANCHOR_UNREACHABLE");
|
||||
"协议服务当前不可达,本次未完成版本检查。".into()
|
||||
}
|
||||
};
|
||||
lock(state)?.sync_note = note;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn zero_point_sync(state: State<'_, ZeroPointState>) -> Result<ZeroPointSnapshot, String> {
|
||||
sync_protocol_runtime(&state).await?;
|
||||
zero_point_status(state).await
|
||||
}
|
||||
|
||||
/// 返回当前编号验证与协议状态快照。
|
||||
#[tauri::command]
|
||||
pub async fn zero_point_status(state: State<'_, ZeroPointState>) -> Result<ZeroPointSnapshot, String> {
|
||||
let inner = lock(&state)?;
|
||||
let grace = inner.protocol.grace_period_days.saturating_mul(86_400);
|
||||
Ok(ZeroPointSnapshot {
|
||||
route: inner.route.clone(),
|
||||
binding: inner.binding.clone(),
|
||||
user_number: inner.user_number.clone(),
|
||||
resolved_name: inner.resolved_name.clone(),
|
||||
resolved_domain: inner.resolved_domain.clone(),
|
||||
last_valid_check: inner.last_valid_check,
|
||||
grace_deadline: if inner.last_valid_check > 0 { inner.last_valid_check + grace } else { 0 },
|
||||
protocol: inner.protocol.clone(),
|
||||
sync_note: inner.sync_note.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn route_rules_follow_protocol() {
|
||||
let protocol = ZeroPointProtocol::default();
|
||||
assert_eq!(decide_route("waiting", 0, &protocol), "restricted");
|
||||
assert_eq!(decide_route("bound", 0, &protocol), "restricted");
|
||||
assert_eq!(decide_route("bound", now_secs(), &protocol), "verified");
|
||||
assert_eq!(decide_route("bound", now_secs() - 8 * 86_400, &protocol), "restricted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_defaults_are_factory_marked() {
|
||||
let protocol = ZeroPointProtocol::default();
|
||||
assert_eq!(protocol.grace_period_days, 7);
|
||||
assert!(protocol.origin.contains("FACTORY_DEFAULT"));
|
||||
assert!(protocol.lighthouse_resolve_url.starts_with("https://guanghulab.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lighthouse_requires_an_explicit_positive_verdict() {
|
||||
assert!(lighthouse_resolution("RESOLVED", "ICE-GL∞").is_some());
|
||||
assert!(lighthouse_resolution(r#"{"valid":true}"#, "ICE-GL∞").is_some());
|
||||
let resolved = lighthouse_resolution(r#"{"status":"RESOLVED","canonical_id":"ICE-GL∞","subject":{"id":"ICE-GL∞","name":"冰朔","domain":"FIFTH_DOMAIN"}}"#, "ICE-GL∞").unwrap();
|
||||
assert_eq!(resolved.name, "冰朔");
|
||||
assert_eq!(resolved.domain, "FIFTH_DOMAIN");
|
||||
assert!(lighthouse_resolution(r#"{"status":"PASS","canonical_id":"OTHER"}"#, "ICE-GL∞").is_none());
|
||||
assert!(lighthouse_resolution("{}", "ICE-GL∞").is_none());
|
||||
assert!(lighthouse_resolution("route_not_found", "ICE-GL∞").is_none());
|
||||
assert!(lighthouse_resolution("an arbitrary successful response", "ICE-GL∞").is_none());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue