Skip to main content

qualia_client_core/
user_profile.rs

1//! Local user profile and sharing policy for chats and connect invites.
2
3use std::fs;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8/// Per-flag sharing policy for chats and connect invites.
9///
10/// `#[serde(default)]` on the struct (and the `Default` impl) means older on-disk
11/// `profile.json` files and partial UI patches can omit fields without hard-failing.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default)]
14pub struct SharingPolicy {
15    pub share_display_name: bool,
16    pub share_public_did: bool,
17    pub share_active_model: bool,
18    /// Allow sharing Webizen-processed outcomes (not raw prompts) with group chat peers.
19    pub share_llm_outcomes: bool,
20    pub share_ontology_scope: bool,
21    pub share_installed_qapps: bool,
22    pub share_daemon_status: bool,
23    pub allow_group_chat_invites: bool,
24    pub allow_directory_lookup: bool,
25    pub allow_email_invites: bool,
26}
27
28impl Default for SharingPolicy {
29    fn default() -> Self {
30        Self {
31            share_display_name: true,
32            share_public_did: true,
33            share_active_model: false,
34            share_llm_outcomes: false,
35            share_ontology_scope: false,
36            share_installed_qapps: false,
37            share_daemon_status: false,
38            allow_group_chat_invites: true,
39            allow_directory_lookup: true,
40            allow_email_invites: true,
41        }
42    }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct UserProfile {
47    pub display_name: String,
48    pub bio: Option<String>,
49    pub public_did: String,
50    pub active_front_door_id: Option<String>,
51    #[serde(default)]
52    pub relay_base_url: Option<String>,
53    pub sharing: SharingPolicy,
54    pub updated_at: u64,
55}
56
57impl Default for UserProfile {
58    fn default() -> Self {
59        Self {
60            display_name: "Qualia User".to_string(),
61            bio: None,
62            public_did: String::new(),
63            active_front_door_id: None,
64            relay_base_url: None,
65            sharing: SharingPolicy::default(),
66            updated_at: 0,
67        }
68    }
69}
70
71pub fn profile_path() -> PathBuf {
72    crate::state::app_meta_dir().join("profile.json")
73}
74
75pub fn load_profile() -> UserProfile {
76    let path = profile_path();
77    if let Ok(text) = fs::read_to_string(&path) {
78        if let Ok(mut p) = serde_json::from_str::<UserProfile>(&text) {
79            if p.public_did.is_empty() {
80                p.public_did = resolve_public_did(&p);
81            }
82            return p;
83        }
84    }
85    let mut profile = UserProfile::default();
86    profile.public_did = resolve_public_did(&profile);
87    profile.updated_at = unix_now();
88    let _ = save_profile(&profile);
89    profile
90}
91
92pub fn save_profile(profile: &UserProfile) -> Result<(), String> {
93    let path = profile_path();
94    if let Some(parent) = path.parent() {
95        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
96    }
97    let mut p = profile.clone();
98    p.updated_at = unix_now();
99    if p.public_did.is_empty() {
100        p.public_did = resolve_public_did(&p);
101    }
102    let text = serde_json::to_string_pretty(&p).map_err(|e| e.to_string())?;
103    fs::write(path, text).map_err(|e| e.to_string())
104}
105
106/// Apply a partial profile JSON patch onto `base` (load-merge-save semantics).
107///
108/// Used by the People tab "Save + enable invites" path, which intentionally sends only
109/// `display_name` + a subset of `sharing` flags. Unknown top-level keys are ignored.
110/// Nested `sharing` objects are field-wise merged so omitted flags keep their current values.
111pub fn apply_profile_patch(
112    base: &UserProfile,
113    patch: &serde_json::Value,
114) -> Result<UserProfile, String> {
115    if !patch.is_object() {
116        return Err("profile patch must be a JSON object".into());
117    }
118    let mut out = base.clone();
119
120    if let Some(v) = patch.get("display_name") {
121        out.display_name = v
122            .as_str()
123            .ok_or_else(|| "display_name must be a string".to_string())?
124            .to_string();
125    }
126    if let Some(v) = patch.get("bio") {
127        out.bio = match v {
128            serde_json::Value::Null => None,
129            serde_json::Value::String(s) => Some(s.clone()),
130            _ => return Err("bio must be a string or null".into()),
131        };
132    }
133    if let Some(v) = patch.get("public_did") {
134        if let Some(s) = v.as_str() {
135            if !s.is_empty() {
136                out.public_did = s.to_string();
137            }
138        } else if !v.is_null() {
139            return Err("public_did must be a string".into());
140        }
141    }
142    if let Some(v) = patch.get("active_front_door_id") {
143        out.active_front_door_id = match v {
144            serde_json::Value::Null => None,
145            serde_json::Value::String(s) => Some(s.clone()),
146            _ => return Err("active_front_door_id must be a string or null".into()),
147        };
148    }
149    if let Some(v) = patch.get("relay_base_url") {
150        out.relay_base_url = match v {
151            serde_json::Value::Null => None,
152            serde_json::Value::String(s) => Some(s.clone()),
153            _ => return Err("relay_base_url must be a string or null".into()),
154        };
155    }
156    if let Some(sharing) = patch.get("sharing") {
157        apply_sharing_patch(&mut out.sharing, sharing)?;
158    }
159
160    Ok(out)
161}
162
163fn apply_sharing_patch(
164    policy: &mut SharingPolicy,
165    patch: &serde_json::Value,
166) -> Result<(), String> {
167    let obj = patch
168        .as_object()
169        .ok_or_else(|| "sharing must be a JSON object".to_string())?;
170    for (key, value) in obj {
171        let Some(flag) = value.as_bool() else {
172            return Err(format!("sharing.{key} must be a boolean"));
173        };
174        match key.as_str() {
175            "share_display_name" => policy.share_display_name = flag,
176            "share_public_did" => policy.share_public_did = flag,
177            "share_active_model" => policy.share_active_model = flag,
178            "share_llm_outcomes" => policy.share_llm_outcomes = flag,
179            "share_ontology_scope" => policy.share_ontology_scope = flag,
180            "share_installed_qapps" => policy.share_installed_qapps = flag,
181            "share_daemon_status" => policy.share_daemon_status = flag,
182            "allow_group_chat_invites" => policy.allow_group_chat_invites = flag,
183            "allow_directory_lookup" => policy.allow_directory_lookup = flag,
184            "allow_email_invites" => policy.allow_email_invites = flag,
185            // Forward-compatible: ignore unknown sharing keys rather than fail closed.
186            _ => {}
187        }
188    }
189    Ok(())
190}
191
192pub fn resolve_public_did(profile: &UserProfile) -> String {
193    let state = match crate::state::APP_STATE.get() {
194        Some(s) => s,
195        None => return format!("did:qualia:local:{}", unix_now()),
196    };
197
198    if let Some(ref fd_id) = profile.active_front_door_id {
199        let doors = state.front_doors.lock().unwrap();
200        if let Some(door) = doors.iter().find(|d| d.id == *fd_id) {
201            return door.did_uri.clone();
202        }
203    }
204
205    let doors = state.front_doors.lock().unwrap();
206    if let Some(door) = doors.first() {
207        return door.did_uri.clone();
208    }
209
210    let vault = state.key_vault.lock().unwrap();
211    let key = vault.derive_key("profile-root");
212    let pub_hex = hex::encode(ed25519_dalek::VerifyingKey::from(&key).as_bytes());
213    format!("did:qualia:root:{pub_hex}")
214}
215
216pub fn public_profile_card(profile: &UserProfile) -> serde_json::Value {
217    let mut card = serde_json::json!({
218        "version": 1,
219        "updated_at": profile.updated_at,
220    });
221
222    if profile.sharing.share_display_name {
223        card["display_name"] = serde_json::Value::String(profile.display_name.clone());
224    }
225    if profile.sharing.share_public_did {
226        card["public_did"] = serde_json::Value::String(profile.public_did.clone());
227    }
228    if let Some(ref bio) = profile.bio {
229        if profile.sharing.share_display_name {
230            card["bio"] = serde_json::Value::String(bio.clone());
231        }
232    }
233
234    card
235}
236
237fn unix_now() -> u64 {
238    std::time::SystemTime::now()
239        .duration_since(std::time::UNIX_EPOCH)
240        .unwrap_or_default()
241        .as_secs()
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use serde_json::json;
248
249    #[test]
250    fn partial_people_tab_patch_enables_invites_without_wiping_fields() {
251        let base = UserProfile {
252            display_name: "Old Name".into(),
253            bio: Some("keeps bio".into()),
254            public_did: "did:qualia:test:abc".into(),
255            active_front_door_id: Some("door-1".into()),
256            relay_base_url: Some("https://relay.example".into()),
257            sharing: SharingPolicy {
258                allow_group_chat_invites: false,
259                share_active_model: true, // must survive a partial sharing patch
260                ..SharingPolicy::default()
261            },
262            updated_at: 1,
263        };
264        // Exact shape the People tab "Save + enable invites" button used to send
265        // (and that previously failed with missing field `share_display_name`).
266        let patch = json!({
267            "display_name": "Timothy",
268            "sharing": { "allow_group_chat_invites": true }
269        });
270        let out = apply_profile_patch(&base, &patch).expect("partial patch must apply");
271        assert_eq!(out.display_name, "Timothy");
272        assert!(out.sharing.allow_group_chat_invites);
273        assert!(
274            out.sharing.share_active_model,
275            "unmentioned sharing flags must be preserved"
276        );
277        assert_eq!(out.bio.as_deref(), Some("keeps bio"));
278        assert_eq!(out.public_did, "did:qualia:test:abc");
279        assert_eq!(out.active_front_door_id.as_deref(), Some("door-1"));
280        assert_eq!(out.relay_base_url.as_deref(), Some("https://relay.example"));
281    }
282
283    #[test]
284    fn sharing_policy_deserializes_when_fields_are_omitted() {
285        let p: SharingPolicy = serde_json::from_str(r#"{"allow_group_chat_invites":false}"#)
286            .expect("#[serde(default)] must fill omitted SharingPolicy fields");
287        assert!(!p.allow_group_chat_invites);
288        assert!(p.share_display_name);
289        assert!(p.share_public_did);
290    }
291}