Skip to main content

qualia_client_core/
setup.rs

1//! Persisted first-run setup state for the desktop shell.
2//!
3//! Setup is intentionally separate from `AgentConfig`: configuration may be
4//! saved more than once during onboarding, while the gate must only disappear
5//! after the required choices have been reviewed.
6
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::state::{app_meta_dir, config_file_path};
12
13pub const SETUP_STATE_VERSION: u32 = 5;
14
15/// First-run gate: local foundations that do **not** require peers, domains, or live mesh.
16///
17/// Relational and network configuration is progressive — see [`PROGRESSIVE_SETUP_PATHS`].
18pub const REQUIRED_SETUP_STEPS: [&str; 8] = [
19    "welcome",
20    "storage",
21    "control",
22    "device",
23    "inference",
24    "relations", // how you want to be known (local profile only)
25    "care",
26    "ready",
27];
28
29/// Paths that become meaningful after the apparatus is running and people can connect.
30/// These never block the first-run gate; they surface in Setup Health / Relations over time.
31pub const PROGRESSIVE_SETUP_PATHS: [&str; 6] = [
32    "reachability",       // private / mesh / public posture
33    "assurance",          // backup destination + verified restore
34    "people_connections", // invites, contacts, groups
35    "domains_mail",       // front door, DNS, purpose mailboxes
36    "care_records",       // provenance-backed health material
37    "peer_agreements",    // multi-party norms once peers exist
38];
39
40/// Social and tenure context for the machine Webizen is being installed on.
41///
42/// This is not hardware telemetry. It answers: whose machine, only machine or not,
43/// one person or several, and (if several) what kind of shared setting. All fields
44/// are optional plain tokens; empty means “prefer not to say / not set yet”.
45#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
46pub struct DeviceContext {
47    /// e.g. `owned_by_me` | `employer` | `school` | `organisation` | `shared_household`
48    /// | `borrowed_or_public` | `prefer_not_say` | `other`
49    #[serde(default)]
50    pub ownership: String,
51    /// `only_machine` | `one_of_several` | `prefer_not_say`
52    #[serde(default)]
53    pub machine_fleet: String,
54    /// `just_me` | `more_than_one` | `prefer_not_say`
55    #[serde(default)]
56    pub user_scope: String,
57    /// When `user_scope` is multi-person: `family` | `household` | `work` | `school`
58    /// | `organisation` | `public_shared` | `mixed` | `other` | `prefer_not_say`
59    #[serde(default)]
60    pub shared_setting: String,
61    /// Free-text clarification the person chooses to add (optional).
62    #[serde(default)]
63    pub notes: String,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
67pub struct SetupProfile {
68    #[serde(default)]
69    pub preferred_name: String,
70    #[serde(default)]
71    pub locale: String,
72    #[serde(default)]
73    pub timezone: String,
74    #[serde(default)]
75    pub accessibility_needs: Vec<String>,
76    #[serde(default)]
77    pub interests: Vec<String>,
78    #[serde(default)]
79    pub preferred_ontologies: Vec<String>,
80    #[serde(default)]
81    pub care_priorities: Vec<String>,
82    #[serde(default)]
83    pub qapp_goals: Vec<String>,
84    /// Situation of this machine (ownership, sole vs fleet, single vs multi-user).
85    #[serde(default)]
86    pub device_context: DeviceContext,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90pub struct SetupState {
91    pub version: u32,
92    #[serde(default)]
93    pub completed: bool,
94    #[serde(default = "default_current_step")]
95    pub current_step: String,
96    #[serde(default)]
97    pub completed_steps: Vec<String>,
98    #[serde(default)]
99    pub migrated_from_legacy_config: bool,
100    #[serde(default)]
101    pub profile: SetupProfile,
102}
103
104fn default_current_step() -> String {
105    "welcome".to_string()
106}
107
108impl Default for SetupState {
109    fn default() -> Self {
110        Self {
111            version: SETUP_STATE_VERSION,
112            completed: false,
113            current_step: default_current_step(),
114            completed_steps: Vec::new(),
115            migrated_from_legacy_config: false,
116            profile: SetupProfile::default(),
117        }
118    }
119}
120
121pub fn update_setup_profile(profile: SetupProfile) -> Result<SetupState, String> {
122    let mut state = get_setup_state()?;
123    state.profile = profile;
124    save_setup_state_to(&setup_state_path(), &state)?;
125    // Keep apparatus device_context in the fleet identity plane (person ≠ machine).
126    let _ = crate::identity_plane::sync_local_device_context(&state.profile.device_context);
127    Ok(state)
128}
129
130pub fn setup_state_path() -> PathBuf {
131    app_meta_dir().join("setup-state.json")
132}
133
134pub fn get_setup_state() -> Result<SetupState, String> {
135    load_setup_state_from(&setup_state_path(), config_file_path().exists())
136}
137
138pub fn complete_setup_step(step: String) -> Result<SetupState, String> {
139    let step = step.trim();
140    let is_required = REQUIRED_SETUP_STEPS.contains(&step);
141    let is_progressive = PROGRESSIVE_SETUP_PATHS.contains(&step);
142    if !is_required && !is_progressive {
143        return Err(format!("Unknown setup step: {step}"));
144    }
145
146    let mut state = get_setup_state()?;
147    // Progressive paths may be recorded after first-run is already complete.
148    if state.completed && is_required {
149        return Ok(state);
150    }
151    if !state.completed_steps.iter().any(|done| done == step) {
152        state.completed_steps.push(step.to_string());
153    }
154    if !state.completed {
155        state.current_step = next_incomplete_step(&state).unwrap_or("ready").to_string();
156    }
157    save_setup_state_to(&setup_state_path(), &state)?;
158    Ok(state)
159}
160
161pub fn finish_setup() -> Result<SetupState, String> {
162    let mut state = get_setup_state()?;
163    let missing: Vec<&str> = REQUIRED_SETUP_STEPS
164        .iter()
165        .copied()
166        .filter(|required| {
167            !state
168                .completed_steps
169                .iter()
170                .any(|completed| completed == required)
171        })
172        .collect();
173    if !missing.is_empty() {
174        return Err(format!(
175            "Complete the required setup steps first: {}",
176            missing.join(", ")
177        ));
178    }
179
180    state.completed = true;
181    state.current_step = "complete".to_string();
182    save_setup_state_to(&setup_state_path(), &state)?;
183    // Mint / refresh person + local apparatus under the fleet plane so multi-device
184    // job targeting has a real local device_id (not OS account, not “the person”).
185    crate::identity_plane::ensure_local_apparatus(Some(state.profile.device_context.clone()))?;
186    Ok(state)
187}
188
189fn next_incomplete_step(state: &SetupState) -> Option<&'static str> {
190    REQUIRED_SETUP_STEPS.iter().copied().find(|required| {
191        !state
192            .completed_steps
193            .iter()
194            .any(|completed| completed == required)
195    })
196}
197
198fn load_setup_state_from(path: &Path, legacy_config_exists: bool) -> Result<SetupState, String> {
199    match fs::read_to_string(path) {
200        Ok(text) => {
201            let mut state: SetupState = serde_json::from_str(&text)
202                .map_err(|error| format!("Could not read setup state: {error}"))?;
203            state.version = SETUP_STATE_VERSION;
204            Ok(state)
205        }
206        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
207            if legacy_config_exists {
208                let state = SetupState {
209                    completed: true,
210                    current_step: "complete".to_string(),
211                    completed_steps: REQUIRED_SETUP_STEPS
212                        .iter()
213                        .map(|step| (*step).to_string())
214                        .collect(),
215                    migrated_from_legacy_config: true,
216                    ..SetupState::default()
217                };
218                save_setup_state_to(path, &state)?;
219                Ok(state)
220            } else {
221                Ok(SetupState::default())
222            }
223        }
224        Err(error) => Err(format!("Could not read setup state: {error}")),
225    }
226}
227
228fn save_setup_state_to(path: &Path, state: &SetupState) -> Result<(), String> {
229    if let Some(parent) = path.parent() {
230        fs::create_dir_all(parent)
231            .map_err(|error| format!("Could not create setup directory: {error}"))?;
232    }
233    let json = serde_json::to_string_pretty(state)
234        .map_err(|error| format!("Could not encode setup state: {error}"))?;
235    fs::write(path, json).map_err(|error| format!("Could not save setup state: {error}"))
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn new_install_starts_at_welcome() {
244        let dir = tempfile::tempdir().unwrap();
245        let state = load_setup_state_from(&dir.path().join("setup.json"), false).unwrap();
246        assert!(!state.completed);
247        assert_eq!(state.current_step, "welcome");
248        assert!(state.completed_steps.is_empty());
249    }
250
251    #[test]
252    fn legacy_config_is_migrated_without_showing_the_gate() {
253        let dir = tempfile::tempdir().unwrap();
254        let path = dir.path().join("setup.json");
255        let state = load_setup_state_from(&path, true).unwrap();
256        assert!(state.completed);
257        assert!(state.migrated_from_legacy_config);
258        assert!(path.exists());
259    }
260
261    #[test]
262    fn required_step_order_is_deterministic() {
263        let mut state = SetupState::default();
264        assert_eq!(next_incomplete_step(&state), Some("welcome"));
265        state.completed_steps.push("welcome".into());
266        assert_eq!(next_incomplete_step(&state), Some("storage"));
267        state.completed_steps.push("storage".into());
268        for step in REQUIRED_SETUP_STEPS.iter().skip(2) {
269            state.completed_steps.push((*step).into());
270        }
271        assert_eq!(next_incomplete_step(&state), None);
272    }
273
274    #[test]
275    fn first_run_gate_does_not_require_relational_paths() {
276        assert!(!REQUIRED_SETUP_STEPS.contains(&"reachability"));
277        assert!(!REQUIRED_SETUP_STEPS.contains(&"assurance"));
278        assert!(PROGRESSIVE_SETUP_PATHS.contains(&"reachability"));
279        assert!(PROGRESSIVE_SETUP_PATHS.contains(&"people_connections"));
280        assert_eq!(REQUIRED_SETUP_STEPS.len(), 8);
281    }
282
283    #[test]
284    fn older_setup_state_gets_an_empty_profile() {
285        let state: SetupState = serde_json::from_str(
286            r#"{"version":2,"completed":false,"current_step":"care","completed_steps":[]}"#,
287        )
288        .unwrap();
289        assert_eq!(state.profile, SetupProfile::default());
290        assert_eq!(state.profile.device_context, DeviceContext::default());
291    }
292
293    #[test]
294    fn device_context_deserializes_on_v5_profile() {
295        let state: SetupState = serde_json::from_str(
296            r#"{
297                "version":5,
298                "completed":false,
299                "current_step":"device",
300                "completed_steps":[],
301                "profile":{
302                    "device_context":{
303                        "ownership":"owned_by_me",
304                        "machine_fleet":"one_of_several",
305                        "user_scope":"more_than_one",
306                        "shared_setting":"family",
307                        "notes":""
308                    }
309                }
310            }"#,
311        )
312        .unwrap();
313        assert_eq!(state.profile.device_context.ownership, "owned_by_me");
314        assert_eq!(state.profile.device_context.shared_setting, "family");
315    }
316}